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.
@@ -6,8 +6,8 @@
6
6
  * conversations, which left ~1,500 files to stat and parse on every refresh.
7
7
  *
8
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.
9
+ * change again, so maintenance folds them into one archive snapshot and
10
+ * deletes the originals. The directory then only holds today's shards.
11
11
  *
12
12
  * Correctness rules:
13
13
  * - Buckets keep the global and model epochs they were written under. Readers
@@ -20,18 +20,38 @@
20
20
  * - Archived data only feeds the all-days (session-scoped) aggregate. Its
21
21
  * buckets keep their original day, so the current-day aggregate filters
22
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.
23
+ *
24
+ * Storage (generations): each snapshot is an immutable file
25
+ * `archive/archive.<generation>.json`. A compactor that read generation N
26
+ * publishes N+1 with link(temp, target), which fails with EEXIST when anyone
27
+ * else already published N+1. That makes publishing a compare-and-swap on
28
+ * Windows and POSIX alike: a holder that stalled past the stale-lock window
29
+ * (laptop sleep) and lost its lock can never replace a newer snapshot, and it
30
+ * deletes no raw shard unless its own snapshot is the newest one. Readers use
31
+ * the highest generation; old generations are removed one generation late
32
+ * and readers retry when the file they listed is gone.
33
+ *
34
+ * 2.9.0 compatibility: 2.9.0 kept a single `archive/archive.json`. It is still
35
+ * read while no generation exists and migrated by the next compaction, which
36
+ * then replaces it with a directory of the same name. 2.9.0 treats that as an
37
+ * unreadable archive: it never compacts (so it cannot overwrite or delete
38
+ * anything) and its footer shows only history that is not archived, exactly
39
+ * like the versions before 2.9.0. A file that 2.9.0 still manages to write
40
+ * there afterwards (only if that directory is removed) is never counted: it is
41
+ * set aside as `archive.legacy-<time>.json`, trading the counters it absorbed
42
+ * for a guarantee against double counting.
26
43
  */
