pi-cache-optimizer 2.9.0 → 2.9.2
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 +5 -1
- package/README.zh-CN.md +5 -1
- package/index.ts +107 -14
- package/package.json +1 -1
- package/src/stats-archive.ts +1350 -178
- package/src/stats-store.ts +263 -72
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";
|
|
@@ -272,8 +272,15 @@ export async function readModelStatsEpochStrict(modelKeyValue: string): Promise<
|
|
|
272
272
|
export async function writeStatsEpoch(path: string, epoch: string): Promise<void> {
|
|
273
273
|
await mkdir(dirname(path), { recursive: true });
|
|
274
274
|
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
275
|
-
|
|
276
|
-
|
|
275
|
+
try {
|
|
276
|
+
await writeFile(tempPath, JSON.stringify({ version: 1, epoch, createdAt: Date.now() }, null, 2) + "\n", "utf8");
|
|
277
|
+
// Compaction reads every epoch file strictly, so on Windows a reset can
|
|
278
|
+
// meet a reader (or a scanner) holding the target: retry like the shards.
|
|
279
|
+
await renameWithRetry(tempPath, path);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
await unlink(tempPath).catch(() => undefined);
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
277
284
|
}
|
|
278
285
|
|
|
279
286
|
export async function advanceGlobalStatsEpoch(): Promise<string> {
|
|
@@ -360,7 +367,7 @@ export async function writeStatsShardV7(path: string, shard: PersistedStatsShard
|
|
|
360
367
|
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
361
368
|
try {
|
|
362
369
|
await writeFile(tempPath, JSON.stringify(shard, null, 2) + "\n", "utf8");
|
|
363
|
-
await
|
|
370
|
+
await renameWithRetry(tempPath, path);
|
|
364
371
|
} catch (error) {
|
|
365
372
|
// Do not leave an orphaned temp file behind until the next cleanup pass.
|
|
366
373
|
await unlink(tempPath).catch(() => undefined);
|
|
@@ -368,10 +375,38 @@ export async function writeStatsShardV7(path: string, shard: PersistedStatsShard
|
|
|
368
375
|
}
|
|
369
376
|
}
|
|
370
377
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
378
|
+
/**
|
|
379
|
+
* What a parse cache compares to decide a file is unchanged. Besides inode,
|
|
380
|
+
* size and mtime it includes ctime and birthtime: on Windows the inode is a
|
|
381
|
+
* 64-bit file id that loses precision as a double, FAT/exFAT and some
|
|
382
|
+
* network drives report 0, and FAT mtimes have a 2 s resolution, so a
|
|
383
|
+
* same-size rewrite (a new file renamed into place) could otherwise look
|
|
384
|
+
* unchanged. A renamed-in file always has a new ctime/birthtime.
|
|
385
|
+
*/
|
|
386
|
+
export type StatsFileIdentity = { ino: number | bigint; size: number; mtimeMs: number; ctimeMs: number; birthtimeMs: number };
|
|
387
|
+
|
|
388
|
+
export function statsFileIdentityOf(info: import("node:fs").Stats): StatsFileIdentity {
|
|
389
|
+
return { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, birthtimeMs: info.birthtimeMs };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function sameStatsFileIdentity(cached: StatsFileIdentity, info: import("node:fs").Stats): boolean {
|
|
393
|
+
return cached.ino === info.ino && cached.size === info.size && cached.mtimeMs === info.mtimeMs &&
|
|
394
|
+
cached.ctimeMs === info.ctimeMs && cached.birthtimeMs === info.birthtimeMs;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Test hooks for filesystem faults; production never sets them. */
|
|
398
|
+
export type StatsFsTestHooks = {
|
|
399
|
+
/** Runs before every rename attempt in renameWithRetry; throw to simulate a Windows sharing violation. */
|
|
400
|
+
beforeRename?: (from: string, to: string) => void | Promise<void>;
|
|
401
|
+
/** Rewrites the stats a shard read sees (e.g. ino 0 and a coarse mtime). */
|
|
402
|
+
shardStat?: (path: string, info: import("node:fs").Stats) => import("node:fs").Stats;
|
|
403
|
+
};
|
|
404
|
+
let statsFsTestHooks: StatsFsTestHooks = {};
|
|
405
|
+
export function setStatsFsTestHooks(hooks: StatsFsTestHooks | undefined): void {
|
|
406
|
+
statsFsTestHooks = hooks ?? {};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
type CachedShardRead = StatsFileIdentity & {
|
|
375
410
|
/** Valid shard whose embedded instanceId matches its filename. */
|
|
376
411
|
shard: PersistedStatsShardV7 | undefined;
|
|
377
412
|
/** Schema-valid parse regardless of filename match (used by retention). */
|
|
@@ -421,19 +456,20 @@ type ShardFileRead = {
|
|
|
421
456
|
*/
|
|
422
457
|
async function readShardFileCached(path: string, name: string): Promise<ShardFileRead | undefined> {
|
|
423
458
|
try {
|
|
424
|
-
|
|
459
|
+
let info = await lstat(path);
|
|
460
|
+
if (statsFsTestHooks.shardStat) info = statsFsTestHooks.shardStat(path, info);
|
|
425
461
|
if (!info.isFile() || info.isSymbolicLink()) {
|
|
426
462
|
shardReadCache.delete(path);
|
|
427
463
|
return undefined;
|
|
428
464
|
}
|
|
429
465
|
const cached = shardReadCache.get(path);
|
|
430
|
-
if (cached && cached
|
|
466
|
+
if (cached && sameStatsFileIdentity(cached, info)) {
|
|
431
467
|
return { info, shard: cached.shard, parsed: cached.parsed };
|
|
432
468
|
}
|
|
433
469
|
const filenameInstanceId = name.slice(0, -".json".length);
|
|
434
470
|
const parsed = parsePersistedStatsShardV7(JSON.parse(await readFile(path, "utf8")));
|
|
435
471
|
const shard = parsed && parsed.instanceId === filenameInstanceId ? parsed : undefined;
|
|
436
|
-
shardReadCache.set(path, {
|
|
472
|
+
shardReadCache.set(path, { ...statsFileIdentityOf(info), shard, parsed });
|
|
437
473
|
return { info, shard, parsed };
|
|
438
474
|
} catch (error) {
|
|
439
475
|
shardReadCache.delete(path);
|
|
@@ -442,7 +478,7 @@ async function readShardFileCached(path: string, name: string): Promise<ShardFil
|
|
|
442
478
|
}
|
|
443
479
|
|
|
444
480
|
/**
|
|
445
|
-
* Parsed-shard cache keyed by path and validated by
|
|
481
|
+
* Parsed-shard cache keyed by path and validated by the file identity.
|
|
446
482
|
* Writers always publish through temp-file + rename, so an updated shard gets
|
|
447
483
|
* a new inode; unchanged shards are not re-read or re-parsed on every refresh.
|
|
448
484
|
*/
|
|
@@ -486,10 +522,48 @@ export async function readValidStatsShardsV7(directory: string = SHARD_FILES_DIR
|
|
|
486
522
|
return shards;
|
|
487
523
|
}
|
|
488
524
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
525
|
+
/**
|
|
526
|
+
* Pre-reduced counters from many shards (the stats archive), already filtered
|
|
527
|
+
* to the current global and model epochs. Merging one into an aggregator
|
|
528
|
+
* gives the same result as feeding it the shards it was reduced from, at that
|
|
529
|
+
* position of the shard sequence. Only valid for the all-days (`day === null`)
|
|
530
|
+
* aggregate. Entries are listed in the order their first shard contributed,
|
|
531
|
+
* so key order in the result matches too. Treat as read-only.
|
|
532
|
+
*/
|
|
533
|
+
export type ShardContribution = {
|
|
534
|
+
sessions: ReadonlyArray<{
|
|
535
|
+
sessionHash: string;
|
|
536
|
+
/** Contributing shards of this session (each counts once in instancesBySession). */
|
|
537
|
+
instances: number;
|
|
538
|
+
models: ReadonlyArray<{ key: string; stats: CacheStats; instances: number }>;
|
|
539
|
+
}>;
|
|
540
|
+
models: ReadonlyArray<{
|
|
541
|
+
key: string;
|
|
542
|
+
stats: CacheStats;
|
|
543
|
+
instances: number;
|
|
544
|
+
sessions: readonly string[];
|
|
545
|
+
}>;
|
|
546
|
+
/**
|
|
547
|
+
* Per model, the first shard with the newest lifecycle.updatedAt that
|
|
548
|
+
* carried it, in the order an aggregation would first record each one.
|
|
549
|
+
*/
|
|
550
|
+
refs: ReadonlyArray<{ key: string; updatedAt: number; ref: PersistedRoutedModelRef }>;
|
|
551
|
+
/** Per session, the first entry with the newest updatedAt. */
|
|
552
|
+
routed: ReadonlyArray<{ sessionHash: string; updatedAt: number; model: PersistedRoutedModelRef }>;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
export type ShardAggregator = {
|
|
556
|
+
readonly day: string | null;
|
|
557
|
+
readonly globalEpoch: string;
|
|
558
|
+
/** The model epoch this aggregation filters `key` with (read once per aggregation). */
|
|
559
|
+
modelEpoch(key: string): Promise<string>;
|
|
560
|
+
add(shard: PersistedStatsShardV7): Promise<void>;
|
|
561
|
+
addContribution(contribution: ShardContribution): void;
|
|
562
|
+
finish(): ShardAggregate;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
/** Incremental form of aggregateStatsShardsV7: add shards (and pre-reduced contributions) in order, then finish. */
|
|
566
|
+
export async function createShardAggregatorV7(day: string | null = currentLocalDay()): Promise<ShardAggregator> {
|
|
493
567
|
const globalEpoch = await readGlobalStatsEpoch();
|
|
494
568
|
const modelEpochs = new Map<string, string>();
|
|
495
569
|
const result: ShardAggregate = {
|
|
@@ -505,46 +579,99 @@ export async function aggregateStatsShardsV7(
|
|
|
505
579
|
const routedUpdatedAt = new Map<string, number>();
|
|
506
580
|
const modelRefUpdatedAt = new Map<string, number>();
|
|
507
581
|
const modelSessions = new Map<string, Set<string>>();
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
routedUpdatedAt.set(shard.sessionHash, shard.lifecycle.updatedAt);
|
|
514
|
-
result.lastRoutedModelBySession[shard.sessionHash] = shard.lastRoutedModel;
|
|
582
|
+
const modelEpoch = async (key: string): Promise<string> => {
|
|
583
|
+
let epoch = modelEpochs.get(key);
|
|
584
|
+
if (!epoch) {
|
|
585
|
+
epoch = await readModelStatsEpoch(key);
|
|
586
|
+
modelEpochs.set(key, epoch);
|
|
515
587
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
588
|
+
return epoch;
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
day,
|
|
593
|
+
globalEpoch,
|
|
594
|
+
modelEpoch,
|
|
595
|
+
async add(shard) {
|
|
596
|
+
if ((day !== null && shard.day !== day) || shard.globalEpoch !== globalEpoch) return;
|
|
597
|
+
let contributed = false;
|
|
598
|
+
if (shard.lastRoutedModel && (routedUpdatedAt.get(shard.sessionHash) ?? -1) < shard.lifecycle.updatedAt) {
|
|
599
|
+
routedUpdatedAt.set(shard.sessionHash, shard.lifecycle.updatedAt);
|
|
600
|
+
result.lastRoutedModelBySession[shard.sessionHash] = shard.lastRoutedModel;
|
|
521
601
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
602
|
+
for (const [key, entry] of Object.entries(shard.models)) {
|
|
603
|
+
const epoch = modelEpochs.get(key) ?? await modelEpoch(key);
|
|
604
|
+
if (entry.modelEpoch !== epoch || (day !== null && entry.stats.day !== day)) continue;
|
|
605
|
+
contributed = true;
|
|
606
|
+
const sessionModels = result.bySession[shard.sessionHash] ??= {};
|
|
607
|
+
sessionModels[key] = mergeStatsAcrossDays(sessionModels[key], entry.stats, day);
|
|
608
|
+
result.totalsByModel[key] = mergeStatsAcrossDays(result.totalsByModel[key], entry.stats, day);
|
|
609
|
+
result.instancesByModel[key] = (result.instancesByModel[key] ?? 0) + 1;
|
|
610
|
+
if ((modelRefUpdatedAt.get(key) ?? -1) < shard.lifecycle.updatedAt) {
|
|
611
|
+
modelRefUpdatedAt.set(key, shard.lifecycle.updatedAt);
|
|
612
|
+
result.modelRefsByKey[key] = {
|
|
613
|
+
provider: entry.provider,
|
|
614
|
+
id: entry.modelId,
|
|
615
|
+
name: entry.modelName ?? entry.modelId,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
const sessionInstances = result.instancesBySessionModel[shard.sessionHash] ??= {};
|
|
619
|
+
sessionInstances[key] = (sessionInstances[key] ?? 0) + 1;
|
|
620
|
+
const sessions = modelSessions.get(key) ?? new Set<string>();
|
|
621
|
+
sessions.add(shard.sessionHash);
|
|
622
|
+
modelSessions.set(key, sessions);
|
|
535
623
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
624
|
+
if (contributed) {
|
|
625
|
+
result.instancesBySession[shard.sessionHash] = (result.instancesBySession[shard.sessionHash] ?? 0) + 1;
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
addContribution(contribution) {
|
|
629
|
+
if (day !== null) throw new Error("shard contributions only apply to the all-days aggregate");
|
|
630
|
+
for (const entry of contribution.routed) {
|
|
631
|
+
if ((routedUpdatedAt.get(entry.sessionHash) ?? -1) < entry.updatedAt) {
|
|
632
|
+
routedUpdatedAt.set(entry.sessionHash, entry.updatedAt);
|
|
633
|
+
result.lastRoutedModelBySession[entry.sessionHash] = { ...entry.model };
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
for (const model of contribution.models) {
|
|
637
|
+
// mergeStatsAcrossDays clones on first insert, so the shared
|
|
638
|
+
// contribution is never handed out (callers mutate the aggregate).
|
|
639
|
+
result.totalsByModel[model.key] = mergeStatsAcrossDays(result.totalsByModel[model.key], model.stats, null);
|
|
640
|
+
result.instancesByModel[model.key] = (result.instancesByModel[model.key] ?? 0) + model.instances;
|
|
641
|
+
const sessions = modelSessions.get(model.key) ?? new Set<string>();
|
|
642
|
+
for (const sessionHash of model.sessions) sessions.add(sessionHash);
|
|
643
|
+
modelSessions.set(model.key, sessions);
|
|
644
|
+
}
|
|
645
|
+
for (const entry of contribution.refs) {
|
|
646
|
+
if ((modelRefUpdatedAt.get(entry.key) ?? -1) < entry.updatedAt) {
|
|
647
|
+
modelRefUpdatedAt.set(entry.key, entry.updatedAt);
|
|
648
|
+
result.modelRefsByKey[entry.key] = { ...entry.ref };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
for (const session of contribution.sessions) {
|
|
652
|
+
const sessionModels = result.bySession[session.sessionHash] ??= {};
|
|
653
|
+
const sessionInstances = result.instancesBySessionModel[session.sessionHash] ??= {};
|
|
654
|
+
for (const model of session.models) {
|
|
655
|
+
sessionModels[model.key] = mergeStatsAcrossDays(sessionModels[model.key], model.stats, null);
|
|
656
|
+
sessionInstances[model.key] = (sessionInstances[model.key] ?? 0) + model.instances;
|
|
657
|
+
}
|
|
658
|
+
result.instancesBySession[session.sessionHash] = (result.instancesBySession[session.sessionHash] ?? 0) + session.instances;
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
finish() {
|
|
662
|
+
for (const [key, sessions] of modelSessions) result.sessionsByModel[key] = sessions.size;
|
|
663
|
+
return result;
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export async function aggregateStatsShardsV7(
|
|
669
|
+
shards: PersistedStatsShardV7[],
|
|
670
|
+
day: string | null = currentLocalDay(),
|
|
671
|
+
): Promise<ShardAggregate> {
|
|
672
|
+
const aggregator = await createShardAggregatorV7(day);
|
|
673
|
+
for (const shard of shards) await aggregator.add(shard);
|
|
674
|
+
return aggregator.finish();
|
|
548
675
|
}
|
|
549
676
|
|
|
550
677
|
export async function loadStatsShardAggregateV7(directory: string = SHARD_FILES_DIR): Promise<ShardAggregate> {
|
|
@@ -613,6 +740,14 @@ export async function cleanupStatsShardsV7(now = Date.now(), directory: string =
|
|
|
613
740
|
|
|
614
741
|
const SHARD_CLEANUP_LOCK_OWNER_FILE = "owner";
|
|
615
742
|
|
|
743
|
+
/**
|
|
744
|
+
* Tokens whose owner file this process actually wrote. For those, a lock
|
|
745
|
+
* directory without an owner file can only mean the directory was replaced
|
|
746
|
+
* (a 2.8.x stale-lock takeover writes none, and a 2.9 takeover is caught
|
|
747
|
+
* between its mkdir and its owner write), so ownership is lost.
|
|
748
|
+
*/
|
|
749
|
+
const writtenLockTokens = new Set<string>();
|
|
750
|
+
|
|
616
751
|
/**
|
|
617
752
|
* Take the cross-process maintenance lock (a directory, so creation is atomic
|
|
618
753
|
* on every platform). A lock older than SHARD_CLEANUP_LOCK_STALE_MS is
|
|
@@ -626,7 +761,10 @@ export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<str
|
|
|
626
761
|
const claim = async (): Promise<string> => {
|
|
627
762
|
// Best effort: an older version (or a failed write) leaves no owner file,
|
|
628
763
|
// which only disables the ownership check, never the lock itself.
|
|
629
|
-
|
|
764
|
+
try {
|
|
765
|
+
await writeFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), token, "utf8");
|
|
766
|
+
writtenLockTokens.add(token);
|
|
767
|
+
} catch {}
|
|
630
768
|
return token;
|
|
631
769
|
};
|
|
632
770
|
try {
|
|
@@ -636,7 +774,11 @@ export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<str
|
|
|
636
774
|
if (getErrorCode(error) !== "EEXIST") return undefined;
|
|
637
775
|
try {
|
|
638
776
|
const lock = await stat(SHARD_CLEANUP_LOCK_PATH);
|
|
639
|
-
|
|
777
|
+
// A lock dated more than the stale window in the future was left by a
|
|
778
|
+
// crash while the clock ran ahead; without this it would block
|
|
779
|
+
// maintenance until the wall clock caught up (possibly for months).
|
|
780
|
+
const age = now - lock.mtimeMs;
|
|
781
|
+
if (age <= SHARD_CLEANUP_LOCK_STALE_MS && age >= -SHARD_CLEANUP_LOCK_STALE_MS) return undefined;
|
|
640
782
|
await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
|
|
641
783
|
await mkdir(SHARD_CLEANUP_LOCK_PATH);
|
|
642
784
|
return await claim();
|
|
@@ -646,19 +788,23 @@ export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<str
|
|
|
646
788
|
}
|
|
647
789
|
}
|
|
648
790
|
|
|
649
|
-
/**
|
|
650
|
-
|
|
791
|
+
/**
|
|
792
|
+
* False when the lock directory records a different owner, is gone, or lost
|
|
793
|
+
* the owner file this holder wrote. An unreadable owner file fails closed
|
|
794
|
+
* (destructive publishes are skipped and retried) unless `failOpen` is set.
|
|
795
|
+
*/
|
|
796
|
+
export async function holdsStatsMaintenanceLock(token: string | undefined, { failOpen = false }: { failOpen?: boolean } = {}): Promise<boolean> {
|
|
651
797
|
if (!token) return true;
|
|
652
798
|
try {
|
|
653
799
|
const owner = await readFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), "utf8");
|
|
654
800
|
return owner === token;
|
|
655
801
|
} catch (error) {
|
|
802
|
+
if (getErrorCode(error) !== "ENOENT") return failOpen;
|
|
656
803
|
// Lock directory gone: someone treated us as stale. Owner file missing
|
|
657
|
-
// inside an existing lock:
|
|
658
|
-
if (getErrorCode(error) !== "ENOENT") return true;
|
|
804
|
+
// inside an existing lock: ours only if we never managed to write one.
|
|
659
805
|
try {
|
|
660
806
|
await stat(SHARD_CLEANUP_LOCK_PATH);
|
|
661
|
-
return
|
|
807
|
+
return !writtenLockTokens.has(token);
|
|
662
808
|
} catch {
|
|
663
809
|
return false;
|
|
664
810
|
}
|
|
@@ -666,41 +812,86 @@ export async function holdsStatsMaintenanceLock(token: string | undefined): Prom
|
|
|
666
812
|
}
|
|
667
813
|
|
|
668
814
|
export async function releaseStatsMaintenanceLock(token?: string): Promise<void> {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
815
|
+
try {
|
|
816
|
+
// Do not delete a lock another process took over after ours went stale.
|
|
817
|
+
if (token && !(await holdsStatsMaintenanceLock(token, { failOpen: true }))) return;
|
|
818
|
+
await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
|
|
819
|
+
} finally {
|
|
820
|
+
if (token) writtenLockTokens.delete(token);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Heartbeat: refresh the lock directory's mtime so a long maintenance pass is
|
|
826
|
+
* not mistaken for a crashed holder and taken over. A no-op (false) unless
|
|
827
|
+
* `token` still owns the lock. It cannot help while the process is suspended
|
|
828
|
+
* (laptop sleep); archive publishing is a compare-and-swap for that case.
|
|
829
|
+
*/
|
|
830
|
+
export async function touchStatsMaintenanceLock(token: string | undefined): Promise<boolean> {
|
|
831
|
+
if (!token) return false;
|
|
832
|
+
try {
|
|
833
|
+
if (!(await holdsStatsMaintenanceLock(token))) return false;
|
|
834
|
+
const now = new Date();
|
|
835
|
+
await utimes(SHARD_CLEANUP_LOCK_PATH, now, now);
|
|
836
|
+
return true;
|
|
837
|
+
} catch {
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
672
840
|
}
|
|
673
841
|
|
|
674
842
|
/** True when the marker file is missing or older than `intervalMs`. */
|
|
675
843
|
export async function isMaintenanceMarkerDue(path: string, now = Date.now(), intervalMs = SHARD_CLEANUP_INTERVAL_MS): Promise<boolean> {
|
|
676
844
|
try {
|
|
677
845
|
const marker = await stat(path);
|
|
678
|
-
|
|
846
|
+
const age = now - marker.mtimeMs;
|
|
847
|
+
// A marker from the future (the clock was set back) must not suspend
|
|
848
|
+
// maintenance until the wall clock reaches it again.
|
|
849
|
+
return age >= intervalMs || age < -intervalMs;
|
|
679
850
|
} catch {
|
|
680
851
|
return true;
|
|
681
852
|
}
|
|
682
853
|
}
|
|
683
854
|
|
|
684
|
-
|
|
855
|
+
/**
|
|
856
|
+
* Write a maintenance marker. `markedAt` (default `now`) becomes its mtime,
|
|
857
|
+
* which isMaintenanceMarkerDue measures: a marker dated earlier makes the
|
|
858
|
+
* next run due sooner (a failed pass retries after a short backoff).
|
|
859
|
+
*/
|
|
860
|
+
export async function writeMaintenanceMarker(path: string, now = Date.now(), markedAt = now): Promise<void> {
|
|
685
861
|
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
686
|
-
|
|
687
|
-
|
|
862
|
+
try {
|
|
863
|
+
await writeFile(tempPath, `${now}\n`, "utf8");
|
|
864
|
+
if (markedAt !== now) await utimes(tempPath, new Date(markedAt), new Date(markedAt));
|
|
865
|
+
await renameWithRetry(tempPath, path);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
await unlink(tempPath).catch(() => undefined);
|
|
868
|
+
throw error;
|
|
869
|
+
}
|
|
688
870
|
}
|
|
689
871
|
|
|
690
872
|
const RENAME_RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
691
873
|
|
|
874
|
+
/** Default total time renameWithRetry keeps retrying (a Defender scan easily holds a file for over a second). */
|
|
875
|
+
export const RENAME_RETRY_BUDGET_MS = 2_000;
|
|
876
|
+
const RENAME_RETRY_MAX_DELAY_MS = 250;
|
|
877
|
+
|
|
692
878
|
/**
|
|
693
|
-
* rename() that retries
|
|
694
|
-
*
|
|
879
|
+
* rename() that retries on the transient errors Windows raises while another
|
|
880
|
+
* process (or an antivirus scanner) holds the source or target open: backoff
|
|
881
|
+
* from 20 ms up to 250 ms per attempt, for `budgetMs` in total.
|
|
695
882
|
*/
|
|
696
|
-
export async function renameWithRetry(from: string, to: string,
|
|
883
|
+
export async function renameWithRetry(from: string, to: string, budgetMs = RENAME_RETRY_BUDGET_MS): Promise<void> {
|
|
884
|
+
const deadline = Date.now() + budgetMs;
|
|
697
885
|
for (let attempt = 0; ; attempt++) {
|
|
698
886
|
try {
|
|
887
|
+
await statsFsTestHooks.beforeRename?.(from, to);
|
|
699
888
|
await rename(from, to);
|
|
700
889
|
return;
|
|
701
890
|
} catch (error) {
|
|
702
|
-
|
|
703
|
-
|
|
891
|
+
const remaining = deadline - Date.now();
|
|
892
|
+
if (remaining <= 0 || !RENAME_RETRY_CODES.has(getErrorCode(error) ?? "")) throw error;
|
|
893
|
+
const delay = Math.min(20 * 2 ** attempt, RENAME_RETRY_MAX_DELAY_MS, remaining);
|
|
894
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
704
895
|
}
|
|
705
896
|
}
|
|
706
897
|
}
|