pi-cache-optimizer 2.8.26 → 2.9.0

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 CHANGED
@@ -137,6 +137,8 @@ The fixture verifier reports numeric tool-order changes and confirms cache-marke
137
137
 
138
138
  Current versions store stats under `pi-cache-optimizer-stats.d/shards/` in Pi's agent directory. Each loaded extension instance owns one active UUID-named daily shard and writes it through temp-file + atomic rename; at local midnight it closes the old shard and starts a new UUID without erasing session history. This prevents parent/child/parallel Pi processes from overwriting one another. Upgrading from the old v6 single-file format deletes the old local stats files and starts footer counters from zero; upstream provider caches are not affected.
139
139
 
140
+ Since 2.9.0, maintenance (at most every six hours, under the shared maintenance lock, in the background) folds closed past-day shards, and active ones whose process has been gone for over 24 hours, into a single snapshot at `pi-cache-optimizer-stats.d/archive/archive.json`, then deletes the originals. The shard directory therefore only holds roughly today's shards, so startup and refresh cost no longer grows with the 60-day retention window. Archived counters keep their reset epochs, so `reset` hides them immediately, and they expire on the same 60-day window. The archive records which shards it absorbed, so a crash mid-compaction never double counts. Older versions ignore the archive: if you downgrade or run an older version alongside, its `session` footer shows only history that has not been archived yet; current-day `total` and `process` counters are unaffected.
141
+
140
142
  Response stats stay with the dispatched catalog/request-local model even if you switch to another model on the same provider while waiting. Legacy response aliases use the request snapshot, not the new selection; the footer continues showing the selected direct model. Old corrupt JSON/schema shards expire by file modification time after two months; young corrupt files, valid current-day shards, live old instances, and symlink targets remain protected.
141
143
 
142
144
  The footer defaults to `session`, which reflects the current Pi conversation rather than another parallel Pi terminal using the same provider/model. Use either the command or environment variable to select the scope:
package/README.zh-CN.md CHANGED
@@ -137,6 +137,8 @@ bun .trellis/tasks/09-03-context-epoch-tool-ordering/verify.ts
137
137
 
138
138
  当前版本把统计保存在 Pi agent 目录的 `pi-cache-optimizer-stats.d/shards/` 下。每个已加载的 extension instance 独占一个当天的活跃 UUID shard,并通过临时文件 + 原子 rename 写入;本地日期切换时关闭旧 shard、创建新的 UUID,保留 session 历史,因此父/子/并行 Pi 进程不会互相覆盖。旧 v6 单文件统计在升级时直接删除,本地 footer 计数从零开始;不会影响上游 provider 的实际缓存。
139
139
 
140
+ 从 2.9.0 起,维护任务会把已关闭的往日 shard,以及进程已退出超过 24 小时的活跃 shard,合并进 `pi-cache-optimizer-stats.d/archive/archive.json` 这一份快照,再删除原文件。维护最多每 6 小时一次,在共享维护锁下后台运行。这样 shard 目录里基本只剩当天的 shard,启动和刷新的开销不再随 60 天保留期增长。归档计数保留 reset epoch,`reset` 立即生效,并按同样的 60 天窗口过期。归档会记录已吸收的 shard,所以合并中途崩溃也不会重复计数。旧版本不会读取归档:如果降级或与旧版本同时运行,旧版的 `session` footer 只显示尚未归档的历史;当天的 `total` 和 `process` 计数不受影响。
141
+
140
142
  即使等待响应时切换到同一 provider 的另一个模型,响应统计仍归属于实际请求的 catalog/request-local 模型。旧 transport 的响应别名只根据请求快照处理,不使用新选择的模型;footer 继续显示当前选择的 direct 模型。损坏 JSON/schema 的旧 shard 按文件修改时间在两个月后清理;较新的损坏文件、有效的当天 shard、仍存活的旧 instance 和 symlink 目标均受保护。
141
143
 
142
144
  Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 provider/model 时污染当前窗口。可以通过命令或环境变量切换显示范围:
package/index.ts CHANGED
@@ -13,7 +13,8 @@ import { atomicCreateTextFileNoReplace, atomicReplaceTextFilePreservingMode, ato
13
13
  import { getAssistantMessageModelTokenValues, getModelIdNameTokenValues, isAdaptiveGenerationModel, isKimiCodingAdaptiveModel } from "./src/model-detect.ts";
14
14
  import { findLastExactModelDefinition, mergeCacheCompat, resolveEffectiveCompatFromConfig } from "./src/compat-config.ts";
15
15
  import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, chooseFixPlacement, composeFixInsertion, composeMissingEntryInsertion, composeModelOverrideInsertion, composeModelsJsonReceiptRollback, composeProviderAffinityInsertion, createModelsJsonFixReceipt, createRollbackBackupPath, decideFixPlacement, formatCompatKeysForInsertion, hasExplicitLongRetentionOptInFromConfig, hasReceiptReasoningProtocolChange, isActionableModelsJsonFixReceipt, locateModelInJsonc, markModelsJsonFixReceiptRolledBack, parseModelsJsonFixReceipt, prepareModelsJsonRollback, readModelsJsonFixReceipt, readModelsJsonFixReceiptSnapshot, receiptBackupPath, resolveExplicitCompatValue, selfCheckFix, selfCheckMissingEntryInsertion, validateModelsJsonRollback, writeModelsJsonFixReceipt } from "./src/models-json-fix.ts";
16
- import { type CacheProviderId, type CacheStats, LEGACY_STATE_FILE_PATH, type PersistedRoutedModelRef, type PersistedStatsShardV7, SHARD_FILES_DIR, SHARD_GLOBAL_EPOCH_PATH, SHARD_STATE_DIR, STATE_FILE_PATH, type ShardAggregate, type UsageSnapshot, addUsageToCacheStats, advanceGlobalStatsEpoch, advanceModelStatsEpoch, aggregateStatsShardsV7, cleanupStatsShardsV7, cloneCacheStats, currentLocalDay, emptyAllCacheStats, emptyCacheStats, initialEpoch, loadStatsShardAggregateV7, maybeCleanupStatsShardsV7, modelEpochPath, parseCacheStats, parsePersistedRoutedModelRef, parsePersistedStatsShardV7, readGlobalStatsEpoch, readModelStatsEpoch, readValidStatsShardsV7, removeLegacyStatsFiles, writeStatsShardV7 } from "./src/stats-store.ts";
16
+ import { SHARD_ARCHIVE_PATH, compactStatsShardsV7, maybeRunStatsMaintenance, readStatsArchive } from "./src/stats-archive.ts";
17
+ import { type CacheProviderId, type CacheStats, LEGACY_STATE_FILE_PATH, acquireStatsMaintenanceLock, holdsStatsMaintenanceLock, releaseStatsMaintenanceLock, type PersistedRoutedModelRef, type PersistedStatsShardV7, SHARD_FILES_DIR, SHARD_GLOBAL_EPOCH_PATH, SHARD_RETENTION_MS, SHARD_STATE_DIR, STATE_FILE_PATH, type ShardAggregate, type UsageSnapshot, addUsageToCacheStats, advanceGlobalStatsEpoch, advanceModelStatsEpoch, aggregateStatsShardsV7, cleanupStatsShardsV7, cloneCacheStats, mapWithConcurrency, currentLocalDay, emptyAllCacheStats, emptyCacheStats, initialEpoch, loadStatsShardAggregateV7, modelEpochPath, parseCacheStats, parsePersistedRoutedModelRef, parsePersistedStatsShardV7, readGlobalStatsEpoch, readModelStatsEpoch, readValidStatsShardsV7, removeLegacyStatsFiles, writeStatsShardV7 } from "./src/stats-store.ts";
17
18
  import { PI_VIRTUAL_MODEL_API, getCompat, isAnthropicMessagesApi, isKimiCodingEmptySignatureModel, isKnownThirdPartyOpenAIEndpoint, isMistralConversationsApi, isNativeVirtualModel, isOfficialOpenAIBaseUrl, isOpenAICompatibleApi, isOpenAICompatibleProxyApi, isOpenAIFamilyAssistantMessage, isOpenAIFamilyModel, isOpenAIFamilyToken, isPiBuiltInLlamaCppModel, isResponsesPromptRewriteBypassApi, isRoutedFallbackModel, isValidModelsConfigForEffectiveCompat, modelKey, readEffectiveCompatConfig } from "./src/model-identity.ts";
