pi-cache-optimizer 2.9.0 → 2.9.2

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,52 @@
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
+ * Without hard links (FAT/exFAT, some NAS mounts) a generation is created
35
+ * exclusively in place (O_EXCL, the same compare-and-swap) and only counts
36
+ * once it parses completely.
37
+ *
38
+ * Recovery: every generation is self-consistent (its counters cover exactly
39
+ * the raw shards it lists as absorbed), so a generation that cannot be read
40
+ * (damaged on disk, truncated by a crash) is skipped for the previous one,
41
+ * which never double counts. The next compaction publishes above it and
42
+ * quarantines it as `archive.<generation>.corrupt-<time>.json`. A generation
43
+ * in a newer format is never skipped or overwritten.
44
+ *
45
+ * 2.9.0 compatibility: 2.9.0 kept a single `archive/archive.json`. It is still
46
+ * read while no generation exists and migrated by the next compaction, which
47
+ * moves it aside to a placeholder, verifies the moved bytes are the ones it
48
+ * read, replaces it with a directory of the same name, and then links the
49
+ * first generation. 2.9.0 treats that directory as an unreadable archive: it
50
+ * never compacts (so it cannot overwrite or delete anything) and its footer
51
+ * shows only history that is not archived, exactly like the versions before
52
+ * 2.9.0. A file that 2.9.0 still manages to write there afterwards (only if
53
+ * that directory is removed) is never counted: it is set aside as
54
+ * `archive.legacy-<time>.json`, trading the counters it absorbed for a
55
+ * guarantee against double counting. An unreadable archive.json is set aside
56
+ * the same way and the first generation starts over from the raw shards.
26
57
  */
27
- import { randomUUID } from "node:crypto";
28
- import { lstat, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
58
+ import { createHash, randomUUID } from "node:crypto";
59
+ import { link, lstat, mkdir, open, readFile, readdir, rm, unlink, writeFile } from "node:fs/promises";
29
60
  import { dirname, join } from "node:path";
30
61
  import { LOG_PREFIX, asRecord, getErrorCode, isNonEmptyString, isProcessAlive } from "./common.ts";
31
62
  import {
32
63
  type CacheStats,
33
64
  type PersistedRoutedModelRef,
34
65
  type PersistedStatsShardV7,
66
+ type ShardAggregate,
67
+ type ShardAggregator,
68
+ type ShardContribution,
35
69
  SHARD_CLEANUP_MARKER_PATH,
36
70
  SHARD_FILES_DIR,
37
71
  SHARD_IO_CONCURRENCY,
@@ -42,6 +76,7 @@ import {
42
76
  acquireStatsMaintenanceLock,
43
77
  cleanupStatsShardsV7,
44
78
  cloneCacheStats,
79
+ createShardAggregatorV7,
45
80
  currentLocalDay,
46
81
  isMaintenanceMarkerDue,
47
82
  mapWithConcurrency,
@@ -54,10 +89,20 @@ import {
54
89
  readValidStatsShardsV7,
55
90
  releaseStatsMaintenanceLock,
56
91
  renameWithRetry,
92
+ sameStatsFileIdentity,
93
+ statsFileIdentityOf,
94
+ type StatsFileIdentity,
95
+ SHARD_CLEANUP_INTERVAL_MS,
96
+ touchStatsMaintenanceLock,
57
97
  writeMaintenanceMarker,
58
98
  } from "./stats-store.ts";
59
99
 
60
100
  export const SHARD_ARCHIVE_DIR = join(SHARD_STATE_DIR, "archive");
101
+ /**
102
+ * 2.9.0's single-file archive. Read (and migrated) while no generation file
103
+ * exists; afterwards a directory blocks 2.9.0 from writing it. The other
104
+ * archive functions take this path and keep generations next to it.
105
+ */
61
106
  export const SHARD_ARCHIVE_PATH = join(SHARD_ARCHIVE_DIR, "archive.json");
62
107
  export const SHARD_COMPACTION_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-compaction");
63
108
 
@@ -68,6 +113,12 @@ export const SHARD_COMPACTION_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-co
68
113
  */
69
114
  export const ARCHIVE_ABANDONED_ACTIVE_MS = 24 * 60 * 60 * 1000;
70
115
 
116
+ /** How soon maintenance retries a compaction pass that failed with an error. */
117
+ export const COMPACTION_RETRY_AFTER_FAILURE_MS = 10 * 60 * 1000;
118
+
119
+ /** How often a running maintenance pass refreshes the lock's mtime. */
120
+ export const STATS_MAINTENANCE_HEARTBEAT_MS = 5 * 60 * 1000;
121
+
71
122
  export type StatsArchiveBucketV1 = {
72
123
  globalEpoch: string;
73
124
  sessionHash: string;
@@ -92,6 +143,7 @@ export type StatsArchiveRoutedV1 = {
92
143
  model: PersistedRoutedModelRef;
93
144
  };
94
145
 
146
+ /** In-memory archive contents (and 2.9.0's on-disk format). */
95
147
  export type StatsArchiveV1 = {
96
148
  version: 1;
97
149
  kind: "pi-cache-optimizer-archive";
@@ -102,7 +154,39 @@ export type StatsArchiveV1 = {
102
154
  routed: StatsArchiveRoutedV1[];
103
155
  };
104
156
 
157
+ /**
158
+ * On-disk generation format: the same entries as v1 with every string
159
+ * interned in one table, so a bucket is one short row of numbers.
160
+ * models rows: [provider, modelId, modelName | -1, api | -1] (string indexes).
161
+ * buckets rows: [globalEpoch, sessionHash, model, modelEpoch, day, updatedAt,
162
+ * stats.day, totalRequests, hitRequests, cachedInputTokens,
163
+ * cacheWriteInputTokens, totalInputTokens] (strings and model are indexes).
164
+ * routed rows: [globalEpoch, sessionHash, day, updatedAt, provider, id, name | -1].
165
+ */
166
+ export type StatsArchiveV2Document = {
167
+ version: 2;
168
+ kind: "pi-cache-optimizer-archive";
169
+ generation: number;
170
+ updatedAt: number;
171
+ /** sha256 of the 2.9.0 archive.json this archive was migrated from. */
172
+ legacySha256?: string;
173
+ absorbed: Record<string, number>;
174
+ strings: string[];
175
+ models: number[][];
176
+ buckets: number[][];
177
+ routed: number[][];
178
+ /**
179
+ * Completeness marker, always the last property: a generation written in
180
+ * place (filesystems without hard links) is only valid once it parses,
181
+ * and a truncated document never does. Older versions ignore it.
182
+ */
183
+ complete?: true;
184
+ };
185
+
105
186
  const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
187
+ const GENERATION_FILE_PATTERN = /^archive\.(\d{1,15})\.json$/;
188
+ /** 2.9.0's archive.json while it is being migrated (see compactStatsShardsV7). */
189
+ const MIGRATION_PLACEHOLDER_PATTERN = /^archive\.migrating-[0-9a-f-]+\.json$/;
106
190
 
107
191
  export function emptyStatsArchive(now = Date.now()): StatsArchiveV1 {
108
192
  return { version: 1, kind: "pi-cache-optimizer-archive", updatedAt: now, absorbed: {}, buckets: [], routed: [] };
@@ -112,59 +196,71 @@ function isFiniteNumber(value: unknown): value is number {
112
196
  return typeof value === "number" && Number.isFinite(value);
113
197
  }
114
198
 
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;
199
+ function parseArchiveBucket(raw: unknown): StatsArchiveBucketV1 | undefined {
200
+ const entry = asRecord(raw);
201
+ const stats = parseCacheStats(entry?.stats);
202
+ if (
203
+ !entry || !stats ||
204
+ !isNonEmptyString(entry.globalEpoch) || !isNonEmptyString(entry.sessionHash) ||
205
+ !isNonEmptyString(entry.modelKey) || !isNonEmptyString(entry.modelEpoch) ||
206
+ !isNonEmptyString(entry.provider) || !isNonEmptyString(entry.modelId) ||
207
+ typeof entry.day !== "string" || !DAY_PATTERN.test(entry.day) ||
208
+ !isFiniteNumber(entry.updatedAt) ||
209
+ entry.modelKey !== `${entry.provider}/${entry.modelId}`
210
+ ) return undefined;
211
+ return {
212
+ globalEpoch: entry.globalEpoch,
213
+ sessionHash: entry.sessionHash,
214
+ modelKey: entry.modelKey,
215
+ modelEpoch: entry.modelEpoch,
216
+ day: entry.day,
217
+ provider: entry.provider,
218
+ modelId: entry.modelId,
219
+ ...(isNonEmptyString(entry.modelName) ? { modelName: entry.modelName } : {}),
220
+ ...(isNonEmptyString(entry.api) ? { api: entry.api } : {}),
221
+ updatedAt: entry.updatedAt,
222
+ stats,
223
+ };
224
+ }
225
+
226
+ function parseArchiveRouted(raw: unknown): StatsArchiveRoutedV1 | undefined {
227
+ const entry = asRecord(raw);
228
+ const model = parsePersistedRoutedModelRef(entry?.model);
229
+ if (
230
+ !entry || !model ||
231
+ !isNonEmptyString(entry.globalEpoch) || !isNonEmptyString(entry.sessionHash) ||
232
+ typeof entry.day !== "string" || !DAY_PATTERN.test(entry.day) ||
233
+ !isFiniteNumber(entry.updatedAt)
234
+ ) return undefined;
235
+ return { globalEpoch: entry.globalEpoch, sessionHash: entry.sessionHash, day: entry.day, updatedAt: entry.updatedAt, model };
236
+ }
121
237
 
238
+ function parseAbsorbed(value: unknown): Record<string, number> | undefined {
239
+ const rawAbsorbed = asRecord(value);
240
+ if (!rawAbsorbed) return undefined;
122
241
  const absorbed: Record<string, number> = {};
123
242
  for (const [id, at] of Object.entries(rawAbsorbed)) {
124
243
  if (isNonEmptyString(id) && isFiniteNumber(at)) absorbed[id] = at;
125
244
  }
245
+ return absorbed;
246
+ }
126
247
 
248
+ /** Strict parse of 2.9.0's format: undefined when the document is not a v1 archive. Invalid entries are dropped. */
249
+ export function parseStatsArchive(value: unknown): StatsArchiveV1 | undefined {
250
+ const record = asRecord(value);
251
+ if (!record || record.version !== 1 || record.kind !== "pi-cache-optimizer-archive") return undefined;
252
+ const absorbed = parseAbsorbed(record.absorbed);
253
+ if (!absorbed || !Array.isArray(record.buckets) || !Array.isArray(record.routed)) return undefined;
127
254
  const buckets: StatsArchiveBucketV1[] = [];
128
255
  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
- });
256
+ const bucket = parseArchiveBucket(raw);
257
+ if (bucket) buckets.push(bucket);
153
258
  }
154
-
155
259
  const routed: StatsArchiveRoutedV1[] = [];
156
260
  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 });
261
+ const entry = parseArchiveRouted(raw);
262
+ if (entry) routed.push(entry);
166
263
  }
167
-
168
264
  return {
169
265
  version: 1,
170
266
  kind: "pi-cache-optimizer-archive",
@@ -175,28 +271,173 @@ export function parseStatsArchive(value: unknown): StatsArchiveV1 | undefined {
175
271
  };
176
272
  }
177
273
 
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
- };
274
+ /** Encode an archive in the compact generation format. */
275
+ export function encodeStatsArchiveV2(archive: StatsArchiveV1, generation: number, legacySha256?: string): StatsArchiveV2Document {
276
+ const strings: string[] = [];
277
+ const stringIndex = new Map<string, number>();
278
+ const str = (value: string): number => {
279
+ let index = stringIndex.get(value);
280
+ if (index === undefined) {
281
+ index = strings.length;
282
+ strings.push(value);
283
+ stringIndex.set(value, index);
284
+ }
285
+ return index;
286
+ };
287
+ const optional = (value: string | undefined): number => (value === undefined ? -1 : str(value));
288
+ const models: number[][] = [];
289
+ const modelIndex = new Map<string, number>();
290
+ const model = (bucket: StatsArchiveBucketV1): number => {
291
+ const row = [str(bucket.provider), str(bucket.modelId), optional(bucket.modelName), optional(bucket.api)];
292
+ const id = row.join(",");
293
+ let index = modelIndex.get(id);
294
+ if (index === undefined) {
295
+ index = models.length;
296
+ models.push(row);
297
+ modelIndex.set(id, index);
298
+ }
299
+ return index;
300
+ };
301
+ const buckets = archive.buckets.map((bucket) => [
302
+ str(bucket.globalEpoch), str(bucket.sessionHash), model(bucket), str(bucket.modelEpoch), str(bucket.day), bucket.updatedAt,
303
+ str(bucket.stats.day), bucket.stats.totalRequests, bucket.stats.hitRequests, bucket.stats.cachedInputTokens,
304
+ bucket.stats.cacheWriteInputTokens, bucket.stats.totalInputTokens,
305
+ ]);
306
+ const routed = archive.routed.map((entry) => [
307
+ str(entry.globalEpoch), str(entry.sessionHash), str(entry.day), entry.updatedAt,
308
+ str(entry.model.provider), str(entry.model.id), optional(entry.model.name),
309
+ ]);
310
+ return {
311
+ version: 2,
312
+ kind: "pi-cache-optimizer-archive",
313
+ generation,
314
+ updatedAt: archive.updatedAt,
315
+ ...(legacySha256 ? { legacySha256 } : {}),
316
+ absorbed: archive.absorbed,
317
+ strings,
318
+ models,
319
+ buckets,
320
+ routed,
321
+ complete: true,
322
+ };
323
+ }
188
324
 