27
- import { randomUUID } from "node:crypto";
28
- import { lstat, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
44
+ import { createHash, randomUUID } from "node:crypto";
45
+ import { link, lstat, mkdir, open, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
29
46
  import { dirname, join } from "node:path";
30
47
  import { LOG_PREFIX, asRecord, getErrorCode, isNonEmptyString, isProcessAlive } from "./common.ts";
31
48
  import {
32
49
  type CacheStats,
33
50
  type PersistedRoutedModelRef,
34
51
  type PersistedStatsShardV7,
52
+ type ShardAggregate,
53
+ type ShardAggregator,
54
+ type ShardContribution,
35
55
  SHARD_CLEANUP_MARKER_PATH,
36
56
  SHARD_FILES_DIR,
37
57
  SHARD_IO_CONCURRENCY,
@@ -42,6 +62,7 @@ import {
42
62
  acquireStatsMaintenanceLock,
43
63
  cleanupStatsShardsV7,
44
64
  cloneCacheStats,
65
+ createShardAggregatorV7,
45
66
  currentLocalDay,
46
67
  isMaintenanceMarkerDue,
47
68
  mapWithConcurrency,
@@ -54,10 +75,16 @@ import {
54
75
  readValidStatsShardsV7,
55
76
  releaseStatsMaintenanceLock,
56
77
  renameWithRetry,
78
+ touchStatsMaintenanceLock,
57
79
  writeMaintenanceMarker,
58
80
  } from "./stats-store.ts";
59
81
 
60
82
  export const SHARD_ARCHIVE_DIR = join(SHARD_STATE_DIR, "archive");
83
+ /**
84
+ * 2.9.0's single-file archive. Read (and migrated) while no generation file
85
+ * exists; afterwards a directory blocks 2.9.0 from writing it. The other
86
+ * archive functions take this path and keep generations next to it.
87
+ */
61
88
  export const SHARD_ARCHIVE_PATH = join(SHARD_ARCHIVE_DIR, "archive.json");
62
89
  export const SHARD_COMPACTION_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-compaction");
63
90
 
@@ -68,6 +95,9 @@ export const SHARD_COMPACTION_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-co
68
95
  */
69
96
  export const ARCHIVE_ABANDONED_ACTIVE_MS = 24 * 60 * 60 * 1000;
70
97
 
98
+ /** How often a running maintenance pass refreshes the lock's mtime. */
99
+ export const STATS_MAINTENANCE_HEARTBEAT_MS = 5 * 60 * 1000;
100
+
71
101
  export type StatsArchiveBucketV1 = {
72
102
  globalEpoch: string;
73
103
  sessionHash: string;
@@ -92,6 +122,7 @@ export type StatsArchiveRoutedV1 = {
92
122
  model: PersistedRoutedModelRef;
93
123
  };
94
124
 
125
+ /** In-memory archive contents (and 2.9.0's on-disk format). */
95
126
  export type StatsArchiveV1 = {
96
127
  version: 1;
97
128
  kind: "pi-cache-optimizer-archive";
@@ -102,7 +133,31 @@ export type StatsArchiveV1 = {
102
133
  routed: StatsArchiveRoutedV1[];
103
134
  };
104
135
 
136
+ /**
137
+ * On-disk generation format: the same entries as v1 with every string
138
+ * interned in one table, so a bucket is one short row of numbers.
139
+ * models rows: [provider, modelId, modelName | -1, api | -1] (string indexes).
140
+ * buckets rows: [globalEpoch, sessionHash, model, modelEpoch, day, updatedAt,
141
+ * stats.day, totalRequests, hitRequests, cachedInputTokens,
142
+ * cacheWriteInputTokens, totalInputTokens] (strings and model are indexes).
143
+ * routed rows: [globalEpoch, sessionHash, day, updatedAt, provider, id, name | -1].
144
+ */
145
+ export type StatsArchiveV2Document = {
146
+ version: 2;
147
+ kind: "pi-cache-optimizer-archive";
148
+ generation: number;
149
+ updatedAt: number;
150
+ /** sha256 of the 2.9.0 archive.json this archive was migrated from. */
151
+ legacySha256?: string;
152
+ absorbed: Record<string, number>;
153
+ strings: string[];
154
+ models: number[][];
155
+ buckets: number[][];
156
+ routed: number[][];
157
+ };
158
+
105
159
  const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
160
+ const GENERATION_FILE_PATTERN = /^archive\.(\d{1,15})\.json$/;
106
161
 
107
162
  export function emptyStatsArchive(now = Date.now()): StatsArchiveV1 {
108
163
  return { version: 1, kind: "pi-cache-optimizer-archive", updatedAt: now, absorbed: {}, buckets: [], routed: [] };
@@ -112,59 +167,71 @@ function isFiniteNumber(value: unknown): value is number {
112
167
  return typeof value === "number" && Number.isFinite(value);
113
168
  }
114
169
 
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;
170
+ function parseArchiveBucket(raw: unknown): StatsArchiveBucketV1 | undefined {
171
+ const entry = asRecord(raw);
172
+ const stats = parseCacheStats(entry?.stats);
173
+ if (
174
+ !entry || !stats ||
175
+ !isNonEmptyString(entry.globalEpoch) || !isNonEmptyString(entry.sessionHash) ||
176
+ !isNonEmptyString(entry.modelKey) || !isNonEmptyString(entry.modelEpoch) ||
177
+ !isNonEmptyString(entry.provider) || !isNonEmptyString(entry.modelId) ||
178
+ typeof entry.day !== "string" || !DAY_PATTERN.test(entry.day) ||
179
+ !isFiniteNumber(entry.updatedAt) ||
180
+ entry.modelKey !== `${entry.provider}/${entry.modelId}`
181
+ ) return undefined;
182
+ return {
183
+ globalEpoch: entry.globalEpoch,
184
+ sessionHash: entry.sessionHash,
185
+ modelKey: entry.modelKey,
186
+ modelEpoch: entry.modelEpoch,
187
+ day: entry.day,
188
+ provider: entry.provider,
189
+ modelId: entry.modelId,
190
+ ...(isNonEmptyString(entry.modelName) ? { modelName: entry.modelName } : {}),
191
+ ...(isNonEmptyString(entry.api) ? { api: entry.api } : {}),
192
+ updatedAt: entry.updatedAt,
193
+ stats,
194
+ };
195
+ }
196
+
197
+ function parseArchiveRouted(raw: unknown): StatsArchiveRoutedV1 | undefined {
198
+ const entry = asRecord(raw);
199
+ const model = parsePersistedRoutedModelRef(entry?.model);
200
+ if (
201
+ !entry || !model ||
202
+ !isNonEmptyString(entry.globalEpoch) || !isNonEmptyString(entry.sessionHash) ||
203
+ typeof entry.day !== "string" || !DAY_PATTERN.test(entry.day) ||
204
+ !isFiniteNumber(entry.updatedAt)
205
+ ) return undefined;
206
+ return { globalEpoch: entry.globalEpoch, sessionHash: entry.sessionHash, day: entry.day, updatedAt: entry.updatedAt, model };
207
+ }
121
208
 
209
+ function parseAbsorbed(value: unknown): Record<string, number> | undefined {
210
+ const rawAbsorbed = asRecord(value);
211
+ if (!rawAbsorbed) return undefined;
122
212
  const absorbed: Record<string, number> = {};
123
213
  for (const [id, at] of Object.entries(rawAbsorbed)) {
124
214
  if (isNonEmptyString(id) && isFiniteNumber(at)) absorbed[id] = at;
125
215
  }
216
+ return absorbed;
217
+ }
126
218
 
219
+ /** Strict parse of 2.9.0's format: undefined when the document is not a v1 archive. Invalid entries are dropped. */
220
+ export function parseStatsArchive(value: unknown): StatsArchiveV1 | undefined {
221
+ const record = asRecord(value);
222
+ if (!record || record.version !== 1 || record.kind !== "pi-cache-optimizer-archive") return undefined;
223
+ const absorbed = parseAbsorbed(record.absorbed);
224
+ if (!absorbed || !Array.isArray(record.buckets) || !Array.isArray(record.routed)) return undefined;
127
225
  const buckets: StatsArchiveBucketV1[] = [];
128
226
  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
- });
227
+ const bucket = parseArchiveBucket(raw);
228
+ if (bucket) buckets.push(bucket);
153
229
  }
154
-
155
230
  const routed: StatsArchiveRoutedV1[] = [];
156
231
  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 });
232
+ const entry = parseArchiveRouted(raw);
233
+ if (entry) routed.push(entry);
166
234
  }
167
-
168
235
  return {
169
236
  version: 1,
170
237
  kind: "pi-cache-optimizer-archive",
@@ -175,28 +242,172 @@ export function parseStatsArchive(value: unknown): StatsArchiveV1 | undefined {
175
242
  };
176
243
  }
177
244
 
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
- };
245
+ /** Encode an archive in the compact generation format. */
246
+ export function encodeStatsArchiveV2(archive: StatsArchiveV1, generation: number, legacySha256?: string): StatsArchiveV2Document {
247
+ const strings: string[] = [];
248
+ const stringIndex = new Map<string, number>();
249
+ const str = (value: string): number => {
250
+ let index = stringIndex.get(value);
251
+ if (index === undefined) {
252
+ index = strings.length;
253
+ strings.push(value);
254
+ stringIndex.set(value, index);
255
+ }
256
+ return index;
257
+ };
258
+ const optional = (value: string | undefined): number => (value === undefined ? -1 : str(value));
259
+ const models: number[][] = [];
260
+ const modelIndex = new Map<string, number>();
261
+ const model = (bucket: StatsArchiveBucketV1): number => {
262
+ const row = [str(bucket.provider), str(bucket.modelId), optional(bucket.modelName), optional(bucket.api)];
263
+ const id = row.join(",");
264
+ let index = modelIndex.get(id);
265
+ if (index === undefined) {
266
+ index = models.length;
267
+ models.push(row);
268
+ modelIndex.set(id, index);
269
+ }
270
+ return index;
271
+ };
272
+ const buckets = archive.buckets.map((bucket) => [
273
+ str(bucket.globalEpoch), str(bucket.sessionHash), model(bucket), str(bucket.modelEpoch), str(bucket.day), bucket.updatedAt,
274
+ str(bucket.stats.day), bucket.stats.totalRequests, bucket.stats.hitRequests, bucket.stats.cachedInputTokens,
275
+ bucket.stats.cacheWriteInputTokens, bucket.stats.totalInputTokens,
276
+ ]);
277
+ const routed = archive.routed.map((entry) => [
278
+ str(entry.globalEpoch), str(entry.sessionHash), str(entry.day), entry.updatedAt,
279
+ str(entry.model.provider), str(entry.model.id), optional(entry.model.name),
280
+ ]);
281
+ return {
282
+ version: 2,
283
+ kind: "pi-cache-optimizer-archive",
284
+ generation,
285
+ updatedAt: archive.updatedAt,
286
+ ...(legacySha256 ? { legacySha256 } : {}),
287
+ absorbed: archive.absorbed,
288
+ strings,
289
+ models,
290
+ buckets,
291
+ routed,
292
+ };
293
+ }
188
294
 
189
- type CachedArchive = { ino: number | bigint; size: number; mtimeMs: number; loaded: LoadedStatsArchive };
190
- const archiveCache = new Map<string, CachedArchive>();
295
+ /**
296
+ * Strict parse of a generation file: undefined when the document itself is
297
+ * not a v2 archive. Rows with bad indexes or values are dropped, applying the
298
+ * same per-entry validation as v1.
299
+ */
300
+ export function parseStatsArchiveV2(value: unknown): { archive: StatsArchiveV1; generation: number; legacySha256?: string } | undefined {
301
+ const record = asRecord(value);
302
+ if (!record || record.version !== 2 || record.kind !== "pi-cache-optimizer-archive") return undefined;
303
+ if (!Number.isSafeInteger(record.generation) || (record.generation as number) < 1) return undefined;
304
+ const absorbed = parseAbsorbed(record.absorbed);
305
+ const { strings, models, buckets: rawBuckets, routed: rawRouted } = record;
306
+ if (
307
+ !absorbed || !Array.isArray(strings) || !Array.isArray(models) || !Array.isArray(rawBuckets) || !Array.isArray(rawRouted) ||
308
+ !strings.every((entry) => typeof entry === "string")
309
+ ) return undefined;
310
+ const str = (index: unknown): string | undefined =>
311
+ Number.isInteger(index) && (index as number) >= 0 && (index as number) < strings.length ? strings[index as number] as string : undefined;
312
+ const row = (value: unknown, length: number): unknown[] | undefined => (Array.isArray(value) && value.length === length ? value : undefined);
191
313
 
192
- export function clearStatsArchiveCache(): void {
193
- archiveCache.clear();
314
+ const modelTable = models.map((raw) => {
315
+ const entry = row(raw, 4);
316
+ return entry ? { provider: str(entry[0]), modelId: str(entry[1]), modelName: str(entry[2]), api: str(entry[3]) } : undefined;
317
+ });
318
+ const buckets: StatsArchiveBucketV1[] = [];
319
+ for (const raw of rawBuckets) {
320
+ const entry = row(raw, 12);
321
+ if (!entry) continue;
322
+ const modelEntry = Number.isInteger(entry[2]) ? modelTable[entry[2] as number] : undefined;
323
+ if (!modelEntry) continue;
324
+ const bucket = parseArchiveBucket({
325
+ globalEpoch: str(entry[0]),
326
+ sessionHash: str(entry[1]),
327
+ modelKey: `${modelEntry.provider}/${modelEntry.modelId}`,
328
+ modelEpoch: str(entry[3]),
329
+ day: str(entry[4]),
330
+ provider: modelEntry.provider,
331
+ modelId: modelEntry.modelId,
332
+ modelName: modelEntry.modelName,
333
+ api: modelEntry.api,
334
+ updatedAt: entry[5],
335
+ stats: {
336
+ day: str(entry[6]), totalRequests: entry[7], hitRequests: entry[8], cachedInputTokens: entry[9],
337
+ cacheWriteInputTokens: entry[10], totalInputTokens: entry[11],
338
+ },
339
+ });
340
+ if (bucket) buckets.push(bucket);
341
+ }
342
+ const routed: StatsArchiveRoutedV1[] = [];
343
+ for (const raw of rawRouted) {
344
+ const entry = row(raw, 7);
345
+ if (!entry) continue;
346
+ const parsed = parseArchiveRouted({
347
+ globalEpoch: str(entry[0]),
348
+ sessionHash: str(entry[1]),
349
+ day: str(entry[2]),
350
+ updatedAt: entry[3],
351
+ model: { provider: str(entry[4]), id: str(entry[5]), name: str(entry[6]) },
352
+ });
353
+ if (parsed) routed.push(parsed);
354
+ }
355
+ return {
356
+ archive: {
357
+ version: 1,
358
+ kind: "pi-cache-optimizer-archive",
359
+ updatedAt: isFiniteNumber(record.updatedAt) ? record.updatedAt : 0,
360
+ absorbed,
361
+ buckets,
362
+ routed,
363
+ },
364
+ generation: record.generation as number,
365
+ ...(isNonEmptyString(record.legacySha256) ? { legacySha256: record.legacySha256 } : {}),
366
+ };
194
367
  }
195
368
 
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 => ({
369
+ export function statsArchiveGenerationPath(archivePath: string, generation: number): string {
370
+ return join(dirname(archivePath), `archive.${String(generation).padStart(12, "0")}.json`);
371
+ }
372
+
373
+ /** Published generations next to `archivePath`, ascending. */
374
+ export async function listStatsArchiveGenerations(archivePath: string = SHARD_ARCHIVE_PATH): Promise<number[]> {
375
+ let names: string[];
376
+ try {
377
+ names = await readdir(dirname(archivePath));
378
+ } catch (error) {
379
+ if (getErrorCode(error) === "ENOENT") return [];
380
+ throw error;
381
+ }
382
+ const generations: number[] = [];
383
+ for (const name of names) {
384
+ const match = GENERATION_FILE_PATTERN.exec(name);
385
+ if (match) generations.push(Number(match[1]));
386
+ }
387
+ return generations.sort((a, b) => a - b);
388
+ }
389
+
390
+ function bucketToSyntheticShard(bucket: StatsArchiveBucketV1, index: number): PersistedStatsShardV7 {
391
+ const shard = syntheticShardBase(`archive-bucket-${index}`, bucket.sessionHash, bucket.day, bucket.globalEpoch, bucket.updatedAt);
392
+ shard.models[bucket.modelKey] = {
393
+ modelEpoch: bucket.modelEpoch,
394
+ provider: bucket.provider,
395
+ modelId: bucket.modelId,
396
+ ...(bucket.modelName ? { modelName: bucket.modelName } : {}),
397
+ ...(bucket.api ? { api: bucket.api } : {}),
398
+ stats: cloneCacheStats(bucket.stats),
399
+ };
400
+ return shard;
401
+ }
402
+
403
+ function routedToSyntheticShard(entry: StatsArchiveRoutedV1, index: number): PersistedStatsShardV7 {
404
+ const shard = syntheticShardBase(`archive-routed-${index}`, entry.sessionHash, entry.day, entry.globalEpoch, entry.updatedAt);
405
+ shard.lastRoutedModel = { ...entry.model };
406
+ return shard;
407
+ }
408
+
409
+ function syntheticShardBase(id: string, sessionHash: string, day: string, globalEpoch: string, updatedAt: number): PersistedStatsShardV7 {
410
+ return {
200
411
  version: 7,
201
412
  kind: "pi-cache-optimizer-shard",
202
413
  instanceId: id,
@@ -206,103 +417,442 @@ export function archiveToSyntheticShards(archive: StatsArchiveV1): PersistedStat
206
417
  day,
207
418
  globalEpoch,
208
419
  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;
420
+ };
228
421
  }
229
422
 
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
- };
423
+ /**
424
+ * Archive entries as synthetic closed shards: the reference semantics.
425
+ * Aggregating these (after the raw shards) is what the archive must be
426
+ * equivalent to; readers use StatsArchiveView's reduced forms instead.
427
+ */
428
+ export function archiveToSyntheticShards(archive: StatsArchiveV1): PersistedStatsShardV7[] {
429
+ return [
430
+ ...archive.buckets.map(bucketToSyntheticShard),
431
+ ...archive.routed.map(routedToSyntheticShard),
432
+ ];
238
433
  }
239
434
 
435
+ export type LoadedStatsArchive = {
436
+ /** False when no archive exists; true for a parsed (or unreadable) one. */
437
+ present: boolean;
438
+ /** True when an archive exists but cannot be parsed; compaction then refuses to overwrite it. */
439
+ corrupt: boolean;
440
+ /** Generation this snapshot came from; 0 for 2.9.0's archive.json or no archive. */
441
+ generation: number;
442
+ archive: StatsArchiveV1;
443
+ absorbedIds: ReadonlySet<string>;
444
+ /** Every archive entry as a synthetic shard (reference form, built on first access). Do not mutate. */
445
+ readonly shards: readonly PersistedStatsShardV7[];
446
+ };
447
+
448
+ type BucketGroup = {
449
+ /** Position in ReducedArchive.groups. */
450
+ order: number;
451
+ globalEpoch: string;
452
+ sessionHash: string;
453
+ key: string;
454
+ modelEpoch: string;
455
+ /** The single bucket's own stats until a second bucket is merged (then a private copy). */
456
+ stats: CacheStats;
457
+ count: number;
458
+ /** First bucket (in archive order) with the group's newest updatedAt. */
459
+ bestUpdatedAt: number;
460
+ bestIndex: number;
461
+ /** Index of the first bucket an aggregation could record a ref from (updatedAt > -1); Infinity if none. */
462
+ firstEligibleIndex: number;
463
+ };
464
+
465
+ type RoutedGroup = {
466
+ globalEpoch: string;
467
+ sessionHash: string;
468
+ bestUpdatedAt: number;
469
+ model: PersistedRoutedModelRef;
470
+ /** Index of the first entry an aggregation could pick (updatedAt > -1); Infinity if none. */
471
+ firstEligibleIndex: number;
472
+ };
473
+
474
+ type ReducedArchive = {
475
+ /** Buckets folded per (globalEpoch, session, model, modelEpoch), in order of first appearance. */
476
+ groups: BucketGroup[];
477
+ routed: RoutedGroup[];
478
+ /** The same groups per session (each list in archive order). */
479
+ groupsBySession: Map<string, BucketGroup[]>;
480
+ routedBySession: Map<string, RoutedGroup[]>;
481
+ /** Model keys with buckets under each global epoch, sorted. */
482
+ keysByGlobalEpoch: Map<string, string[]>;
483
+ };
484
+
240
485
  /**
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.
486
+ * One parsed archive snapshot, cached per file identity. Besides the raw
487
+ * entries it lazily builds what a refresh needs: the per-day synthetic shards
488
+ * for the current-day view (archived days are past days, so normally none)
489
+ * and, per set of current epochs, the all-days aggregate pre-reduced to one
490
+ * entry per (session, model).
247
491
  */
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);
492
+ export class StatsArchiveView implements LoadedStatsArchive {
493
+ readonly absorbedIds: ReadonlySet<string>;
494
+ private syntheticShards?: PersistedStatsShardV7[];
495
+ private dayIndex?: Map<string, { buckets: number[]; routed: number[] }>;
496
+ private dayShards?: { day: string; shards: readonly PersistedStatsShardV7[] };
497
+ private reduced?: ReducedArchive;
498
+ private contributionCache?: { fingerprint: string; contribution: ShardContribution };
499
+
500
+ constructor(
501
+ readonly archive: StatsArchiveV1,
502
+ readonly present: boolean,
503
+ readonly corrupt: boolean,
504
+ readonly generation: number,
505
+ /** For 2.9.0's archive.json: its sha256; for a generation: the legacy file it was migrated from. */
506
+ readonly legacySha256?: string,
507
+ ) {
508
+ this.absorbedIds = new Set(Object.keys(archive.absorbed));
509
+ }
510
+
511
+ get shards(): readonly PersistedStatsShardV7[] {
512
+ return this.syntheticShards ??= archiveToSyntheticShards(this.archive);
513
+ }
514
+
515
+ /** The synthetic shards of `day`, in archive order. Do not mutate. */
516
+ shardsForDay(day: string): readonly PersistedStatsShardV7[] {
517
+ if (this.dayShards?.day === day) return this.dayShards.shards;
518
+ if (!this.dayIndex) {
519
+ this.dayIndex = new Map();
520
+ const slot = (entryDay: string) => {
521
+ let entry = this.dayIndex!.get(entryDay);
522
+ if (!entry) {
523
+ entry = { buckets: [], routed: [] };
524
+ this.dayIndex!.set(entryDay, entry);
525
+ }
526
+ return entry;
527
+ };
528
+ this.archive.buckets.forEach((bucket, index) => slot(bucket.day).buckets.push(index));
529
+ this.archive.routed.forEach((entry, index) => slot(entry.day).routed.push(index));
260
530
  }
261
- return transientFailure();
531
+ const indexes = this.dayIndex.get(day);
532
+ const shards = indexes
533
+ ? [
534
+ ...indexes.buckets.map((index) => bucketToSyntheticShard(this.archive.buckets[index]!, index)),
535
+ ...indexes.routed.map((index) => routedToSyntheticShard(this.archive.routed[index]!, index)),
536
+ ]
537
+ : [];
538
+ this.dayShards = { day, shards };
539
+ return shards;
540
+ }
541
+
542
+ private reduce(): ReducedArchive {
543
+ if (this.reduced) return this.reduced;
544
+ const groups = new Map<string, BucketGroup>();
545
+ const keys = new Map<string, Set<string>>();
546
+ this.archive.buckets.forEach((bucket, index) => {
547
+ const id = JSON.stringify([bucket.globalEpoch, bucket.sessionHash, bucket.modelKey, bucket.modelEpoch]);
548
+ const group = groups.get(id);
549
+ if (!group) {
550
+ groups.set(id, {
551
+ order: groups.size,
552
+ globalEpoch: bucket.globalEpoch,
553
+ sessionHash: bucket.sessionHash,
554
+ key: bucket.modelKey,
555
+ modelEpoch: bucket.modelEpoch,
556
+ stats: bucket.stats,
557
+ count: 1,
558
+ bestUpdatedAt: bucket.updatedAt,
559
+ bestIndex: index,
560
+ firstEligibleIndex: bucket.updatedAt > -1 ? index : Infinity,
561
+ });
562
+ let set = keys.get(bucket.globalEpoch);
563
+ if (!set) keys.set(bucket.globalEpoch, set = new Set());
564
+ set.add(bucket.modelKey);
565
+ return;
566
+ }
567
+ if (group.count === 1) group.stats = cloneCacheStats(group.stats);
568
+ mergeStatsAcrossDays(group.stats, bucket.stats, null);
569
+ group.count++;
570
+ if (group.firstEligibleIndex === Infinity && bucket.updatedAt > -1) group.firstEligibleIndex = index;
571
+ if (bucket.updatedAt > group.bestUpdatedAt) {
572
+ group.bestUpdatedAt = bucket.updatedAt;
573
+ group.bestIndex = index;
574
+ }
575
+ });
576
+ const routed = new Map<string, RoutedGroup>();
577
+ this.archive.routed.forEach((entry, index) => {
578
+ const id = JSON.stringify([entry.globalEpoch, entry.sessionHash]);
579
+ const group = routed.get(id);
580
+ const eligible = entry.updatedAt > -1 ? index : Infinity;
581
+ if (!group) {
582
+ routed.set(id, { globalEpoch: entry.globalEpoch, sessionHash: entry.sessionHash, bestUpdatedAt: entry.updatedAt, model: entry.model, firstEligibleIndex: eligible });
583
+ return;
584
+ }
585
+ if (group.firstEligibleIndex === Infinity) group.firstEligibleIndex = eligible;
586
+ if (entry.updatedAt > group.bestUpdatedAt) {
587
+ group.bestUpdatedAt = entry.updatedAt;
588
+ group.model = entry.model;
589
+ }
590
+ });
591
+ const bySession = <T extends { sessionHash: string }>(items: T[]): Map<string, T[]> => {
592
+ const map = new Map<string, T[]>();
593
+ for (const item of items) {
594
+ let list = map.get(item.sessionHash);
595
+ if (!list) map.set(item.sessionHash, list = []);
596
+ list.push(item);
597
+ }
598
+ return map;
599
+ };
600
+ const groupList = Array.from(groups.values());
601
+ const routedList = Array.from(routed.values());
602
+ this.reduced = {
603
+ groups: groupList,
604
+ routed: routedList,
605
+ groupsBySession: bySession(groupList),
606
+ routedBySession: bySession(routedList),
607
+ keysByGlobalEpoch: new Map(Array.from(keys, ([epoch, set]) => [epoch, Array.from(set).sort()])),
608
+ };
609
+ return this.reduced;
262
610
  }
263
- if (!info.isFile() || info.isSymbolicLink()) {
264
- archiveCache.delete(path);
265
- return buildLoaded(emptyStatsArchive(0), true, true);
611
+
612
+ /**
613
+ * The archive's share of an all-days aggregate under the aggregator's
614
+ * epochs. Equivalent to adding `shards` (only those of `sessions`, when
615
+ * given) at that point of the sequence; cached until an epoch, the
616
+ * session selection, or the archive changes.
617
+ */
618
+ async contribution(aggregator: Pick<ShardAggregator, "globalEpoch" | "modelEpoch">, only?: ReadonlySet<string>): Promise<ShardContribution> {
619
+ const reduced = this.reduce();
620
+ const globalEpoch = aggregator.globalEpoch;
621
+ let groupList = reduced.groups;
622
+ let routedList = reduced.routed;
623
+ let keys = reduced.keysByGlobalEpoch.get(globalEpoch) ?? [];
624
+ if (only) {
625
+ groupList = Array.from(only, (session) => reduced.groupsBySession.get(session) ?? []).flat()
626
+ .sort((a, b) => a.order - b.order);
627
+ routedList = Array.from(only, (session) => reduced.routedBySession.get(session) ?? []).flat();
628
+ keys = Array.from(new Set(groupList.filter((group) => group.globalEpoch === globalEpoch).map((group) => group.key))).sort();
629
+ }
630
+ const epochs = new Map<string, string>();
631
+ for (const key of keys) epochs.set(key, await aggregator.modelEpoch(key));
632
+ const fingerprint = JSON.stringify([globalEpoch, ...keys.flatMap((key) => [key, epochs.get(key)]), only ? Array.from(only).sort() : null]);
633
+ if (this.contributionCache?.fingerprint === fingerprint) return this.contributionCache.contribution;
634
+
635
+ type SessionOut = { sessionHash: string; instances: number; models: Array<{ key: string; stats: CacheStats; instances: number }> };
636
+ type ModelOut = { key: string; stats: CacheStats; instances: number; sessions: string[]; refUpdatedAt: number; refIndex: number; firstEligibleIndex: number };
637
+ const sessions = new Map<string, SessionOut>();
638
+ const models = new Map<string, ModelOut>();
639
+ for (const group of groupList) {
640
+ if (group.globalEpoch !== globalEpoch || group.modelEpoch !== epochs.get(group.key)) continue;
641
+ let session = sessions.get(group.sessionHash);
642
+ if (!session) sessions.set(group.sessionHash, session = { sessionHash: group.sessionHash, instances: 0, models: [] });
643
+ session.models.push({ key: group.key, stats: group.stats, instances: group.count });
644
+ session.instances += group.count;
645
+ const model = models.get(group.key);
646
+ if (!model) {
647
+ models.set(group.key, {
648
+ key: group.key,
649
+ stats: cloneCacheStats(group.stats),
650
+ instances: group.count,
651
+ sessions: [group.sessionHash],
652
+ refUpdatedAt: group.bestUpdatedAt,
653
+ refIndex: group.bestIndex,
654
+ firstEligibleIndex: group.firstEligibleIndex,
655
+ });
656
+ continue;
657
+ }
658
+ model.firstEligibleIndex = Math.min(model.firstEligibleIndex, group.firstEligibleIndex);
659
+ mergeStatsAcrossDays(model.stats, group.stats, null);
660
+ model.instances += group.count;
661
+ model.sessions.push(group.sessionHash);
662
+ // The sequential aggregation keeps the first shard with the newest updatedAt.
663
+ if (group.bestUpdatedAt > model.refUpdatedAt || (group.bestUpdatedAt === model.refUpdatedAt && group.bestIndex < model.refIndex)) {
664
+ model.refUpdatedAt = group.bestUpdatedAt;
665
+ model.refIndex = group.bestIndex;
666
+ }
667
+ }
668
+ const routed = routedList
669
+ .filter((group) => group.globalEpoch === globalEpoch && group.firstEligibleIndex !== Infinity)
670
+ .sort((a, b) => a.firstEligibleIndex - b.firstEligibleIndex)
671
+ .map((group) => ({ sessionHash: group.sessionHash, updatedAt: group.bestUpdatedAt, model: group.model }));
672
+ const contribution: ShardContribution = {
673
+ sessions: Array.from(sessions.values()),
674
+ models: Array.from(models.values(), ({ key, stats, instances, sessions: list }) => ({ key, stats, instances, sessions: list })),
675
+ refs: Array.from(models.values())
676
+ .filter((model) => model.firstEligibleIndex !== Infinity)
677
+ .sort((a, b) => a.firstEligibleIndex - b.firstEligibleIndex)
678
+ .map((model) => {
679
+ const bucket = this.archive.buckets[model.refIndex]!;
680
+ return { key: model.key, updatedAt: model.refUpdatedAt, ref: { provider: bucket.provider, id: bucket.modelId, name: bucket.modelName ?? bucket.modelId } };
681
+ }),
682
+ routed,
683
+ };
684
+ this.contributionCache = { fingerprint, contribution };
685
+ return contribution;
266
686
  }
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);
687
+ }
688
+
689
+ /**
690
+ * Aggregate `leading` shards, then the archive, then `trailing` shards: the
691
+ * same result as aggregateStatsShardsV7([...leading, ...archive.shards,
692
+ * ...trailing], day), without materializing the archive. With `sessions`,
693
+ * only shards of those sessions take part. The current-day view only sees
694
+ * archive entries of that day; the all-days view merges the archive's
695
+ * pre-reduced contribution.
696
+ */
697
+ export async function aggregateStatsWithArchiveV7(
698
+ leading: readonly PersistedStatsShardV7[],
699
+ archive: StatsArchiveView,
700
+ trailing: readonly PersistedStatsShardV7[] = [],
701
+ day: string | null = currentLocalDay(),
702
+ sessions?: ReadonlySet<string>,
703
+ ): Promise<ShardAggregate> {
704
+ const aggregator = await createShardAggregatorV7(day);
705
+ const include = (shard: PersistedStatsShardV7) => !sessions || sessions.has(shard.sessionHash);
706
+ for (const shard of leading) if (include(shard)) await aggregator.add(shard);
707
+ if (day === null) aggregator.addContribution(await archive.contribution(aggregator, sessions));
708
+ else for (const shard of archive.shardsForDay(day)) if (include(shard)) await aggregator.add(shard);
709
+ for (const shard of trailing) if (include(shard)) await aggregator.add(shard);
710
+ return aggregator.finish();
711
+ }
712
+
713
+ type ArchiveSource =
714
+ | { kind: "none" }
715
+ | { kind: "invalid"; path: string }
716
+ | { kind: "legacy"; path: string; info: import("node:fs").Stats }
717
+ | { kind: "generation"; path: string; generation: number; info: import("node:fs").Stats };
718
+
719
+ const SOURCE_ATTEMPTS = 5;
720
+
721
+ /**
722
+ * The newest snapshot: the highest generation, or 2.9.0's archive.json while
723
+ * no generation exists. Retries when a listed generation disappears (it was
724
+ * superseded and cleaned up meanwhile). Throws on other I/O errors.
725
+ */
726
+ async function locateArchiveSource(archivePath: string): Promise<ArchiveSource> {
727
+ for (let attempt = 0; attempt < SOURCE_ATTEMPTS; attempt++) {
728
+ const generations = await listStatsArchiveGenerations(archivePath);
729
+ const generation = generations.at(-1);
730
+ if (generation !== undefined) {
731
+ const path = statsArchiveGenerationPath(archivePath, generation);
732
+ try {
733
+ const info = await lstat(path);
734
+ if (!info.isFile() || info.isSymbolicLink()) return { kind: "invalid", path };
735
+ return { kind: "generation", path, generation, info };
736
+ } catch (error) {
737
+ if (getErrorCode(error) === "ENOENT") continue;
738
+ throw error;
739
+ }
740
+ }
741
+ try {
742
+ const info = await lstat(archivePath);
743
+ if (info.isFile() && !info.isSymbolicLink()) return { kind: "legacy", path: archivePath, info };
744
+ // The migration tombstone is created only after a generation exists:
745
+ // the listing raced a cleanup, or the generations were removed by hand.
746
+ if (info.isDirectory()) continue;
747
+ return { kind: "invalid", path: archivePath };
748
+ } catch (error) {
749
+ if (getErrorCode(error) === "ENOENT") return { kind: "none" };
750
+ throw error;
277
751
  }
278
- return transientFailure();
279
752
  }
280
- let loaded: LoadedStatsArchive;
753
+ return { kind: "none" };
754
+ }
755
+
756
+ type CachedArchive = { path: string; ino: number | bigint; size: number; mtimeMs: number; view: StatsArchiveView };
757
+ const archiveCache = new Map<string, CachedArchive>();
758
+
759
+ export function clearStatsArchiveCache(): void {
760
+ archiveCache.clear();
761
+ }
762
+
763
+ const emptyView = (): StatsArchiveView => new StatsArchiveView(emptyStatsArchive(0), false, false, 0);
764
+ const corruptView = (): StatsArchiveView => new StatsArchiveView(emptyStatsArchive(0), true, true, 0);
765
+
766
+ function sha256(text: string | Buffer): string {
767
+ return createHash("sha256").update(text).digest("hex");
768
+ }
769
+
770
+ function parseArchiveFile(source: Extract<ArchiveSource, { kind: "legacy" | "generation" }>, bytes: Buffer): StatsArchiveView {
771
+ let value: unknown;
281
772
  try {
282
- const parsed = parseStatsArchive(JSON.parse(text));
283
- loaded = parsed ? buildLoaded(parsed, true, false) : buildLoaded(emptyStatsArchive(0), true, true);
773
+ value = JSON.parse(bytes.toString("utf8"));
284
774
  } catch {
285
- loaded = buildLoaded(emptyStatsArchive(0), true, true);
775
+ return corruptView();
776
+ }
777
+ if (source.kind === "legacy") {
778
+ const archive = parseStatsArchive(value);
779
+ return archive ? new StatsArchiveView(archive, true, false, 0, sha256(bytes)) : corruptView();
286
780
  }
287
- archiveCache.set(path, { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, loaded });
288
- return loaded;
781
+ const parsed = parseStatsArchiveV2(value);
782
+ if (!parsed || parsed.generation !== source.generation) return corruptView();
783
+ return new StatsArchiveView(parsed.archive, true, false, parsed.generation, parsed.legacySha256);
289
784
  }
290
785
 
291
- type ArchiveFileIdentity = { ino: number | bigint; size: number; mtimeMs: number } | undefined;
786
+ export type ReadStatsArchiveOptions = {
787
+ /** Throw on a transient I/O failure instead of serving the last good snapshot (compaction). */
788
+ strict?: boolean;
789
+ };
292
790
 
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;
791
+ /**
792
+ * Read the newest archive snapshot, re-parsing only when the file (path,
793
+ * inode, size, mtime) changed. No archive is an empty one; an invalid one is
794
+ * reported as corrupt and treated as empty, so raw shards stay the source of
795
+ * truth. A transient I/O failure (Windows EPERM/EBUSY while another process
796
+ * or a scanner holds the file) is never cached: the last good snapshot is
797
+ * returned when there is one, otherwise an uncached corrupt result, so the
798
+ * next refresh retries.
799
+ */
800
+ export async function readStatsArchiveView(archivePath: string = SHARD_ARCHIVE_PATH, options: ReadStatsArchiveOptions = {}): Promise<StatsArchiveView> {
801
+ const transientFailure = (error: unknown): StatsArchiveView => {
802
+ if (options.strict) throw error;
803
+ const previous = archiveCache.get(archivePath);
804
+ return previous && !previous.view.corrupt ? previous.view : corruptView();
805
+ };
806
+ for (let attempt = 0; attempt < SOURCE_ATTEMPTS; attempt++) {
807
+ let source: ArchiveSource;
808
+ try {
809
+ source = await locateArchiveSource(archivePath);
810
+ } catch (error) {
811
+ return transientFailure(error);
812
+ }
813
+ if (source.kind === "none") {
814
+ archiveCache.delete(archivePath);
815
+ return emptyView();
816
+ }
817
+ if (source.kind === "invalid") {
818
+ archiveCache.delete(archivePath);
819
+ return corruptView();
820
+ }
821
+ const { info } = source;
822
+ const cached = archiveCache.get(archivePath);
823
+ // Generations are immutable, so (path, identity) is a safe cache key. 2.9.0
824
+ // rewrites archive.json in place of the old one, and on filesystems with
825
+ // ino 0 and coarse mtimes (FAT/SMB on Windows) a rewrite of the same size
826
+ // keeps the same identity. Compaction migrates from it and then deletes
827
+ // shards, so it always re-reads that file instead of trusting the cache.
828
+ const cacheable = !(options.strict && source.kind === "legacy");
829
+ if (cacheable && cached && cached.path === source.path && cached.ino === info.ino && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
830
+ return cached.view;
831
+ }
832
+ let bytes: Buffer;
833
+ try {
834
+ bytes = await readFile(source.path);
835
+ } catch (error) {
836
+ // Superseded and cleaned up (or migrated) between listing and reading.
837
+ if (getErrorCode(error) === "ENOENT") continue;
838
+ // On Windows a generation that is being deleted while someone holds it
839
+ // open fails with EPERM instead; a newer one exists by then.
840
+ if (source.kind === "generation") {
841
+ const newest = await listStatsArchiveGenerations(archivePath).then((list) => list.at(-1), () => undefined);
842
+ if (newest !== undefined && newest > source.generation) continue;
843
+ }
844
+ return transientFailure(error);
845
+ }
846
+ const view = parseArchiveFile(source, bytes);
847
+ archiveCache.set(archivePath, { path: source.path, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, view });
848
+ return view;
300
849
  }
850
+ return transientFailure(new Error(`stats archive ${archivePath} kept changing while being read`));
301
851
  }
302
852
 
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;
853
+ /** Reference-form read (used by tests and tools): the snapshot with synthetic shards available. */
854
+ export async function readStatsArchive(archivePath: string = SHARD_ARCHIVE_PATH, options: ReadStatsArchiveOptions = {}): Promise<LoadedStatsArchive> {
855
+ return readStatsArchiveView(archivePath, options);
306
856
  }
307
857
 
308
858
  /** Remove archive temp files a crash left behind (shard cleanup only covers shards/). */
@@ -324,17 +874,126 @@ async function cleanupStaleArchiveTemps(archivePath: string, now: number): Promi
324
874
  }
325
875
  }
