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