pi-cache-optimizer 2.8.27 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/index.ts +43 -7
- package/package.json +1 -1
- package/src/stats-archive.ts +1272 -0
- package/src/stats-store.ts +294 -56
|
@@ -0,0 +1,1272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive snapshot for closed past-day stats shards (issue #28, phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Every Pi process writes one shard per local day. Shards are kept for
|
|
5
|
+
* SHARD_RETENTION_MS so session-scoped footer totals survive long
|
|
6
|
+
* conversations, which left ~1,500 files to stat and parse on every refresh.
|
|
7
|
+
*
|
|
8
|
+
* Past-day shards that are closed (or abandoned by a dead process) never
|
|
9
|
+
* change again, so maintenance folds them into one archive snapshot and
|
|
10
|
+
* deletes the originals. The directory then only holds today's shards.
|
|
11
|
+
*
|
|
12
|
+
* Correctness rules:
|
|
13
|
+
* - Buckets keep the global and model epochs they were written under. Readers
|
|
14
|
+
* filter them with the same epoch checks as raw shards, so a reset hides
|
|
15
|
+
* archived counters at once, before the next compaction drops them.
|
|
16
|
+
* - The archive records every instanceId it absorbed. Readers ignore raw
|
|
17
|
+
* shards (and in-memory retired shards) with an absorbed id, so a crash
|
|
18
|
+
* between publishing the archive and deleting the originals, or two
|
|
19
|
+
* processes compacting at once, never double counts.
|
|
20
|
+
* - Archived data only feeds the all-days (session-scoped) aggregate. Its
|
|
21
|
+
* buckets keep their original day, so the current-day aggregate filters
|
|
22
|
+
* them out exactly as it filters the raw past-day shards they replace.
|
|
23
|
+
*
|
|
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.
|
|
43
|
+
*/
|
|
44
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
45
|
+
import { link, lstat, mkdir, open, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
46
|
+
import { dirname, join } from "node:path";
|
|
47
|
+
import { LOG_PREFIX, asRecord, getErrorCode, isNonEmptyString, isProcessAlive } from "./common.ts";
|
|
48
|
+
import {
|
|
49
|
+
type CacheStats,
|
|
50
|
+
type PersistedRoutedModelRef,
|
|
51
|
+
type PersistedStatsShardV7,
|
|
52
|
+
type ShardAggregate,
|
|
53
|
+
type ShardAggregator,
|
|
54
|
+
type ShardContribution,
|
|
55
|
+
SHARD_CLEANUP_MARKER_PATH,
|
|
56
|
+
SHARD_FILES_DIR,
|
|
57
|
+
SHARD_IO_CONCURRENCY,
|
|
58
|
+
SHARD_MAINTENANCE_DIR,
|
|
59
|
+
SHARD_RETENTION_MS,
|
|
60
|
+
SHARD_STATE_DIR,
|
|
61
|
+
SHARD_TEMP_RETENTION_MS,
|
|
62
|
+
acquireStatsMaintenanceLock,
|
|
63
|
+
cleanupStatsShardsV7,
|
|
64
|
+
cloneCacheStats,
|
|
65
|
+
createShardAggregatorV7,
|
|
66
|
+
currentLocalDay,
|
|
67
|
+
isMaintenanceMarkerDue,
|
|
68
|
+
mapWithConcurrency,
|
|
69
|
+
mergeStatsAcrossDays,
|
|
70
|
+
parseCacheStats,
|
|
71
|
+
parsePersistedRoutedModelRef,
|
|
72
|
+
holdsStatsMaintenanceLock,
|
|
73
|
+
readGlobalStatsEpochStrict,
|
|
74
|
+
readModelStatsEpochStrict,
|
|
75
|
+
readValidStatsShardsV7,
|
|
76
|
+
releaseStatsMaintenanceLock,
|
|
77
|
+
renameWithRetry,
|
|
78
|
+
touchStatsMaintenanceLock,
|
|
79
|
+
writeMaintenanceMarker,
|
|
80
|
+
} from "./stats-store.ts";
|
|
81
|
+
|
|
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
|
+
*/
|
|
88
|
+
export const SHARD_ARCHIVE_PATH = join(SHARD_ARCHIVE_DIR, "archive.json");
|
|
89
|
+
export const SHARD_COMPACTION_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-compaction");
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* An active shard is only treated as abandoned once its process is gone and
|
|
93
|
+
* it has not been touched for this long, so a recycled Windows PID or a
|
|
94
|
+
* transient process-check failure cannot fold a live shard away.
|
|
95
|
+
*/
|
|
96
|
+
export const ARCHIVE_ABANDONED_ACTIVE_MS = 24 * 60 * 60 * 1000;
|
|
97
|
+
|
|
98
|
+
/** How often a running maintenance pass refreshes the lock's mtime. */
|
|
99
|
+
export const STATS_MAINTENANCE_HEARTBEAT_MS = 5 * 60 * 1000;
|
|
100
|
+
|
|
101
|
+
export type StatsArchiveBucketV1 = {
|
|
102
|
+
globalEpoch: string;
|
|
103
|
+
sessionHash: string;
|
|
104
|
+
modelKey: string;
|
|
105
|
+
modelEpoch: string;
|
|
106
|
+
/** Day of the shards folded into this bucket. */
|
|
107
|
+
day: string;
|
|
108
|
+
provider: string;
|
|
109
|
+
modelId: string;
|
|
110
|
+
modelName?: string;
|
|
111
|
+
api?: string;
|
|
112
|
+
/** Latest lifecycle.updatedAt among the folded shards. */
|
|
113
|
+
updatedAt: number;
|
|
114
|
+
stats: CacheStats;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type StatsArchiveRoutedV1 = {
|
|
118
|
+
globalEpoch: string;
|
|
119
|
+
sessionHash: string;
|
|
120
|
+
day: string;
|
|
121
|
+
updatedAt: number;
|
|
122
|
+
model: PersistedRoutedModelRef;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** In-memory archive contents (and 2.9.0's on-disk format). */
|
|
126
|
+
export type StatsArchiveV1 = {
|
|
127
|
+
version: 1;
|
|
128
|
+
kind: "pi-cache-optimizer-archive";
|
|
129
|
+
updatedAt: number;
|
|
130
|
+
/** instanceId -> time it was folded in. */
|
|
131
|
+
absorbed: Record<string, number>;
|
|
132
|
+
buckets: StatsArchiveBucketV1[];
|
|
133
|
+
routed: StatsArchiveRoutedV1[];
|
|
134
|
+
};
|
|
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
|
+
|
|
159
|
+
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
160
|
+
const GENERATION_FILE_PATTERN = /^archive\.(\d{1,15})\.json$/;
|
|
161
|
+
|
|
162
|
+
export function emptyStatsArchive(now = Date.now()): StatsArchiveV1 {
|
|
163
|
+
return { version: 1, kind: "pi-cache-optimizer-archive", updatedAt: now, absorbed: {}, buckets: [], routed: [] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
167
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
168
|
+
}
|
|
169
|
+
|
|
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
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseAbsorbed(value: unknown): Record<string, number> | undefined {
|
|
210
|
+
const rawAbsorbed = asRecord(value);
|
|
211
|
+
if (!rawAbsorbed) return undefined;
|
|
212
|
+
const absorbed: Record<string, number> = {};
|
|
213
|
+
for (const [id, at] of Object.entries(rawAbsorbed)) {
|
|
214
|
+
if (isNonEmptyString(id) && isFiniteNumber(at)) absorbed[id] = at;
|
|
215
|
+
}
|
|
216
|
+
return absorbed;
|
|
217
|
+
}
|
|
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;
|
|
225
|
+
const buckets: StatsArchiveBucketV1[] = [];
|
|
226
|
+
for (const raw of record.buckets) {
|
|
227
|
+
const bucket = parseArchiveBucket(raw);
|
|
228
|
+
if (bucket) buckets.push(bucket);
|
|
229
|
+
}
|
|
230
|
+
const routed: StatsArchiveRoutedV1[] = [];
|
|
231
|
+
for (const raw of record.routed) {
|
|
232
|
+
const entry = parseArchiveRouted(raw);
|
|
233
|
+
if (entry) routed.push(entry);
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
version: 1,
|
|
237
|
+
kind: "pi-cache-optimizer-archive",
|
|
238
|
+
updatedAt: isFiniteNumber(record.updatedAt) ? record.updatedAt : 0,
|
|
239
|
+
absorbed,
|
|
240
|
+
buckets,
|
|
241
|
+
routed,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
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
|
+
}
|
|
294
|
+
|
|
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);
|
|
313
|
+
|
|
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
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
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 {
|
|
411
|
+
version: 7,
|
|
412
|
+
kind: "pi-cache-optimizer-shard",
|
|
413
|
+
instanceId: id,
|
|
414
|
+
sessionHash,
|
|
415
|
+
process: { pid: 0, ppid: 0, instanceStartedAt: 0 },
|
|
416
|
+
lifecycle: { state: "closed", createdAt: updatedAt, updatedAt, closedAt: updatedAt },
|
|
417
|
+
day,
|
|
418
|
+
globalEpoch,
|
|
419
|
+
models: {},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
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
|
+
];
|
|
433
|
+
}
|
|
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
|
+
|
|
485
|
+
/**
|
|
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).
|
|
491
|
+
*/
|
|
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));
|
|
530
|
+
}
|
|
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;
|
|
610
|
+
}
|
|
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;
|
|
686
|
+
}
|
|
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;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
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;
|
|
772
|
+
try {
|
|
773
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
774
|
+
} catch {
|
|
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();
|
|
780
|
+
}
|
|
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);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export type ReadStatsArchiveOptions = {
|
|
787
|
+
/** Throw on a transient I/O failure instead of serving the last good snapshot (compaction). */
|
|
788
|
+
strict?: boolean;
|
|
789
|
+
};
|
|
790
|
+
|
|
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;
|
|
849
|
+
}
|
|
850
|
+
return transientFailure(new Error(`stats archive ${archivePath} kept changing while being read`));
|
|
851
|
+
}
|
|
852
|
+
|
|
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);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Remove archive temp files a crash left behind (shard cleanup only covers shards/). */
|
|
859
|
+
async function cleanupStaleArchiveTemps(archivePath: string, now: number): Promise<void> {
|
|
860
|
+
const directory = dirname(archivePath);
|
|
861
|
+
let names: string[];
|
|
862
|
+
try {
|
|
863
|
+
names = await readdir(directory);
|
|
864
|
+
} catch {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
for (const name of names) {
|
|
868
|
+
if (!name.endsWith(".tmp")) continue;
|
|
869
|
+
const path = join(directory, name);
|
|
870
|
+
try {
|
|
871
|
+
const info = await lstat(path);
|
|
872
|
+
if (info.isFile() && !info.isSymbolicLink() && now - info.mtimeMs >= SHARD_TEMP_RETENTION_MS) await unlink(path);
|
|
873
|
+
} catch {}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
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> {
|
|
879
|
+
try {
|
|
880
|
+
const info = await lstat(archivePath);
|
|
881
|
+
return JSON.stringify([String(info.ino), info.size, info.mtimeMs, info.isFile()]);
|
|
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 {
|
|
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();
|
|
994
|
+
throw error;
|
|
995
|
+
}
|
|
996
|
+
return new Set(names.filter((name) => /^[0-9a-f-]+\.json$/i.test(name)).map((name) => name.slice(0, -".json".length)));
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** A shard maintenance may fold away: from a past day and no longer written to. */
|
|
1000
|
+
export function isArchivableShard(shard: PersistedStatsShardV7, now: number, today: string): boolean {
|
|
1001
|
+
if (shard.day >= today) return false;
|
|
1002
|
+
if (now - shard.lifecycle.updatedAt >= SHARD_RETENTION_MS) return false; // retention deletes it instead
|
|
1003
|
+
if (shard.lifecycle.state === "closed") return true;
|
|
1004
|
+
return now - shard.lifecycle.updatedAt >= ARCHIVE_ABANDONED_ACTIVE_MS && !isProcessAlive(shard.process.pid);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
export type CompactionResult = {
|
|
1008
|
+
/** Raw shards newly folded into the archive. */
|
|
1009
|
+
absorbed: number;
|
|
1010
|
+
/** Raw shard files deleted (newly or previously absorbed). */
|
|
1011
|
+
removed: number;
|
|
1012
|
+
/** Archive entries dropped for an old epoch or past retention. */
|
|
1013
|
+
dropped: number;
|
|
1014
|
+
/** True when an unreadable archive blocked compaction. */
|
|
1015
|
+
skippedCorrupt: boolean;
|
|
1016
|
+
/**
|
|
1017
|
+
* True when the archive, an epoch, or lock ownership changed while this
|
|
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.
|
|
1021
|
+
*/
|
|
1022
|
+
skippedConcurrent: boolean;
|
|
1023
|
+
/** Generation this pass published, when it published one. */
|
|
1024
|
+
generation?: number;
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
export type CompactionOptions = {
|
|
1028
|
+
/** Owner token from acquireStatsMaintenanceLock; ownership is re-checked before publishing. */
|
|
1029
|
+
lockToken?: string;
|
|
1030
|
+
/** Test hook: runs after the plan is built, right before the publish checks. */
|
|
1031
|
+
beforePublish?: () => Promise<void>;
|
|
1032
|
+
} & ArchivePublishHooks;
|
|
1033
|
+
|
|
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)]);
|
|
1045
|
+
const routedId = (r: Pick<StatsArchiveRoutedV1, "globalEpoch" | "sessionHash">): string =>
|
|
1046
|
+
JSON.stringify([r.globalEpoch, r.sessionHash]);
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Fold archivable shards into the archive, then delete them. Call only while
|
|
1050
|
+
* holding the maintenance lock. Order matters for crash safety: the archive
|
|
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.
|
|
1053
|
+
*/
|
|
1054
|
+
export async function compactStatsShardsV7(
|
|
1055
|
+
now = Date.now(),
|
|
1056
|
+
directory: string = SHARD_FILES_DIR,
|
|
1057
|
+
archivePath: string = SHARD_ARCHIVE_PATH,
|
|
1058
|
+
options: CompactionOptions = {},
|
|
1059
|
+
): Promise<CompactionResult> {
|
|
1060
|
+
const result: CompactionResult = { absorbed: 0, removed: 0, dropped: 0, skippedCorrupt: false, skippedConcurrent: false };
|
|
1061
|
+
await cleanupStaleArchiveTemps(archivePath, now);
|
|
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) {
|
|
1069
|
+
result.skippedCorrupt = true;
|
|
1070
|
+
return result;
|
|
1071
|
+
}
|
|
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;
|
|
1076
|
+
const today = currentLocalDay();
|
|
1077
|
+
// Strict reads: compaction permanently drops counters whose epoch differs,
|
|
1078
|
+
// so an unreadable epoch file must abort instead of falling back to the
|
|
1079
|
+
// initial epoch (which would discard every archived and absorbed counter).
|
|
1080
|
+
const globalEpoch = await readGlobalStatsEpochStrict();
|
|
1081
|
+
const modelEpochs = new Map<string, string>();
|
|
1082
|
+
const currentModelEpoch = async (key: string): Promise<string> => {
|
|
1083
|
+
let epoch = modelEpochs.get(key);
|
|
1084
|
+
if (!epoch) {
|
|
1085
|
+
epoch = await readModelStatsEpochStrict(key);
|
|
1086
|
+
modelEpochs.set(key, epoch);
|
|
1087
|
+
}
|
|
1088
|
+
return epoch;
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
// Carry forward live entries; drop ones a reset or retention invalidated.
|
|
1092
|
+
const buckets = new Map<string, StatsArchiveBucketV1>();
|
|
1093
|
+
for (const bucket of previous.buckets) {
|
|
1094
|
+
if (
|
|
1095
|
+
bucket.globalEpoch !== globalEpoch ||
|
|
1096
|
+
bucket.modelEpoch !== await currentModelEpoch(bucket.modelKey) ||
|
|
1097
|
+
now - bucket.updatedAt >= SHARD_RETENTION_MS
|
|
1098
|
+
) {
|
|
1099
|
+
result.dropped++;
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
buckets.set(bucketId(bucket), { ...bucket, stats: cloneCacheStats(bucket.stats) });
|
|
1103
|
+
}
|
|
1104
|
+
const routed = new Map<string, StatsArchiveRoutedV1>();
|
|
1105
|
+
for (const entry of previous.routed) {
|
|
1106
|
+
if (entry.globalEpoch !== globalEpoch || now - entry.updatedAt >= SHARD_RETENTION_MS) {
|
|
1107
|
+
result.dropped++;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
routed.set(routedId(entry), { ...entry, model: { ...entry.model } });
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const shards = await readValidStatsShardsV7(directory);
|
|
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> = {};
|
|
1122
|
+
for (const [id, at] of Object.entries(previous.absorbed)) if (presentIds.has(id)) absorbed[id] = at;
|
|
1123
|
+
|
|
1124
|
+
for (const shard of shards) {
|
|
1125
|
+
if (shard.instanceId in absorbed || !isArchivableShard(shard, now, today)) continue;
|
|
1126
|
+
absorbed[shard.instanceId] = now;
|
|
1127
|
+
result.absorbed++;
|
|
1128
|
+
if (shard.globalEpoch !== globalEpoch) continue; // reset away: nothing to keep
|
|
1129
|
+
if (shard.lastRoutedModel) {
|
|
1130
|
+
const id = routedId({ globalEpoch, sessionHash: shard.sessionHash });
|
|
1131
|
+
const existing = routed.get(id);
|
|
1132
|
+
if (!existing || existing.updatedAt < shard.lifecycle.updatedAt) {
|
|
1133
|
+
routed.set(id, { globalEpoch, sessionHash: shard.sessionHash, day: shard.day, updatedAt: shard.lifecycle.updatedAt, model: { ...shard.lastRoutedModel } });
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
for (const [key, entry] of Object.entries(shard.models)) {
|
|
1137
|
+
if (entry.modelEpoch !== await currentModelEpoch(key)) continue;
|
|
1138
|
+
const id = bucketId({ globalEpoch, sessionHash: shard.sessionHash, modelKey: key, modelEpoch: entry.modelEpoch, day: shard.day, updatedAt: shard.lifecycle.updatedAt });
|
|
1139
|
+
const existing = buckets.get(id);
|
|
1140
|
+
const newer = !existing || existing.updatedAt <= shard.lifecycle.updatedAt;
|
|
1141
|
+
buckets.set(id, {
|
|
1142
|
+
globalEpoch,
|
|
1143
|
+
sessionHash: shard.sessionHash,
|
|
1144
|
+
modelKey: key,
|
|
1145
|
+
modelEpoch: entry.modelEpoch,
|
|
1146
|
+
day: shard.day,
|
|
1147
|
+
provider: newer ? entry.provider : existing.provider,
|
|
1148
|
+
modelId: newer ? entry.modelId : existing.modelId,
|
|
1149
|
+
...((newer ? entry.modelName : existing.modelName) ? { modelName: newer ? entry.modelName : existing.modelName } : {}),
|
|
1150
|
+
...((newer ? entry.api : existing.api) ? { api: newer ? entry.api : existing.api } : {}),
|
|
1151
|
+
updatedAt: Math.max(existing?.updatedAt ?? 0, shard.lifecycle.updatedAt),
|
|
1152
|
+
stats: mergeStatsAcrossDays(existing?.stats, entry.stats, null),
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
await touchStatsMaintenanceLock(options.lockToken);
|
|
1158
|
+
|
|
1159
|
+
const absorbedChanged =
|
|
1160
|
+
Object.keys(absorbed).length !== Object.keys(previous.absorbed).length ||
|
|
1161
|
+
Object.keys(absorbed).some((id) => !(id in previous.absorbed));
|
|
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) {
|
|
1169
|
+
await options.beforePublish?.();
|
|
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.
|
|
1174
|
+
let concurrent =
|
|
1175
|
+
!(await holdsStatsMaintenanceLock(options.lockToken)) ||
|
|
1176
|
+
((await listStatsArchiveGenerations(archivePath)).at(-1) ?? 0) !== base.generation ||
|
|
1177
|
+
(base.generation === 0 && (await legacyArchiveIdentity(archivePath)) !== legacyIdentityAtRead) ||
|
|
1178
|
+
(await readGlobalStatsEpochStrict()) !== globalEpoch;
|
|
1179
|
+
for (const [key, epoch] of modelEpochs) {
|
|
1180
|
+
if (concurrent) break;
|
|
1181
|
+
if ((await readModelStatsEpochStrict(key)) !== epoch) concurrent = true;
|
|
1182
|
+
}
|
|
1183
|
+
if (concurrent) return skip();
|
|
1184
|
+
const next: StatsArchiveV1 = {
|
|
1185
|
+
version: 1,
|
|
1186
|
+
kind: "pi-cache-optimizer-archive",
|
|
1187
|
+
updatedAt: now,
|
|
1188
|
+
absorbed,
|
|
1189
|
+
buckets: Array.from(buckets.values()),
|
|
1190
|
+
routed: Array.from(routed.values()),
|
|
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);
|
|
1209
|
+
}
|
|
1210
|
+
if (result.generation !== undefined || base.generation > 0) await ensureLegacyArchiveTombstone(archivePath, base.legacySha256);
|
|
1211
|
+
await touchStatsMaintenanceLock(options.lockToken);
|
|
1212
|
+
|
|
1213
|
+
// Only now that the archive covering them is durable, delete the originals.
|
|
1214
|
+
const toDelete = Object.keys(absorbed);
|
|
1215
|
+
const outcomes = await mapWithConcurrency(toDelete, SHARD_IO_CONCURRENCY, async (id): Promise<number> => {
|
|
1216
|
+
try {
|
|
1217
|
+
await unlink(join(directory, `${id}.json`));
|
|
1218
|
+
return 1;
|
|
1219
|
+
} catch {
|
|
1220
|
+
// ENOENT: already gone. Anything else: the id stays absorbed (so the
|
|
1221
|
+
// file is ignored by readers) and the next compaction retries.
|
|
1222
|
+
return 0;
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
result.removed = outcomes.reduce((sum, value) => sum + value, 0);
|
|
1226
|
+
return result;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
export type StatsMaintenanceResult = { cleanupRemoved: number; compaction?: CompactionResult };
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Due retention cleanup and archive compaction under one lock. Each has its
|
|
1233
|
+
* own marker so the first run after upgrading compacts immediately even when
|
|
1234
|
+
* cleanup ran recently.
|
|
1235
|
+
*/
|
|
1236
|
+
export async function maybeRunStatsMaintenance(now = Date.now()): Promise<StatsMaintenanceResult> {
|
|
1237
|
+
const result: StatsMaintenanceResult = { cleanupRemoved: 0 };
|
|
1238
|
+
await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
|
|
1239
|
+
const cleanupDue = await isMaintenanceMarkerDue(SHARD_CLEANUP_MARKER_PATH, now);
|
|
1240
|
+
const compactionDue = await isMaintenanceMarkerDue(SHARD_COMPACTION_MARKER_PATH, now);
|
|
1241
|
+
if (!cleanupDue && !compactionDue) return result;
|
|
1242
|
+
const lockToken = await acquireStatsMaintenanceLock(now);
|
|
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?.();
|
|
1249
|
+
try {
|
|
1250
|
+
if (cleanupDue) {
|
|
1251
|
+
result.cleanupRemoved = await cleanupStatsShardsV7(now);
|
|
1252
|
+
await writeMaintenanceMarker(SHARD_CLEANUP_MARKER_PATH, now);
|
|
1253
|
+
}
|
|
1254
|
+
if (compactionDue) {
|
|
1255
|
+
try {
|
|
1256
|
+
result.compaction = await compactStatsShardsV7(now, SHARD_FILES_DIR, SHARD_ARCHIVE_PATH, { lockToken });
|
|
1257
|
+
if (result.compaction.skippedCorrupt) {
|
|
1258
|
+
console.warn(`${LOG_PREFIX}: stats archive ${SHARD_ARCHIVE_PATH} is unreadable; keeping raw shards`);
|
|
1259
|
+
}
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
console.warn(`${LOG_PREFIX}: stats archive compaction failed`, error);
|
|
1262
|
+
}
|
|
1263
|
+
// A pass that lost a race published nothing; let the next startup retry
|
|
1264
|
+
// instead of waiting out the full interval.
|
|
1265
|
+
if (!result.compaction?.skippedConcurrent) await writeMaintenanceMarker(SHARD_COMPACTION_MARKER_PATH, now);
|
|
1266
|
+
}
|
|
1267
|
+
} finally {
|
|
1268
|
+
clearInterval(heartbeat);
|
|
1269
|
+
await releaseStatsMaintenanceLock(lockToken);
|
|
1270
|
+
}
|
|
1271
|
+
return result;
|
|
1272
|
+
}
|