326
876
 
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`;
877
+ /** Identity of whatever sits at 2.9.0's archive.json path (undefined when nothing does). */
878
+ async function legacyArchiveIdentity(archivePath: string): Promise<string | undefined> {
330
879
  try {
331
- await writeFile(tempPath, JSON.stringify(archive) + "\n", "utf8");
332
- await renameWithRetry(tempPath, path);
880
+ const info = await lstat(archivePath);
881
+ return JSON.stringify([String(info.ino), info.size, info.mtimeMs, info.isFile()]);
333
882
  } catch (error) {
883
+ if (getErrorCode(error) === "ENOENT") return undefined;
884
+ throw error;
885
+ }
886
+ }
887
+
888
+ /** Test hooks for the publish protocol; production never sets them. */
889
+ export type ArchivePublishHooks = {
890
+ /** Runs after every publish check passed, right before the new generation becomes visible. */
891
+ beforeCommit?: () => Promise<void>;
892
+ };
893
+
894
+ /**
895
+ * Publish `generation` atomically and exclusively: the snapshot is written
896
+ * and flushed to a private temp file, then hard-linked to its final name.
897
+ * link() fails with EEXIST when that generation already exists, on Windows
898
+ * and POSIX alike, so of all writers that read generation N exactly one can
899
+ * publish N+1. Resolves false when another writer won.
900
+ */
901
+ async function commitStatsArchiveGeneration(archivePath: string, generation: number, text: string, hooks: ArchivePublishHooks): Promise<boolean> {
902
+ await mkdir(dirname(archivePath), { recursive: true });
903
+ const target = statsArchiveGenerationPath(archivePath, generation);
904
+ const tempPath = `${target}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
905
+ try {
906
+ const handle = await open(tempPath, "wx");
907
+ try {
908
+ await handle.writeFile(text, "utf8");
909
+ await handle.sync();
910
+ } finally {
911
+ await handle.close();
912
+ }
913
+ await hooks.beforeCommit?.();
914
+ try {
915
+ await link(tempPath, target);
916
+ } catch (error) {
917
+ if (getErrorCode(error) === "EEXIST") return false;
918
+ // A filesystem without hard links (FAT/exFAT) cannot publish safely;
919
+ // the caller reports it and raw shards simply stay where they are.
920
+ throw error;
921
+ }
922
+ return true;
923
+ } finally {
334
924
  await unlink(tempPath).catch(() => undefined);
925
+ }
926
+ }
927
+
928
+ /**
929
+ * Drop generations older than the one before `current`. The previous one is
930
+ * kept for readers that listed the directory just before `current` appeared;
931
+ * a reader that still finds its file gone re-lists and reads a newer one.
932
+ */
933
+ async function removeSupersededGenerations(archivePath: string, current: number): Promise<void> {
934
+ for (const generation of await listStatsArchiveGenerations(archivePath).catch(() => [] as number[])) {
935
+ if (generation >= current - 1) continue;
936
+ await unlink(statsArchiveGenerationPath(archivePath, generation)).catch(() => undefined);
937
+ }
938
+ }
939
+
940
+ const LEGACY_TOMBSTONE_README =
941
+ "pi-cache-optimizer stores its stats archive in the archive.<generation>.json files next to this directory.\n" +
942
+ "This directory replaces the single archive.json of version 2.9.0 so that 2.9.0 can no longer write it\n" +
943
+ "(it would publish without a compare-and-swap). 2.9.0 reports the archive as unreadable and keeps raw shards.\n" +
944
+ "Do not remove it while any Pi process might still run pi-cache-optimizer 2.9.0.\n";
945
+
946
+ /**
947
+ * After migrating (or whenever generations exist), make sure 2.9.0 cannot
948
+ * write archive.json: move a file there aside and put a directory in its
949
+ * place. A file whose content is the one this archive was migrated from is
950
+ * simply deleted. Anything else was written by 2.9.0 after the migration
951
+ * (only possible if the directory was removed); it is never counted, because
952
+ * merging it could double count, and is kept for inspection.
953
+ */
954
+ async function ensureLegacyArchiveTombstone(archivePath: string, migratedFromSha256: string | undefined): Promise<void> {
955
+ let info: import("node:fs").Stats | undefined;
956
+ try {
957
+ info = await lstat(archivePath);
958
+ } catch (error) {
959
+ if (getErrorCode(error) !== "ENOENT") return;
960
+ }
961
+ if (info?.isDirectory()) return;
962
+ if (info) {
963
+ const retired = `${archivePath}.${process.pid}.${randomUUID()}.retired.tmp`;
964
+ try {
965
+ await renameWithRetry(archivePath, retired);
966
+ } catch {
967
+ return; // retried by the next pass; readers ignore archive.json once a generation exists
968
+ }
969
+ let sha: string | undefined;
970
+ try {
971
+ sha = sha256(await readFile(retired));
972
+ } catch {}
973
+ if (sha !== undefined && sha === migratedFromSha256) {
974
+ await unlink(retired).catch(() => undefined);
975
+ } else {
976
+ const orphan = join(dirname(archivePath), `archive.legacy-${Date.now()}.json`);
977
+ await rename(retired, orphan).catch(() => undefined);
978
+ console.warn(`${LOG_PREFIX}: ignored a stats archive written by an older pi-cache-optimizer after migration; kept as ${orphan}`);
979
+ }
980
+ }
981
+ try {
982
+ await mkdir(archivePath);
983
+ await writeFile(join(archivePath, "README.txt"), LEGACY_TOMBSTONE_README, "utf8");
984
+ } catch {}
985
+ }
986
+
987
+ /** Instance ids of every shard-named file in `directory`, readable or not. */
988
+ async function listShardInstanceIds(directory: string): Promise<Set<string>> {
989
+ let names: string[];
990
+ try {
991
+ names = await readdir(directory);
992
+ } catch (error) {
993
+ if (getErrorCode(error) === "ENOENT") return new Set();
335
994
  throw error;
336
995
  }
337
- archiveCache.delete(path);
996
+ return new Set(names.filter((name) => /^[0-9a-f-]+\.json$/i.test(name)).map((name) => name.slice(0, -".json".length)));
338
997
  }
