pi-cache-optimizer 2.9.0 → 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";
@@ -486,10 +486,48 @@ export async function readValidStatsShardsV7(directory: string = SHARD_FILES_DIR
486
486
  return shards;
487
487
  }
488
488
 
489
- export async function aggregateStatsShardsV7(
490
- shards: PersistedStatsShardV7[],
491
- day: string | null = currentLocalDay(),
492
- ): 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> {
493
531
  const globalEpoch = await readGlobalStatsEpoch();
494
532
  const modelEpochs = new Map<string, string>();
495
533
  const result: ShardAggregate = {
@@ -505,46 +543,99 @@ export async function aggregateStatsShardsV7(
505
543
  const routedUpdatedAt = new Map<string, number>();
506
544
  const modelRefUpdatedAt = new Map<string, number>();
507
545
  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;
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);
515
551
  }
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);
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;
521
565
  }
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
- };
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);
535
587
  }
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;
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();
548
639
  }
549
640
 
550
641
  export async function loadStatsShardAggregateV7(directory: string = SHARD_FILES_DIR): Promise<ShardAggregate> {
@@ -613,6 +704,14 @@ export async function cleanupStatsShardsV7(now = Date.now(), directory: string =
613
704
 
614
705
  const SHARD_CLEANUP_LOCK_OWNER_FILE = "owner";
615
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>();
714
+
616
715
  /**
617
716
  * Take the cross-process maintenance lock (a directory, so creation is atomic
618
717
  * on every platform). A lock older than SHARD_CLEANUP_LOCK_STALE_MS is
@@ -626,7 +725,10 @@ export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<str
626
725
  const claim = async (): Promise<string> => {
627
726
  // Best effort: an older version (or a failed write) leaves no owner file,
628
727
  // 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);
728
+ try {
729
+ await writeFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), token, "utf8");
730
+ writtenLockTokens.add(token);
731
+ } catch {}
630
732
  return token;
631
733
  };
632
734
  try {
@@ -636,7 +738,11 @@ export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<str
636
738
  if (getErrorCode(error) !== "EEXIST") return undefined;
637
739
  try {
638
740
  const lock = await stat(SHARD_CLEANUP_LOCK_PATH);
639
- if (now - lock.mtimeMs <= SHARD_CLEANUP_LOCK_STALE_MS) return undefined;
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;
640
746
  await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
641
747
  await mkdir(SHARD_CLEANUP_LOCK_PATH);
642
748
  return await claim();
@@ -646,19 +752,23 @@ export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<str
646
752
  }
647
753
  }
648
754
 
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> {
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> {
651
761
  if (!token) return true;
652
762
  try {
653
763
  const owner = await readFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), "utf8");
654
764
  return owner === token;
655
765
  } catch (error) {
766
+ if (getErrorCode(error) !== "ENOENT") return failOpen;
656
767
  // 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;
768
+ // inside an existing lock: ours only if we never managed to write one.
659
769
  try {
660
770
  await stat(SHARD_CLEANUP_LOCK_PATH);
661
- return true;
771
+ return !writtenLockTokens.has(token);
662
772
  } catch {
663
773
  return false;
664
774
  }
@@ -666,16 +776,41 @@ export async function holdsStatsMaintenanceLock(token: string | undefined): Prom
666
776
  }
667
777
 
668
778
  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 });
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
+ }
672
804
  }
673
805
 
674
806
  /** True when the marker file is missing or older than `intervalMs`. */
675
807
  export async function isMaintenanceMarkerDue(path: string, now = Date.now(), intervalMs = SHARD_CLEANUP_INTERVAL_MS): Promise<boolean> {
676
808
  try {
677
809
  const marker = await stat(path);
678
- return now - marker.mtimeMs >= intervalMs;
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;
679
814
  } catch {
680
815
  return true;
681
816
  }