pi-cache-optimizer 2.8.27 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/index.ts +43 -7
- package/package.json +1 -1
- package/src/stats-archive.ts +1272 -0
- package/src/stats-store.ts +294 -56
package/src/stats-store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, utimes, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { LOG_PREFIX, asRecord, getErrorCode, getNonNegativeNumber, isNonEmptyString, isProcessAlive } from "./common.ts";
|
|
5
5
|
import { STATE_DIR } from "./paths.ts";
|
|
@@ -234,6 +234,25 @@ export async function readEpochFile(path: string, fallback: string): Promise<str
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Like readEpochFile, but only a missing file maps to the fallback. Any other
|
|
239
|
+
* read or parse failure throws, so destructive maintenance (archive
|
|
240
|
+
* compaction drops counters whose epoch differs) never mistakes a transient
|
|
241
|
+
* Windows EPERM/EBUSY or a damaged epoch file for "no reset ever happened".
|
|
242
|
+
*/
|
|
243
|
+
export async function readEpochFileStrict(path: string, fallback: string): Promise<string> {
|
|
244
|
+
let text: string;
|
|
245
|
+
try {
|
|
246
|
+
text = await readFile(path, "utf8");
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (getErrorCode(error) === "ENOENT") return fallback;
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
const epoch = parseEpochRecord(JSON.parse(text));
|
|
252
|
+
if (!epoch) throw new Error(`invalid stats epoch file ${path}`);
|
|
253
|
+
return epoch;
|
|
254
|
+
}
|
|
255
|
+
|
|
237
256
|
export async function readGlobalStatsEpoch(): Promise<string> {
|
|
238
257
|
return readEpochFile(SHARD_GLOBAL_EPOCH_PATH, initialEpoch("global"));
|
|
239
258
|
}
|
|
@@ -242,6 +261,14 @@ export async function readModelStatsEpoch(modelKeyValue: string): Promise<string
|
|
|
242
261
|
return readEpochFile(modelEpochPath(modelKeyValue), initialEpoch(`model:${modelKeyValue}`));
|
|
243
262
|
}
|
|
244
263
|
|
|
264
|
+
export async function readGlobalStatsEpochStrict(): Promise<string> {
|
|
265
|
+
return readEpochFileStrict(SHARD_GLOBAL_EPOCH_PATH, initialEpoch("global"));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function readModelStatsEpochStrict(modelKeyValue: string): Promise<string> {
|
|
269
|
+
return readEpochFileStrict(modelEpochPath(modelKeyValue), initialEpoch(`model:${modelKeyValue}`));
|
|
270
|
+
}
|
|
271
|
+
|
|
245
272
|
export async function writeStatsEpoch(path: string, epoch: string): Promise<void> {
|
|
246
273
|
await mkdir(dirname(path), { recursive: true });
|
|
247
274
|
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
@@ -459,10 +486,48 @@ export async function readValidStatsShardsV7(directory: string = SHARD_FILES_DIR
|
|
|
459
486
|
return shards;
|
|
460
487
|
}
|
|
461
488
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
489
|
+
/**
|
|
490
|
+
* Pre-reduced counters from many shards (the stats archive), already filtered
|
|
491
|
+
* to the current global and model epochs. Merging one into an aggregator
|
|
492
|
+
* gives the same result as feeding it the shards it was reduced from, at that
|
|
493
|
+
* position of the shard sequence. Only valid for the all-days (`day === null`)
|
|
494
|
+
* aggregate. Entries are listed in the order their first shard contributed,
|
|
495
|
+
* so key order in the result matches too. Treat as read-only.
|
|
496
|
+
*/
|
|
497
|
+
export type ShardContribution = {
|
|
498
|
+
sessions: ReadonlyArray<{
|
|
499
|
+
sessionHash: string;
|
|
500
|
+
/** Contributing shards of this session (each counts once in instancesBySession). */
|
|
501
|
+
instances: number;
|
|
502
|
+
models: ReadonlyArray<{ key: string; stats: CacheStats; instances: number }>;
|
|
503
|
+
}>;
|
|
504
|
+
models: ReadonlyArray<{
|
|
505
|
+
key: string;
|
|
506
|
+
stats: CacheStats;
|
|
507
|
+
instances: number;
|
|
508
|
+
sessions: readonly string[];
|
|
509
|
+
}>;
|
|
510
|
+
/**
|
|
511
|
+
* Per model, the first shard with the newest lifecycle.updatedAt that
|
|
512
|
+
* carried it, in the order an aggregation would first record each one.
|
|
513
|
+
*/
|
|
514
|
+
refs: ReadonlyArray<{ key: string; updatedAt: number; ref: PersistedRoutedModelRef }>;
|
|
515
|
+
/** Per session, the first entry with the newest updatedAt. */
|
|
516
|
+
routed: ReadonlyArray<{ sessionHash: string; updatedAt: number; model: PersistedRoutedModelRef }>;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
export type ShardAggregator = {
|
|
520
|
+
readonly day: string | null;
|
|
521
|
+
readonly globalEpoch: string;
|
|
522
|
+
/** The model epoch this aggregation filters `key` with (read once per aggregation). */
|
|
523
|
+
modelEpoch(key: string): Promise<string>;
|
|
524
|
+
add(shard: PersistedStatsShardV7): Promise<void>;
|
|
525
|
+
addContribution(contribution: ShardContribution): void;
|
|
526
|
+
finish(): ShardAggregate;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/** Incremental form of aggregateStatsShardsV7: add shards (and pre-reduced contributions) in order, then finish. */
|
|
530
|
+
export async function createShardAggregatorV7(day: string | null = currentLocalDay()): Promise<ShardAggregator> {
|
|
466
531
|
const globalEpoch = await readGlobalStatsEpoch();
|
|
467
532
|
const modelEpochs = new Map<string, string>();
|
|
468
533
|
const result: ShardAggregate = {
|
|
@@ -478,46 +543,99 @@ export async function aggregateStatsShardsV7(
|
|
|
478
543
|
const routedUpdatedAt = new Map<string, number>();
|
|
479
544
|
const modelRefUpdatedAt = new Map<string, number>();
|
|
480
545
|
const modelSessions = new Map<string, Set<string>>();
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
routedUpdatedAt.set(shard.sessionHash, shard.lifecycle.updatedAt);
|
|
487
|
-
result.lastRoutedModelBySession[shard.sessionHash] = shard.lastRoutedModel;
|
|
546
|
+
const modelEpoch = async (key: string): Promise<string> => {
|
|
547
|
+
let epoch = modelEpochs.get(key);
|
|
548
|
+
if (!epoch) {
|
|
549
|
+
epoch = await readModelStatsEpoch(key);
|
|
550
|
+
modelEpochs.set(key, epoch);
|
|
488
551
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
552
|
+
return epoch;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
day,
|
|
557
|
+
globalEpoch,
|
|
558
|
+
modelEpoch,
|
|
559
|
+
async add(shard) {
|
|
560
|
+
if ((day !== null && shard.day !== day) || shard.globalEpoch !== globalEpoch) return;
|
|
561
|
+
let contributed = false;
|
|
562
|
+
if (shard.lastRoutedModel && (routedUpdatedAt.get(shard.sessionHash) ?? -1) < shard.lifecycle.updatedAt) {
|
|
563
|
+
routedUpdatedAt.set(shard.sessionHash, shard.lifecycle.updatedAt);
|
|
564
|
+
result.lastRoutedModelBySession[shard.sessionHash] = shard.lastRoutedModel;
|
|
494
565
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
566
|
+
for (const [key, entry] of Object.entries(shard.models)) {
|
|
567
|
+
const epoch = modelEpochs.get(key) ?? await modelEpoch(key);
|
|
568
|
+
if (entry.modelEpoch !== epoch || (day !== null && entry.stats.day !== day)) continue;
|
|
569
|
+
contributed = true;
|
|
570
|
+
const sessionModels = result.bySession[shard.sessionHash] ??= {};
|
|
571
|
+
sessionModels[key] = mergeStatsAcrossDays(sessionModels[key], entry.stats, day);
|
|
572
|
+
result.totalsByModel[key] = mergeStatsAcrossDays(result.totalsByModel[key], entry.stats, day);
|
|
573
|
+
result.instancesByModel[key] = (result.instancesByModel[key] ?? 0) + 1;
|
|
574
|
+
if ((modelRefUpdatedAt.get(key) ?? -1) < shard.lifecycle.updatedAt) {
|
|
575
|
+
modelRefUpdatedAt.set(key, shard.lifecycle.updatedAt);
|
|
576
|
+
result.modelRefsByKey[key] = {
|
|
577
|
+
provider: entry.provider,
|
|
578
|
+
id: entry.modelId,
|
|
579
|
+
name: entry.modelName ?? entry.modelId,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const sessionInstances = result.instancesBySessionModel[shard.sessionHash] ??= {};
|
|
583
|
+
sessionInstances[key] = (sessionInstances[key] ?? 0) + 1;
|
|
584
|
+
const sessions = modelSessions.get(key) ?? new Set<string>();
|
|
585
|
+
sessions.add(shard.sessionHash);
|
|
586
|
+
modelSessions.set(key, sessions);
|
|
508
587
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
588
|
+
if (contributed) {
|
|
589
|
+
result.instancesBySession[shard.sessionHash] = (result.instancesBySession[shard.sessionHash] ?? 0) + 1;
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
addContribution(contribution) {
|
|
593
|
+
if (day !== null) throw new Error("shard contributions only apply to the all-days aggregate");
|
|
594
|
+
for (const entry of contribution.routed) {
|
|
595
|
+
if ((routedUpdatedAt.get(entry.sessionHash) ?? -1) < entry.updatedAt) {
|
|
596
|
+
routedUpdatedAt.set(entry.sessionHash, entry.updatedAt);
|
|
597
|
+
result.lastRoutedModelBySession[entry.sessionHash] = { ...entry.model };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
for (const model of contribution.models) {
|
|
601
|
+
// mergeStatsAcrossDays clones on first insert, so the shared
|
|
602
|
+
// contribution is never handed out (callers mutate the aggregate).
|
|
603
|
+
result.totalsByModel[model.key] = mergeStatsAcrossDays(result.totalsByModel[model.key], model.stats, null);
|
|
604
|
+
result.instancesByModel[model.key] = (result.instancesByModel[model.key] ?? 0) + model.instances;
|
|
605
|
+
const sessions = modelSessions.get(model.key) ?? new Set<string>();
|
|
606
|
+
for (const sessionHash of model.sessions) sessions.add(sessionHash);
|
|
607
|
+
modelSessions.set(model.key, sessions);
|
|
608
|
+
}
|
|
609
|
+
for (const entry of contribution.refs) {
|
|
610
|
+
if ((modelRefUpdatedAt.get(entry.key) ?? -1) < entry.updatedAt) {
|
|
611
|
+
modelRefUpdatedAt.set(entry.key, entry.updatedAt);
|
|
612
|
+
result.modelRefsByKey[entry.key] = { ...entry.ref };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (const session of contribution.sessions) {
|
|
616
|
+
const sessionModels = result.bySession[session.sessionHash] ??= {};
|
|
617
|
+
const sessionInstances = result.instancesBySessionModel[session.sessionHash] ??= {};
|
|
618
|
+
for (const model of session.models) {
|
|
619
|
+
sessionModels[model.key] = mergeStatsAcrossDays(sessionModels[model.key], model.stats, null);
|
|
620
|
+
sessionInstances[model.key] = (sessionInstances[model.key] ?? 0) + model.instances;
|
|
621
|
+
}
|
|
622
|
+
result.instancesBySession[session.sessionHash] = (result.instancesBySession[session.sessionHash] ?? 0) + session.instances;
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
finish() {
|
|
626
|
+
for (const [key, sessions] of modelSessions) result.sessionsByModel[key] = sessions.size;
|
|
627
|
+
return result;
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export async function aggregateStatsShardsV7(
|
|
633
|
+
shards: PersistedStatsShardV7[],
|
|
634
|
+
day: string | null = currentLocalDay(),
|
|
635
|
+
): Promise<ShardAggregate> {
|
|
636
|
+
const aggregator = await createShardAggregatorV7(day);
|
|
637
|
+
for (const shard of shards) await aggregator.add(shard);
|
|
638
|
+
return aggregator.finish();
|
|
521
639
|
}
|
|
522
640
|
|
|
523
641
|
export async function loadStatsShardAggregateV7(directory: string = SHARD_FILES_DIR): Promise<ShardAggregate> {
|
|
@@ -584,35 +702,155 @@ export async function cleanupStatsShardsV7(now = Date.now(), directory: string =
|
|
|
584
702
|
return outcomes.reduce((sum, value) => sum + value, 0);
|
|
585
703
|
}
|
|
586
704
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
705
|
+
const SHARD_CLEANUP_LOCK_OWNER_FILE = "owner";
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Tokens whose owner file this process actually wrote. For those, a lock
|
|
709
|
+
* directory without an owner file can only mean the directory was replaced
|
|
710
|
+
* (a 2.8.x stale-lock takeover writes none, and a 2.9 takeover is caught
|
|
711
|
+
* between its mkdir and its owner write), so ownership is lost.
|
|
712
|
+
*/
|
|
713
|
+
const writtenLockTokens = new Set<string>();
|
|
594
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Take the cross-process maintenance lock (a directory, so creation is atomic
|
|
717
|
+
* on every platform). A lock older than SHARD_CLEANUP_LOCK_STALE_MS is
|
|
718
|
+
* treated as abandoned by a crashed process and replaced. Resolves to an
|
|
719
|
+
* owner token (truthy) on success, undefined otherwise. The token lets a
|
|
720
|
+
* holder whose lock was taken over avoid releasing the new owner's lock.
|
|
721
|
+
*/
|
|
722
|
+
export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<string | undefined> {
|
|
723
|
+
await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
|
|
724
|
+
const token = `${process.pid}-${randomUUID()}`;
|
|
725
|
+
const claim = async (): Promise<string> => {
|
|
726
|
+
// Best effort: an older version (or a failed write) leaves no owner file,
|
|
727
|
+
// which only disables the ownership check, never the lock itself.
|
|
728
|
+
try {
|
|
729
|
+
await writeFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), token, "utf8");
|
|
730
|
+
writtenLockTokens.add(token);
|
|
731
|
+
} catch {}
|
|
732
|
+
return token;
|
|
733
|
+
};
|
|
595
734
|
try {
|
|
596
735
|
await mkdir(SHARD_CLEANUP_LOCK_PATH);
|
|
736
|
+
return await claim();
|
|
597
737
|
} catch (error) {
|
|
598
|
-
if (getErrorCode(error) !== "EEXIST") return
|
|
738
|
+
if (getErrorCode(error) !== "EEXIST") return undefined;
|
|
599
739
|
try {
|
|
600
740
|
const lock = await stat(SHARD_CLEANUP_LOCK_PATH);
|
|
601
|
-
|
|
741
|
+
// A lock dated more than the stale window in the future was left by a
|
|
742
|
+
// crash while the clock ran ahead; without this it would block
|
|
743
|
+
// maintenance until the wall clock caught up (possibly for months).
|
|
744
|
+
const age = now - lock.mtimeMs;
|
|
745
|
+
if (age <= SHARD_CLEANUP_LOCK_STALE_MS && age >= -SHARD_CLEANUP_LOCK_STALE_MS) return undefined;
|
|
602
746
|
await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
|
|
603
747
|
await mkdir(SHARD_CLEANUP_LOCK_PATH);
|
|
748
|
+
return await claim();
|
|
604
749
|
} catch {
|
|
605
|
-
return
|
|
750
|
+
return undefined;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* False when the lock directory records a different owner, is gone, or lost
|
|
757
|
+
* the owner file this holder wrote. An unreadable owner file fails closed
|
|
758
|
+
* (destructive publishes are skipped and retried) unless `failOpen` is set.
|
|
759
|
+
*/
|
|
760
|
+
export async function holdsStatsMaintenanceLock(token: string | undefined, { failOpen = false }: { failOpen?: boolean } = {}): Promise<boolean> {
|
|
761
|
+
if (!token) return true;
|
|
762
|
+
try {
|
|
763
|
+
const owner = await readFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), "utf8");
|
|
764
|
+
return owner === token;
|
|
765
|
+
} catch (error) {
|
|
766
|
+
if (getErrorCode(error) !== "ENOENT") return failOpen;
|
|
767
|
+
// Lock directory gone: someone treated us as stale. Owner file missing
|
|
768
|
+
// inside an existing lock: ours only if we never managed to write one.
|
|
769
|
+
try {
|
|
770
|
+
await stat(SHARD_CLEANUP_LOCK_PATH);
|
|
771
|
+
return !writtenLockTokens.has(token);
|
|
772
|
+
} catch {
|
|
773
|
+
return false;
|
|
606
774
|
}
|
|
607
775
|
}
|
|
776
|
+
}
|
|
608
777
|
|
|
778
|
+
export async function releaseStatsMaintenanceLock(token?: string): Promise<void> {
|
|
779
|
+
try {
|
|
780
|
+
// Do not delete a lock another process took over after ours went stale.
|
|
781
|
+
if (token && !(await holdsStatsMaintenanceLock(token, { failOpen: true }))) return;
|
|
782
|
+
await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
|
|
783
|
+
} finally {
|
|
784
|
+
if (token) writtenLockTokens.delete(token);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Heartbeat: refresh the lock directory's mtime so a long maintenance pass is
|
|
790
|
+
* not mistaken for a crashed holder and taken over. A no-op (false) unless
|
|
791
|
+
* `token` still owns the lock. It cannot help while the process is suspended
|
|
792
|
+
* (laptop sleep); archive publishing is a compare-and-swap for that case.
|
|
793
|
+
*/
|
|
794
|
+
export async function touchStatsMaintenanceLock(token: string | undefined): Promise<boolean> {
|
|
795
|
+
if (!token) return false;
|
|
796
|
+
try {
|
|
797
|
+
if (!(await holdsStatsMaintenanceLock(token))) return false;
|
|
798
|
+
const now = new Date();
|
|
799
|
+
await utimes(SHARD_CLEANUP_LOCK_PATH, now, now);
|
|
800
|
+
return true;
|
|
801
|
+
} catch {
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** True when the marker file is missing or older than `intervalMs`. */
|
|
807
|
+
export async function isMaintenanceMarkerDue(path: string, now = Date.now(), intervalMs = SHARD_CLEANUP_INTERVAL_MS): Promise<boolean> {
|
|
808
|
+
try {
|
|
809
|
+
const marker = await stat(path);
|
|
810
|
+
const age = now - marker.mtimeMs;
|
|
811
|
+
// A marker from the future (the clock was set back) must not suspend
|
|
812
|
+
// maintenance until the wall clock reaches it again.
|
|
813
|
+
return age >= intervalMs || age < -intervalMs;
|
|
814
|
+
} catch {
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export async function writeMaintenanceMarker(path: string, now = Date.now()): Promise<void> {
|
|
820
|
+
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
821
|
+
await writeFile(tempPath, `${now}\n`, "utf8");
|
|
822
|
+
await renameWithRetry(tempPath, path);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const RENAME_RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* rename() that retries briefly on the transient errors Windows raises while
|
|
829
|
+
* another process (or an antivirus scanner) holds the target open.
|
|
830
|
+
*/
|
|
831
|
+
export async function renameWithRetry(from: string, to: string, attempts = 5): Promise<void> {
|
|
832
|
+
for (let attempt = 0; ; attempt++) {
|
|
833
|
+
try {
|
|
834
|
+
await rename(from, to);
|
|
835
|
+
return;
|
|
836
|
+
} catch (error) {
|
|
837
|
+
if (attempt >= attempts - 1 || !RENAME_RETRY_CODES.has(getErrorCode(error) ?? "")) throw error;
|
|
838
|
+
await new Promise((resolve) => setTimeout(resolve, 20 * 2 ** attempt));
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Run due retention maintenance; resolves to the number of files removed (0 when skipped). */
|
|
844
|
+
export async function maybeCleanupStatsShardsV7(now = Date.now()): Promise<number> {
|
|
845
|
+
await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
|
|
846
|
+
if (!(await isMaintenanceMarkerDue(SHARD_CLEANUP_MARKER_PATH, now))) return 0;
|
|
847
|
+
const lockToken = await acquireStatsMaintenanceLock(now);
|
|
848
|
+
if (!lockToken) return 0;
|
|
609
849
|
try {
|
|
610
850
|
const removed = await cleanupStatsShardsV7(now);
|
|
611
|
-
|
|
612
|
-
await writeFile(tempPath, `${now}\n`, "utf8");
|
|
613
|
-
await rename(tempPath, SHARD_CLEANUP_MARKER_PATH);
|
|
851
|
+
await writeMaintenanceMarker(SHARD_CLEANUP_MARKER_PATH, now);
|
|
614
852
|
return removed;
|
|
615
853
|
} finally {
|
|
616
|
-
await
|
|
854
|
+
await releaseStatsMaintenanceLock(lockToken);
|
|
617
855
|
}
|
|
618
856
|
}
|