339
998
 
340
999
  /** A shard maintenance may fold away: from a past day and no longer written to. */
@@ -356,10 +1015,13 @@ export type CompactionResult = {
356
1015
  skippedCorrupt: boolean;
357
1016
  /**
358
1017
  * 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.
1018
+ * pass ran (e.g. a stale-lock takeover or a concurrent reset), or another
1019
+ * compactor published a newer generation first. No raw shard was deleted
1020
+ * and no visible archive was replaced; the next pass retries.
361
1021
  */
362
1022
  skippedConcurrent: boolean;
1023
+ /** Generation this pass published, when it published one. */
1024
+ generation?: number;
363
1025
  };
364
1026
 
365
1027
  export type CompactionOptions = {
@@ -367,17 +1029,27 @@ export type CompactionOptions = {
367
1029
  lockToken?: string;
368
1030
  /** Test hook: runs after the plan is built, right before the publish checks. */
369
1031
  beforePublish?: () => Promise<void>;
370
- };
1032
+ } & ArchivePublishHooks;
371
1033
 
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]);
1034
+ /**
1035
+ * Buckets expire by their newest folded shard. Shards of one day can be
1036
+ * closed much later (an idle process closes yesterday's shard at its next
1037
+ * rollover, possibly weeks later), so buckets are also split by the UTC day
1038
+ * of `updatedAt`. Otherwise the earliest shard would outlive its own 60-day
1039
+ * retention by as long as the latest one, and archived totals would exceed
1040
+ * what the raw shards would have shown. The remaining error is under a day.
1041
+ */
1042
+ const ARCHIVE_EXPIRY_GRANULARITY_MS = 24 * 60 * 60 * 1000;
1043
+ const bucketId = (b: Pick<StatsArchiveBucketV1, "globalEpoch" | "sessionHash" | "modelKey" | "modelEpoch" | "day" | "updatedAt">): string =>
1044
+ JSON.stringify([b.globalEpoch, b.sessionHash, b.modelKey, b.modelEpoch, b.day, Math.floor(b.updatedAt / ARCHIVE_EXPIRY_GRANULARITY_MS)]);
374
1045
  const routedId = (r: Pick<StatsArchiveRoutedV1, "globalEpoch" | "sessionHash">): string =>
