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.
@@ -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
- export async function aggregateStatsShardsV7(
463
- shards: PersistedStatsShardV7[],
464
- day: string | null = currentLocalDay(),
465
- ): Promise<ShardAggregate> {
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
- for (const shard of shards) {
483
- if ((day !== null && shard.day !== day) || shard.globalEpoch !== globalEpoch) continue;
484
- let contributed = false;
485
- if (shard.lastRoutedModel && (routedUpdatedAt.get(shard.sessionHash) ?? -1) < shard.lifecycle.updatedAt) {
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
- for (const [key, entry] of Object.entries(shard.models)) {
490
- let epoch = modelEpochs.get(key);
491
- if (!epoch) {
492
- epoch = await readModelStatsEpoch(key);
493
- modelEpochs.set(key, epoch);
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
- if (entry.modelEpoch !== epoch || (day !== null && entry.stats.day !== day)) continue;
496
- contributed = true;
497
- const sessionModels = result.bySession[shard.sessionHash] ??= {};
498
- sessionModels[key] = mergeStatsAcrossDays(sessionModels[key], entry.stats, day);
499
- result.totalsByModel[key] = mergeStatsAcrossDays(result.totalsByModel[key], entry.stats, day);
500
- result.instancesByModel[key] = (result.instancesByModel[key] ?? 0) + 1;
501
- if ((modelRefUpdatedAt.get(key) ?? -1) < shard.lifecycle.updatedAt) {
502
- modelRefUpdatedAt.set(key, shard.lifecycle.updatedAt);
503
- result.modelRefsByKey[key] = {
504
- provider: entry.provider,
505
- id: entry.modelId,
506
- name: entry.modelName ?? entry.modelId,
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
- const sessionInstances = result.instancesBySessionModel[shard.sessionHash] ??= {};
510
- sessionInstances[key] = (sessionInstances[key] ?? 0) + 1;
511
- const sessions = modelSessions.get(key) ?? new Set<string>();
512
- sessions.add(shard.sessionHash);
513
- modelSessions.set(key, sessions);
514
- }
515
- if (contributed) {
516
- result.instancesBySession[shard.sessionHash] = (result.instancesBySession[shard.sessionHash] ?? 0) + 1;
517
- }
518
- }
519
- for (const [key, sessions] of modelSessions) result.sessionsByModel[key] = sessions.size;
520
- return result;
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
- /** Run due retention maintenance; resolves to the number of files removed (0 when skipped). */
588
- export async function maybeCleanupStatsShardsV7(now = Date.now()): Promise<number> {
589
- await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
590
- try {
591
- const marker = await stat(SHARD_CLEANUP_MARKER_PATH);
592
- if (now - marker.mtimeMs < SHARD_CLEANUP_INTERVAL_MS) return 0;
593
- } catch {}
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 0;
738
+ if (getErrorCode(error) !== "EEXIST") return undefined;
599
739
  try {
600
740
  const lock = await stat(SHARD_CLEANUP_LOCK_PATH);
601
- if (now - lock.mtimeMs <= SHARD_CLEANUP_LOCK_STALE_MS) return 0;
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 0;
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
- const tempPath = `${SHARD_CLEANUP_MARKER_PATH}.${process.pid}.${randomUUID()}.tmp`;
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 rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
854
+ await releaseStatsMaintenanceLock(lockToken);
617
855
  }
618
856
  }