pi-cache-optimizer 2.9.1 → 2.9.3
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 -2
- package/README.zh-CN.md +4 -2
- package/index.ts +104 -10
- package/package.json +1 -1
- package/src/stats-archive.ts +697 -160
- package/src/stats-store.ts +81 -19
package/src/stats-store.ts
CHANGED
|
@@ -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
|
*/
|
|
@@ -816,26 +852,52 @@ export async function isMaintenanceMarkerDue(path: string, now = Date.now(), int
|
|
|
816
852
|
}
|
|
817
853
|
}
|
|
818
854
|
|
|
819
|
-
|
|
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
|
-
|
|
822
|
-
|
|
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
|
|
829
|
-
*
|
|
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,
|
|
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
|
-
|
|
838
|
-
|
|
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
|
+
// Deliberately not unref'd: every caller awaits this rename (a Pi
|
|
895
|
+
// handler, or the restore that session_shutdown awaits), and Pi's
|
|
896
|
+
// print mode exits by letting the event loop drain. An unref'd delay
|
|
897
|
+
// would let the process exit in the middle of an awaited handler,
|
|
898
|
+
// skipping session_shutdown and its final flush. The delay is bounded
|
|
899
|
+
// by budgetMs, and interactive mode exits with process.exit anyway.
|
|
900
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delay));
|
|
839
901
|
}
|
|
840
902
|
}
|
|
841
903
|
}
|