18
19
  import { CONFIG_FILE_PATH, VIRTUAL_REWRITE_ENV, FOOTER_MODE_ENV, TOOL_ORDER_ENV, featureEnabled, footerStatsMode, isEnabledEnv, isToolOrderEnabled, parseFooterStatsMode, parsePersistedCacheOptimizerConfig, readPersistedCacheOptimizerConfig, resolveFooterStatsMode, runtimeOptimizerEnabled, setPersistedCacheOptimizerConfig, shouldInjectOpenAIPromptCacheKey, writePersistedCacheOptimizerConfig, writePersistedFeature, writePersistedFooterMode } from "./src/config.ts";
19
20
  import { decideZeroPriceWarming, readCacheWarmingMode } from "./src/cache-warming.ts";
@@ -103,7 +104,13 @@ function notifyCacheCompatIfNeeded(
103
104
  // Internal helpers exported only so the task verification script
104
105
  // (.trellis/tasks/.../verify.ts) can exercise them. They are not part of the
105
106
  // extension's public API; pi only invokes the default export below.
107
+ // Most recent background stats restore started by any extension instance in
108
+ // this module. Tests await it after session_start; runtime code uses the
109
+ // per-instance promise instead.
110
+ let latestStatsRestoreForTests: Promise<void> = Promise.resolve();
111
+
106
112
  export const __internals_for_tests = {
113
+ waitForStatsRestore: (): Promise<void> => latestStatsRestoreForTests,
107
114
  getLastSkillCompressionOutcome,
108
115
  decidePromptCacheRetention,
109
116
  userRequestedLongCacheRetention,
@@ -317,6 +324,15 @@ export const __internals_for_tests = {
317
324
  aggregateStatsShardsV7,
318
325
  loadStatsShardAggregateV7,
319
326
  cleanupStatsShardsV7,
327
+ compactStatsShardsV7,
328
+ maybeRunStatsMaintenance,
329
+ readStatsArchive,
330
+ acquireStatsMaintenanceLock,
331
+ holdsStatsMaintenanceLock,
332
+ releaseStatsMaintenanceLock,
333
+ SHARD_ARCHIVE_PATH,
334
+ SHARD_RETENTION_MS,
335
+ mapWithConcurrency,
320
336
  removeLegacyStatsFiles,
321
337
  readGlobalStatsEpoch,
322
338
  readModelStatsEpoch,
@@ -410,6 +426,20 @@ export default function (pi: ExtensionAPI) {
410
426
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
411
427
  let shardRefreshTimer: ReturnType<typeof setTimeout> | null = null;
412
428
  let shardWatcher: ReturnType<typeof watch> | undefined;
429
+ // Historical stats are restored in the background so a large shard
430
+ // directory never holds up Pi's session_start (issue #28). Handlers that
431
+ // read or persist aggregated stats await this first.
432
+ let statsRestorePromise: Promise<void> = Promise.resolve();
433
+ let statsRestoreGeneration = 0;
434
+ async function waitForStatsRestore(): Promise<void> {
435
+ // A session_start may chain a newer restore while we wait; keep waiting
436
+ // until the chain is stable so callers never act on a half-restored view.
437
+ let current: Promise<void>;
438
+ do {
439
+ current = statsRestorePromise;
440
+ await current;
441
+ } while (current !== statsRestorePromise);
442
+ }
413
443
  const enqueuePersist = createSerializedAsyncRunner();
414
444
  let instanceId = randomUUID();
415
445
  const instanceStartedAt = Date.now();
@@ -578,10 +608,21 @@ export default function (pi: ExtensionAPI) {
578
608
  }
579
609
 
580
610
  async function refreshShardAggregate(): Promise<ShardAggregate> {
581
- const persistedShards = await readValidStatsShardsV7();
611
+ // Order matters: compaction publishes the archive before deleting the
612
+ // shards it absorbed, so reading the directory first and the archive
613
+ // second always sees each absorbed shard in at least one of them. Reading
614
+ // them concurrently could pair an old archive with a post-delete listing
615
+ // and drop those counters (and the last routed model) for this refresh.
616
+ const rawShards = await readValidStatsShardsV7();
617
+ const archive = await readStatsArchive();
618
+ // Shards folded into the archive are counted there. Ignore their raw file
619
+ // (a crash may leave it behind) and this process's in-memory retired copy.
620
+ for (const id of archive.absorbedIds) retiredShards.delete(id);
621
+ const persistedShards = rawShards.filter((shard) => !archive.absorbedIds.has(shard.instanceId));
582
622
  for (const shard of persistedShards) retiredShards.delete(shard.instanceId);
583
623
  const shards = persistedShards.filter((shard) => shard.instanceId !== instanceId);
584
624
  shards.push(...retiredShards.values());
625
+ shards.push(...archive.shards);
585
626
  if (currentSessionHashSet) shards.push(buildCurrentStatsShard());
586
627
  // Keep lifetime session footer data separate from daily totals/commands.
587
628
  // null explicitly means all retained days; undefined invokes the default day.
@@ -664,7 +705,8 @@ export default function (pi: ExtensionAPI) {
664
705
  if (shardRefreshTimer !== null) clearTimeout(shardRefreshTimer);
665
706
  shardRefreshTimer = setTimeout(() => {
666
707
  shardRefreshTimer = null;
667
- void refreshShardAggregate()
708
+ void waitForStatsRestore()
709
+ .then(() => refreshShardAggregate())
668
710
  .then(() => publishStatus(ctx as ExtensionContext, model ?? ctx?.model))
669
711
  .catch(() => undefined);
670
712
  }, SHARD_REFRESH_DEBOUNCE_MS);
@@ -771,7 +813,12 @@ export default function (pi: ExtensionAPI) {
771
813
  if (persistTimer !== null) clearTimeout(persistTimer);
772
814
  persistTimer = setTimeout(() => {
773
815
  persistTimer = null;
774
- void persistCacheStats(ctx).then(() => refreshShardAggregate()).catch(() => undefined);
816
+ // A persist scheduled while a background restore runs (e.g. a routed
817
+ // model change without usage) must not race the restore's own write.
818
+ void waitForStatsRestore()
819
+ .then(() => persistCacheStats(ctx))
820
+ .then(() => refreshShardAggregate())
821
+ .catch(() => undefined);
775
822
  }, PERSIST_DEBOUNCE_MS);
776
823
  persistTimer.unref?.();
777
824
  }
@@ -818,7 +865,12 @@ export default function (pi: ExtensionAPI) {
818
865
  finally { rolloverPromise = undefined; }
819
866
  }
820
867
 
821
- async function restoreCacheStats(reason: string, ctx: ExtensionContext): Promise<void> {
868
+ /**
869
+ * Synchronous part of a session restore: reset in-memory state immediately
870
+ * at session_start, so usage recorded before the background disk restore
871
+ * finishes is never wiped by a reset that runs later.
872
+ */
873
+ function resetStatsForSessionStart(reason: string, ctx: ExtensionContext): void {
822
874
  syncSessionHash(ctx);
823
875
  lastStatusText = undefined;
824
876
  cacheStatsProcessByModel = {};
@@ -826,19 +878,48 @@ export default function (pi: ExtensionAPI) {
826
878
  modelEpochByKey.clear();
827
879
  modelApiByKey.clear();
828
880
  modelNameByKey.clear();
829
- currentGlobalEpoch = await readGlobalStatsEpoch();
830
881
  if (reason === "reload") {
831
882
  clearRecentSamples();
832
883
  }
884
+ }
885
+
886
+ async function restoreCacheStats(ctx: ExtensionContext): Promise<void> {
887
+ // Session identity was synced by resetStatsForSessionStart. Usage may
888
+ // already have been recorded (message_end does not wait to record), and
889
+ // message_end syncs the epoch before recording. If the disk epoch differs
890
+ // now, a reset happened after that usage: adopt it and drop the stale
891
+ // process bucket instead of re-labelling old counters with the new epoch.
892
+ const latestGlobalEpoch = await readGlobalStatsEpoch();
893
+ if (latestGlobalEpoch !== currentGlobalEpoch) {
894
+ currentGlobalEpoch = latestGlobalEpoch;
895
+ cacheStatsProcessByModel = {};
896
+ modelEpochByKey.clear();
897
+ modelApiByKey.clear();
898
+ modelNameByKey.clear();
899
+ }
833
900
  await removeLegacyStatsFiles();
834
901
  await mkdir(SHARD_FILES_DIR, { recursive: true });
902
+ await rollOverStatsIfNeeded(ctx);
903
+ // Run due retention maintenance before the single startup scan, so the
904
+ // aggregate never retains counters from files maintenance just removed and
905
+ // no second directory pass is needed. Cleanup also warms the parsed-shard
906
+ // cache, so the scan below only re-stats files.
907
+ try {
908
+ await maybeRunStatsMaintenance();
909
+ } catch (error) {
910
+ console.warn(`${LOG_PREFIX}: stats shard maintenance failed`, error);
911
+ }
912
+ // One scan adopts other processes' epochs and restores this session's
913
+ // routed-model metadata before this instance publishes its own shard.
914
+ // In-memory process counters were cleared above, so no pre-persist
915
+ // re-scan is needed; this instance's own shard is always replaced by its
916
+ // in-memory copy during aggregation, so no post-persist scan either.
835
917
  await refreshShardAggregate();
836
- await flushPersistCacheStats(ctx);
837
- await maybeCleanupStatsShardsV7();
838
- // Maintenance may remove expired shard files after the initial refresh.
839
- // Re-scan before publishing so startup/footer command state cannot retain
840
- // counters from files that no longer exist.
841
- await refreshShardAggregate();
918
+ if (persistTimer !== null) {
919
+ clearTimeout(persistTimer);
920
+ persistTimer = null;
921
+ }
922
+ await persistCacheStats(ctx);
842
923
  }
843
924
 
844
925
  async function publishStatus(ctx: ExtensionContext, model: PiModel | undefined = ctx.model): Promise<void> {
@@ -968,7 +1049,26 @@ export default function (pi: ExtensionAPI) {
968
1049
 
969
1050
  pi.on("session_start", async (event, ctx) => {
970
1051
  if (runtimeOptimizerEnabled) requestLongCacheRetention();
971
- await restoreCacheStats(event.reason, ctx);
1052
+ // Do not block Pi's startup on historical stats (issue #28). The
1053
+ // restore runs in the background (chained after any earlier restore so
1054
+ // two never interleave) and the footer is published when it finishes.
1055
+ // A newer session_start supersedes an older restore, which then must not
1056
+ // publish a stale footer. Handlers that read or persist stats await
1057
+ // waitForStatsRestore() first.
1058
+ const generation = ++statsRestoreGeneration;
1059
+ resetStatsForSessionStart(event.reason, ctx);
1060
+ const previousRestore = statsRestorePromise;
1061
+ statsRestorePromise = previousRestore
1062
+ // A restore superseded before it started is skipped entirely: the newer
1063
+ // one redoes all of its work, and its ctx may already be stale.
1064
+ .then(() => (generation === statsRestoreGeneration ? restoreCacheStats(ctx) : undefined))
1065
+ .then(async () => {
1066
+ if (generation === statsRestoreGeneration) await publishStatus(ctx);
1067
+ })
1068
+ .catch((error) => {
1069
+ console.warn(`${LOG_PREFIX}: failed to restore cache stats`, error);
1070
+ });
1071
+ latestStatsRestoreForTests = statsRestorePromise;
972
1072
  if (ctx.mode === "tui" && !shardWatcher) {
973
1073
  try {
974
1074
  shardWatcher = watch(SHARD_FILES_DIR, () => scheduleShardRefresh(ctx));
@@ -977,13 +1077,13 @@ export default function (pi: ExtensionAPI) {
977
1077
  // Lifecycle and explicit command refreshes remain authoritative.
978
1078
  }
979
1079
  }
980
- await publishStatus(ctx);
981
1080
  });
982
1081
 
983
1082
  pi.on("tool_execution_end", async (event, ctx) => {
984
1083
  // Pi 0.99+ codemode scripts run nested tool calls in parallel; the calling
985
1084
  // tool's own end event follows them, so one refresh covers the batch.
986
1085
  if (isNonEmptyString((event as { parentToolCallId?: unknown } | undefined)?.parentToolCallId)) return;
1086
+ await waitForStatsRestore();
987
1087
  await refreshShardAggregate();
988
1088
  await publishStatus(ctx);
989
1089
  });
@@ -1003,12 +1103,14 @@ export default function (pi: ExtensionAPI) {
1003
1103
  // a record consumed out of order) so they cannot be correlated with the
1004
1104
  // next run's messages.
1005
1105
  providerRequestStates.length = 0;
1106
+ await waitForStatsRestore();
1006
1107
  await refreshShardAggregate();
1007
1108
  await publishStatus(ctx);
1008
1109
  });
1009
1110
 
1010
1111
  pi.on("session_shutdown", async (_event, ctx) => {
1011
1112
  try {
1113
+ await waitForStatsRestore();
1012
1114
  await flushPersistCacheStats(ctx, "closed");
1013
1115
  } finally {
1014
1116
  if (shardRefreshTimer !== null) clearTimeout(shardRefreshTimer);
@@ -1025,6 +1127,7 @@ export default function (pi: ExtensionAPI) {
1025
1127
 
1026
1128
  pi.on("model_select", async (event, ctx) => {
1027
1129
  if (runtimeOptimizerEnabled) notifyCacheCompatIfNeeded(resolveRouteModel(event.model, ctx) ?? event.model, ctx, warnedModels);
1130
+ await waitForStatsRestore();
1028
1131
  await refreshShardAggregate();
1029
1132
  await publishStatus(ctx, event.model);
1030
1133
  });
@@ -1568,6 +1671,7 @@ export default function (pi: ExtensionAPI) {
1568
1671
  addUsageToCacheStats(getStatsForModel(undefined, adapter), usage);
1569
1672
  }
1570
1673
 
1674
+ await waitForStatsRestore();
1571
1675
  schedulePersistCacheStats(ctx);
1572
1676
  await refreshShardAggregate();
1573
1677
  // A late response from an older selection must not replace the active
@@ -1627,6 +1731,11 @@ export default function (pi: ExtensionAPI) {
1627
1731
  pi.registerCommand("cache-optimizer", {
1628
1732
  description: "Configure and diagnose Pi cache behavior",
1629
1733
  getArgumentCompletions: getCacheOptimizerArgumentCompletions,
1630
- handler: handleCacheOptimizerCommand,
1734
+ handler: async (args: string, cmdCtx: Parameters<typeof handleCacheOptimizerCommand>[1]) => {
1735
+ // Commands read and reset aggregated stats; let a background restore
1736
+ // finish first so they never act on a half-restored view.
1737
+ await waitForStatsRestore();
1738
+ return handleCacheOptimizerCommand(args, cmdCtx);
1739
+ },
1631
1740
  });
1632
1741
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.8.26",
3
+ "version": "2.9.0",
4
4
  "description": "Improve Pi prompt/KV cache hit rates with stable prompts, OpenAI-compatible cache keys, proxy compat warnings, and footer cache stats.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,560 @@
1
+ /**
2
+ * Archive snapshot for closed past-day stats shards (issue #28, phase 2).
3
+ *
4
+ * Every Pi process writes one shard per local day. Shards are kept for
5
+ * SHARD_RETENTION_MS so session-scoped footer totals survive long
6
+ * conversations, which left ~1,500 files to stat and parse on every refresh.
7
+ *
8
+ * Past-day shards that are closed (or abandoned by a dead process) never
9
+ * change again, so maintenance folds them into one archive file and deletes
10
+ * the originals. The directory then only holds today's shards.
11
+ *
12
+ * Correctness rules:
13
+ * - Buckets keep the global and model epochs they were written under. Readers
14
+ * filter them with the same epoch checks as raw shards, so a reset hides
15
+ * archived counters at once, before the next compaction drops them.
16
+ * - The archive records every instanceId it absorbed. Readers ignore raw
17
+ * shards (and in-memory retired shards) with an absorbed id, so a crash
18
+ * between publishing the archive and deleting the originals, or two
19
+ * processes compacting at once, never double counts.
20
+ * - Archived data only feeds the all-days (session-scoped) aggregate. Its
21
+ * buckets keep their original day, so the current-day aggregate filters
22
+ * them out exactly as it filters the raw past-day shards they replace.
23
+ * Per-instance counters in the all-days aggregate count archive buckets,
24
+ * not processes; only `bySession` and `lastRoutedModelBySession` are read
25
+ * from it.
26
+ */
27
+ import { randomUUID } from "node:crypto";
28
+ import { lstat, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
29
+ import { dirname, join } from "node:path";
30
+ import { LOG_PREFIX, asRecord, getErrorCode, isNonEmptyString, isProcessAlive } from "./common.ts";
31
+ import {
32
+ type CacheStats,
33
+ type PersistedRoutedModelRef,
34
+ type PersistedStatsShardV7,
35
+ SHARD_CLEANUP_MARKER_PATH,
36
+ SHARD_FILES_DIR,
37
+ SHARD_IO_CONCURRENCY,
38
+ SHARD_MAINTENANCE_DIR,
39
+ SHARD_RETENTION_MS,
40
+ SHARD_STATE_DIR,
41
+ SHARD_TEMP_RETENTION_MS,
42
+ acquireStatsMaintenanceLock,
43
+ cleanupStatsShardsV7,
44
+ cloneCacheStats,
45
+ currentLocalDay,
46
+ isMaintenanceMarkerDue,
47
+ mapWithConcurrency,
48
+ mergeStatsAcrossDays,
49
+ parseCacheStats,
50
+ parsePersistedRoutedModelRef,
51
+ holdsStatsMaintenanceLock,
52
+ readGlobalStatsEpochStrict,
53
+ readModelStatsEpochStrict,
54
+ readValidStatsShardsV7,
55
+ releaseStatsMaintenanceLock,
56
+ renameWithRetry,
57
+ writeMaintenanceMarker,
58
+ } from "./stats-store.ts";
59
+
60
+ export const SHARD_ARCHIVE_DIR = join(SHARD_STATE_DIR, "archive");
61
+ export const SHARD_ARCHIVE_PATH = join(SHARD_ARCHIVE_DIR, "archive.json");
62
+ export const SHARD_COMPACTION_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-compaction");
63
+
64
+ /**
65
+ * An active shard is only treated as abandoned once its process is gone and
66
+ * it has not been touched for this long, so a recycled Windows PID or a
67
+ * transient process-check failure cannot fold a live shard away.
68
+ */
69
+ export const ARCHIVE_ABANDONED_ACTIVE_MS = 24 * 60 * 60 * 1000;
70
+
71
+ export type StatsArchiveBucketV1 = {
72
+ globalEpoch: string;
73
+ sessionHash: string;
74
+ modelKey: string;
75
+ modelEpoch: string;
76
+ /** Day of the shards folded into this bucket. */
77
+ day: string;
78
+ provider: string;
79
+ modelId: string;
80
+ modelName?: string;
81
+ api?: string;
82
+ /** Latest lifecycle.updatedAt among the folded shards. */
83
+ updatedAt: number;
84
+ stats: CacheStats;
85
+ };
86
+
87
+ export type StatsArchiveRoutedV1 = {
88
+ globalEpoch: string;
89
+ sessionHash: string;
90
+ day: string;
91
+ updatedAt: number;
92
+ model: PersistedRoutedModelRef;
93
+ };
94
+
95
+ export type StatsArchiveV1 = {
96
+ version: 1;
97
+ kind: "pi-cache-optimizer-archive";
98
+ updatedAt: number;
99
+ /** instanceId -> time it was folded in. */
100
+ absorbed: Record<string, number>;
101
+ buckets: StatsArchiveBucketV1[];
102
+ routed: StatsArchiveRoutedV1[];
103
+ };
104
+
105
+ const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
106
+
107
+ export function emptyStatsArchive(now = Date.now()): StatsArchiveV1 {
108
+ return { version: 1, kind: "pi-cache-optimizer-archive", updatedAt: now, absorbed: {}, buckets: [], routed: [] };
109
+ }
110
+
111
+ function isFiniteNumber(value: unknown): value is number {
112
+ return typeof value === "number" && Number.isFinite(value);
113
+ }
114
+
115
+ /** Strict parse: returns undefined when the document is not a v1 archive. Invalid entries are dropped. */
116
+ export function parseStatsArchive(value: unknown): StatsArchiveV1 | undefined {
117
+ const record = asRecord(value);
118
+ if (!record || record.version !== 1 || record.kind !== "pi-cache-optimizer-archive") return undefined;
119
+ const rawAbsorbed = asRecord(record.absorbed);
120
+ if (!rawAbsorbed || !Array.isArray(record.buckets) || !Array.isArray(record.routed)) return undefined;
121
+
122
+ const absorbed: Record<string, number> = {};
123
+ for (const [id, at] of Object.entries(rawAbsorbed)) {
124
+ if (isNonEmptyString(id) && isFiniteNumber(at)) absorbed[id] = at;
125
+ }
126
+
127
+ const buckets: StatsArchiveBucketV1[] = [];
128
+ for (const raw of record.buckets) {
129
+ const entry = asRecord(raw);
130
+ const stats = parseCacheStats(entry?.stats);
131
+ if (
132
+ !entry || !stats ||
133
+ !isNonEmptyString(entry.globalEpoch) || !isNonEmptyString(entry.sessionHash) ||
134
+ !isNonEmptyString(entry.modelKey) || !isNonEmptyString(entry.modelEpoch) ||
135
+ !isNonEmptyString(entry.provider) || !isNonEmptyString(entry.modelId) ||
136
+ typeof entry.day !== "string" || !DAY_PATTERN.test(entry.day) ||
137
+ !isFiniteNumber(entry.updatedAt) ||
138
+ entry.modelKey !== `${entry.provider}/${entry.modelId}`
139
+ ) continue;
140
+ buckets.push({
141
+ globalEpoch: entry.globalEpoch,
142
+ sessionHash: entry.sessionHash,
143
+ modelKey: entry.modelKey,
144
+ modelEpoch: entry.modelEpoch,
145
+ day: entry.day,
146
+ provider: entry.provider,
147
+ modelId: entry.modelId,
148
+ ...(isNonEmptyString(entry.modelName) ? { modelName: entry.modelName } : {}),
149
+ ...(isNonEmptyString(entry.api) ? { api: entry.api } : {}),
150
+ updatedAt: entry.updatedAt,
151
+ stats,
152
+ });
153
+ }
154
+
155
+ const routed: StatsArchiveRoutedV1[] = [];
156
+ for (const raw of record.routed) {
157
+ const entry = asRecord(raw);
158
+ const model = parsePersistedRoutedModelRef(entry?.model);
159
+ if (
160
+ !entry || !model ||
161
+ !isNonEmptyString(entry.globalEpoch) || !isNonEmptyString(entry.sessionHash) ||
162
+ typeof entry.day !== "string" || !DAY_PATTERN.test(entry.day) ||
163
+ !isFiniteNumber(entry.updatedAt)
164
+ ) continue;
165
+ routed.push({ globalEpoch: entry.globalEpoch, sessionHash: entry.sessionHash, day: entry.day, updatedAt: entry.updatedAt, model });
166
+ }
167
+
168
+ return {
169
+ version: 1,
170
+ kind: "pi-cache-optimizer-archive",
171
+ updatedAt: isFiniteNumber(record.updatedAt) ? record.updatedAt : 0,
172
+ absorbed,
173
+ buckets,
174
+ routed,
175
+ };
176
+ }
177
+
178
+ export type LoadedStatsArchive = {
179
+ /** False when no archive file exists; true for a parsed archive. */
180
+ present: boolean;
181
+ /** True when a file exists but cannot be parsed; compaction then refuses to overwrite it. */
182
+ corrupt: boolean;
183
+ archive: StatsArchiveV1;
184
+ absorbedIds: ReadonlySet<string>;
185
+ /** Archive buckets/routed entries as synthetic shards for aggregateStatsShardsV7. Do not mutate. */
186
+ shards: readonly PersistedStatsShardV7[];
187
+ };
188
+
189
+ type CachedArchive = { ino: number | bigint; size: number; mtimeMs: number; loaded: LoadedStatsArchive };
190
+ const archiveCache = new Map<string, CachedArchive>();
191
+
192
+ export function clearStatsArchiveCache(): void {
193
+ archiveCache.clear();
194
+ }
195
+
196
+ /** Convert archive entries into synthetic closed shards so the existing aggregation applies unchanged. */
197
+ export function archiveToSyntheticShards(archive: StatsArchiveV1): PersistedStatsShardV7[] {
198
+ const shards: PersistedStatsShardV7[] = [];
199
+ const base = (id: string, sessionHash: string, day: string, globalEpoch: string, updatedAt: number): PersistedStatsShardV7 => ({
200
+ version: 7,
201
+ kind: "pi-cache-optimizer-shard",
202
+ instanceId: id,
203
+ sessionHash,
204
+ process: { pid: 0, ppid: 0, instanceStartedAt: 0 },
205
+ lifecycle: { state: "closed", createdAt: updatedAt, updatedAt, closedAt: updatedAt },
206
+ day,
207
+ globalEpoch,
208
+ models: {},
209
+ });
210
+ archive.buckets.forEach((bucket, index) => {
211
+ const shard = base(`archive-bucket-${index}`, bucket.sessionHash, bucket.day, bucket.globalEpoch, bucket.updatedAt);
212
+ shard.models[bucket.modelKey] = {
213
+ modelEpoch: bucket.modelEpoch,
214
+ provider: bucket.provider,
215
+ modelId: bucket.modelId,
216
+ ...(bucket.modelName ? { modelName: bucket.modelName } : {}),
217
+ ...(bucket.api ? { api: bucket.api } : {}),
218
+ stats: cloneCacheStats(bucket.stats),
219
+ };
220
+ shards.push(shard);
221
+ });
222
+ archive.routed.forEach((entry, index) => {
223
+ const shard = base(`archive-routed-${index}`, entry.sessionHash, entry.day, entry.globalEpoch, entry.updatedAt);
224
+ shard.lastRoutedModel = { ...entry.model };
225
+ shards.push(shard);
226
+ });
227
+ return shards;
228
+ }
229
+
230
+ function buildLoaded(archive: StatsArchiveV1, present: boolean, corrupt: boolean): LoadedStatsArchive {
231
+ return {
232
+ present,
233
+ corrupt,
234
+ archive,
235
+ absorbedIds: new Set(Object.keys(archive.absorbed)),
236
+ shards: archiveToSyntheticShards(archive),
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Read the archive, re-parsing only when inode/size/mtime changed. A missing
242
+ * file is an empty archive; an invalid one is reported as corrupt and treated
243
+ * as empty, so raw shards stay the source of truth. A transient I/O failure
244
+ * (Windows EPERM/EBUSY while another process or a scanner holds the file) is
245
+ * never cached: the last good parse is returned when there is one, otherwise
246
+ * an uncached corrupt result, so the next refresh retries.
247
+ */
248
+ export async function readStatsArchive(path: string = SHARD_ARCHIVE_PATH): Promise<LoadedStatsArchive> {
249
+ const transientFailure = (): LoadedStatsArchive => {
250
+ const previous = archiveCache.get(path);
251
+ return previous && !previous.loaded.corrupt ? previous.loaded : buildLoaded(emptyStatsArchive(0), true, true);
252
+ };
253
+ let info: import("node:fs").Stats;
254
+ try {
255
+ info = await lstat(path);
256
+ } catch (error) {
257
+ if (getErrorCode(error) === "ENOENT") {
258
+ archiveCache.delete(path);
259
+ return buildLoaded(emptyStatsArchive(0), false, false);
260
+ }
261
+ return transientFailure();
262
+ }
263
+ if (!info.isFile() || info.isSymbolicLink()) {
264
+ archiveCache.delete(path);
265
+ return buildLoaded(emptyStatsArchive(0), true, true);
266
+ }
267
+ const cached = archiveCache.get(path);
268
+ if (cached && cached.ino === info.ino && cached.size === info.size && cached.mtimeMs === info.mtimeMs) return cached.loaded;
269
+ let text: string;
270
+ try {
271
+ text = await readFile(path, "utf8");
272
+ } catch (error) {
273
+ // A file renamed away between lstat and read is simply gone.
274
+ if (getErrorCode(error) === "ENOENT") {
275
+ archiveCache.delete(path);
276
+ return buildLoaded(emptyStatsArchive(0), false, false);
277
+ }
278
+ return transientFailure();
279
+ }
280
+ let loaded: LoadedStatsArchive;
281
+ try {
282
+ const parsed = parseStatsArchive(JSON.parse(text));
283
+ loaded = parsed ? buildLoaded(parsed, true, false) : buildLoaded(emptyStatsArchive(0), true, true);
284
+ } catch {
285
+ loaded = buildLoaded(emptyStatsArchive(0), true, true);
286
+ }
287
+ archiveCache.set(path, { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, loaded });
288
+ return loaded;
289
+ }
290
+
291
+ type ArchiveFileIdentity = { ino: number | bigint; size: number; mtimeMs: number } | undefined;
292
+
293
+ async function archiveFileIdentity(path: string): Promise<ArchiveFileIdentity> {
294
+ try {
295
+ const info = await lstat(path);
296
+ return { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs };
297
+ } catch (error) {
298
+ if (getErrorCode(error) === "ENOENT") return undefined;
299
+ throw error;
300
+ }
301
+ }
302
+
303
+ function sameArchiveFile(a: ArchiveFileIdentity, b: ArchiveFileIdentity): boolean {
304
+ if (!a || !b) return a === b;
305
+ return a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs;
306
+ }
307
+
308
+ /** Remove archive temp files a crash left behind (shard cleanup only covers shards/). */
309
+ async function cleanupStaleArchiveTemps(archivePath: string, now: number): Promise<void> {
310
+ const directory = dirname(archivePath);
311
+ let names: string[];
312
+ try {
313
+ names = await readdir(directory);
314
+ } catch {
315
+ return;
316
+ }
317
+ for (const name of names) {
318
+ if (!name.endsWith(".tmp")) continue;
319
+ const path = join(directory, name);
320
+ try {
321
+ const info = await lstat(path);
322
+ if (info.isFile() && !info.isSymbolicLink() && now - info.mtimeMs >= SHARD_TEMP_RETENTION_MS) await unlink(path);
323
+ } catch {}
324
+ }
325
+ }
326
+
327
+ async function writeStatsArchive(path: string, archive: StatsArchiveV1): Promise<void> {
328
+ await mkdir(dirname(path), { recursive: true });
329
+ const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
330
+ try {
331
+ await writeFile(tempPath, JSON.stringify(archive) + "\n", "utf8");
332
+ await renameWithRetry(tempPath, path);
333
+ } catch (error) {
334
+ await unlink(tempPath).catch(() => undefined);
335
+ throw error;
336
+ }
337
+ archiveCache.delete(path);
338
+ }
339
+
340
+ /** A shard maintenance may fold away: from a past day and no longer written to. */
341
+ export function isArchivableShard(shard: PersistedStatsShardV7, now: number, today: string): boolean {
342
+ if (shard.day >= today) return false;
343
+ if (now - shard.lifecycle.updatedAt >= SHARD_RETENTION_MS) return false; // retention deletes it instead
344
+ if (shard.lifecycle.state === "closed") return true;
345
+ return now - shard.lifecycle.updatedAt >= ARCHIVE_ABANDONED_ACTIVE_MS && !isProcessAlive(shard.process.pid);
346
+ }
347
+
348
+ export type CompactionResult = {
349
+ /** Raw shards newly folded into the archive. */
350
+ absorbed: number;
351
+ /** Raw shard files deleted (newly or previously absorbed). */
352
+ removed: number;
353
+ /** Archive entries dropped for an old epoch or past retention. */
354
+ dropped: number;
355
+ /** True when an unreadable archive blocked compaction. */
356
+ skippedCorrupt: boolean;
357
+ /**
358
+ * True when the archive, an epoch, or lock ownership changed while this
359
+ * pass ran (e.g. a stale-lock takeover or a concurrent reset). Nothing was
360
+ * published or deleted; the next pass retries.
361
+ */
362
+ skippedConcurrent: boolean;
363
+ };
364
+
365
+ export type CompactionOptions = {
366
+ /** Owner token from acquireStatsMaintenanceLock; ownership is re-checked before publishing. */
367
+ lockToken?: string;
368
+ /** Test hook: runs after the plan is built, right before the publish checks. */
369
+ beforePublish?: () => Promise<void>;
370
+ };
371
+
372
+ const bucketId = (b: Pick<StatsArchiveBucketV1, "globalEpoch" | "sessionHash" | "modelKey" | "modelEpoch" | "day">): string =>
373
+ JSON.stringify([b.globalEpoch, b.sessionHash, b.modelKey, b.modelEpoch, b.day]);
374
+ const routedId = (r: Pick<StatsArchiveRoutedV1, "globalEpoch" | "sessionHash">): string =>
375
+ JSON.stringify([r.globalEpoch, r.sessionHash]);
376
+
377
+ /**
378
+ * Fold archivable shards into the archive, then delete them. Call only while
379
+ * holding the maintenance lock. Order matters for crash safety: the archive
380
+ * (with the absorbed ids) is published before any raw shard is deleted.
381
+ */
382
+ export async function compactStatsShardsV7(
383
+ now = Date.now(),
384
+ directory: string = SHARD_FILES_DIR,
385
+ archivePath: string = SHARD_ARCHIVE_PATH,
386
+ options: CompactionOptions = {},
387
+ ): Promise<CompactionResult> {
388
+ const result: CompactionResult = { absorbed: 0, removed: 0, dropped: 0, skippedCorrupt: false, skippedConcurrent: false };
389
+ await cleanupStaleArchiveTemps(archivePath, now);
390
+ clearStatsArchiveCache();
391
+ const identityAtRead = await archiveFileIdentity(archivePath);
392
+ const loaded = await readStatsArchive(archivePath);
393
+ if (loaded.corrupt) {
394
+ result.skippedCorrupt = true;
395
+ return result;
396
+ }
397
+ const previous = loaded.archive;
398
+ const today = currentLocalDay();
399
+ // Strict reads: compaction permanently drops counters whose epoch differs,
400
+ // so an unreadable epoch file must abort instead of falling back to the
401
+ // initial epoch (which would discard every archived and absorbed counter).
402
+ const globalEpoch = await readGlobalStatsEpochStrict();
403
+ const modelEpochs = new Map<string, string>();
404
+ const currentModelEpoch = async (key: string): Promise<string> => {
405
+ let epoch = modelEpochs.get(key);
406
+ if (!epoch) {
407
+ epoch = await readModelStatsEpochStrict(key);
408
+ modelEpochs.set(key, epoch);
409
+ }
410
+ return epoch;
411
+ };
412
+
413
+ // Carry forward live entries; drop ones a reset or retention invalidated.
414
+ const buckets = new Map<string, StatsArchiveBucketV1>();
415
+ for (const bucket of previous.buckets) {
416
+ if (
417
+ bucket.globalEpoch !== globalEpoch ||
418
+ bucket.modelEpoch !== await currentModelEpoch(bucket.modelKey) ||
419
+ now - bucket.updatedAt >= SHARD_RETENTION_MS
420
+ ) {
421
+ result.dropped++;
422
+ continue;
423
+ }
424
+ buckets.set(bucketId(bucket), { ...bucket, stats: cloneCacheStats(bucket.stats) });
425
+ }
426
+ const routed = new Map<string, StatsArchiveRoutedV1>();
427
+ for (const entry of previous.routed) {
428
+ if (entry.globalEpoch !== globalEpoch || now - entry.updatedAt >= SHARD_RETENTION_MS) {
429
+ result.dropped++;
430
+ continue;
431
+ }
432
+ routed.set(routedId(entry), { ...entry, model: { ...entry.model } });
433
+ }
434
+
435
+ const shards = await readValidStatsShardsV7(directory);
436
+ const presentIds = new Set(shards.map((shard) => shard.instanceId));
437
+ const absorbed: Record<string, number> = {};
438
+ // Keep ids whose raw file still exists (a crash or a failed delete left it).
439
+ for (const [id, at] of Object.entries(previous.absorbed)) if (presentIds.has(id)) absorbed[id] = at;
440
+
441
+ for (const shard of shards) {
442
+ if (shard.instanceId in absorbed || !isArchivableShard(shard, now, today)) continue;
443
+ absorbed[shard.instanceId] = now;
444
+ result.absorbed++;
445
+ if (shard.globalEpoch !== globalEpoch) continue; // reset away: nothing to keep
446
+ if (shard.lastRoutedModel) {
447
+ const id = routedId({ globalEpoch, sessionHash: shard.sessionHash });
448
+ const existing = routed.get(id);
449
+ if (!existing || existing.updatedAt < shard.lifecycle.updatedAt) {
450
+ routed.set(id, { globalEpoch, sessionHash: shard.sessionHash, day: shard.day, updatedAt: shard.lifecycle.updatedAt, model: { ...shard.lastRoutedModel } });
451
+ }
452
+ }
453
+ for (const [key, entry] of Object.entries(shard.models)) {
454
+ if (entry.modelEpoch !== await currentModelEpoch(key)) continue;
455
+ const id = bucketId({ globalEpoch, sessionHash: shard.sessionHash, modelKey: key, modelEpoch: entry.modelEpoch, day: shard.day });
456
+ const existing = buckets.get(id);
457
+ const newer = !existing || existing.updatedAt <= shard.lifecycle.updatedAt;
458
+ buckets.set(id, {
459
+ globalEpoch,
460
+ sessionHash: shard.sessionHash,
461
+ modelKey: key,
462
+ modelEpoch: entry.modelEpoch,
463
+ day: shard.day,
464
+ provider: newer ? entry.provider : existing.provider,
465
+ modelId: newer ? entry.modelId : existing.modelId,
466
+ ...((newer ? entry.modelName : existing.modelName) ? { modelName: newer ? entry.modelName : existing.modelName } : {}),
467
+ ...((newer ? entry.api : existing.api) ? { api: newer ? entry.api : existing.api } : {}),
468
+ updatedAt: Math.max(existing?.updatedAt ?? 0, shard.lifecycle.updatedAt),
469
+ stats: mergeStatsAcrossDays(existing?.stats, entry.stats, null),
470
+ });
471
+ }
472
+ }
473
+
474
+ const absorbedChanged =
475
+ Object.keys(absorbed).length !== Object.keys(previous.absorbed).length ||
476
+ Object.keys(absorbed).some((id) => !(id in previous.absorbed));
477
+ if (result.absorbed > 0 || result.dropped > 0 || absorbedChanged) {
478
+ await options.beforePublish?.();
479
+ // Publish only if nothing this plan depends on moved underneath it: a
480
+ // reset during the pass (counters written under the new epoch would be
481
+ // dropped as stale), or another compactor after a stale-lock takeover
482
+ // (overwriting its archive would lose shards it already deleted).
483
+ let concurrent =
484
+ !(await holdsStatsMaintenanceLock(options.lockToken)) ||
485
+ !sameArchiveFile(identityAtRead, await archiveFileIdentity(archivePath)) ||
486
+ (await readGlobalStatsEpochStrict()) !== globalEpoch;
487
+ for (const [key, epoch] of modelEpochs) {
488
+ if (concurrent) break;
489
+ if ((await readModelStatsEpochStrict(key)) !== epoch) concurrent = true;
490
+ }
491
+ if (concurrent) {
492
+ result.skippedConcurrent = true;
493
+ result.absorbed = 0;
494
+ result.dropped = 0;
495
+ return result;
496
+ }
497
+ await writeStatsArchive(archivePath, {
498
+ version: 1,
499
+ kind: "pi-cache-optimizer-archive",
500
+ updatedAt: now,
501
+ absorbed,
502
+ buckets: Array.from(buckets.values()),
503
+ routed: Array.from(routed.values()),
504
+ });
505
+ }
506
+
507
+ // Only now that the archive covering them is durable, delete the originals.
508
+ const toDelete = Object.keys(absorbed);
509
+ const outcomes = await mapWithConcurrency(toDelete, SHARD_IO_CONCURRENCY, async (id): Promise<number> => {
510
+ try {
511
+ await unlink(join(directory, `${id}.json`));
512
+ return 1;
513
+ } catch {
514
+ // ENOENT: already gone. Anything else: the id stays absorbed (so the
515
+ // file is ignored by readers) and the next compaction retries.
516
+ return 0;
517
+ }
518
+ });
519
+ result.removed = outcomes.reduce((sum, value) => sum + value, 0);
520
+ return result;
521
+ }
522
+
523
+ export type StatsMaintenanceResult = { cleanupRemoved: number; compaction?: CompactionResult };
524
+
525
+ /**
526
+ * Due retention cleanup and archive compaction under one lock. Each has its
527
+ * own marker so the first run after upgrading compacts immediately even when
528
+ * cleanup ran recently.
529
+ */
530
+ export async function maybeRunStatsMaintenance(now = Date.now()): Promise<StatsMaintenanceResult> {
531
+ const result: StatsMaintenanceResult = { cleanupRemoved: 0 };
532
+ await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
533
+ const cleanupDue = await isMaintenanceMarkerDue(SHARD_CLEANUP_MARKER_PATH, now);
534
+ const compactionDue = await isMaintenanceMarkerDue(SHARD_COMPACTION_MARKER_PATH, now);
535
+ if (!cleanupDue && !compactionDue) return result;
536
+ const lockToken = await acquireStatsMaintenanceLock(now);
537
+ if (!lockToken) return result;
538
+ try {
539
+ if (cleanupDue) {
540
+ result.cleanupRemoved = await cleanupStatsShardsV7(now);
541
+ await writeMaintenanceMarker(SHARD_CLEANUP_MARKER_PATH, now);
542
+ }
543
+ if (compactionDue) {
544
+ try {
545
+ result.compaction = await compactStatsShardsV7(now, SHARD_FILES_DIR, SHARD_ARCHIVE_PATH, { lockToken });
546
+ if (result.compaction.skippedCorrupt) {
547
+ console.warn(`${LOG_PREFIX}: stats archive ${SHARD_ARCHIVE_PATH} is unreadable; keeping raw shards`);
548
+ }
549
+ } catch (error) {
550
+ console.warn(`${LOG_PREFIX}: stats archive compaction failed`, error);
551
+ }
552
+ // A pass that lost a race published nothing; let the next startup retry
553
+ // instead of waiting out the full interval.
554
+ if (!result.compaction?.skippedConcurrent) await writeMaintenanceMarker(SHARD_COMPACTION_MARKER_PATH, now);
555
+ }
556
+ } finally {
557
+ await releaseStatsMaintenanceLock(lockToken);
558
+ }
559
+ return result;
560
+ }
@@ -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`;
@@ -345,9 +372,75 @@ type CachedShardRead = {
345
372
  ino: number | bigint;
346
373
  size: number;
347
374
  mtimeMs: number;
375
+ /** Valid shard whose embedded instanceId matches its filename. */
348
376
  shard: PersistedStatsShardV7 | undefined;
377
+ /** Schema-valid parse regardless of filename match (used by retention). */
378
+ parsed: PersistedStatsShardV7 | undefined;
349
379
  };
350
380
 
381
+ /**
382
+ * Upper bound on concurrent filesystem operations during shard scans. Startup
383
+ * on Windows (antivirus-scanned AppData) was dominated by thousands of
384
+ * serialized lstat/readFile awaits; a small pool hides per-call latency
385
+ * without opening an unbounded number of descriptors.
386
+ */
387
+ export const SHARD_IO_CONCURRENCY = 16;
388
+
389
+ /** Run `worker` over `items` with at most `limit` in flight; results keep input order. */
390
+ export async function mapWithConcurrency<T, R>(
391
+ items: readonly T[],
392
+ limit: number,
393
+ worker: (item: T, index: number) => Promise<R>,
394
+ ): Promise<R[]> {
395
+ const results = new Array<R>(items.length);
396
+ let next = 0;
397
+ const run = async (): Promise<void> => {
398
+ while (next < items.length) {
399
+ const index = next++;
400
+ results[index] = await worker(items[index] as T, index);
401
+ }
402
+ };
403
+ const pool = Math.max(1, Math.min(limit, items.length));
404
+ await Promise.all(Array.from({ length: pool }, run));
405
+ return results;
406
+ }
407
+
408
+ type ShardFileRead = {
409
+ info: import("node:fs").Stats;
410
+ /** Valid shard with matching filename instanceId. */
411
+ shard: PersistedStatsShardV7 | undefined;
412
+ /** Schema-valid parse regardless of filename match. */
413
+ parsed: PersistedStatsShardV7 | undefined;
414
+ };
415
+
416
+ /**
417
+ * Stat a shard file and return its parsed contents, re-reading only when the
418
+ * inode/size/mtime changed. Returns undefined for non-regular files. Throws on
419
+ * read/parse failure (after dropping any cache entry) so callers can apply
420
+ * their own policy.
421
+ */
422
+ async function readShardFileCached(path: string, name: string): Promise<ShardFileRead | undefined> {
423
+ try {
424
+ const info = await lstat(path);
425
+ if (!info.isFile() || info.isSymbolicLink()) {
426
+ shardReadCache.delete(path);
427
+ return undefined;
428
+ }
429
+ const cached = shardReadCache.get(path);
430
+ if (cached && cached.ino === info.ino && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
431
+ return { info, shard: cached.shard, parsed: cached.parsed };
432
+ }
433
+ const filenameInstanceId = name.slice(0, -".json".length);
434
+ const parsed = parsePersistedStatsShardV7(JSON.parse(await readFile(path, "utf8")));
435
+ const shard = parsed && parsed.instanceId === filenameInstanceId ? parsed : undefined;
436
+ shardReadCache.set(path, { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, shard, parsed });
437
+ return { info, shard, parsed };
438
+ } catch (error) {
439
+ shardReadCache.delete(path);
440
+ throw error;
441
+ }
442
+ }
443
+
351
444
  /**
352
445
  * Parsed-shard cache keyed by path and validated by inode, size, and mtime.
353
446
  * Writers always publish through temp-file + rename, so an updated shard gets
@@ -371,37 +464,22 @@ export async function readValidStatsShardsV7(directory: string = SHARD_FILES_DIR
371
464
  if (getErrorCode(error) === "ENOENT") return [];
372
465
  throw error;
373
466
  }
374
- const shards: PersistedStatsShardV7[] = [];
375
467
  const seenPaths = new Set<string>();
376
- for (const name of names) {
377
- if (!/^[0-9a-f-]+\.json$/i.test(name)) continue;
378
- const path = join(directory, name);
379
- seenPaths.add(path);
468
+ const candidates = names.filter((name) => /^[0-9a-f-]+\.json$/i.test(name));
469
+ for (const name of candidates) seenPaths.add(join(directory, name));
470
+ const reads = await mapWithConcurrency(candidates, SHARD_IO_CONCURRENCY, async (name) => {
380
471
  try {
381
- const info = await lstat(path);
382
- if (!info.isFile() || info.isSymbolicLink()) {
383
- shardReadCache.delete(path);
384
- continue;
385
- }
386
- const cached = shardReadCache.get(path);
387
- let parsed: PersistedStatsShardV7 | undefined;
388
- if (cached && cached.ino === info.ino && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
389
- parsed = cached.shard;
390
- } else {
391
- const filenameInstanceId = name.slice(0, -".json".length);
392
- const candidate = parsePersistedStatsShardV7(JSON.parse(await readFile(path, "utf8")));
393
- parsed = candidate && candidate.instanceId === filenameInstanceId ? candidate : undefined;
394
- shardReadCache.set(path, { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, shard: parsed });
395
- }
396
- // Callers may mutate the aggregate they build; hand out copies so the
397
- // cache stays authoritative.
398
- if (parsed) shards.push(cloneShard(parsed));
472
+ return (await readShardFileCached(join(directory, name), name))?.shard;
399
473
  } catch {
400
474
  // Ignore malformed or transiently unavailable shards. Atomic writers will
401
475
  // publish a complete replacement on the next successful update.
402
- shardReadCache.delete(path);
476
+ return undefined;
403
477
  }
404
- }
478
+ });
479
+ // Callers may mutate the aggregate they build; hand out copies so the cache
480
+ // stays authoritative. Preserve directory order for deterministic ties.
481
+ const shards: PersistedStatsShardV7[] = [];
482
+ for (const shard of reads) if (shard) shards.push(cloneShard(shard));
405
483
  for (const path of Array.from(shardReadCache.keys())) {
406
484
  if (!seenPaths.has(path) && path.startsWith(directory)) shardReadCache.delete(path);
407
485
  }
@@ -491,68 +569,153 @@ export async function cleanupStatsShardsV7(now = Date.now(), directory: string =
491
569
  return getErrorCode(error) === "ENOENT" ? 0 : Promise.reject(error);
492
570
  }
493
571
  const today = currentLocalDay();
494
- let removed = 0;
495
- for (const name of names) {
496
- const isShard = /^[0-9a-f-]+\.json$/i.test(name);
497
- const isTemp = name.endsWith(".tmp");
498
- if (!isShard && !isTemp) continue;
572
+ const candidates = names.filter((name) => /^[0-9a-f-]+\.json$/i.test(name) || name.endsWith(".tmp"));
573
+ const outcomes = await mapWithConcurrency(candidates, SHARD_IO_CONCURRENCY, async (name): Promise<number> => {
574
+ const isTemp = !/^[0-9a-f-]+\.json$/i.test(name);
499
575
  const path = join(directory, name);
500
576
  try {
501
- const info = await lstat(path);
502
- if (!info.isFile() || info.isSymbolicLink()) continue;
503
577
  if (isTemp) {
504
- if (now - info.mtimeMs < SHARD_TEMP_RETENTION_MS) continue;
578
+ const info = await lstat(path);
579
+ if (!info.isFile() || info.isSymbolicLink()) return 0;
580
+ if (now - info.mtimeMs < SHARD_TEMP_RETENTION_MS) return 0;
505
581
  } else {
506
- const text = await readFile(path, "utf8");
507
- let parsed: PersistedStatsShardV7 | undefined;
582
+ let read: ShardFileRead | undefined;
583
+ let mtimeMs: number;
508
584
  try {
509
- parsed = parsePersistedStatsShardV7(JSON.parse(text));
510
- } catch {
585
+ read = await readShardFileCached(path, name);
586
+ if (!read) return 0;
587
+ mtimeMs = read.info.mtimeMs;
588
+ } catch (error) {
511
589
  // A corrupt JSON shard has no usable lifecycle metadata. Apply the
512
590
  // same mtime retention as schema-invalid shards instead of keeping
513
591
  // it forever; read failures still leave the file untouched.
592
+ if (!(error instanceof SyntaxError)) return 0;
593
+ const info = await lstat(path);
594
+ if (!info.isFile() || info.isSymbolicLink()) return 0;
595
+ mtimeMs = info.mtimeMs;
514
596
  }
515
- if (parsed?.day === today) continue;
516
- const updatedAt = parsed?.lifecycle.updatedAt ?? info.mtimeMs;
517
- if (now - updatedAt < SHARD_RETENTION_MS) continue;
518
- if (parsed?.lifecycle.state === "active" && isProcessAlive(parsed.process.pid)) continue;
597
+ const parsed = read?.parsed;
598
+ if (parsed?.day === today) return 0;
599
+ const updatedAt = parsed?.lifecycle.updatedAt ?? mtimeMs;
600
+ if (now - updatedAt < SHARD_RETENTION_MS) return 0;
601
+ if (parsed?.lifecycle.state === "active" && isProcessAlive(parsed.process.pid)) return 0;
519
602
  }
520
603
  await unlink(path);
521
- removed += 1;
604
+ shardReadCache.delete(path);
605
+ return 1;
522
606
  } catch {
523
607
  // Best-effort maintenance must never block the extension.
608
+ return 0;
524
609
  }
525
- }
526
- return removed;
610
+ });
611
+ return outcomes.reduce((sum, value) => sum + value, 0);
527
612
  }
528
613
 
529
- export async function maybeCleanupStatsShardsV7(now = Date.now()): Promise<void> {
530
- await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
531
- try {
532
- const marker = await stat(SHARD_CLEANUP_MARKER_PATH);
533
- if (now - marker.mtimeMs < SHARD_CLEANUP_INTERVAL_MS) return;
534
- } catch {}
614
+ const SHARD_CLEANUP_LOCK_OWNER_FILE = "owner";
535
615
 
616
+ /**
617
+ * Take the cross-process maintenance lock (a directory, so creation is atomic
618
+ * on every platform). A lock older than SHARD_CLEANUP_LOCK_STALE_MS is
619
+ * treated as abandoned by a crashed process and replaced. Resolves to an
620
+ * owner token (truthy) on success, undefined otherwise. The token lets a
621
+ * holder whose lock was taken over avoid releasing the new owner's lock.
622
+ */
623
+ export async function acquireStatsMaintenanceLock(now = Date.now()): Promise<string | undefined> {
624
+ await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
625
+ const token = `${process.pid}-${randomUUID()}`;
626
+ const claim = async (): Promise<string> => {
627
+ // Best effort: an older version (or a failed write) leaves no owner file,
628
+ // 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);
630
+ return token;
631
+ };
536
632
  try {
537
633
  await mkdir(SHARD_CLEANUP_LOCK_PATH);
634
+ return await claim();
538
635
  } catch (error) {
539
- if (getErrorCode(error) !== "EEXIST") return;
636
+ if (getErrorCode(error) !== "EEXIST") return undefined;
540
637
  try {
541
638
  const lock = await stat(SHARD_CLEANUP_LOCK_PATH);
542
- if (now - lock.mtimeMs <= SHARD_CLEANUP_LOCK_STALE_MS) return;
639
+ if (now - lock.mtimeMs <= SHARD_CLEANUP_LOCK_STALE_MS) return undefined;
543
640
  await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
544
641
  await mkdir(SHARD_CLEANUP_LOCK_PATH);
642
+ return await claim();
545
643
  } catch {
644
+ return undefined;
645
+ }
646
+ }
647
+ }
648
+
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> {
651
+ if (!token) return true;
652
+ try {
653
+ const owner = await readFile(join(SHARD_CLEANUP_LOCK_PATH, SHARD_CLEANUP_LOCK_OWNER_FILE), "utf8");
654
+ return owner === token;
655
+ } catch (error) {
656
+ // 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;
659
+ try {
660
+ await stat(SHARD_CLEANUP_LOCK_PATH);
661
+ return true;
662
+ } catch {
663
+ return false;
664
+ }
665
+ }
666
+ }
667
+
668
+ 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 });
672
+ }
673
+
674
+ /** True when the marker file is missing or older than `intervalMs`. */
675
+ export async function isMaintenanceMarkerDue(path: string, now = Date.now(), intervalMs = SHARD_CLEANUP_INTERVAL_MS): Promise<boolean> {
676
+ try {
677
+ const marker = await stat(path);
678
+ return now - marker.mtimeMs >= intervalMs;
679
+ } catch {
680
+ return true;
681
+ }
682
+ }
683
+
684
+ export async function writeMaintenanceMarker(path: string, now = Date.now()): Promise<void> {
685
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
686
+ await writeFile(tempPath, `${now}\n`, "utf8");
687
+ await renameWithRetry(tempPath, path);
688
+ }
689
+
690
+ const RENAME_RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
691
+
692
+ /**
693
+ * rename() that retries briefly on the transient errors Windows raises while
694
+ * another process (or an antivirus scanner) holds the target open.
695
+ */
696
+ export async function renameWithRetry(from: string, to: string, attempts = 5): Promise<void> {
697
+ for (let attempt = 0; ; attempt++) {
698
+ try {
699
+ await rename(from, to);
546
700
  return;
701
+ } 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));
547
704
  }
548
705
  }
706
+ }
549
707
 
708
+ /** Run due retention maintenance; resolves to the number of files removed (0 when skipped). */
709
+ export async function maybeCleanupStatsShardsV7(now = Date.now()): Promise<number> {
710
+ await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
711
+ if (!(await isMaintenanceMarkerDue(SHARD_CLEANUP_MARKER_PATH, now))) return 0;
712
+ const lockToken = await acquireStatsMaintenanceLock(now);
713
+ if (!lockToken) return 0;
550
714
  try {
551
- await cleanupStatsShardsV7(now);
552
- const tempPath = `${SHARD_CLEANUP_MARKER_PATH}.${process.pid}.${randomUUID()}.tmp`;
553
- await writeFile(tempPath, `${now}\n`, "utf8");
554
- await rename(tempPath, SHARD_CLEANUP_MARKER_PATH);
715
+ const removed = await cleanupStatsShardsV7(now);
716
+ await writeMaintenanceMarker(SHARD_CLEANUP_MARKER_PATH, now);
717
+ return removed;
555
718
  } finally {
556
- await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
719
+ await releaseStatsMaintenanceLock(lockToken);
557
720
  }
558
721
  }