189
- type CachedArchive = { ino: number | bigint; size: number; mtimeMs: number; loaded: LoadedStatsArchive };
190
- const archiveCache = new Map<string, CachedArchive>();
325
+ /**
326
+ * Strict parse of a generation file: undefined when the document itself is
327
+ * not a v2 archive. Rows with bad indexes or values are dropped, applying the
328
+ * same per-entry validation as v1.
329
+ */
330
+ export function parseStatsArchiveV2(value: unknown): { archive: StatsArchiveV1; generation: number; legacySha256?: string } | undefined {
331
+ const record = asRecord(value);
332
+ if (!record || record.version !== 2 || record.kind !== "pi-cache-optimizer-archive") return undefined;
333
+ if (!Number.isSafeInteger(record.generation) || (record.generation as number) < 1) return undefined;
334
+ const absorbed = parseAbsorbed(record.absorbed);
335
+ const { strings, models, buckets: rawBuckets, routed: rawRouted } = record;
336
+ if (
337
+ !absorbed || !Array.isArray(strings) || !Array.isArray(models) || !Array.isArray(rawBuckets) || !Array.isArray(rawRouted) ||
338
+ !strings.every((entry) => typeof entry === "string")
339
+ ) return undefined;
340
+ const str = (index: unknown): string | undefined =>
341
+ Number.isInteger(index) && (index as number) >= 0 && (index as number) < strings.length ? strings[index as number] as string : undefined;
342
+ const row = (value: unknown, length: number): unknown[] | undefined => (Array.isArray(value) && value.length === length ? value : undefined);
191
343
 
192
- export function clearStatsArchiveCache(): void {
193
- archiveCache.clear();
344
+ const modelTable = models.map((raw) => {
345
+ const entry = row(raw, 4);
346
+ return entry ? { provider: str(entry[0]), modelId: str(entry[1]), modelName: str(entry[2]), api: str(entry[3]) } : undefined;
347
+ });
348
+ const buckets: StatsArchiveBucketV1[] = [];
349
+ for (const raw of rawBuckets) {
350
+ const entry = row(raw, 12);
351
+ if (!entry) continue;
352
+ const modelEntry = Number.isInteger(entry[2]) ? modelTable[entry[2] as number] : undefined;
353
+ if (!modelEntry) continue;
354
+ const bucket = parseArchiveBucket({
355
+ globalEpoch: str(entry[0]),
356
+ sessionHash: str(entry[1]),
357
+ modelKey: `${modelEntry.provider}/${modelEntry.modelId}`,
358
+ modelEpoch: str(entry[3]),
359
+ day: str(entry[4]),
360
+ provider: modelEntry.provider,
361
+ modelId: modelEntry.modelId,
362
+ modelName: modelEntry.modelName,
363
+ api: modelEntry.api,
364
+ updatedAt: entry[5],
365
+ stats: {
366
+ day: str(entry[6]), totalRequests: entry[7], hitRequests: entry[8], cachedInputTokens: entry[9],
367
+ cacheWriteInputTokens: entry[10], totalInputTokens: entry[11],
368
+ },
369
+ });
370
+ if (bucket) buckets.push(bucket);
371
+ }
372
+ const routed: StatsArchiveRoutedV1[] = [];
373
+ for (const raw of rawRouted) {
374
+ const entry = row(raw, 7);
375
+ if (!entry) continue;
376
+ const parsed = parseArchiveRouted({
377
+ globalEpoch: str(entry[0]),
378
+ sessionHash: str(entry[1]),
379
+ day: str(entry[2]),
380
+ updatedAt: entry[3],
381
+ model: { provider: str(entry[4]), id: str(entry[5]), name: str(entry[6]) },
382
+ });
383
+ if (parsed) routed.push(parsed);
384
+ }
385
+ return {
386
+ archive: {
387
+ version: 1,
388
+ kind: "pi-cache-optimizer-archive",
389
+ updatedAt: isFiniteNumber(record.updatedAt) ? record.updatedAt : 0,
390
+ absorbed,
391
+ buckets,
392
+ routed,
393
+ },
394
+ generation: record.generation as number,
395
+ ...(isNonEmptyString(record.legacySha256) ? { legacySha256: record.legacySha256 } : {}),
396
+ };
194
397
  }
195
398
 
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 => ({
399
+ export function statsArchiveGenerationPath(archivePath: string, generation: number): string {
400
+ return join(dirname(archivePath), `archive.${String(generation).padStart(12, "0")}.json`);
401
+ }
402
+
403
+ /** Published generations next to `archivePath`, ascending. */
404
+ export async function listStatsArchiveGenerations(archivePath: string = SHARD_ARCHIVE_PATH): Promise<number[]> {
405
+ let names: string[];
406
+ try {
407
+ names = await readdir(dirname(archivePath));
408
+ } catch (error) {
409
+ if (getErrorCode(error) === "ENOENT") return [];
410
+ throw error;
411
+ }
412
+ const generations: number[] = [];
413
+ for (const name of names) {
414
+ const match = GENERATION_FILE_PATTERN.exec(name);
415
+ if (match) generations.push(Number(match[1]));
416
+ }
417
+ return generations.sort((a, b) => a - b);
418
+ }
419
+
420
+ function bucketToSyntheticShard(bucket: StatsArchiveBucketV1, index: number): PersistedStatsShardV7 {
421
+ const shard = syntheticShardBase(`archive-bucket-${index}`, bucket.sessionHash, bucket.day, bucket.globalEpoch, bucket.updatedAt);
422
+ shard.models[bucket.modelKey] = {
423
+ modelEpoch: bucket.modelEpoch,
424
+ provider: bucket.provider,
425
+ modelId: bucket.modelId,
426
+ ...(bucket.modelName ? { modelName: bucket.modelName } : {}),
427
+ ...(bucket.api ? { api: bucket.api } : {}),
428
+ stats: cloneCacheStats(bucket.stats),
429
+ };
430
+ return shard;
431
+ }
432
+
433
+ function routedToSyntheticShard(entry: StatsArchiveRoutedV1, index: number): PersistedStatsShardV7 {
434
+ const shard = syntheticShardBase(`archive-routed-${index}`, entry.sessionHash, entry.day, entry.globalEpoch, entry.updatedAt);
435
+ shard.lastRoutedModel = { ...entry.model };
436
+ return shard;
437
+ }
438
+
439
+ function syntheticShardBase(id: string, sessionHash: string, day: string, globalEpoch: string, updatedAt: number): PersistedStatsShardV7 {
440
+ return {
200
441
  version: 7,
201
442
  kind: "pi-cache-optimizer-shard",
202
443
  instanceId: id,
@@ -206,103 +447,676 @@ export function archiveToSyntheticShards(archive: StatsArchiveV1): PersistedStat
206
447
  day,
207
448
  globalEpoch,
208
449
  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;
450
+ };
228
451
  }
229
452
 
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
- };
453
+ /**
454
+ * Archive entries as synthetic closed shards: the reference semantics.
455
+ * Aggregating these (after the raw shards) is what the archive must be
456
+ * equivalent to; readers use StatsArchiveView's reduced forms instead.
457
+ */
458
+ export function archiveToSyntheticShards(archive: StatsArchiveV1): PersistedStatsShardV7[] {
459
+ return [
460
+ ...archive.buckets.map(bucketToSyntheticShard),
461
+ ...archive.routed.map(routedToSyntheticShard),
462
+ ];
238
463
  }
239
464
 
465
+ export type LoadedStatsArchive = {
466
+ /** False when no archive exists; true for a parsed (or unreadable) one. */
467
+ present: boolean;
468
+ /** True when an archive exists but cannot be parsed; compaction then refuses to overwrite it. */
469
+ corrupt: boolean;
470
+ /** Generation this snapshot came from; 0 for 2.9.0's archive.json or no archive. */
471
+ generation: number;
472
+ archive: StatsArchiveV1;
473
+ absorbedIds: ReadonlySet<string>;
474
+ /** Every archive entry as a synthetic shard (reference form, built on first access). Do not mutate. */
475
+ readonly shards: readonly PersistedStatsShardV7[];
476
+ };
477
+
478
+ type BucketGroup = {
479
+ /** Position in ReducedArchive.groups. */
480
+ order: number;
481
+ globalEpoch: string;
482
+ sessionHash: string;
483
+ key: string;
484
+ modelEpoch: string;
485
+ /** The single bucket's own stats until a second bucket is merged (then a private copy). */
486
+ stats: CacheStats;
487
+ count: number;
488
+ /** First bucket (in archive order) with the group's newest updatedAt. */
489
+ bestUpdatedAt: number;
490
+ bestIndex: number;
491
+ /** Index of the first bucket an aggregation could record a ref from (updatedAt > -1); Infinity if none. */
492
+ firstEligibleIndex: number;
493
+ };
494
+
495
+ type RoutedGroup = {
496
+ globalEpoch: string;
497
+ sessionHash: string;
498
+ bestUpdatedAt: number;
499
+ model: PersistedRoutedModelRef;
500
+ /** Index of the first entry an aggregation could pick (updatedAt > -1); Infinity if none. */
501
+ firstEligibleIndex: number;
502
+ };
503
+
504
+ type ReducedArchive = {
505
+ /** Buckets folded per (globalEpoch, session, model, modelEpoch), in order of first appearance. */
506
+ groups: BucketGroup[];
507
+ routed: RoutedGroup[];
508
+ /** The same groups per session (each list in archive order). */
509
+ groupsBySession: Map<string, BucketGroup[]>;
510
+ routedBySession: Map<string, RoutedGroup[]>;
511
+ /** Model keys with buckets under each global epoch, sorted. */
512
+ keysByGlobalEpoch: Map<string, string[]>;
513
+ };
514
+
240
515
  /**
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.
516
+ * One parsed archive snapshot, cached per file identity. Besides the raw
517
+ * entries it lazily builds what a refresh needs: the per-day synthetic shards
518
+ * for the current-day view (archived days are past days, so normally none)
519
+ * and, per set of current epochs, the all-days aggregate pre-reduced to one
520
+ * entry per (session, model).
247
521
  */
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);
522
+ export class StatsArchiveView implements LoadedStatsArchive {
523
+ readonly absorbedIds: ReadonlySet<string>;
524
+ private syntheticShards?: PersistedStatsShardV7[];
525
+ private dayIndex?: Map<string, { buckets: number[]; routed: number[] }>;
526
+ private dayShards?: { day: string; shards: readonly PersistedStatsShardV7[] };
527
+ private reduced?: ReducedArchive;
528
+ private contributionCache?: { fingerprint: string; contribution: ShardContribution };
529
+
530
+ /** Highest generation listed, including unreadable ones above `generation`; the next publish targets newestGeneration + 1. */
531
+ readonly newestGeneration: number;
532
+ /** Unreadable generations above the one this snapshot came from (recovered from; quarantined by the next publish). */
533
+ readonly corruptGenerations: readonly number[];
534
+ /** The newest generation does not parse yet but is young: it may still be written in place (no-hard-link fallback). */
535
+ readonly pendingGeneration: boolean;
536
+ /** 2.9.0's file this snapshot was read from: archive.json, or a migration placeholder after an interrupted migration. */
537
+ readonly legacySourcePath?: string;
538
+ /** sha256 of the legacy source file's bytes (also set when it is unreadable, to verify it is unchanged before healing). */
539
+ readonly legacySourceSha256?: string;
540
+ /** No generation exists and 2.9.0's archive.json is unreadable: the next publish starts over and sets the file aside. */
541
+ readonly legacyCorrupt: boolean;
542
+
543
+ constructor(
544
+ readonly archive: StatsArchiveV1,
545
+ readonly present: boolean,
546
+ readonly corrupt: boolean,
547
+ readonly generation: number,
548
+ /** For 2.9.0's archive.json: its sha256; for a generation: the legacy file it was migrated from. */
549
+ readonly legacySha256?: string,
550
+ recovery: {
551
+ newestGeneration?: number;
552
+ corruptGenerations?: readonly number[];
553
+ pendingGeneration?: boolean;
554
+ legacySourcePath?: string;
555
+ legacySourceSha256?: string;
556
+ legacyCorrupt?: boolean;
557
+ } = {},
558
+ ) {
559
+ this.absorbedIds = new Set(Object.keys(archive.absorbed));
560
+ this.newestGeneration = Math.max(generation, recovery.newestGeneration ?? 0);
561
+ this.corruptGenerations = recovery.corruptGenerations ?? [];
562
+ this.pendingGeneration = recovery.pendingGeneration ?? false;
563
+ this.legacySourcePath = recovery.legacySourcePath;
564
+ this.legacySourceSha256 = recovery.legacySourceSha256;
565
+ this.legacyCorrupt = recovery.legacyCorrupt ?? false;
566
+ }
567
+
568
+ /** The same snapshot, recovered from below unreadable newer generations. */
569
+ withRecovery(newestGeneration: number, corruptGenerations: readonly number[], pendingGeneration: boolean): StatsArchiveView {
570
+ const key = JSON.stringify([newestGeneration, corruptGenerations, pendingGeneration]);
571
+ if (this.recovered?.key === key) return this.recovered.view;
572
+ const view = new StatsArchiveView(this.archive, this.present, this.corrupt, this.generation, this.legacySha256, {
573
+ newestGeneration, corruptGenerations, pendingGeneration,
574
+ legacySourcePath: this.legacySourcePath, legacySourceSha256: this.legacySourceSha256,
575
+ });
576
+ this.recovered = { key, view };
577
+ return view;
578
+ }
579
+ private recovered?: { key: string; view: StatsArchiveView };
580
+
581
+ get shards(): readonly PersistedStatsShardV7[] {
582
+ return this.syntheticShards ??= archiveToSyntheticShards(this.archive);
583
+ }
584
+
585
+ /** The synthetic shards of `day`, in archive order. Do not mutate. */
586
+ shardsForDay(day: string): readonly PersistedStatsShardV7[] {
587
+ if (this.dayShards?.day === day) return this.dayShards.shards;
588
+ if (!this.dayIndex) {
589
+ this.dayIndex = new Map();
590
+ const slot = (entryDay: string) => {
591
+ let entry = this.dayIndex!.get(entryDay);
592
+ if (!entry) {
593
+ entry = { buckets: [], routed: [] };
594
+ this.dayIndex!.set(entryDay, entry);
595
+ }
596
+ return entry;
597
+ };
598
+ this.archive.buckets.forEach((bucket, index) => slot(bucket.day).buckets.push(index));
599
+ this.archive.routed.forEach((entry, index) => slot(entry.day).routed.push(index));
260
600
  }
261
- return transientFailure();
601
+ const indexes = this.dayIndex.get(day);
602
+ const shards = indexes
603
+ ? [
604
+ ...indexes.buckets.map((index) => bucketToSyntheticShard(this.archive.buckets[index]!, index)),
605
+ ...indexes.routed.map((index) => routedToSyntheticShard(this.archive.routed[index]!, index)),
606
+ ]
607
+ : [];
608
+ this.dayShards = { day, shards };
609
+ return shards;
262
610
  }
263
- if (!info.isFile() || info.isSymbolicLink()) {
264
- archiveCache.delete(path);
265
- return buildLoaded(emptyStatsArchive(0), true, true);
611
+
612
+ private reduce(): ReducedArchive {
613
+ if (this.reduced) return this.reduced;
614
+ const groups = new Map<string, BucketGroup>();
615
+ const keys = new Map<string, Set<string>>();
616
+ this.archive.buckets.forEach((bucket, index) => {
617
+ const id = JSON.stringify([bucket.globalEpoch, bucket.sessionHash, bucket.modelKey, bucket.modelEpoch]);
618
+ const group = groups.get(id);
619
+ if (!group) {
620
+ groups.set(id, {
621
+ order: groups.size,
622
+ globalEpoch: bucket.globalEpoch,
623
+ sessionHash: bucket.sessionHash,
624
+ key: bucket.modelKey,
625
+ modelEpoch: bucket.modelEpoch,
626
+ stats: bucket.stats,
627
+ count: 1,
628
+ bestUpdatedAt: bucket.updatedAt,
629
+ bestIndex: index,
630
+ firstEligibleIndex: bucket.updatedAt > -1 ? index : Infinity,
631
+ });
632
+ let set = keys.get(bucket.globalEpoch);
633
+ if (!set) keys.set(bucket.globalEpoch, set = new Set());
634
+ set.add(bucket.modelKey);
635
+ return;
636
+ }
637
+ if (group.count === 1) group.stats = cloneCacheStats(group.stats);
638
+ mergeStatsAcrossDays(group.stats, bucket.stats, null);
639
+ group.count++;
640
+ if (group.firstEligibleIndex === Infinity && bucket.updatedAt > -1) group.firstEligibleIndex = index;
641
+ if (bucket.updatedAt > group.bestUpdatedAt) {
642
+ group.bestUpdatedAt = bucket.updatedAt;
643
+ group.bestIndex = index;
644
+ }
645
+ });
646
+ const routed = new Map<string, RoutedGroup>();
647
+ this.archive.routed.forEach((entry, index) => {
648
+ const id = JSON.stringify([entry.globalEpoch, entry.sessionHash]);
649
+ const group = routed.get(id);
650
+ const eligible = entry.updatedAt > -1 ? index : Infinity;
651
+ if (!group) {
652
+ routed.set(id, { globalEpoch: entry.globalEpoch, sessionHash: entry.sessionHash, bestUpdatedAt: entry.updatedAt, model: entry.model, firstEligibleIndex: eligible });
653
+ return;
654
+ }
655
+ if (group.firstEligibleIndex === Infinity) group.firstEligibleIndex = eligible;
656
+ if (entry.updatedAt > group.bestUpdatedAt) {
657
+ group.bestUpdatedAt = entry.updatedAt;
658
+ group.model = entry.model;
659
+ }
660
+ });
661
+ const bySession = <T extends { sessionHash: string }>(items: T[]): Map<string, T[]> => {
662
+ const map = new Map<string, T[]>();
663
+ for (const item of items) {
664
+ let list = map.get(item.sessionHash);
665
+ if (!list) map.set(item.sessionHash, list = []);
666
+ list.push(item);
667
+ }
668
+ return map;
669
+ };
670
+ const groupList = Array.from(groups.values());
671
+ const routedList = Array.from(routed.values());
672
+ this.reduced = {
673
+ groups: groupList,
674
+ routed: routedList,
675
+ groupsBySession: bySession(groupList),
676
+ routedBySession: bySession(routedList),
677
+ keysByGlobalEpoch: new Map(Array.from(keys, ([epoch, set]) => [epoch, Array.from(set).sort()])),
678
+ };
679
+ return this.reduced;
266
680
  }
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;
681
+
682
+ /**
683
+ * The archive's share of an all-days aggregate under the aggregator's
684
+ * epochs. Equivalent to adding `shards` (only those of `sessions`, when
685
+ * given) at that point of the sequence; cached until an epoch, the
686
+ * session selection, or the archive changes.
687
+ */
688
+ async contribution(aggregator: Pick<ShardAggregator, "globalEpoch" | "modelEpoch">, only?: ReadonlySet<string>): Promise<ShardContribution> {
689
+ const reduced = this.reduce();
690
+ const globalEpoch = aggregator.globalEpoch;
691
+ let groupList = reduced.groups;
692
+ let routedList = reduced.routed;
693
+ let keys = reduced.keysByGlobalEpoch.get(globalEpoch) ?? [];
694
+ if (only) {
695
+ groupList = Array.from(only, (session) => reduced.groupsBySession.get(session) ?? []).flat()
696
+ .sort((a, b) => a.order - b.order);
697
+ routedList = Array.from(only, (session) => reduced.routedBySession.get(session) ?? []).flat();
698
+ keys = Array.from(new Set(groupList.filter((group) => group.globalEpoch === globalEpoch).map((group) => group.key))).sort();
699
+ }
700
+ const epochs = new Map<string, string>();
701
+ for (const key of keys) epochs.set(key, await aggregator.modelEpoch(key));
702
+ const fingerprint = JSON.stringify([globalEpoch, ...keys.flatMap((key) => [key, epochs.get(key)]), only ? Array.from(only).sort() : null]);
703
+ if (this.contributionCache?.fingerprint === fingerprint) return this.contributionCache.contribution;
704
+
705
+ type SessionOut = { sessionHash: string; instances: number; models: Array<{ key: string; stats: CacheStats; instances: number }> };
706
+ type ModelOut = { key: string; stats: CacheStats; instances: number; sessions: string[]; refUpdatedAt: number; refIndex: number; firstEligibleIndex: number };
707
+ const sessions = new Map<string, SessionOut>();
708
+ const models = new Map<string, ModelOut>();
709
+ for (const group of groupList) {
710
+ if (group.globalEpoch !== globalEpoch || group.modelEpoch !== epochs.get(group.key)) continue;
711
+ let session = sessions.get(group.sessionHash);
712
+ if (!session) sessions.set(group.sessionHash, session = { sessionHash: group.sessionHash, instances: 0, models: [] });
713
+ session.models.push({ key: group.key, stats: group.stats, instances: group.count });
714
+ session.instances += group.count;
715
+ const model = models.get(group.key);
716
+ if (!model) {
717
+ models.set(group.key, {
718
+ key: group.key,
719
+ stats: cloneCacheStats(group.stats),
720
+ instances: group.count,
721
+ sessions: [group.sessionHash],
722
+ refUpdatedAt: group.bestUpdatedAt,
723
+ refIndex: group.bestIndex,
724
+ firstEligibleIndex: group.firstEligibleIndex,
725
+ });
726
+ continue;
727
+ }
728
+ model.firstEligibleIndex = Math.min(model.firstEligibleIndex, group.firstEligibleIndex);
729
+ mergeStatsAcrossDays(model.stats, group.stats, null);
730
+ model.instances += group.count;
731
+ model.sessions.push(group.sessionHash);
732
+ // The sequential aggregation keeps the first shard with the newest updatedAt.
733
+ if (group.bestUpdatedAt > model.refUpdatedAt || (group.bestUpdatedAt === model.refUpdatedAt && group.bestIndex < model.refIndex)) {
734
+ model.refUpdatedAt = group.bestUpdatedAt;
735
+ model.refIndex = group.bestIndex;
736
+ }
737
+ }
738
+ const routed = routedList
739
+ .filter((group) => group.globalEpoch === globalEpoch && group.firstEligibleIndex !== Infinity)
740
+ .sort((a, b) => a.firstEligibleIndex - b.firstEligibleIndex)
741
+ .map((group) => ({ sessionHash: group.sessionHash, updatedAt: group.bestUpdatedAt, model: group.model }));
742
+ const contribution: ShardContribution = {
743
+ sessions: Array.from(sessions.values()),
744
+ models: Array.from(models.values(), ({ key, stats, instances, sessions: list }) => ({ key, stats, instances, sessions: list })),
745
+ refs: Array.from(models.values())
746
+ .filter((model) => model.firstEligibleIndex !== Infinity)
747
+ .sort((a, b) => a.firstEligibleIndex - b.firstEligibleIndex)
748
+ .map((model) => {
749
+ const bucket = this.archive.buckets[model.refIndex]!;
750
+ return { key: model.key, updatedAt: model.refUpdatedAt, ref: { provider: bucket.provider, id: bucket.modelId, name: bucket.modelName ?? bucket.modelId } };
751
+ }),
752
+ routed,
753
+ };
754
+ this.contributionCache = { fingerprint, contribution };
755
+ return contribution;
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Aggregate `leading` shards, then the archive, then `trailing` shards: the
761
+ * same result as aggregateStatsShardsV7([...leading, ...archive.shards,
762
+ * ...trailing], day), without materializing the archive. With `sessions`,
763
+ * only shards of those sessions take part. The current-day view only sees
764
+ * archive entries of that day; the all-days view merges the archive's
765
+ * pre-reduced contribution.
766
+ */
767
+ export async function aggregateStatsWithArchiveV7(
768
+ leading: readonly PersistedStatsShardV7[],
769
+ archive: StatsArchiveView,
770
+ trailing: readonly PersistedStatsShardV7[] = [],
771
+ day: string | null = currentLocalDay(),
772
+ sessions?: ReadonlySet<string>,
773
+ ): Promise<ShardAggregate> {
774
+ const aggregator = await createShardAggregatorV7(day);
775
+ const include = (shard: PersistedStatsShardV7) => !sessions || sessions.has(shard.sessionHash);
776
+ for (const shard of leading) if (include(shard)) await aggregator.add(shard);
777
+ if (day === null) aggregator.addContribution(await archive.contribution(aggregator, sessions));
778
+ else for (const shard of archive.shardsForDay(day)) if (include(shard)) await aggregator.add(shard);
779
+ for (const shard of trailing) if (include(shard)) await aggregator.add(shard);
780
+ return aggregator.finish();
781
+ }
782
+
783
+ /** Test hooks for archive I/O races and faults; production never sets them. */
784
+ export type StatsArchiveTestHooks = {
785
+ /** Runs after each directory listing a reader makes (to land a concurrent migration or publish in between). */
786
+ afterList?: (archivePath: string) => void | Promise<void>;
787
+ /** Runs before a reader's lstat of a generation or legacy file; throw to simulate a fault. */
788
+ beforeLstat?: (path: string) => void | Promise<void>;
789
+ /** Runs before a reader's readFile of a generation or legacy file; throw to simulate a fault. */
790
+ beforeReadFile?: (path: string) => void | Promise<void>;
791
+ /** Replaces link() when publishing (e.g. throw EPERM like a filesystem without hard links). */
792
+ link?: (existingPath: string, newPath: string) => Promise<void>;
793
+ /** Runs right after the no-hard-link fallback created a generation file exclusively, before writing it. */
794
+ afterExclusiveCreate?: (target: string) => Promise<void>;
795
+ /** Overrides the maintenance heartbeat interval. */
796
+ heartbeatMs?: number;
797
+ };
798
+ let testHooks: StatsArchiveTestHooks = {};
799
+ export function setStatsArchiveTestHooks(hooks: StatsArchiveTestHooks | undefined): void {
800
+ testHooks = hooks ?? {};
801
+ }
802
+
803
+ type ArchiveListing = { generations: number[]; placeholders: string[] };
804
+
805
+ async function listArchiveDirectory(archivePath: string): Promise<ArchiveListing> {
806
+ let names: string[];
270
807
  try {
271
- text = await readFile(path, "utf8");
808
+ names = await readdir(dirname(archivePath));
272
809
  } 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();
810
+ if (getErrorCode(error) === "ENOENT") return { generations: [], placeholders: [] };
811
+ throw error;
812
+ }
813
+ const generations: number[] = [];
814
+ const placeholders: string[] = [];
815
+ for (const name of names) {
816
+ const match = GENERATION_FILE_PATTERN.exec(name);
817
+ if (match) generations.push(Number(match[1]));
818
+ else if (MIGRATION_PLACEHOLDER_PATTERN.test(name)) placeholders.push(join(dirname(archivePath), name));
279
819
  }
280
- let loaded: LoadedStatsArchive;
820
+ return { generations: generations.sort((a, b) => a - b), placeholders: placeholders.sort() };
821
+ }
822
+
823
+ const SOURCE_ATTEMPTS = 6;
824
+ /** Retries (with these delays) of a transient lstat/read failure before a reader gives up on this refresh. */
825
+ const TRANSIENT_RETRY_DELAYS_MS = [15, 60, 150];
826
+ const TRANSIENT_CODES = new Set(["EPERM", "EBUSY", "EACCES", "EAGAIN"]);
827
+ /**
828
+ * A newest generation that does not parse and is younger than this may still
829
+ * be being written in place (the no-hard-link fallback): compaction waits
830
+ * instead of recovering past it. Readers always skip it.
831
+ */
832
+ export const ARCHIVE_PENDING_GENERATION_MS = 10 * 60 * 1000;
833
+
834
+ type ParsedArchiveFile =
835
+ | { kind: "ok"; view: StatsArchiveView }
836
+ /** Not an archive this version can read (damaged, truncated, wrong generation). */
837
+ | { kind: "invalid"; sha256: string }
838
+ /** A well-formed archive in a newer format: never recovered past or overwritten. */
839
+ | { kind: "unsupported" };
840
+
841
+ type CachedArchiveFile = StatsFileIdentity & { parsed: ParsedArchiveFile };
842
+ /** Parsed archive files by path (generations are immutable; archive.json is re-validated by identity). */
843
+ const archiveFileCache = new Map<string, CachedArchiveFile>();
844
+ /** The last good snapshot per archive path, served (stale) when a transient error persists. */
845
+ const lastGoodArchive = new Map<string, StatsArchiveView>();
846
+
847
+ export function clearStatsArchiveCache(): void {
848
+ archiveFileCache.clear();
849
+ lastGoodArchive.clear();
850
+ }
851
+
852
+ const emptyView = (): StatsArchiveView => new StatsArchiveView(emptyStatsArchive(0), false, false, 0);
853
+ const corruptView = (): StatsArchiveView => new StatsArchiveView(emptyStatsArchive(0), true, true, 0);
854
+
855
+ function sha256(text: string | Buffer): string {
856
+ return createHash("sha256").update(text).digest("hex");
857
+ }
858
+
859
+ function parseArchiveBytes(kind: "legacy" | "generation", generation: number, path: string, bytes: Buffer): ParsedArchiveFile {
860
+ const invalid = (): ParsedArchiveFile => ({ kind: "invalid", sha256: sha256(bytes) });
861
+ let value: unknown;
281
862
  try {
282
- const parsed = parseStatsArchive(JSON.parse(text));
283
- loaded = parsed ? buildLoaded(parsed, true, false) : buildLoaded(emptyStatsArchive(0), true, true);
863
+ value = JSON.parse(bytes.toString("utf8"));
284
864
  } catch {
285
- loaded = buildLoaded(emptyStatsArchive(0), true, true);
865
+ return invalid();
286
866
  }
287
- archiveCache.set(path, { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, loaded });
288
- return loaded;
867
+ const record = asRecord(value);
868
+ const supported = kind === "legacy" ? 1 : 2;
869
+ if (record?.kind === "pi-cache-optimizer-archive" && isFiniteNumber(record.version) && record.version > supported) {
870
+ return { kind: "unsupported" };
871
+ }
872
+ if (kind === "legacy") {
873
+ const archive = parseStatsArchive(value);
874
+ const sha = sha256(bytes);
875
+ return archive
876
+ ? { kind: "ok", view: new StatsArchiveView(archive, true, false, 0, sha, { legacySourcePath: path, legacySourceSha256: sha }) }
877
+ : invalid();
878
+ }
879
+ const parsed = parseStatsArchiveV2(value);
880
+ if (!parsed || parsed.generation !== generation) return invalid();
881
+ return { kind: "ok", view: new StatsArchiveView(parsed.archive, true, false, parsed.generation, parsed.legacySha256) };
289
882
  }
290
883
 
291
- type ArchiveFileIdentity = { ino: number | bigint; size: number; mtimeMs: number } | undefined;
884
+ class ArchiveRestart extends Error {}
885
+
886
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
887
+
888
+ export type ReadStatsArchiveOptions = {
889
+ /** Throw on a transient I/O failure instead of serving the last good snapshot (compaction). */
890
+ strict?: boolean;
891
+ };
892
+
893
+ export type StatsArchiveRead = {
894
+ view: StatsArchiveView;
895
+ /**
896
+ * True when a transient I/O failure persisted and `view` is the last good
897
+ * snapshot (or an empty corrupt one): it may be older than a directory
898
+ * listing taken just before. See readStatsSnapshot.
899
+ */
900
+ stale: boolean;
901
+ };
292
902
 
293
- async function archiveFileIdentity(path: string): Promise<ArchiveFileIdentity> {
903
+ /**
904
+ * Read the newest usable archive snapshot.
905
+ *
906
+ * - The highest generation wins. A generation that is not a readable
907
+ * archive (damaged on disk, truncated by a crash of the in-place fallback,
908
+ * not a regular file) is skipped for the next lower one: every published
909
+ * generation is self-consistent (its counters cover exactly the raw shards
910
+ * it lists as absorbed), so falling back never double counts; it only
911
+ * misses what the unreadable one absorbed and already deleted. The next
912
+ * compaction publishes above it and quarantines it. A generation in a newer
913
+ * format is never skipped: it is reported as corrupt, like before.
914
+ * - Without generations, 2.9.0's archive.json (or a migration placeholder
915
+ * left by an interrupted migration) is read. An unreadable archive.json is
916
+ * treated as empty, and the next compaction starts over from it.
917
+ * - A file that disappears between listing and reading (superseded and
918
+ * removed, or migrated) makes the reader list again, as does a missing or
919
+ * directory archive.json, which a migration in progress produces.
920
+ * - Transient errors (Windows EPERM/EBUSY while a scanner holds a file) are
921
+ * retried briefly; if they persist, the last good snapshot is served and
922
+ * flagged stale (never cached as an empty archive).
923
+ */
924
+ export async function readStatsArchiveDetailed(archivePath: string = SHARD_ARCHIVE_PATH, options: ReadStatsArchiveOptions = {}): Promise<StatsArchiveRead> {
925
+ let transientRetries = 0;
926
+ let lastError: unknown;
927
+ for (let attempt = 0; attempt < SOURCE_ATTEMPTS + TRANSIENT_RETRY_DELAYS_MS.length; attempt++) {
928
+ try {
929
+ const view = await locateAndReadArchive(archivePath, options, attempt);
930
+ if (view === undefined) continue;
931
+ if (!view.corrupt) lastGoodArchive.set(archivePath, view);
932
+ else lastGoodArchive.delete(archivePath);
933
+ return { view, stale: false };
934
+ } catch (error) {
935
+ if (error instanceof ArchiveRestart) continue;
936
+ lastError = error;
937
+ if (TRANSIENT_CODES.has(getErrorCode(error) ?? "") && transientRetries < TRANSIENT_RETRY_DELAYS_MS.length) {
938
+ await sleep(TRANSIENT_RETRY_DELAYS_MS[transientRetries++]!);
939
+ continue;
940
+ }
941
+ break;
942
+ }
943
+ }
944
+ lastError ??= new Error(`stats archive ${archivePath} kept changing while being read`);
945
+ if (options.strict) throw lastError;
946
+ return { view: lastGoodArchive.get(archivePath) ?? corruptView(), stale: true };
947
+ }
948
+
949
+ /**
950
+ * One attempt. Resolves undefined (or throws ArchiveRestart) to list again;
951
+ * throws other errors for the caller's transient policy.
952
+ */
953
+ async function locateAndReadArchive(archivePath: string, options: ReadStatsArchiveOptions, attempt: number): Promise<StatsArchiveView | undefined> {
954
+ const listing = await listArchiveDirectory(archivePath);
955
+ await testHooks.afterList?.(archivePath);
956
+ const restartIfNewer = async (generation: number, error: unknown): Promise<never> => {
957
+ const code = getErrorCode(error);
958
+ // Superseded and removed (or migrated) between listing and reading.
959
+ if (code === "ENOENT") throw new ArchiveRestart();
960
+ // On Windows, opening or stat-ing a generation that is being deleted
961
+ // while someone holds it open fails with EPERM; a newer one exists then.
962
+ if (generation > 0) {
963
+ const newest = await listArchiveDirectory(archivePath).then((l) => l.generations.at(-1), () => undefined);
964
+ if (newest !== undefined && newest > generation) throw new ArchiveRestart();
965
+ }
966
+ throw error;
967
+ };
968
+ if (listing.generations.length > 0) {
969
+ const newest = listing.generations.at(-1)!;
970
+ const corrupt: number[] = [];
971
+ let pending = false;
972
+ for (let index = listing.generations.length - 1; index >= 0; index--) {
973
+ const generation = listing.generations[index]!;
974
+ const path = statsArchiveGenerationPath(archivePath, generation);
975
+ const parsed = await readArchiveFile(path, "generation", generation, options, restartIfNewer);
976
+ if (parsed === undefined || parsed.parsed.kind === "invalid") {
977
+ const age = parsed ? Date.now() - parsed.mtimeMs : Infinity;
978
+ if (generation === newest && Math.abs(age) < ARCHIVE_PENDING_GENERATION_MS) pending = true;
979
+ corrupt.unshift(generation);
980
+ continue;
981
+ }
982
+ if (parsed.parsed.kind === "unsupported") return corruptView();
983
+ const view = parsed.parsed.view;
984
+ return corrupt.length > 0 ? view.withRecovery(newest, corrupt, pending) : view;
985
+ }
986
+ // No generation is readable: start over from an empty archive. Raw
987
+ // shards the unreadable generations absorbed but did not delete yet are
988
+ // counted raw again (their ids are no longer known to be absorbed).
989
+ return new StatsArchiveView(emptyStatsArchive(0), false, false, 0, undefined, { newestGeneration: newest, corruptGenerations: corrupt, pendingGeneration: pending });
990
+ }
991
+
992
+ // No generation: 2.9.0's archive.json, or a placeholder of an interrupted migration.
993
+ let info: import("node:fs").Stats | undefined;
294
994
  try {
295
- const info = await lstat(path);
296
- return { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs };
995
+ await testHooks.beforeLstat?.(archivePath);
996
+ info = await lstat(archivePath);
297
997
  } catch (error) {
298
- if (getErrorCode(error) === "ENOENT") return undefined;
299
- throw error;
998
+ if (getErrorCode(error) !== "ENOENT") throw error;
999
+ }
1000
+ if (info?.isFile() && !info.isSymbolicLink()) {
1001
+ const parsed = await readArchiveFile(archivePath, "legacy", 0, options, restartIfNewer);
1002
+ if (!parsed) throw new ArchiveRestart();
1003
+ if (parsed.parsed.kind === "ok") return parsed.parsed.view;
1004
+ if (parsed.parsed.kind === "unsupported") return corruptView();
1005
+ return legacyCorruptView(archivePath, parsed.parsed.sha256);
1006
+ }
1007
+ if (info && !info.isDirectory()) return legacyCorruptView(archivePath, undefined);
1008
+ const placeholder = listing.placeholders.at(-1);
1009
+ if (placeholder) {
1010
+ const parsed = await readArchiveFile(placeholder, "legacy", 0, options, restartIfNewer);
1011
+ if (!parsed) throw new ArchiveRestart();
1012
+ if (parsed.parsed.kind === "ok") return parsed.parsed.view;
1013
+ if (parsed.parsed.kind === "unsupported") return corruptView();
1014
+ return legacyCorruptView(placeholder, parsed.parsed.sha256);
1015
+ }
1016
+ // archive.json is missing or is the migration tombstone, which exists only
1017
+ // once a generation does (or while a migration moves archive.json aside):
1018
+ // the listing raced a migration or a cleanup. List again before concluding
1019
+ // there is no archive.
1020
+ if (attempt < 2) return undefined;
1021
+ return emptyView();
1022
+ }
1023
+
1024
+ function legacyCorruptView(path: string, sha: string | undefined): StatsArchiveView {
1025
+ return new StatsArchiveView(emptyStatsArchive(0), true, false, 0, undefined, { legacySourcePath: path, legacySourceSha256: sha, legacyCorrupt: true });
1026
+ }
1027
+
1028
+ /** lstat + cached parse of one archive file; undefined when it is not a regular file. */
1029
+ async function readArchiveFile(
1030
+ path: string,
1031
+ kind: "legacy" | "generation",
1032
+ generation: number,
1033
+ options: ReadStatsArchiveOptions,
1034
+ onError: (generation: number, error: unknown) => Promise<never>,
1035
+ ): Promise<{ parsed: ParsedArchiveFile; mtimeMs: number } | undefined> {
1036
+ let info: import("node:fs").Stats;
1037
+ try {
1038
+ await testHooks.beforeLstat?.(path);
1039
+ info = await lstat(path);
1040
+ } catch (error) {
1041
+ return onError(generation, error);
1042
+ }
1043
+ if (!info.isFile() || info.isSymbolicLink()) return undefined;
1044
+ const cached = archiveFileCache.get(path);
1045
+ // Generations are immutable, so their identity is a safe cache key. 2.9.0
1046
+ // rewrites archive.json in place, and on filesystems with ino 0 and coarse
1047
+ // mtimes (FAT/SMB on Windows) a rewrite of the same size may keep the same
1048
+ // identity. Compaction migrates from it and then deletes shards, so it
1049
+ // always re-reads that file instead of trusting the cache.
1050
+ const cacheable = !(options.strict && kind === "legacy");
1051
+ if (cacheable && cached && sameStatsFileIdentity(cached, info)) return { parsed: cached.parsed, mtimeMs: info.mtimeMs };
1052
+ let bytes: Buffer;
1053
+ try {
1054
+ await testHooks.beforeReadFile?.(path);
1055
+ bytes = await readFile(path);
1056
+ } catch (error) {
1057
+ archiveFileCache.delete(path);
1058
+ return onError(generation, error);
1059
+ }
1060
+ const parsed = parseArchiveBytes(kind, generation, path, bytes);
1061
+ // A young unparseable generation may still be written in place: do not
1062
+ // cache it. An old one stays unreadable until compaction quarantines it
1063
+ // (possibly hours later); cache it like any other file so readers do not
1064
+ // re-read and re-parse it on every refresh. Completing a write would
1065
+ // change its size, so the identity check still notices that.
1066
+ const young = Math.abs(Date.now() - info.mtimeMs) < ARCHIVE_PENDING_GENERATION_MS;
1067
+ if (!(parsed.kind === "invalid" && kind === "generation" && young)) archiveFileCache.set(path, { ...statsFileIdentityOf(info), parsed });
1068
+ else archiveFileCache.delete(path);
1069
+ if (archiveFileCache.size > 16) {
1070
+ for (const key of archiveFileCache.keys()) {
1071
+ if (archiveFileCache.size <= 16) break;
1072
+ if (key !== path) archiveFileCache.delete(key);
1073
+ }
1074
+ }
1075
+ return { parsed, mtimeMs: info.mtimeMs };
1076
+ }
1077
+
1078
+ /** Read the newest archive snapshot (see readStatsArchiveDetailed). */
1079
+ export async function readStatsArchiveView(archivePath: string = SHARD_ARCHIVE_PATH, options: ReadStatsArchiveOptions = {}): Promise<StatsArchiveView> {
1080
+ return (await readStatsArchiveDetailed(archivePath, options)).view;
1081
+ }
1082
+
1083
+ export type StatsSnapshot = { rawShards: PersistedStatsShardV7[]; archive: StatsArchiveView };
1084
+
1085
+ /**
1086
+ * The raw shards and the archive as one consistent snapshot. Order matters:
1087
+ * compaction publishes the archive before deleting the shards it absorbed,
1088
+ * so reading the directory first and the archive second always sees each
1089
+ * absorbed shard in at least one of them. When the archive read hit a
1090
+ * persistent transient error and served the last good snapshot, that
1091
+ * snapshot may predate shards deleted since; pairing it with the fresh
1092
+ * listing would undercount, so the previous snapshot is reused as a whole
1093
+ * (it was consistent when read) when it holds that same archive.
1094
+ */
1095
+ export async function readStatsSnapshot(
1096
+ previous?: StatsSnapshot,
1097
+ directory: string = SHARD_FILES_DIR,
1098
+ archivePath: string = SHARD_ARCHIVE_PATH,
1099
+ ): Promise<StatsSnapshot> {
1100
+ const rawShards = await readValidStatsShardsV7(directory);
1101
+ const { view, stale } = await readStatsArchiveDetailed(archivePath);
1102
+ if (stale && previous && previous.archive === view) {
1103
+ // Only a newer generation can have deleted shards this archive misses.
1104
+ // While it is still the newest one (highest generations never go back:
1105
+ // only lower ones are removed, and an unreadable newest one only once a
1106
+ // higher one exists), the fresh listing pairs with it consistently, so
1107
+ // a persistent error on its file does not freeze everyone else's shards.
1108
+ if (!view.corrupt && view.generation > 0 && view.corruptGenerations.length === 0) {
1109
+ const newest = await listStatsArchiveGenerations(archivePath).then((generations) => generations.at(-1), () => undefined);
1110
+ if (newest === view.generation) return { rawShards, archive: view };
1111
+ }
1112
+ return previous;
300
1113
  }
1114
+ return { rawShards, archive: view };
301
1115
  }
302
1116
 
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;
1117
+ /** Reference-form read (used by tests and tools): the snapshot with synthetic shards available. */
1118
+ export async function readStatsArchive(archivePath: string = SHARD_ARCHIVE_PATH, options: ReadStatsArchiveOptions = {}): Promise<LoadedStatsArchive> {
1119
+ return readStatsArchiveView(archivePath, options);
306
1120
  }
307
1121
 
308
1122
  /** Remove archive temp files a crash left behind (shard cleanup only covers shards/). */
@@ -324,22 +1138,286 @@ async function cleanupStaleArchiveTemps(archivePath: string, now: number): Promi
324
1138
  }
325
1139
  }
326
1140
 
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`;
1141
+ /** Identity of whatever sits at 2.9.0's archive.json path (undefined when nothing does). */
1142
+ async function legacyArchiveIdentity(archivePath: string): Promise<string | undefined> {
330
1143
  try {
331
- await writeFile(tempPath, JSON.stringify(archive) + "\n", "utf8");
332
- await renameWithRetry(tempPath, path);
1144
+ const info = await lstat(archivePath);
1145
+ return JSON.stringify([String(info.ino), info.size, info.mtimeMs, info.isFile()]);
1146
+ } catch (error) {
1147
+ if (getErrorCode(error) === "ENOENT") return undefined;
1148
+ throw error;
1149
+ }
1150
+ }
1151
+
1152
+ /** Test hooks for the publish protocol; production never sets them. */
1153
+ export type ArchivePublishHooks = {
1154
+ /** Runs after every publish check passed, right before the new generation becomes visible (and before a migration moves archive.json). */
1155
+ beforeCommit?: () => Promise<void>;
1156
+ };
1157
+
1158
+ /** Flush a directory entry change (link, rename) to stable storage where the platform supports it. */
1159
+ async function fsyncDirectory(directory: string): Promise<void> {
1160
+ if (process.platform === "win32") return; // directories cannot be opened for fsync there
1161
+ try {
1162
+ const handle = await open(directory, "r");
1163
+ try {
1164
+ await handle.sync();
1165
+ } finally {
1166
+ await handle.close();
1167
+ }
1168
+ } catch {}
1169
+ }
1170
+
1171
+ /** link() failures that mean "this filesystem has no hard links" (FAT/exFAT, some NAS/SMB mounts). */
1172
+ const NO_HARDLINK_CODES = new Set(["EPERM", "ENOTSUP", "EINVAL"]);
1173
+
1174
+ /** A snapshot written and flushed to a private temp file, ready to be published. */
1175
+ type PreparedGeneration = { target: string; tempPath: string; text: string };
1176
+
1177
+ async function prepareStatsArchiveGeneration(archivePath: string, generation: number, text: string): Promise<PreparedGeneration> {
1178
+ await mkdir(dirname(archivePath), { recursive: true });
1179
+ const target = statsArchiveGenerationPath(archivePath, generation);
1180
+ const tempPath = `${target}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
1181
+ try {
1182
+ const handle = await open(tempPath, "wx");
1183
+ try {
1184
+ await handle.writeFile(text, "utf8");
1185
+ await handle.sync();
1186
+ } finally {
1187
+ await handle.close();
1188
+ }
333
1189
  } catch (error) {
334
1190
  await unlink(tempPath).catch(() => undefined);
335
1191
  throw error;
336
1192
  }
337
- archiveCache.delete(path);
1193
+ return { target, tempPath, text };
1194
+ }
1195
+
1196
+ /**
1197
+ * Publish a prepared generation atomically and exclusively: hard-link the
1198
+ * flushed temp file to its final name. link() fails with EEXIST when that
1199
+ * generation already exists, on Windows and POSIX alike, so of all writers
1200
+ * that read generation N exactly one can publish N+1. Resolves false when
1201
+ * another writer won.
1202
+ *
1203
+ * Without hard links (link fails with EPERM/ENOTSUP/EINVAL), the generation
1204
+ * is created exclusively in place (O_EXCL, the same compare-and-swap) and
1205
+ * written and flushed there. Until it is complete it does not parse, so
1206
+ * readers skip it for the previous generation, and compaction treats a
1207
+ * young unparseable newest generation as still being written. The writer
1208
+ * deletes raw shards only after it completed, so nothing is counted twice
1209
+ * or lost at any point; a crash mid-write leaves an unreadable generation
1210
+ * that the next pass recovers past.
1211
+ */
1212
+ async function commitPreparedGeneration(prepared: PreparedGeneration): Promise<boolean> {
1213
+ try {
1214
+ await (testHooks.link ?? link)(prepared.tempPath, prepared.target);
1215
+ } catch (error) {
1216
+ const code = getErrorCode(error);
1217
+ if (code === "EEXIST") return false;
1218
+ if (!NO_HARDLINK_CODES.has(code ?? "")) throw error;
1219
+ let handle;
1220
+ try {
1221
+ handle = await open(prepared.target, "wx");
1222
+ } catch (openError) {
1223
+ if (getErrorCode(openError) === "EEXIST") return false;
1224
+ throw openError;
1225
+ }
1226
+ try {
1227
+ await testHooks.afterExclusiveCreate?.(prepared.target);
1228
+ await handle.writeFile(prepared.text, "utf8");
1229
+ await handle.sync();
1230
+ } catch (writeError) {
1231
+ await handle.close().catch(() => undefined);
1232
+ // Ours (created exclusively) and incomplete: never leave it published.
1233
+ await unlink(prepared.target).catch(() => undefined);
1234
+ throw writeError;
1235
+ }
1236
+ await handle.close();
1237
+ }
1238
+ await fsyncDirectory(dirname(prepared.target));
1239
+ return true;
1240
+ }
1241
+
1242
+ /**
1243
+ * Drop generations older than the one before `current`. The previous one is
1244
+ * kept for readers that listed the directory just before `current` appeared;
1245
+ * a reader that still finds its file gone re-lists and reads a newer one.
1246
+ */
1247
+ async function removeSupersededGenerations(archivePath: string, current: number): Promise<void> {
1248
+ for (const generation of await listStatsArchiveGenerations(archivePath).catch(() => [] as number[])) {
1249
+ if (generation >= current - 1) continue;
1250
+ await unlink(statsArchiveGenerationPath(archivePath, generation)).catch(() => undefined);
1251
+ }
1252
+ }
1253
+
1254
+ /** Move an unreadable generation out of the generation namespace, keeping it for inspection. */
1255
+ async function quarantineGeneration(archivePath: string, generation: number): Promise<void> {
1256
+ const path = statsArchiveGenerationPath(archivePath, generation);
1257
+ const kept = path.replace(/\.json$/, `.corrupt-${Date.now()}.json`);
1258
+ try {
1259
+ await renameWithRetry(path, kept);
1260
+ console.warn(`${LOG_PREFIX}: stats archive generation ${generation} was unreadable; recovered from the previous one and kept it as ${kept}`);
1261
+ } catch (error) {
1262
+ if (getErrorCode(error) !== "ENOENT") await unlink(path).catch(() => undefined);
1263
+ }
1264
+ }
1265
+
1266
+ /** Retry budget for moving 2.9.0's archive.json (a scanner may hold it for seconds). */
1267
+ export const LEGACY_ARCHIVE_RENAME_BUDGET_MS = 5_000;
1268
+
1269
+ const LEGACY_TOMBSTONE_README =
1270
+ "pi-cache-optimizer stores its stats archive in the archive.<generation>.json files next to this directory.\n" +
1271
+ "This directory replaces the single archive.json of version 2.9.0 so that 2.9.0 can no longer write it\n" +
1272
+ "(it would publish without a compare-and-swap). 2.9.0 reports the archive as unreadable and keeps raw shards.\n" +
1273
+ "Do not remove it while any Pi process might still run pi-cache-optimizer 2.9.0.\n";
1274
+
1275
+ async function createLegacyTombstone(archivePath: string): Promise<void> {
1276
+ await mkdir(archivePath);
1277
+ await writeFile(join(archivePath, "README.txt"), LEGACY_TOMBSTONE_README, "utf8").catch(() => undefined);
1278
+ }
1279
+
1280
+ /** Keep a legacy file that is not part of any generation, for inspection. */
1281
+ async function setAsideLegacyFile(path: string, reason: string): Promise<void> {
1282
+ let stamp = Date.now();
1283
+ let kept = join(dirname(path), `archive.legacy-${stamp}.json`);
1284
+ while (await lstat(kept).then(() => true, () => false)) kept = join(dirname(path), `archive.legacy-${++stamp}.json`);
1285
+ await renameWithRetry(path, kept, LEGACY_ARCHIVE_RENAME_BUDGET_MS).catch(() => undefined);
1286
+ console.warn(`${LOG_PREFIX}: ${reason}; kept as ${kept}`);
1287
+ }
1288
+
1289
+ /**
1290
+ * Whenever generations exist, make sure 2.9.0 cannot write archive.json:
1291
+ * move a file there aside and put a directory in its place, and resolve
1292
+ * migration placeholders. A file whose content is the one the newest
1293
+ * generation was migrated from is simply deleted. Anything else is never
1294
+ * counted, because merging it could double count, and is kept for
1295
+ * inspection: a file 2.9.0 wrote after the migration (only possible if the
1296
+ * directory was removed), or a placeholder of a migration that another
1297
+ * compactor overtook.
1298
+ */
1299
+ async function ensureLegacyArchiveTombstone(archivePath: string, migratedFromSha256: string | undefined): Promise<void> {
1300
+ const resolve = async (path: string): Promise<void> => {
1301
+ let sha: string | undefined;
1302
+ try {
1303
+ sha = sha256(await readFile(path));
1304
+ } catch {}
1305
+ if (sha !== undefined && sha === migratedFromSha256) await unlink(path).catch(() => undefined);
1306
+ else await setAsideLegacyFile(path, "ignored a stats archive of an older pi-cache-optimizer that is not part of the current archive");
1307
+ };
1308
+ const listing = await listArchiveDirectory(archivePath).catch(() => undefined);
1309
+ for (const placeholder of listing?.placeholders ?? []) await resolve(placeholder);
1310
+
1311
+ let info: import("node:fs").Stats | undefined;
1312
+ try {
1313
+ info = await lstat(archivePath);
1314
+ } catch (error) {
1315
+ if (getErrorCode(error) !== "ENOENT") return;
1316
+ }
1317
+ if (info?.isDirectory()) return;
1318
+ if (info) {
1319
+ const retired = `${archivePath}.${process.pid}.${randomUUID()}.retired.tmp`;
1320
+ try {
1321
+ await renameWithRetry(archivePath, retired, LEGACY_ARCHIVE_RENAME_BUDGET_MS);
1322
+ } catch {
1323
+ return; // retried by the next pass; readers ignore archive.json once a generation exists
1324
+ }
1325
+ await resolve(retired);
1326
+ }
1327
+ await createLegacyTombstone(archivePath).catch(() => undefined);
1328
+ }
1329
+
1330
+ /**
1331
+ * Migration step, run after every publish check passed and before the first
1332
+ * generation is linked: move 2.9.0's archive.json aside to a placeholder,
1333
+ * put the tombstone directory in its place, and verify that the moved bytes
1334
+ * are exactly the snapshot this pass built on.
1335
+ *
1336
+ * The rename is atomic, so a 2.9.0 process that took the lock over while this
1337
+ * pass was suspended and republished archive.json is caught here (the bytes
1338
+ * differ: the move is undone and the next pass migrates its file), instead
1339
+ * of having its newly absorbed counters set aside after the fact. Once the
1340
+ * directory is in place, 2.9.0 sees an unreadable archive and neither
1341
+ * compacts nor deletes. If this pass dies before linking, readers and the
1342
+ * next compaction migrate from the placeholder.
1343
+ *
1344
+ * Resolves false when the migration must be retried by a later pass.
1345
+ */
1346
+ async function moveLegacyArchiveAside(archivePath: string, base: StatsArchiveView): Promise<boolean> {
1347
+ const source = base.legacySourcePath;
1348
+ if (!source) return true;
1349
+ const expected = base.legacySourceSha256;
1350
+ let placeholder = source;
1351
+ if (source === archivePath) {
1352
+ placeholder = join(dirname(archivePath), `archive.migrating-${randomUUID()}.json`);
1353
+ try {
1354
+ await renameWithRetry(archivePath, placeholder, LEGACY_ARCHIVE_RENAME_BUDGET_MS);
1355
+ } catch {
1356
+ return false;
1357
+ }
1358
+ }
1359
+ try {
1360
+ await createLegacyTombstone(archivePath);
1361
+ } catch (error) {
1362
+ if (getErrorCode(error) !== "EEXIST") return false;
1363
+ const info = await lstat(archivePath).catch(() => undefined);
1364
+ // Another 2.9.0 publish landed between the move and the mkdir. Its file
1365
+ // replaces the one moved aside (it read it first, or started over): keep
1366
+ // it as the archive and keep the placeholder only for inspection.
1367
+ if (!info?.isDirectory()) return false;
1368
+ }
1369
+ let sha: string | undefined;
1370
+ try {
1371
+ sha = sha256(await readFile(placeholder));
1372
+ } catch {}
1373
+ if (expected === undefined ? !base.legacyCorrupt : sha !== expected) {
1374
+ // 2.9.0 republished between this pass's read and the move: undo, so the
1375
+ // next pass migrates the newer file.
1376
+ await rm(archivePath, { recursive: true, force: true }).catch(() => undefined);
1377
+ await renameWithRetry(placeholder, archivePath, LEGACY_ARCHIVE_RENAME_BUDGET_MS).catch(() => undefined);
1378
+ return false;
1379
+ }
1380
+ await fsyncDirectory(dirname(archivePath));
1381
+ return true;
1382
+ }
1383
+
1384
+ /** Instance ids of every shard-named file in `directory`, readable or not. */
1385
+ async function listShardInstanceIds(directory: string): Promise<Set<string>> {
1386
+ let names: string[];
1387
+ try {
1388
+ names = await readdir(directory);
1389
+ } catch (error) {
1390
+ if (getErrorCode(error) === "ENOENT") return new Set();
1391
+ throw error;
1392
+ }
1393
+ return new Set(names.filter((name) => /^[0-9a-f-]+\.json$/i.test(name)).map((name) => name.slice(0, -".json".length)));
1394
+ }
1395
+
1396
+ /**
1397
+ * A local day can still be "today" for a Pi process in another time zone
1398
+ * (up to UTC+14 and down to UTC-12) until 12:00 UTC of the next day, i.e.
1399
+ * 36 hours after its UTC midnight. Archived buckets merge shards (instance
1400
+ * counts become bucket counts) and only feed the current-day view of their
1401
+ * own day, so a day is archived only once it is over everywhere: then no
1402
+ * process's current-day view (totals, instance counts) can differ from the
1403
+ * raw shards it replaces.
1404
+ */
1405
+ const DAY_OVER_EVERYWHERE_MS = 36 * 60 * 60 * 1000;
1406
+
1407
+ function dayIsOverEverywhere(day: string, now: number): boolean {
1408
+ const [year, month, date] = day.split("-").map(Number) as [number, number, number];
1409
+ return now >= Date.UTC(year, month - 1, date) + DAY_OVER_EVERYWHERE_MS;
338
1410
  }
339
1411
 
340
1412
  /** A shard maintenance may fold away: from a past day and no longer written to. */
341
1413
  export function isArchivableShard(shard: PersistedStatsShardV7, now: number, today: string): boolean {
342
- if (shard.day >= today) return false;
1414
+ if (shard.day >= today || !dayIsOverEverywhere(shard.day, now)) return false;
1415
+ // Usage recorded after midnight but before the day rollover carries the
1416
+ // next day in stats.day. The current-day view skips such an entry on
1417
+ // either day, but a merged bucket keeps only the latest stats.day and
1418
+ // could hide its whole bucket from that day's view; such rare shards stay
1419
+ // raw (retention removes them).
1420
+ for (const entry of Object.values(shard.models)) if (entry.stats.day !== shard.day) return false;
343
1421
  if (now - shard.lifecycle.updatedAt >= SHARD_RETENTION_MS) return false; // retention deletes it instead
344
1422
  if (shard.lifecycle.state === "closed") return true;
345
1423
  return now - shard.lifecycle.updatedAt >= ARCHIVE_ABANDONED_ACTIVE_MS && !isProcessAlive(shard.process.pid);
@@ -356,10 +1434,13 @@ export type CompactionResult = {
356
1434
  skippedCorrupt: boolean;
357
1435
  /**
358
1436
  * 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.
1437
+ * pass ran (e.g. a stale-lock takeover or a concurrent reset), or another
1438
+ * compactor published a newer generation first. No raw shard was deleted
1439
+ * and no visible archive was replaced; the next pass retries.
361
1440
  */
362
1441
  skippedConcurrent: boolean;
1442
+ /** Generation this pass published, when it published one. */
1443
+ generation?: number;
363
1444
  };
364
1445
 
365
1446
  export type CompactionOptions = {
@@ -367,17 +1448,27 @@ export type CompactionOptions = {
367
1448
  lockToken?: string;
368
1449
  /** Test hook: runs after the plan is built, right before the publish checks. */
369
1450
  beforePublish?: () => Promise<void>;
370
- };
1451
+ } & ArchivePublishHooks;
371
1452
 
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]);
1453
+ /**
1454
+ * Buckets expire by their newest folded shard. Shards of one day can be
1455
+ * closed much later (an idle process closes yesterday's shard at its next
1456
+ * rollover, possibly weeks later), so buckets are also split by the UTC day
1457
+ * of `updatedAt`. Otherwise the earliest shard would outlive its own 60-day
1458
+ * retention by as long as the latest one, and archived totals would exceed
1459
+ * what the raw shards would have shown. The remaining error is under a day.
1460
+ */
1461
+ const ARCHIVE_EXPIRY_GRANULARITY_MS = 24 * 60 * 60 * 1000;
1462
+ const bucketId = (b: Pick<StatsArchiveBucketV1, "globalEpoch" | "sessionHash" | "modelKey" | "modelEpoch" | "day" | "updatedAt">): string =>
1463
+ JSON.stringify([b.globalEpoch, b.sessionHash, b.modelKey, b.modelEpoch, b.day, Math.floor(b.updatedAt / ARCHIVE_EXPIRY_GRANULARITY_MS)]);
374
1464
  const routedId = (r: Pick<StatsArchiveRoutedV1, "globalEpoch" | "sessionHash">): string =>