375
1046
  JSON.stringify([r.globalEpoch, r.sessionHash]);
376
1047
 
377
1048
  /**
378
1049
  * Fold archivable shards into the archive, then delete them. Call only while
379
1050
  * holding the maintenance lock. Order matters for crash safety: the archive
380
- * (with the absorbed ids) is published before any raw shard is deleted.
1051
+ * (with the absorbed ids) is published before any raw shard is deleted, and
1052
+ * only by a pass whose generation is the newest one.
381
1053
  */
382
1054
  export async function compactStatsShardsV7(
383
1055
  now = Date.now(),
@@ -387,14 +1059,20 @@ export async function compactStatsShardsV7(
387
1059
  ): Promise<CompactionResult> {
388
1060
  const result: CompactionResult = { absorbed: 0, removed: 0, dropped: 0, skippedCorrupt: false, skippedConcurrent: false };
389
1061
  await cleanupStaleArchiveTemps(archivePath, now);
390
- clearStatsArchiveCache();
391
- const identityAtRead = await archiveFileIdentity(archivePath);
392
- const loaded = await readStatsArchive(archivePath);
393
- if (loaded.corrupt) {
1062
+ // Taken before the read so a 2.9.0 write racing it is noticed below.
1063
+ const legacyIdentityAtRead = await legacyArchiveIdentity(archivePath);
1064
+ // The snapshot this pass builds on. Generations are immutable, so the
1065
+ // readers' cached parse is safe to reuse; the publish below is a
1066
+ // compare-and-swap against exactly this generation.
1067
+ const base = await readStatsArchiveView(archivePath, { strict: true });
1068
+ if (base.corrupt) {
394
1069
  result.skippedCorrupt = true;
395
1070
  return result;
396
1071
  }
397
- const previous = loaded.archive;
1072
+ // 2.9.0's archive.json: publish it as the first generation even when
1073
+ // nothing else changed, so 2.9.0 can be locked out of writing it.
1074
+ const migrating = base.present && base.generation === 0;
1075
+ const previous = base.archive;
398
1076
  const today = currentLocalDay();
399
1077
  // Strict reads: compaction permanently drops counters whose epoch differs,
400
1078
  // so an unreadable epoch file must abort instead of falling back to the
@@ -433,9 +1111,14 @@ export async function compactStatsShardsV7(
433
1111
  }
434
1112
 
435
1113
  const shards = await readValidStatsShardsV7(directory);
436
- const presentIds = new Set(shards.map((shard) => shard.instanceId));
437
- const absorbed: Record<string, number> = {};
438
1114
  // Keep ids whose raw file still exists (a crash or a failed delete left it).
1115
+ // Presence is decided by the directory listing, not by a successful parse:
1116
+ // a leftover that is only transiently unreadable (Windows EPERM/EBUSY while
1117
+ // a scanner or sync client holds it) must stay absorbed, or it would be
1118
+ // counted raw again once readable and then folded into the archive twice.
1119
+ const presentIds = await listShardInstanceIds(directory);
1120
+ for (const shard of shards) presentIds.add(shard.instanceId);
1121
+ const absorbed: Record<string, number> = {};
439
1122
  for (const [id, at] of Object.entries(previous.absorbed)) if (presentIds.has(id)) absorbed[id] = at;
440
1123
 
441
1124
  for (const shard of shards) {
@@ -452,7 +1135,7 @@ export async function compactStatsShardsV7(
452
1135
  }
453
1136
  for (const [key, entry] of Object.entries(shard.models)) {
454
1137
  if (entry.modelEpoch !== await currentModelEpoch(key)) continue;
455
- const id = bucketId({ globalEpoch, sessionHash: shard.sessionHash, modelKey: key, modelEpoch: entry.modelEpoch, day: shard.day });
1138
+ const id = bucketId({ globalEpoch, sessionHash: shard.sessionHash, modelKey: key, modelEpoch: entry.modelEpoch, day: shard.day, updatedAt: shard.lifecycle.updatedAt });
456
1139
  const existing = buckets.get(id);
457
1140
  const newer = !existing || existing.updatedAt <= shard.lifecycle.updatedAt;
458
1141
  buckets.set(id, {
@@ -471,38 +1154,61 @@ export async function compactStatsShardsV7(
471
1154
  }
472
1155
  }
473
1156
 
1157
+ await touchStatsMaintenanceLock(options.lockToken);
1158
+
474
1159
  const absorbedChanged =
475
1160
  Object.keys(absorbed).length !== Object.keys(previous.absorbed).length ||
476
1161
  Object.keys(absorbed).some((id) => !(id in previous.absorbed));
477
- if (result.absorbed > 0 || result.dropped > 0 || absorbedChanged) {
1162
+ const skip = (): CompactionResult => {
1163
+ result.skippedConcurrent = true;
1164
+ result.absorbed = 0;
1165
+ result.dropped = 0;
1166
+ return result;
1167
+ };
1168
+ if (result.absorbed > 0 || result.dropped > 0 || absorbedChanged || migrating) {
478
1169
  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).
1170
+ // Cheap early exits: lost the lock, someone published meanwhile, or a
1171
+ // reset landed during the pass (counters written under the new epoch
1172
+ // would be dropped as stale). The compare-and-swap below is what makes
1173
+ // a stale holder safe; these only avoid wasted work.
483
1174
  let concurrent =
484
1175
  !(await holdsStatsMaintenanceLock(options.lockToken)) ||
485
- !sameArchiveFile(identityAtRead, await archiveFileIdentity(archivePath)) ||
1176
+ ((await listStatsArchiveGenerations(archivePath)).at(-1) ?? 0) !== base.generation ||
1177
+ (base.generation === 0 && (await legacyArchiveIdentity(archivePath)) !== legacyIdentityAtRead) ||
486
1178
  (await readGlobalStatsEpochStrict()) !== globalEpoch;
487
1179
  for (const [key, epoch] of modelEpochs) {
488
1180
  if (concurrent) break;
489
1181
  if ((await readModelStatsEpochStrict(key)) !== epoch) concurrent = true;
490
1182
  }
491
- if (concurrent) {
492
- result.skippedConcurrent = true;
493
- result.absorbed = 0;
494
- result.dropped = 0;
495
- return result;
496
- }
497
- await writeStatsArchive(archivePath, {
1183
+ if (concurrent) return skip();
1184
+ const next: StatsArchiveV1 = {
498
1185
  version: 1,
499
1186
  kind: "pi-cache-optimizer-archive",
500
1187
  updatedAt: now,
501
1188
  absorbed,
502
1189
  buckets: Array.from(buckets.values()),
503
1190
  routed: Array.from(routed.values()),
504
- });
1191
+ };
1192
+ const generation = base.generation + 1;
1193
+ const text = JSON.stringify(encodeStatsArchiveV2(next, generation, base.legacySha256)) + "\n";
1194
+ if (!(await commitStatsArchiveGeneration(archivePath, generation, text, options))) return skip();
1195
+ // A generation above ours means ours is invisible to readers (only
1196
+ // possible after our base was superseded and cleaned up while this pass
1197
+ // was suspended): it must not delete what its snapshot covers.
1198
+ if (((await listStatsArchiveGenerations(archivePath)).at(-1) ?? 0) !== generation) return skip();
1199
+ result.generation = generation;
1200
+ try {
1201
+ const info = await lstat(statsArchiveGenerationPath(archivePath, generation));
1202
+ archiveCache.set(archivePath, {
1203
+ path: statsArchiveGenerationPath(archivePath, generation),
1204
+ ino: info.ino, size: info.size, mtimeMs: info.mtimeMs,
1205
+ view: new StatsArchiveView(next, true, false, generation, base.legacySha256),
1206
+ });
1207
+ } catch {}
1208
+ await removeSupersededGenerations(archivePath, generation);
505
1209
  }
1210
+ if (result.generation !== undefined || base.generation > 0) await ensureLegacyArchiveTombstone(archivePath, base.legacySha256);
1211
+ await touchStatsMaintenanceLock(options.lockToken);
506
1212
 
507
1213
  // Only now that the archive covering them is durable, delete the originals.
508
1214
  const toDelete = Object.keys(absorbed);
@@ -535,6 +1241,11 @@ export async function maybeRunStatsMaintenance(now = Date.now()): Promise<StatsM
535
1241
  if (!cleanupDue && !compactionDue) return result;
536
1242
  const lockToken = await acquireStatsMaintenanceLock(now);
537
1243
  if (!lockToken) return result;
1244
+ // Heartbeat so a long pass (a huge archive on a slow, scanned disk) is not
1245
+ // taken over as stale. It stops while the process is suspended; the
1246
+ // archive compare-and-swap covers that case.
1247
+ const heartbeat = setInterval(() => void touchStatsMaintenanceLock(lockToken), STATS_MAINTENANCE_HEARTBEAT_MS);
1248
+ heartbeat.unref?.();
538
1249
  try {
539
1250
  if (cleanupDue) {
540
1251
  result.cleanupRemoved = await cleanupStatsShardsV7(now);
@@ -554,6 +1265,7 @@ export async function maybeRunStatsMaintenance(now = Date.now()): Promise<StatsM
554
1265
  if (!result.compaction?.skippedConcurrent) await writeMaintenanceMarker(SHARD_COMPACTION_MARKER_PATH, now);
555
1266
  }
556
1267
  } finally {
1268
+ clearInterval(heartbeat);
557
1269
  await releaseStatsMaintenanceLock(lockToken);
558
1270
  }
559
1271
  return result;