pi-cache-optimizer 2.9.1 → 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.
@@ -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
  */
@@ -816,26 +852,46 @@ export async function isMaintenanceMarkerDue(path: string, now = Date.now(), int
816
852
  }
817
853
  }
818
854
 
819
- 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> {
820
861
  const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
821
- await writeFile(tempPath, `${now}\n`, "utf8");
822
- 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
+ }
823
870
  }
824
871
 
825
872
  const RENAME_RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
826
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
+
827
878
  /**
828
- * rename() that retries briefly on the transient errors Windows raises while
829
- * 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.
830
882
  */
831
- 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;
832
885
  for (let attempt = 0; ; attempt++) {
833
886
  try {
887
+ await statsFsTestHooks.beforeRename?.(from, to);
834
888
  await rename(from, to);
835
889
  return;
836
890
  } 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));
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));
839
895
  }
840
896
  }
841
897
  }