375
1465
  JSON.stringify([r.globalEpoch, r.sessionHash]);
376
1466
 
377
1467
  /**
378
1468
  * Fold archivable shards into the archive, then delete them. Call only while
379
1469
  * holding the maintenance lock. Order matters for crash safety: the archive
380
- * (with the absorbed ids) is published before any raw shard is deleted.
1470
+ * (with the absorbed ids) is published before any raw shard is deleted, and
1471
+ * only by a pass whose generation is the newest one.
381
1472
  */
382
1473
  export async function compactStatsShardsV7(
383
1474
  now = Date.now(),
@@ -387,14 +1478,31 @@ export async function compactStatsShardsV7(
387
1478
  ): Promise<CompactionResult> {
388
1479
  const result: CompactionResult = { absorbed: 0, removed: 0, dropped: 0, skippedCorrupt: false, skippedConcurrent: false };
389
1480
  await cleanupStaleArchiveTemps(archivePath, now);
390
- clearStatsArchiveCache();
391
- const identityAtRead = await archiveFileIdentity(archivePath);
392
- const loaded = await readStatsArchive(archivePath);
393
- if (loaded.corrupt) {
1481
+ // Taken before the read so a 2.9.0 write racing it is noticed below.
1482
+ const legacyIdentityAtRead = await legacyArchiveIdentity(archivePath);
1483
+ // The snapshot this pass builds on. Generations are immutable, so the
1484
+ // readers' cached parse is safe to reuse; the publish below is a
1485
+ // compare-and-swap against exactly this generation.
1486
+ const base = await readStatsArchiveView(archivePath, { strict: true });
1487
+ if (base.corrupt) {
1488
+ // A newer format this version cannot read: never overwrite it.
394
1489
  result.skippedCorrupt = true;
395
1490
  return result;
396
1491
  }
397
- const previous = loaded.archive;
1492
+ if (base.pendingGeneration) {
1493
+ // The newest generation does not parse yet and is young: it may still be
1494
+ // written in place (no-hard-link fallback) by a holder that lost the
1495
+ // lock. Retry later rather than recover past it.
1496
+ result.skippedConcurrent = true;
1497
+ return result;
1498
+ }
1499
+ // 2.9.0's archive.json (readable or not): publish the first generation
1500
+ // even when nothing else changed, so 2.9.0 can be locked out of writing
1501
+ // it. Unreadable generations: publish above them so they are recovered
1502
+ // from and quarantined.
1503
+ const migrating = base.newestGeneration === 0 && base.legacySourcePath !== undefined;
1504
+ const healing = base.corruptGenerations.length > 0;
1505
+ const previous = base.archive;
398
1506
  const today = currentLocalDay();
399
1507
  // Strict reads: compaction permanently drops counters whose epoch differs,
400
1508
  // so an unreadable epoch file must abort instead of falling back to the
@@ -433,9 +1541,14 @@ export async function compactStatsShardsV7(
433
1541
  }
434
1542
 
435
1543
  const shards = await readValidStatsShardsV7(directory);
436
- const presentIds = new Set(shards.map((shard) => shard.instanceId));
437
- const absorbed: Record<string, number> = {};
438
1544
  // Keep ids whose raw file still exists (a crash or a failed delete left it).
1545
+ // Presence is decided by the directory listing, not by a successful parse:
1546
+ // a leftover that is only transiently unreadable (Windows EPERM/EBUSY while
1547
+ // a scanner or sync client holds it) must stay absorbed, or it would be
1548
+ // counted raw again once readable and then folded into the archive twice.
1549
+ const presentIds = await listShardInstanceIds(directory);
1550
+ for (const shard of shards) presentIds.add(shard.instanceId);
1551
+ const absorbed: Record<string, number> = {};
439
1552
  for (const [id, at] of Object.entries(previous.absorbed)) if (presentIds.has(id)) absorbed[id] = at;
440
1553
 
441
1554
  for (const shard of shards) {
@@ -452,7 +1565,7 @@ export async function compactStatsShardsV7(
452
1565
  }
453
1566
  for (const [key, entry] of Object.entries(shard.models)) {
454
1567
  if (entry.modelEpoch !== await currentModelEpoch(key)) continue;
455
- const id = bucketId({ globalEpoch, sessionHash: shard.sessionHash, modelKey: key, modelEpoch: entry.modelEpoch, day: shard.day });
1568
+ const id = bucketId({ globalEpoch, sessionHash: shard.sessionHash, modelKey: key, modelEpoch: entry.modelEpoch, day: shard.day, updatedAt: shard.lifecycle.updatedAt });
456
1569
  const existing = buckets.get(id);
457
1570
  const newer = !existing || existing.updatedAt <= shard.lifecycle.updatedAt;
458
1571
  buckets.set(id, {
@@ -471,38 +1584,82 @@ export async function compactStatsShardsV7(
471
1584
  }
472
1585
  }
473
1586
 
1587
+ await touchStatsMaintenanceLock(options.lockToken);
1588
+
474
1589
  const absorbedChanged =
475
1590
  Object.keys(absorbed).length !== Object.keys(previous.absorbed).length ||
476
1591
  Object.keys(absorbed).some((id) => !(id in previous.absorbed));
477
- if (result.absorbed > 0 || result.dropped > 0 || absorbedChanged) {
1592
+ const skip = (): CompactionResult => {
1593
+ result.skippedConcurrent = true;
1594
+ result.absorbed = 0;
1595
+ result.dropped = 0;
1596
+ return result;
1597
+ };
1598
+ if (result.absorbed > 0 || result.dropped > 0 || absorbedChanged || migrating || healing) {
478
1599
  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).
1600
+ // Cheap early exits: lost the lock, someone published meanwhile, or a
1601
+ // reset landed during the pass (counters written under the new epoch
1602
+ // would be dropped as stale). The compare-and-swap below is what makes
1603
+ // a stale holder safe; these only avoid wasted work.
483
1604
  let concurrent =
484
1605
  !(await holdsStatsMaintenanceLock(options.lockToken)) ||
485
- !sameArchiveFile(identityAtRead, await archiveFileIdentity(archivePath)) ||
1606
+ ((await listStatsArchiveGenerations(archivePath)).at(-1) ?? 0) !== base.newestGeneration ||
1607
+ (base.newestGeneration === 0 && (await legacyArchiveIdentity(archivePath)) !== legacyIdentityAtRead) ||
486
1608
  (await readGlobalStatsEpochStrict()) !== globalEpoch;
487
1609
  for (const [key, epoch] of modelEpochs) {
488
1610
  if (concurrent) break;
489
1611
  if ((await readModelStatsEpochStrict(key)) !== epoch) concurrent = true;
490
1612
  }
491
- if (concurrent) {
492
- result.skippedConcurrent = true;
493
- result.absorbed = 0;
494
- result.dropped = 0;
495
- return result;
496
- }
497
- await writeStatsArchive(archivePath, {
1613
+ if (concurrent) return skip();
1614
+ const next: StatsArchiveV1 = {
498
1615
  version: 1,
499
1616
  kind: "pi-cache-optimizer-archive",
500
1617
  updatedAt: now,
501
1618
  absorbed,
502
1619
  buckets: Array.from(buckets.values()),
503
1620
  routed: Array.from(routed.values()),
504
- });
1621
+ };
1622
+ // Above every listed generation, readable or not, so a holder that read
1623
+ // an older one can never publish over an unreadable newer one.
1624
+ const generation = base.newestGeneration + 1;
1625
+ const text = JSON.stringify(encodeStatsArchiveV2(next, generation, base.legacySha256)) + "\n";
1626
+ const prepared = await prepareStatsArchiveGeneration(archivePath, generation, text);
1627
+ try {
1628
+ await options.beforeCommit?.();
1629
+ if (migrating && !(await moveLegacyArchiveAside(archivePath, base))) return skip();
1630
+ if (!(await commitPreparedGeneration(prepared))) return skip();
1631
+ } finally {
1632
+ await unlink(prepared.tempPath).catch(() => undefined);
1633
+ }
1634
+ // A generation above ours means ours is invisible to readers (only
1635
+ // possible after our base was superseded and cleaned up while this pass
1636
+ // was suspended): it must not delete what its snapshot covers.
1637
+ if (((await listStatsArchiveGenerations(archivePath)).at(-1) ?? 0) !== generation) return skip();
1638
+ // The in-place fallback is not atomic: its generation exists (empty or
1639
+ // partial) from the exclusive create until the write completes. A holder
1640
+ // suspended in between for longer than the stale-lock window has its
1641
+ // lock taken over, and the new holder may treat the old, still
1642
+ // unparseable generation as abandoned and build on the one below it.
1643
+ // If this writer then finished and deleted its absorbed shards before
1644
+ // the new holder listed the shard directory, they would be lost: the
1645
+ // new holder publishes above this generation and quarantines it. The new
1646
+ // holder takes the lock before reading anything, so a writer that still
1647
+ // holds it here cannot have been overtaken; one that lost it deletes
1648
+ // nothing (its absorbed files stay ignored and a later pass removes them).
1649
+ if (!(await holdsStatsMaintenanceLock(options.lockToken))) return skip();
1650
+ result.generation = generation;
1651
+ try {
1652
+ const path = statsArchiveGenerationPath(archivePath, generation);
1653
+ const info = await lstat(path);
1654
+ const view = new StatsArchiveView(next, true, false, generation, base.legacySha256);
1655
+ archiveFileCache.set(path, { ...statsFileIdentityOf(info), parsed: { kind: "ok", view } });
1656
+ lastGoodArchive.set(archivePath, view);
1657
+ } catch {}
1658
+ for (const corrupt of base.corruptGenerations) await quarantineGeneration(archivePath, corrupt);
1659
+ await removeSupersededGenerations(archivePath, generation);
505
1660
  }
1661
+ if (result.generation !== undefined || base.newestGeneration > 0) await ensureLegacyArchiveTombstone(archivePath, base.legacySha256);
1662
+ await touchStatsMaintenanceLock(options.lockToken);
506
1663
 
507
1664
  // Only now that the archive covering them is durable, delete the originals.
508
1665
  const toDelete = Object.keys(absorbed);
@@ -535,25 +1692,40 @@ export async function maybeRunStatsMaintenance(now = Date.now()): Promise<StatsM
535
1692
  if (!cleanupDue && !compactionDue) return result;
536
1693
  const lockToken = await acquireStatsMaintenanceLock(now);
537
1694
  if (!lockToken) return result;
1695
+ // Heartbeat so a long pass (a huge archive on a slow, scanned disk) is not
1696
+ // taken over as stale. It stops while the process is suspended; the
1697
+ // archive compare-and-swap covers that case.
1698
+ const heartbeat = setInterval(() => void touchStatsMaintenanceLock(lockToken), testHooks.heartbeatMs ?? STATS_MAINTENANCE_HEARTBEAT_MS);
1699
+ heartbeat.unref?.();
538
1700
  try {
539
1701
  if (cleanupDue) {
540
1702
  result.cleanupRemoved = await cleanupStatsShardsV7(now);
541
1703
  await writeMaintenanceMarker(SHARD_CLEANUP_MARKER_PATH, now);
542
1704
  }
543
1705
  if (compactionDue) {
1706
+ let failed = false;
544
1707
  try {
545
1708
  result.compaction = await compactStatsShardsV7(now, SHARD_FILES_DIR, SHARD_ARCHIVE_PATH, { lockToken });
546
1709
  if (result.compaction.skippedCorrupt) {
547
- console.warn(`${LOG_PREFIX}: stats archive ${SHARD_ARCHIVE_PATH} is unreadable; keeping raw shards`);
1710
+ console.warn(`${LOG_PREFIX}: stats archive ${SHARD_ARCHIVE_PATH} is in a newer format this version cannot read; keeping raw shards`);
548
1711
  }
549
1712
  } catch (error) {
1713
+ failed = true;
550
1714
  console.warn(`${LOG_PREFIX}: stats archive compaction failed`, error);
551
1715
  }
552
1716
  // 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);
1717
+ // instead of waiting out the full interval. A pass that failed (a
1718
+ // transient Windows EPERM on an epoch or archive file, a full disk)
1719
+ // published nothing either: retry after a short backoff, not 6 hours
1720
+ // later, without retrying on every single startup.
1721
+ if (failed) {
1722
+ await writeMaintenanceMarker(SHARD_COMPACTION_MARKER_PATH, now, now - SHARD_CLEANUP_INTERVAL_MS + COMPACTION_RETRY_AFTER_FAILURE_MS);
1723
+ } else if (!result.compaction?.skippedConcurrent) {
1724
+ await writeMaintenanceMarker(SHARD_COMPACTION_MARKER_PATH, now);
1725
+ }
555
1726
  }
556
1727
  } finally {
1728
+ clearInterval(heartbeat);
557
1729
  await releaseStatsMaintenanceLock(lockToken);
558
1730
  }
559
1731
  return result;