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