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.
@@ -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
- await writeFile(tempPath, JSON.stringify({ version: 1, epoch, createdAt: Date.now() }, null, 2) + "\n", "utf8");
276
- await rename(tempPath, path);
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 rename(tempPath, path);
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
- type CachedShardRead = {
372
- ino: number | bigint;
373
- size: number;
374
- mtimeMs: number;
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
- const info = await lstat(path);
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.ino === info.ino && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
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, { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, shard, parsed });
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 inode, size, and mtime.
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
- export async function aggregateStatsShardsV7(
490
- shards: PersistedStatsShardV7[],
491
- day: string | null = currentLocalDay(),
492
- ): Promise<ShardAggregate> {
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
- for (const shard of shards) {
510
- if ((day !== null && shard.day !== day) || shard.globalEpoch !== globalEpoch) continue;
511
- let contributed = false;
512
- if (shard.lastRoutedModel && (routedUpdatedAt.get(shard.sessionHash) ?? -1) < shard.lifecycle.updatedAt) {
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
- for (const [key, entry] of Object.entries(shard.models)) {
517
- let epoch = modelEpochs.get(key);
518
- if (!epoch) {
519
- epoch = await readModelStatsEpoch(key);
520
- modelEpochs.set(key, epoch);
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
- if (entry.modelEpoch !== epoch || (day !== null && entry.stats.day !== day)) continue;
523
- contributed = true;
524
- const sessionModels = result.bySession[shard.sessionHash] ??= {};
525
- sessionModels[key] = mergeStatsAcrossDays(sessionModels[key], entry.stats, day);
526
- result.totalsByModel[key] = mergeStatsAcrossDays(result.totalsByModel[key], entry.stats, day);
527
- result.instancesByModel[key] = (result.instancesByModel[key] ?? 0) + 1;
528
- if ((modelRefUpdatedAt.get(key) ?? -1) < shard.lifecycle.updatedAt) {
529
- modelRefUpdatedAt.set(key, shard.lifecycle.updatedAt);
530
- result.modelRefsByKey[key] = {
531
- provider: entry.provider,
532
- id: entry.modelId,
533
- name: entry.modelName ?? entry.modelId,
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
- const sessionInstances = result.instancesBySessionModel[shard.sessionHash] ??= {};
537
- sessionInstances[key] = (sessionInstances[key] ?? 0) + 1;
538
- const sessions = modelSessions.get(key) ?? new Set<string>();
539
- sessions.add(shard.sessionHash);
540
- modelSessions.set(key, sessions);
541
- }
542
- if (contributed) {
543
- result.instancesBySession[shard.sessionHash] = (result.instancesBySession[shard.sessionHash] ?? 0) + 1;
544
- }
545
- }
546
- for (const [key, sessions] of modelSessions) result.sessionsByModel[key] = sessions.size;
547
- return result;
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
- await writeFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), token, "utf8").catch(() => undefined);
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
- if (now - lock.mtimeMs <= SHARD_CLEANUP_LOCK_STALE_MS) return undefined;
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
- /** False only when the lock directory now records a different owner (or is gone). */
650
- export async function holdsStatsMaintenanceLock(token: string | undefined): Promise<boolean> {
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: written by a version without tokens; assume ours.
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 true;
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
- // Do not delete a lock another process took over after ours went stale.
670
- if (token && !(await holdsStatsMaintenanceLock(token))) return;
671
- await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
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
- return now - marker.mtimeMs >= intervalMs;
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
- export async function writeMaintenanceMarker(path: string, now = Date.now()): Promise<void> {
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
- await writeFile(tempPath, `${now}\n`, "utf8");
687
- await renameWithRetry(tempPath, path);
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 briefly on the transient errors Windows raises while
694
- * another process (or an antivirus scanner) holds the target open.
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, attempts = 5): Promise<void> {
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
- if (attempt >= attempts - 1 || !RENAME_RETRY_CODES.has(getErrorCode(error) ?? "")) throw error;
703
- await new Promise((resolve) => setTimeout(resolve, 20 * 2 ** attempt));
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
  }