pi-cache-optimizer 2.9.1 → 2.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/README.zh-CN.md +4 -2
- package/index.ts +104 -10
- package/package.json +1 -1
- package/src/stats-archive.ts +697 -160
- package/src/stats-store.ts +81 -19
package/src/stats-archive.ts
CHANGED
|
@@ -31,18 +31,32 @@
|
|
|
31
31
|
* the highest generation; old generations are removed one generation late
|
|
32
32
|
* and readers retry when the file they listed is gone.
|
|
33
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
|
+
*
|
|
34
45
|
* 2.9.0 compatibility: 2.9.0 kept a single `archive/archive.json`. It is still
|
|
35
46
|
* read while no generation exists and migrated by the next compaction, which
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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.
|
|
43
57
|
*/
|
|
44
58
|
import { createHash, randomUUID } from "node:crypto";
|
|
45
|
-
import { link, lstat, mkdir, open, readFile, readdir,
|
|
59
|
+
import { link, lstat, mkdir, open, readFile, readdir, rm, unlink, writeFile } from "node:fs/promises";
|
|
46
60
|
import { dirname, join } from "node:path";
|
|
47
61
|
import { LOG_PREFIX, asRecord, getErrorCode, isNonEmptyString, isProcessAlive } from "./common.ts";
|
|
48
62
|
import {
|
|
@@ -75,6 +89,10 @@ import {
|
|
|
75
89
|
readValidStatsShardsV7,
|
|
76
90
|
releaseStatsMaintenanceLock,
|
|
77
91
|
renameWithRetry,
|
|
92
|
+
sameStatsFileIdentity,
|
|
93
|
+
statsFileIdentityOf,
|
|
94
|
+
type StatsFileIdentity,
|
|
95
|
+
SHARD_CLEANUP_INTERVAL_MS,
|
|
78
96
|
touchStatsMaintenanceLock,
|
|
79
97
|
writeMaintenanceMarker,
|
|
80
98
|
} from "./stats-store.ts";
|
|
@@ -95,6 +113,9 @@ export const SHARD_COMPACTION_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-co
|
|
|
95
113
|
*/
|
|
96
114
|
export const ARCHIVE_ABANDONED_ACTIVE_MS = 24 * 60 * 60 * 1000;
|
|
97
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
|
+
|
|
98
119
|
/** How often a running maintenance pass refreshes the lock's mtime. */
|
|
99
120
|
export const STATS_MAINTENANCE_HEARTBEAT_MS = 5 * 60 * 1000;
|
|
100
121
|
|
|
@@ -154,10 +175,28 @@ export type StatsArchiveV2Document = {
|
|
|
154
175
|
models: number[][];
|
|
155
176
|
buckets: number[][];
|
|
156
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;
|
|
157
184
|
};
|
|
158
185
|
|
|
159
186
|
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
160
187
|
const GENERATION_FILE_PATTERN = /^archive\.(\d{1,15})\.json$/;
|
|
188
|
+
/**
|
|
189
|
+
* 2.9.0's archive.json while it is being migrated (see compactStatsShardsV7):
|
|
190
|
+
* `archive.migrating-<moved-at ms, 13 digits>-<uuid>.json`, or
|
|
191
|
+
* `archive.migrating-<uuid>.json` as 2.9.2 named it. Both match this pattern
|
|
192
|
+
* (2.9.2 reads both).
|
|
193
|
+
*/
|
|
194
|
+
const MIGRATION_PLACEHOLDER_PATTERN = /^archive\.migrating-[0-9a-f-]+\.json$/;
|
|
195
|
+
const MIGRATION_PLACEHOLDER_MOVED_AT_PATTERN = /^archive\.migrating-(\d{13})-[0-9a-f]{8}-[0-9a-f-]+\.json$/;
|
|
196
|
+
|
|
197
|
+
function migrationPlaceholderPath(archivePath: string, movedAt = Date.now()): string {
|
|
198
|
+
return join(dirname(archivePath), `archive.migrating-${String(Math.max(0, Math.floor(movedAt))).padStart(13, "0")}-${randomUUID()}.json`);
|
|
199
|
+
}
|
|
161
200
|
|
|
162
201
|
export function emptyStatsArchive(now = Date.now()): StatsArchiveV1 {
|
|
163
202
|
return { version: 1, kind: "pi-cache-optimizer-archive", updatedAt: now, absorbed: {}, buckets: [], routed: [] };
|
|
@@ -289,6 +328,7 @@ export function encodeStatsArchiveV2(archive: StatsArchiveV1, generation: number
|
|
|
289
328
|
models,
|
|
290
329
|
buckets,
|
|
291
330
|
routed,
|
|
331
|
+
complete: true,
|
|
292
332
|
};
|
|
293
333
|
}
|
|
294
334
|
|
|
@@ -497,6 +537,19 @@ export class StatsArchiveView implements LoadedStatsArchive {
|
|
|
497
537
|
private reduced?: ReducedArchive;
|
|
498
538
|
private contributionCache?: { fingerprint: string; contribution: ShardContribution };
|
|
499
539
|
|
|
540
|
+
/** Highest generation listed, including unreadable ones above `generation`; the next publish targets newestGeneration + 1. */
|
|
541
|
+
readonly newestGeneration: number;
|
|
542
|
+
/** Unreadable generations above the one this snapshot came from (recovered from; quarantined by the next publish). */
|
|
543
|
+
readonly corruptGenerations: readonly number[];
|
|
544
|
+
/** The newest generation does not parse yet but is young: it may still be written in place (no-hard-link fallback). */
|
|
545
|
+
readonly pendingGeneration: boolean;
|
|
546
|
+
/** 2.9.0's file this snapshot was read from: archive.json, or a migration placeholder after an interrupted migration. */
|
|
547
|
+
readonly legacySourcePath?: string;
|
|
548
|
+
/** sha256 of the legacy source file's bytes (also set when it is unreadable, to verify it is unchanged before healing). */
|
|
549
|
+
readonly legacySourceSha256?: string;
|
|
550
|
+
/** No generation exists and 2.9.0's archive.json is unreadable: the next publish starts over and sets the file aside. */
|
|
551
|
+
readonly legacyCorrupt: boolean;
|
|
552
|
+
|
|
500
553
|
constructor(
|
|
501
554
|
readonly archive: StatsArchiveV1,
|
|
502
555
|
readonly present: boolean,
|
|
@@ -504,9 +557,36 @@ export class StatsArchiveView implements LoadedStatsArchive {
|
|
|
504
557
|
readonly generation: number,
|
|
505
558
|
/** For 2.9.0's archive.json: its sha256; for a generation: the legacy file it was migrated from. */
|
|
506
559
|
readonly legacySha256?: string,
|
|
560
|
+
recovery: {
|
|
561
|
+
newestGeneration?: number;
|
|
562
|
+
corruptGenerations?: readonly number[];
|
|
563
|
+
pendingGeneration?: boolean;
|
|
564
|
+
legacySourcePath?: string;
|
|
565
|
+
legacySourceSha256?: string;
|
|
566
|
+
legacyCorrupt?: boolean;
|
|
567
|
+
} = {},
|
|
507
568
|
) {
|
|
508
569
|
this.absorbedIds = new Set(Object.keys(archive.absorbed));
|
|
570
|
+
this.newestGeneration = Math.max(generation, recovery.newestGeneration ?? 0);
|
|
571
|
+
this.corruptGenerations = recovery.corruptGenerations ?? [];
|
|
572
|
+
this.pendingGeneration = recovery.pendingGeneration ?? false;
|
|
573
|
+
this.legacySourcePath = recovery.legacySourcePath;
|
|
574
|
+
this.legacySourceSha256 = recovery.legacySourceSha256;
|
|
575
|
+
this.legacyCorrupt = recovery.legacyCorrupt ?? false;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** The same snapshot, recovered from below unreadable newer generations. */
|
|
579
|
+
withRecovery(newestGeneration: number, corruptGenerations: readonly number[], pendingGeneration: boolean): StatsArchiveView {
|
|
580
|
+
const key = JSON.stringify([newestGeneration, corruptGenerations, pendingGeneration]);
|
|
581
|
+
if (this.recovered?.key === key) return this.recovered.view;
|
|
582
|
+
const view = new StatsArchiveView(this.archive, this.present, this.corrupt, this.generation, this.legacySha256, {
|
|
583
|
+
newestGeneration, corruptGenerations, pendingGeneration,
|
|
584
|
+
legacySourcePath: this.legacySourcePath, legacySourceSha256: this.legacySourceSha256,
|
|
585
|
+
});
|
|
586
|
+
this.recovered = { key, view };
|
|
587
|
+
return view;
|
|
509
588
|
}
|
|
589
|
+
private recovered?: { key: string; view: StatsArchiveView };
|
|
510
590
|
|
|
511
591
|
get shards(): readonly PersistedStatsShardV7[] {
|
|
512
592
|
return this.syntheticShards ??= archiveToSyntheticShards(this.archive);
|
|
@@ -710,54 +790,108 @@ export async function aggregateStatsWithArchiveV7(
|
|
|
710
790
|
return aggregator.finish();
|
|
711
791
|
}
|
|
712
792
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
793
|
+
/** Test hooks for archive I/O races and faults; production never sets them. */
|
|
794
|
+
export type StatsArchiveTestHooks = {
|
|
795
|
+
/** Runs after each directory listing a reader makes (to land a concurrent migration or publish in between). */
|
|
796
|
+
afterList?: (archivePath: string) => void | Promise<void>;
|
|
797
|
+
/** Runs before a reader's lstat of a generation or legacy file; throw to simulate a fault. */
|
|
798
|
+
beforeLstat?: (path: string) => void | Promise<void>;
|
|
799
|
+
/** Runs before a reader's readFile of a generation or legacy file; throw to simulate a fault. */
|
|
800
|
+
beforeReadFile?: (path: string) => void | Promise<void>;
|
|
801
|
+
/** Replaces link() when publishing (e.g. throw EPERM like a filesystem without hard links). */
|
|
802
|
+
link?: (existingPath: string, newPath: string) => Promise<void>;
|
|
803
|
+
/** Runs right after the no-hard-link fallback created a generation file exclusively, before writing it. */
|
|
804
|
+
afterExclusiveCreate?: (target: string) => Promise<void>;
|
|
805
|
+
/**
|
|
806
|
+
* Runs between the steps of moving 2.9.0's archive.json aside (to land a
|
|
807
|
+
* concurrent 2.9.0 write there): "moved-aside" right after archive.json was
|
|
808
|
+
* renamed to its placeholder, before the tombstone directory is made;
|
|
809
|
+
* "undo-removed-tombstone" when undoing, right after the tombstone was
|
|
810
|
+
* removed, before the placeholder is put back.
|
|
811
|
+
*/
|
|
812
|
+
afterMigrationStep?: (step: "moved-aside" | "undo-removed-tombstone", archivePath: string, placeholder: string) => Promise<void>;
|
|
813
|
+
/** Overrides the maintenance heartbeat interval. */
|
|
814
|
+
heartbeatMs?: number;
|
|
815
|
+
};
|
|
816
|
+
let testHooks: StatsArchiveTestHooks = {};
|
|
817
|
+
export function setStatsArchiveTestHooks(hooks: StatsArchiveTestHooks | undefined): void {
|
|
818
|
+
testHooks = hooks ?? {};
|
|
819
|
+
}
|
|
718
820
|
|
|
719
|
-
|
|
821
|
+
type ArchiveListing = { generations: number[]; placeholders: string[] };
|
|
822
|
+
|
|
823
|
+
async function listArchiveDirectory(archivePath: string): Promise<ArchiveListing> {
|
|
824
|
+
let names: string[];
|
|
825
|
+
try {
|
|
826
|
+
names = await readdir(dirname(archivePath));
|
|
827
|
+
} catch (error) {
|
|
828
|
+
if (getErrorCode(error) === "ENOENT") return { generations: [], placeholders: [] };
|
|
829
|
+
throw error;
|
|
830
|
+
}
|
|
831
|
+
const generations: number[] = [];
|
|
832
|
+
const placeholders: string[] = [];
|
|
833
|
+
for (const name of names) {
|
|
834
|
+
const match = GENERATION_FILE_PATTERN.exec(name);
|
|
835
|
+
if (match) generations.push(Number(match[1]));
|
|
836
|
+
else if (MIGRATION_PLACEHOLDER_PATTERN.test(name)) placeholders.push(join(dirname(archivePath), name));
|
|
837
|
+
}
|
|
838
|
+
return { generations: generations.sort((a, b) => a - b), placeholders: placeholders.sort() };
|
|
839
|
+
}
|
|
720
840
|
|
|
721
841
|
/**
|
|
722
|
-
* The newest
|
|
723
|
-
*
|
|
724
|
-
*
|
|
842
|
+
* The newest of several migration placeholders, chosen deterministically.
|
|
843
|
+
* Two exist only after a migration kept one for inspection (a 2.9.0 write
|
|
844
|
+
* landed between its move and its mkdir) and a later one was interrupted
|
|
845
|
+
* after its own move: the later one holds what 2.9.0 wrote after the first
|
|
846
|
+
* was moved, so it is the one to read and migrate from (the older one is set
|
|
847
|
+
* aside once a generation exists). Ordered by the time the file was moved
|
|
848
|
+
* aside (in its name), or for 2.9.2's uuid-only names by its mtime (rename
|
|
849
|
+
* keeps it; content 2.9.0 wrote after an earlier move is always newer than
|
|
850
|
+
* that move), then by mtime, then by name. A file that disappeared sorts
|
|
851
|
+
* first; the caller re-lists when it is gone.
|
|
725
852
|
*/
|
|
726
|
-
async function
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
const path = statsArchiveGenerationPath(archivePath, generation);
|
|
732
|
-
try {
|
|
733
|
-
const info = await lstat(path);
|
|
734
|
-
if (!info.isFile() || info.isSymbolicLink()) return { kind: "invalid", path };
|
|
735
|
-
return { kind: "generation", path, generation, info };
|
|
736
|
-
} catch (error) {
|
|
737
|
-
if (getErrorCode(error) === "ENOENT") continue;
|
|
738
|
-
throw error;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
try {
|
|
742
|
-
const info = await lstat(archivePath);
|
|
743
|
-
if (info.isFile() && !info.isSymbolicLink()) return { kind: "legacy", path: archivePath, info };
|
|
744
|
-
// The migration tombstone is created only after a generation exists:
|
|
745
|
-
// the listing raced a cleanup, or the generations were removed by hand.
|
|
746
|
-
if (info.isDirectory()) continue;
|
|
747
|
-
return { kind: "invalid", path: archivePath };
|
|
748
|
-
} catch (error) {
|
|
749
|
-
if (getErrorCode(error) === "ENOENT") return { kind: "none" };
|
|
853
|
+
async function newestMigrationPlaceholder(placeholders: readonly string[]): Promise<string | undefined> {
|
|
854
|
+
if (placeholders.length <= 1) return placeholders[0];
|
|
855
|
+
const keyed = await Promise.all(placeholders.map(async (path) => {
|
|
856
|
+
const info = await lstat(path).catch((error) => {
|
|
857
|
+
if (getErrorCode(error) === "ENOENT") return undefined;
|
|
750
858
|
throw error;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
|
|
859
|
+
});
|
|
860
|
+
const mtime = info?.mtimeMs ?? -Infinity;
|
|
861
|
+
const moved = MIGRATION_PLACEHOLDER_MOVED_AT_PATTERN.exec(path.slice(dirname(path).length + 1));
|
|
862
|
+
return { path, time: info ? (moved ? Number(moved[1]) : mtime) : -Infinity, mtime };
|
|
863
|
+
}));
|
|
864
|
+
keyed.sort((a, b) => a.time - b.time || a.mtime - b.mtime || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
865
|
+
return keyed.at(-1)!.path;
|
|
754
866
|
}
|
|
755
867
|
|
|
756
|
-
|
|
757
|
-
|
|
868
|
+
const SOURCE_ATTEMPTS = 6;
|
|
869
|
+
/** Retries (with these delays) of a transient lstat/read failure before a reader gives up on this refresh. */
|
|
870
|
+
const TRANSIENT_RETRY_DELAYS_MS = [15, 60, 150];
|
|
871
|
+
const TRANSIENT_CODES = new Set(["EPERM", "EBUSY", "EACCES", "EAGAIN"]);
|
|
872
|
+
/**
|
|
873
|
+
* A newest generation that does not parse and is younger than this may still
|
|
874
|
+
* be being written in place (the no-hard-link fallback): compaction waits
|
|
875
|
+
* instead of recovering past it. Readers always skip it.
|
|
876
|
+
*/
|
|
877
|
+
export const ARCHIVE_PENDING_GENERATION_MS = 10 * 60 * 1000;
|
|
878
|
+
|
|
879
|
+
type ParsedArchiveFile =
|
|
880
|
+
| { kind: "ok"; view: StatsArchiveView }
|
|
881
|
+
/** Not an archive this version can read (damaged, truncated, wrong generation). */
|
|
882
|
+
| { kind: "invalid"; sha256: string }
|
|
883
|
+
/** A well-formed archive in a newer format: never recovered past or overwritten. */
|
|
884
|
+
| { kind: "unsupported" };
|
|
885
|
+
|
|
886
|
+
type CachedArchiveFile = StatsFileIdentity & { parsed: ParsedArchiveFile };
|
|
887
|
+
/** Parsed archive files by path (generations are immutable; archive.json is re-validated by identity). */
|
|
888
|
+
const archiveFileCache = new Map<string, CachedArchiveFile>();
|
|
889
|
+
/** The last good snapshot per archive path, served (stale) when a transient error persists. */
|
|
890
|
+
const lastGoodArchive = new Map<string, StatsArchiveView>();
|
|
758
891
|
|
|
759
892
|
export function clearStatsArchiveCache(): void {
|
|
760
|
-
|
|
893
|
+
archiveFileCache.clear();
|
|
894
|
+
lastGoodArchive.clear();
|
|
761
895
|
}
|
|
762
896
|
|
|
763
897
|
const emptyView = (): StatsArchiveView => new StatsArchiveView(emptyStatsArchive(0), false, false, 0);
|
|
@@ -767,87 +901,268 @@ function sha256(text: string | Buffer): string {
|
|
|
767
901
|
return createHash("sha256").update(text).digest("hex");
|
|
768
902
|
}
|
|
769
903
|
|
|
770
|
-
function
|
|
904
|
+
function parseArchiveBytes(kind: "legacy" | "generation", generation: number, path: string, bytes: Buffer): ParsedArchiveFile {
|
|
905
|
+
const invalid = (): ParsedArchiveFile => ({ kind: "invalid", sha256: sha256(bytes) });
|
|
771
906
|
let value: unknown;
|
|
772
907
|
try {
|
|
773
908
|
value = JSON.parse(bytes.toString("utf8"));
|
|
774
909
|
} catch {
|
|
775
|
-
return
|
|
910
|
+
return invalid();
|
|
911
|
+
}
|
|
912
|
+
const record = asRecord(value);
|
|
913
|
+
const supported = kind === "legacy" ? 1 : 2;
|
|
914
|
+
if (record?.kind === "pi-cache-optimizer-archive" && isFiniteNumber(record.version) && record.version > supported) {
|
|
915
|
+
return { kind: "unsupported" };
|
|
776
916
|
}
|
|
777
|
-
if (
|
|
917
|
+
if (kind === "legacy") {
|
|
778
918
|
const archive = parseStatsArchive(value);
|
|
779
|
-
|
|
919
|
+
const sha = sha256(bytes);
|
|
920
|
+
return archive
|
|
921
|
+
? { kind: "ok", view: new StatsArchiveView(archive, true, false, 0, sha, { legacySourcePath: path, legacySourceSha256: sha }) }
|
|
922
|
+
: invalid();
|
|
780
923
|
}
|
|
781
924
|
const parsed = parseStatsArchiveV2(value);
|
|
782
|
-
if (!parsed || parsed.generation !==
|
|
783
|
-
return new StatsArchiveView(parsed.archive, true, false, parsed.generation, parsed.legacySha256);
|
|
925
|
+
if (!parsed || parsed.generation !== generation) return invalid();
|
|
926
|
+
return { kind: "ok", view: new StatsArchiveView(parsed.archive, true, false, parsed.generation, parsed.legacySha256) };
|
|
784
927
|
}
|
|
785
928
|
|
|
929
|
+
class ArchiveRestart extends Error {}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* A retry delay. Deliberately not unref'd: the read is awaited by a Pi
|
|
933
|
+
* handler (agent_settled, model_select, session_shutdown...), and in print
|
|
934
|
+
* mode an unref'd delay lets the event loop drain mid-handler, so Pi exits
|
|
935
|
+
* without session_shutdown (see renameWithRetry).
|
|
936
|
+
*/
|
|
937
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
938
|
+
|
|
786
939
|
export type ReadStatsArchiveOptions = {
|
|
787
940
|
/** Throw on a transient I/O failure instead of serving the last good snapshot (compaction). */
|
|
788
941
|
strict?: boolean;
|
|
789
942
|
};
|
|
790
943
|
|
|
944
|
+
export type StatsArchiveRead = {
|
|
945
|
+
view: StatsArchiveView;
|
|
946
|
+
/**
|
|
947
|
+
* True when a transient I/O failure persisted and `view` is the last good
|
|
948
|
+
* snapshot (or an empty corrupt one): it may be older than a directory
|
|
949
|
+
* listing taken just before. See readStatsSnapshot.
|
|
950
|
+
*/
|
|
951
|
+
stale: boolean;
|
|
952
|
+
};
|
|
953
|
+
|
|
791
954
|
/**
|
|
792
|
-
* Read the newest archive snapshot
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
955
|
+
* Read the newest usable archive snapshot.
|
|
956
|
+
*
|
|
957
|
+
* - The highest generation wins. A generation that is not a readable
|
|
958
|
+
* archive (damaged on disk, truncated by a crash of the in-place fallback,
|
|
959
|
+
* not a regular file) is skipped for the next lower one: every published
|
|
960
|
+
* generation is self-consistent (its counters cover exactly the raw shards
|
|
961
|
+
* it lists as absorbed), so falling back never double counts; it only
|
|
962
|
+
* misses what the unreadable one absorbed and already deleted. The next
|
|
963
|
+
* compaction publishes above it and quarantines it. A generation in a newer
|
|
964
|
+
* format is never skipped: it is reported as corrupt, like before.
|
|
965
|
+
* - Without generations, 2.9.0's archive.json (or a migration placeholder
|
|
966
|
+
* left by an interrupted migration) is read. An unreadable archive.json is
|
|
967
|
+
* treated as empty, and the next compaction starts over from it.
|
|
968
|
+
* - A file that disappears between listing and reading (superseded and
|
|
969
|
+
* removed, or migrated) makes the reader list again, as does a missing or
|
|
970
|
+
* directory archive.json, which a migration in progress produces.
|
|
971
|
+
* - Transient errors (Windows EPERM/EBUSY while a scanner holds a file) are
|
|
972
|
+
* retried briefly; if they persist, the last good snapshot is served and
|
|
973
|
+
* flagged stale (never cached as an empty archive).
|
|
799
974
|
*/
|
|
800
|
-
export async function
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
return previous && !previous.view.corrupt ? previous.view : corruptView();
|
|
805
|
-
};
|
|
806
|
-
for (let attempt = 0; attempt < SOURCE_ATTEMPTS; attempt++) {
|
|
807
|
-
let source: ArchiveSource;
|
|
975
|
+
export async function readStatsArchiveDetailed(archivePath: string = SHARD_ARCHIVE_PATH, options: ReadStatsArchiveOptions = {}): Promise<StatsArchiveRead> {
|
|
976
|
+
let transientRetries = 0;
|
|
977
|
+
let lastError: unknown;
|
|
978
|
+
for (let attempt = 0; attempt < SOURCE_ATTEMPTS + TRANSIENT_RETRY_DELAYS_MS.length; attempt++) {
|
|
808
979
|
try {
|
|
809
|
-
|
|
980
|
+
const view = await locateAndReadArchive(archivePath, options, attempt);
|
|
981
|
+
if (view === undefined) continue;
|
|
982
|
+
if (!view.corrupt) lastGoodArchive.set(archivePath, view);
|
|
983
|
+
else lastGoodArchive.delete(archivePath);
|
|
984
|
+
return { view, stale: false };
|
|
810
985
|
} catch (error) {
|
|
811
|
-
|
|
986
|
+
if (error instanceof ArchiveRestart) continue;
|
|
987
|
+
lastError = error;
|
|
988
|
+
if (TRANSIENT_CODES.has(getErrorCode(error) ?? "") && transientRetries < TRANSIENT_RETRY_DELAYS_MS.length) {
|
|
989
|
+
await sleep(TRANSIENT_RETRY_DELAYS_MS[transientRetries++]!);
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
break;
|
|
812
993
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
994
|
+
}
|
|
995
|
+
lastError ??= new Error(`stats archive ${archivePath} kept changing while being read`);
|
|
996
|
+
if (options.strict) throw lastError;
|
|
997
|
+
return { view: lastGoodArchive.get(archivePath) ?? corruptView(), stale: true };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* One attempt. Resolves undefined (or throws ArchiveRestart) to list again;
|
|
1002
|
+
* throws other errors for the caller's transient policy.
|
|
1003
|
+
*/
|
|
1004
|
+
async function locateAndReadArchive(archivePath: string, options: ReadStatsArchiveOptions, attempt: number): Promise<StatsArchiveView | undefined> {
|
|
1005
|
+
const listing = await listArchiveDirectory(archivePath);
|
|
1006
|
+
await testHooks.afterList?.(archivePath);
|
|
1007
|
+
const restartIfNewer = async (generation: number, error: unknown): Promise<never> => {
|
|
1008
|
+
const code = getErrorCode(error);
|
|
1009
|
+
// Superseded and removed (or migrated) between listing and reading.
|
|
1010
|
+
if (code === "ENOENT") throw new ArchiveRestart();
|
|
1011
|
+
// On Windows, opening or stat-ing a generation that is being deleted
|
|
1012
|
+
// while someone holds it open fails with EPERM; a newer one exists then.
|
|
1013
|
+
if (generation > 0) {
|
|
1014
|
+
const newest = await listArchiveDirectory(archivePath).then((l) => l.generations.at(-1), () => undefined);
|
|
1015
|
+
if (newest !== undefined && newest > generation) throw new ArchiveRestart();
|
|
816
1016
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1017
|
+
throw error;
|
|
1018
|
+
};
|
|
1019
|
+
if (listing.generations.length > 0) {
|
|
1020
|
+
const newest = listing.generations.at(-1)!;
|
|
1021
|
+
const corrupt: number[] = [];
|
|
1022
|
+
let pending = false;
|
|
1023
|
+
for (let index = listing.generations.length - 1; index >= 0; index--) {
|
|
1024
|
+
const generation = listing.generations[index]!;
|
|
1025
|
+
const path = statsArchiveGenerationPath(archivePath, generation);
|
|
1026
|
+
const parsed = await readArchiveFile(path, "generation", generation, options, restartIfNewer);
|
|
1027
|
+
if (parsed === undefined || parsed.parsed.kind === "invalid") {
|
|
1028
|
+
const age = parsed ? Date.now() - parsed.mtimeMs : Infinity;
|
|
1029
|
+
if (generation === newest && Math.abs(age) < ARCHIVE_PENDING_GENERATION_MS) pending = true;
|
|
1030
|
+
corrupt.unshift(generation);
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
if (parsed.parsed.kind === "unsupported") return corruptView();
|
|
1034
|
+
const view = parsed.parsed.view;
|
|
1035
|
+
return corrupt.length > 0 ? view.withRecovery(newest, corrupt, pending) : view;
|
|
820
1036
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
//
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1037
|
+
// No generation is readable: start over from an empty archive. Raw
|
|
1038
|
+
// shards the unreadable generations absorbed but did not delete yet are
|
|
1039
|
+
// counted raw again (their ids are no longer known to be absorbed).
|
|
1040
|
+
return new StatsArchiveView(emptyStatsArchive(0), false, false, 0, undefined, { newestGeneration: newest, corruptGenerations: corrupt, pendingGeneration: pending });
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// No generation: 2.9.0's archive.json, or a placeholder of an interrupted migration.
|
|
1044
|
+
let info: import("node:fs").Stats | undefined;
|
|
1045
|
+
try {
|
|
1046
|
+
await testHooks.beforeLstat?.(archivePath);
|
|
1047
|
+
info = await lstat(archivePath);
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
if (getErrorCode(error) !== "ENOENT") throw error;
|
|
1050
|
+
}
|
|
1051
|
+
if (info?.isFile() && !info.isSymbolicLink()) {
|
|
1052
|
+
const parsed = await readArchiveFile(archivePath, "legacy", 0, options, restartIfNewer);
|
|
1053
|
+
if (!parsed) throw new ArchiveRestart();
|
|
1054
|
+
if (parsed.parsed.kind === "ok") return parsed.parsed.view;
|
|
1055
|
+
if (parsed.parsed.kind === "unsupported") return corruptView();
|
|
1056
|
+
return legacyCorruptView(archivePath, parsed.parsed.sha256);
|
|
1057
|
+
}
|
|
1058
|
+
if (info && !info.isDirectory()) return legacyCorruptView(archivePath, undefined);
|
|
1059
|
+
const placeholder = await newestMigrationPlaceholder(listing.placeholders);
|
|
1060
|
+
if (placeholder) {
|
|
1061
|
+
const parsed = await readArchiveFile(placeholder, "legacy", 0, options, restartIfNewer);
|
|
1062
|
+
if (!parsed) throw new ArchiveRestart();
|
|
1063
|
+
if (parsed.parsed.kind === "ok") return parsed.parsed.view;
|
|
1064
|
+
if (parsed.parsed.kind === "unsupported") return corruptView();
|
|
1065
|
+
return legacyCorruptView(placeholder, parsed.parsed.sha256);
|
|
1066
|
+
}
|
|
1067
|
+
// archive.json is missing or is the migration tombstone, which exists only
|
|
1068
|
+
// once a generation does (or while a migration moves archive.json aside):
|
|
1069
|
+
// the listing raced a migration or a cleanup. List again before concluding
|
|
1070
|
+
// there is no archive.
|
|
1071
|
+
if (attempt < 2) return undefined;
|
|
1072
|
+
return emptyView();
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function legacyCorruptView(path: string, sha: string | undefined): StatsArchiveView {
|
|
1076
|
+
return new StatsArchiveView(emptyStatsArchive(0), true, false, 0, undefined, { legacySourcePath: path, legacySourceSha256: sha, legacyCorrupt: true });
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** lstat + cached parse of one archive file; undefined when it is not a regular file. */
|
|
1080
|
+
async function readArchiveFile(
|
|
1081
|
+
path: string,
|
|
1082
|
+
kind: "legacy" | "generation",
|
|
1083
|
+
generation: number,
|
|
1084
|
+
options: ReadStatsArchiveOptions,
|
|
1085
|
+
onError: (generation: number, error: unknown) => Promise<never>,
|
|
1086
|
+
): Promise<{ parsed: ParsedArchiveFile; mtimeMs: number } | undefined> {
|
|
1087
|
+
let info: import("node:fs").Stats;
|
|
1088
|
+
try {
|
|
1089
|
+
await testHooks.beforeLstat?.(path);
|
|
1090
|
+
info = await lstat(path);
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
return onError(generation, error);
|
|
1093
|
+
}
|
|
1094
|
+
if (!info.isFile() || info.isSymbolicLink()) return undefined;
|
|
1095
|
+
const cached = archiveFileCache.get(path);
|
|
1096
|
+
// Generations are immutable, so their identity is a safe cache key. 2.9.0
|
|
1097
|
+
// rewrites archive.json in place, and on filesystems with ino 0 and coarse
|
|
1098
|
+
// mtimes (FAT/SMB on Windows) a rewrite of the same size may keep the same
|
|
1099
|
+
// identity. Compaction migrates from it and then deletes shards, so it
|
|
1100
|
+
// always re-reads that file instead of trusting the cache.
|
|
1101
|
+
const cacheable = !(options.strict && kind === "legacy");
|
|
1102
|
+
if (cacheable && cached && sameStatsFileIdentity(cached, info)) return { parsed: cached.parsed, mtimeMs: info.mtimeMs };
|
|
1103
|
+
let bytes: Buffer;
|
|
1104
|
+
try {
|
|
1105
|
+
await testHooks.beforeReadFile?.(path);
|
|
1106
|
+
bytes = await readFile(path);
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
archiveFileCache.delete(path);
|
|
1109
|
+
return onError(generation, error);
|
|
1110
|
+
}
|
|
1111
|
+
const parsed = parseArchiveBytes(kind, generation, path, bytes);
|
|
1112
|
+
// A young unparseable generation may still be written in place: do not
|
|
1113
|
+
// cache it. An old one stays unreadable until compaction quarantines it
|
|
1114
|
+
// (possibly hours later); cache it like any other file so readers do not
|
|
1115
|
+
// re-read and re-parse it on every refresh. Completing a write would
|
|
1116
|
+
// change its size, so the identity check still notices that.
|
|
1117
|
+
const young = Math.abs(Date.now() - info.mtimeMs) < ARCHIVE_PENDING_GENERATION_MS;
|
|
1118
|
+
if (!(parsed.kind === "invalid" && kind === "generation" && young)) archiveFileCache.set(path, { ...statsFileIdentityOf(info), parsed });
|
|
1119
|
+
else archiveFileCache.delete(path);
|
|
1120
|
+
if (archiveFileCache.size > 16) {
|
|
1121
|
+
for (const key of archiveFileCache.keys()) {
|
|
1122
|
+
if (archiveFileCache.size <= 16) break;
|
|
1123
|
+
if (key !== path) archiveFileCache.delete(key);
|
|
831
1124
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1125
|
+
}
|
|
1126
|
+
return { parsed, mtimeMs: info.mtimeMs };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** Read the newest archive snapshot (see readStatsArchiveDetailed). */
|
|
1130
|
+
export async function readStatsArchiveView(archivePath: string = SHARD_ARCHIVE_PATH, options: ReadStatsArchiveOptions = {}): Promise<StatsArchiveView> {
|
|
1131
|
+
return (await readStatsArchiveDetailed(archivePath, options)).view;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
export type StatsSnapshot = { rawShards: PersistedStatsShardV7[]; archive: StatsArchiveView };
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* The raw shards and the archive as one consistent snapshot. Order matters:
|
|
1138
|
+
* compaction publishes the archive before deleting the shards it absorbed,
|
|
1139
|
+
* so reading the directory first and the archive second always sees each
|
|
1140
|
+
* absorbed shard in at least one of them. When the archive read hit a
|
|
1141
|
+
* persistent transient error and served the last good snapshot, that
|
|
1142
|
+
* snapshot may predate shards deleted since; pairing it with the fresh
|
|
1143
|
+
* listing would undercount, so the previous snapshot is reused as a whole
|
|
1144
|
+
* (it was consistent when read) when it holds that same archive.
|
|
1145
|
+
*/
|
|
1146
|
+
export async function readStatsSnapshot(
|
|
1147
|
+
previous?: StatsSnapshot,
|
|
1148
|
+
directory: string = SHARD_FILES_DIR,
|
|
1149
|
+
archivePath: string = SHARD_ARCHIVE_PATH,
|
|
1150
|
+
): Promise<StatsSnapshot> {
|
|
1151
|
+
const rawShards = await readValidStatsShardsV7(directory);
|
|
1152
|
+
const { view, stale } = await readStatsArchiveDetailed(archivePath);
|
|
1153
|
+
if (stale && previous && previous.archive === view) {
|
|
1154
|
+
// Only a newer generation can have deleted shards this archive misses.
|
|
1155
|
+
// While it is still the newest one (highest generations never go back:
|
|
1156
|
+
// only lower ones are removed, and an unreadable newest one only once a
|
|
1157
|
+
// higher one exists), the fresh listing pairs with it consistently, so
|
|
1158
|
+
// a persistent error on its file does not freeze everyone else's shards.
|
|
1159
|
+
if (!view.corrupt && view.generation > 0 && view.corruptGenerations.length === 0) {
|
|
1160
|
+
const newest = await listStatsArchiveGenerations(archivePath).then((generations) => generations.at(-1), () => undefined);
|
|
1161
|
+
if (newest === view.generation) return { rawShards, archive: view };
|
|
845
1162
|
}
|
|
846
|
-
|
|
847
|
-
archiveCache.set(archivePath, { path: source.path, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, view });
|
|
848
|
-
return view;
|
|
1163
|
+
return previous;
|
|
849
1164
|
}
|
|
850
|
-
return
|
|
1165
|
+
return { rawShards, archive: view };
|
|
851
1166
|
}
|
|
852
1167
|
|
|
853
1168
|
/** Reference-form read (used by tests and tools): the snapshot with synthetic shards available. */
|
|
@@ -887,18 +1202,30 @@ async function legacyArchiveIdentity(archivePath: string): Promise<string | unde
|
|
|
887
1202
|
|
|
888
1203
|
/** Test hooks for the publish protocol; production never sets them. */
|
|
889
1204
|
export type ArchivePublishHooks = {
|
|
890
|
-
/** Runs after every publish check passed, right before the new generation becomes visible. */
|
|
1205
|
+
/** Runs after every publish check passed, right before the new generation becomes visible (and before a migration moves archive.json). */
|
|
891
1206
|
beforeCommit?: () => Promise<void>;
|
|
892
1207
|
};
|
|
893
1208
|
|
|
894
|
-
/**
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1209
|
+
/** Flush a directory entry change (link, rename) to stable storage where the platform supports it. */
|
|
1210
|
+
async function fsyncDirectory(directory: string): Promise<void> {
|
|
1211
|
+
if (process.platform === "win32") return; // directories cannot be opened for fsync there
|
|
1212
|
+
try {
|
|
1213
|
+
const handle = await open(directory, "r");
|
|
1214
|
+
try {
|
|
1215
|
+
await handle.sync();
|
|
1216
|
+
} finally {
|
|
1217
|
+
await handle.close();
|
|
1218
|
+
}
|
|
1219
|
+
} catch {}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** link() failures that mean "this filesystem has no hard links" (FAT/exFAT, some NAS/SMB mounts). */
|
|
1223
|
+
const NO_HARDLINK_CODES = new Set(["EPERM", "ENOTSUP", "EINVAL"]);
|
|
1224
|
+
|
|
1225
|
+
/** A snapshot written and flushed to a private temp file, ready to be published. */
|
|
1226
|
+
type PreparedGeneration = { target: string; tempPath: string; text: string };
|
|
1227
|
+
|
|
1228
|
+
async function prepareStatsArchiveGeneration(archivePath: string, generation: number, text: string): Promise<PreparedGeneration> {
|
|
902
1229
|
await mkdir(dirname(archivePath), { recursive: true });
|
|
903
1230
|
const target = statsArchiveGenerationPath(archivePath, generation);
|
|
904
1231
|
const tempPath = `${target}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
@@ -910,19 +1237,57 @@ async function commitStatsArchiveGeneration(archivePath: string, generation: num
|
|
|
910
1237
|
} finally {
|
|
911
1238
|
await handle.close();
|
|
912
1239
|
}
|
|
913
|
-
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
await unlink(tempPath).catch(() => undefined);
|
|
1242
|
+
throw error;
|
|
1243
|
+
}
|
|
1244
|
+
return { target, tempPath, text };
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Publish a prepared generation atomically and exclusively: hard-link the
|
|
1249
|
+
* flushed temp file to its final name. link() fails with EEXIST when that
|
|
1250
|
+
* generation already exists, on Windows and POSIX alike, so of all writers
|
|
1251
|
+
* that read generation N exactly one can publish N+1. Resolves false when
|
|
1252
|
+
* another writer won.
|
|
1253
|
+
*
|
|
1254
|
+
* Without hard links (link fails with EPERM/ENOTSUP/EINVAL), the generation
|
|
1255
|
+
* is created exclusively in place (O_EXCL, the same compare-and-swap) and
|
|
1256
|
+
* written and flushed there. Until it is complete it does not parse, so
|
|
1257
|
+
* readers skip it for the previous generation, and compaction treats a
|
|
1258
|
+
* young unparseable newest generation as still being written. The writer
|
|
1259
|
+
* deletes raw shards only after it completed, so nothing is counted twice
|
|
1260
|
+
* or lost at any point; a crash mid-write leaves an unreadable generation
|
|
1261
|
+
* that the next pass recovers past.
|
|
1262
|
+
*/
|
|
1263
|
+
async function commitPreparedGeneration(prepared: PreparedGeneration): Promise<boolean> {
|
|
1264
|
+
try {
|
|
1265
|
+
await (testHooks.link ?? link)(prepared.tempPath, prepared.target);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
const code = getErrorCode(error);
|
|
1268
|
+
if (code === "EEXIST") return false;
|
|
1269
|
+
if (!NO_HARDLINK_CODES.has(code ?? "")) throw error;
|
|
1270
|
+
let handle;
|
|
914
1271
|
try {
|
|
915
|
-
await
|
|
916
|
-
} catch (
|
|
917
|
-
if (getErrorCode(
|
|
918
|
-
|
|
919
|
-
// the caller reports it and raw shards simply stay where they are.
|
|
920
|
-
throw error;
|
|
1272
|
+
handle = await open(prepared.target, "wx");
|
|
1273
|
+
} catch (openError) {
|
|
1274
|
+
if (getErrorCode(openError) === "EEXIST") return false;
|
|
1275
|
+
throw openError;
|
|
921
1276
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1277
|
+
try {
|
|
1278
|
+
await testHooks.afterExclusiveCreate?.(prepared.target);
|
|
1279
|
+
await handle.writeFile(prepared.text, "utf8");
|
|
1280
|
+
await handle.sync();
|
|
1281
|
+
} catch (writeError) {
|
|
1282
|
+
await handle.close().catch(() => undefined);
|
|
1283
|
+
// Ours (created exclusively) and incomplete: never leave it published.
|
|
1284
|
+
await unlink(prepared.target).catch(() => undefined);
|
|
1285
|
+
throw writeError;
|
|
1286
|
+
}
|
|
1287
|
+
await handle.close();
|
|
925
1288
|
}
|
|
1289
|
+
await fsyncDirectory(dirname(prepared.target));
|
|
1290
|
+
return true;
|
|
926
1291
|
}
|
|
927
1292
|
|
|
928
1293
|
/**
|
|
@@ -937,21 +1302,63 @@ async function removeSupersededGenerations(archivePath: string, current: number)
|
|
|
937
1302
|
}
|
|
938
1303
|
}
|
|
939
1304
|
|
|
1305
|
+
/** Move an unreadable generation out of the generation namespace, keeping it for inspection. */
|
|
1306
|
+
async function quarantineGeneration(archivePath: string, generation: number): Promise<void> {
|
|
1307
|
+
const path = statsArchiveGenerationPath(archivePath, generation);
|
|
1308
|
+
const kept = path.replace(/\.json$/, `.corrupt-${Date.now()}.json`);
|
|
1309
|
+
try {
|
|
1310
|
+
await renameWithRetry(path, kept);
|
|
1311
|
+
console.warn(`${LOG_PREFIX}: stats archive generation ${generation} was unreadable; recovered from the previous one and kept it as ${kept}`);
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
if (getErrorCode(error) !== "ENOENT") await unlink(path).catch(() => undefined);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** Retry budget for moving 2.9.0's archive.json (a scanner may hold it for seconds). */
|
|
1318
|
+
export const LEGACY_ARCHIVE_RENAME_BUDGET_MS = 5_000;
|
|
1319
|
+
|
|
940
1320
|
const LEGACY_TOMBSTONE_README =
|
|
941
1321
|
"pi-cache-optimizer stores its stats archive in the archive.<generation>.json files next to this directory.\n" +
|
|
942
1322
|
"This directory replaces the single archive.json of version 2.9.0 so that 2.9.0 can no longer write it\n" +
|
|
943
1323
|
"(it would publish without a compare-and-swap). 2.9.0 reports the archive as unreadable and keeps raw shards.\n" +
|
|
944
1324
|
"Do not remove it while any Pi process might still run pi-cache-optimizer 2.9.0.\n";
|
|
945
1325
|
|
|
1326
|
+
async function createLegacyTombstone(archivePath: string): Promise<void> {
|
|
1327
|
+
await mkdir(archivePath);
|
|
1328
|
+
await writeFile(join(archivePath, "README.txt"), LEGACY_TOMBSTONE_README, "utf8").catch(() => undefined);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/** Keep a legacy file that is not part of any generation, for inspection. */
|
|
1332
|
+
async function setAsideLegacyFile(path: string, reason: string): Promise<void> {
|
|
1333
|
+
let stamp = Date.now();
|
|
1334
|
+
let kept = join(dirname(path), `archive.legacy-${stamp}.json`);
|
|
1335
|
+
while (await lstat(kept).then(() => true, () => false)) kept = join(dirname(path), `archive.legacy-${++stamp}.json`);
|
|
1336
|
+
await renameWithRetry(path, kept, LEGACY_ARCHIVE_RENAME_BUDGET_MS).catch(() => undefined);
|
|
1337
|
+
console.warn(`${LOG_PREFIX}: ${reason}; kept as ${kept}`);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
946
1340
|
/**
|
|
947
|
-
*
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
* simply deleted. Anything else
|
|
951
|
-
*
|
|
952
|
-
*
|
|
1341
|
+
* Whenever generations exist, make sure 2.9.0 cannot write archive.json:
|
|
1342
|
+
* move a file there aside and put a directory in its place, and resolve
|
|
1343
|
+
* migration placeholders. A file whose content is the one the newest
|
|
1344
|
+
* generation was migrated from is simply deleted. Anything else is never
|
|
1345
|
+
* counted, because merging it could double count, and is kept for
|
|
1346
|
+
* inspection: a file 2.9.0 wrote after the migration (only possible if the
|
|
1347
|
+
* directory was removed), or a placeholder of a migration that another
|
|
1348
|
+
* compactor overtook.
|
|
953
1349
|
*/
|
|
954
1350
|
async function ensureLegacyArchiveTombstone(archivePath: string, migratedFromSha256: string | undefined): Promise<void> {
|
|
1351
|
+
const resolve = async (path: string): Promise<void> => {
|
|
1352
|
+
let sha: string | undefined;
|
|
1353
|
+
try {
|
|
1354
|
+
sha = sha256(await readFile(path));
|
|
1355
|
+
} catch {}
|
|
1356
|
+
if (sha !== undefined && sha === migratedFromSha256) await unlink(path).catch(() => undefined);
|
|
1357
|
+
else await setAsideLegacyFile(path, "ignored a stats archive of an older pi-cache-optimizer that is not part of the current archive");
|
|
1358
|
+
};
|
|
1359
|
+
const listing = await listArchiveDirectory(archivePath).catch(() => undefined);
|
|
1360
|
+
for (const placeholder of listing?.placeholders ?? []) await resolve(placeholder);
|
|
1361
|
+
|
|
955
1362
|
let info: import("node:fs").Stats | undefined;
|
|
956
1363
|
try {
|
|
957
1364
|
info = await lstat(archivePath);
|
|
@@ -962,26 +1369,93 @@ async function ensureLegacyArchiveTombstone(archivePath: string, migratedFromSha
|
|
|
962
1369
|
if (info) {
|
|
963
1370
|
const retired = `${archivePath}.${process.pid}.${randomUUID()}.retired.tmp`;
|
|
964
1371
|
try {
|
|
965
|
-
await renameWithRetry(archivePath, retired);
|
|
1372
|
+
await renameWithRetry(archivePath, retired, LEGACY_ARCHIVE_RENAME_BUDGET_MS);
|
|
966
1373
|
} catch {
|
|
967
1374
|
return; // retried by the next pass; readers ignore archive.json once a generation exists
|
|
968
1375
|
}
|
|
969
|
-
|
|
1376
|
+
await resolve(retired);
|
|
1377
|
+
}
|
|
1378
|
+
await createLegacyTombstone(archivePath).catch(() => undefined);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Migration step, run after every publish check passed and before the first
|
|
1383
|
+
* generation is linked: move 2.9.0's archive.json aside to a placeholder,
|
|
1384
|
+
* put the tombstone directory in its place, and verify that the moved bytes
|
|
1385
|
+
* are exactly the snapshot this pass built on.
|
|
1386
|
+
*
|
|
1387
|
+
* The rename is atomic, so a 2.9.0 process that took the lock over while this
|
|
1388
|
+
* pass was suspended and republished archive.json is caught here (the bytes
|
|
1389
|
+
* differ: the move is undone and the next pass migrates its file), instead
|
|
1390
|
+
* of having its newly absorbed counters set aside after the fact. Once the
|
|
1391
|
+
* directory is in place, 2.9.0 sees an unreadable archive and neither
|
|
1392
|
+
* compacts nor deletes. If this pass dies before linking, readers and the
|
|
1393
|
+
* next compaction migrate from the placeholder.
|
|
1394
|
+
*
|
|
1395
|
+
* Resolves false when the migration must be retried by a later pass.
|
|
1396
|
+
*/
|
|
1397
|
+
async function moveLegacyArchiveAside(archivePath: string, base: StatsArchiveView): Promise<boolean> {
|
|
1398
|
+
const source = base.legacySourcePath;
|
|
1399
|
+
if (!source) return true;
|
|
1400
|
+
const expected = base.legacySourceSha256;
|
|
1401
|
+
let placeholder = source;
|
|
1402
|
+
if (source === archivePath) {
|
|
1403
|
+
placeholder = migrationPlaceholderPath(archivePath);
|
|
970
1404
|
try {
|
|
971
|
-
|
|
972
|
-
} catch {
|
|
973
|
-
|
|
974
|
-
await unlink(retired).catch(() => undefined);
|
|
975
|
-
} else {
|
|
976
|
-
const orphan = join(dirname(archivePath), `archive.legacy-${Date.now()}.json`);
|
|
977
|
-
await rename(retired, orphan).catch(() => undefined);
|
|
978
|
-
console.warn(`${LOG_PREFIX}: ignored a stats archive written by an older pi-cache-optimizer after migration; kept as ${orphan}`);
|
|
1405
|
+
await renameWithRetry(archivePath, placeholder, LEGACY_ARCHIVE_RENAME_BUDGET_MS);
|
|
1406
|
+
} catch {
|
|
1407
|
+
return false;
|
|
979
1408
|
}
|
|
1409
|
+
await testHooks.afterMigrationStep?.("moved-aside", archivePath, placeholder);
|
|
980
1410
|
}
|
|
981
1411
|
try {
|
|
982
|
-
await
|
|
983
|
-
|
|
1412
|
+
await createLegacyTombstone(archivePath);
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
if (getErrorCode(error) !== "EEXIST") return false;
|
|
1415
|
+
const info = await lstat(archivePath).catch(() => undefined);
|
|
1416
|
+
// Another 2.9.0 publish landed between the move and the mkdir. Its file
|
|
1417
|
+
// replaces the one moved aside (it read it first, or started over): keep
|
|
1418
|
+
// it as the archive and keep the placeholder only for inspection.
|
|
1419
|
+
if (!info?.isDirectory()) return false;
|
|
1420
|
+
}
|
|
1421
|
+
let sha: string | undefined;
|
|
1422
|
+
try {
|
|
1423
|
+
sha = sha256(await readFile(placeholder));
|
|
984
1424
|
} catch {}
|
|
1425
|
+
if (expected === undefined ? !base.legacyCorrupt : sha !== expected) {
|
|
1426
|
+
// 2.9.0 republished between this pass's read and the move: undo, so the
|
|
1427
|
+
// next pass migrates the newer file.
|
|
1428
|
+
await rm(archivePath, { recursive: true, force: true }).catch(() => undefined);
|
|
1429
|
+
await testHooks.afterMigrationStep?.("undo-removed-tombstone", archivePath, placeholder);
|
|
1430
|
+
await restoreLegacyArchive(placeholder, archivePath);
|
|
1431
|
+
return false;
|
|
1432
|
+
}
|
|
1433
|
+
await fsyncDirectory(dirname(archivePath));
|
|
1434
|
+
return true;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Undo a move aside: put the placeholder back as archive.json without ever
|
|
1439
|
+
* replacing a file there. Between removing the tombstone and this step a
|
|
1440
|
+
* 2.9.0 process (holding the lock after a stale takeover) may already have
|
|
1441
|
+
* published archive.json again; a rename would silently overwrite it, losing
|
|
1442
|
+
* what it absorbed (its raw shards are already deleted). link() is a
|
|
1443
|
+
* compare-and-swap: EEXIST keeps 2.9.0's newer file as the archive and keeps
|
|
1444
|
+
* the placeholder for inspection (set aside once a generation exists, never
|
|
1445
|
+
* counted, so nothing is counted twice). Without hard links the rename is
|
|
1446
|
+
* the only way back (the window stays open on such filesystems).
|
|
1447
|
+
*/
|
|
1448
|
+
async function restoreLegacyArchive(placeholder: string, archivePath: string): Promise<void> {
|
|
1449
|
+
try {
|
|
1450
|
+
await (testHooks.link ?? link)(placeholder, archivePath);
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
if (getErrorCode(error) === "EEXIST") return;
|
|
1453
|
+
await renameWithRetry(placeholder, archivePath, LEGACY_ARCHIVE_RENAME_BUDGET_MS).catch(() => undefined);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
// Both names are the same file now; drop the placeholder. If that fails it
|
|
1457
|
+
// is resolved later (its bytes are the ones the next migration reads).
|
|
1458
|
+
await unlink(placeholder).catch(() => undefined);
|
|
985
1459
|
}
|
|
986
1460
|
|
|
987
1461
|
/** Instance ids of every shard-named file in `directory`, readable or not. */
|
|
@@ -996,9 +1470,31 @@ async function listShardInstanceIds(directory: string): Promise<Set<string>> {
|
|
|
996
1470
|
return new Set(names.filter((name) => /^[0-9a-f-]+\.json$/i.test(name)).map((name) => name.slice(0, -".json".length)));
|
|
997
1471
|
}
|
|
998
1472
|
|
|
1473
|
+
/**
|
|
1474
|
+
* A local day can still be "today" for a Pi process in another time zone
|
|
1475
|
+
* (up to UTC+14 and down to UTC-12) until 12:00 UTC of the next day, i.e.
|
|
1476
|
+
* 36 hours after its UTC midnight. Archived buckets merge shards (instance
|
|
1477
|
+
* counts become bucket counts) and only feed the current-day view of their
|
|
1478
|
+
* own day, so a day is archived only once it is over everywhere: then no
|
|
1479
|
+
* process's current-day view (totals, instance counts) can differ from the
|
|
1480
|
+
* raw shards it replaces.
|
|
1481
|
+
*/
|
|
1482
|
+
const DAY_OVER_EVERYWHERE_MS = 36 * 60 * 60 * 1000;
|
|
1483
|
+
|
|
1484
|
+
function dayIsOverEverywhere(day: string, now: number): boolean {
|
|
1485
|
+
const [year, month, date] = day.split("-").map(Number) as [number, number, number];
|
|
1486
|
+
return now >= Date.UTC(year, month - 1, date) + DAY_OVER_EVERYWHERE_MS;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
999
1489
|
/** A shard maintenance may fold away: from a past day and no longer written to. */
|
|
1000
1490
|
export function isArchivableShard(shard: PersistedStatsShardV7, now: number, today: string): boolean {
|
|
1001
|
-
if (shard.day >= today) return false;
|
|
1491
|
+
if (shard.day >= today || !dayIsOverEverywhere(shard.day, now)) return false;
|
|
1492
|
+
// Usage recorded after midnight but before the day rollover carries the
|
|
1493
|
+
// next day in stats.day. The current-day view skips such an entry on
|
|
1494
|
+
// either day, but a merged bucket keeps only the latest stats.day and
|
|
1495
|
+
// could hide its whole bucket from that day's view; such rare shards stay
|
|
1496
|
+
// raw (retention removes them).
|
|
1497
|
+
for (const entry of Object.values(shard.models)) if (entry.stats.day !== shard.day) return false;
|
|
1002
1498
|
if (now - shard.lifecycle.updatedAt >= SHARD_RETENTION_MS) return false; // retention deletes it instead
|
|
1003
1499
|
if (shard.lifecycle.state === "closed") return true;
|
|
1004
1500
|
return now - shard.lifecycle.updatedAt >= ARCHIVE_ABANDONED_ACTIVE_MS && !isProcessAlive(shard.process.pid);
|
|
@@ -1066,12 +1562,23 @@ export async function compactStatsShardsV7(
|
|
|
1066
1562
|
// compare-and-swap against exactly this generation.
|
|
1067
1563
|
const base = await readStatsArchiveView(archivePath, { strict: true });
|
|
1068
1564
|
if (base.corrupt) {
|
|
1565
|
+
// A newer format this version cannot read: never overwrite it.
|
|
1069
1566
|
result.skippedCorrupt = true;
|
|
1070
1567
|
return result;
|
|
1071
1568
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1569
|
+
if (base.pendingGeneration) {
|
|
1570
|
+
// The newest generation does not parse yet and is young: it may still be
|
|
1571
|
+
// written in place (no-hard-link fallback) by a holder that lost the
|
|
1572
|
+
// lock. Retry later rather than recover past it.
|
|
1573
|
+
result.skippedConcurrent = true;
|
|
1574
|
+
return result;
|
|
1575
|
+
}
|
|
1576
|
+
// 2.9.0's archive.json (readable or not): publish the first generation
|
|
1577
|
+
// even when nothing else changed, so 2.9.0 can be locked out of writing
|
|
1578
|
+
// it. Unreadable generations: publish above them so they are recovered
|
|
1579
|
+
// from and quarantined.
|
|
1580
|
+
const migrating = base.newestGeneration === 0 && base.legacySourcePath !== undefined;
|
|
1581
|
+
const healing = base.corruptGenerations.length > 0;
|
|
1075
1582
|
const previous = base.archive;
|
|
1076
1583
|
const today = currentLocalDay();
|
|
1077
1584
|
// Strict reads: compaction permanently drops counters whose epoch differs,
|
|
@@ -1165,7 +1672,7 @@ export async function compactStatsShardsV7(
|
|
|
1165
1672
|
result.dropped = 0;
|
|
1166
1673
|
return result;
|
|
1167
1674
|
};
|
|
1168
|
-
if (result.absorbed > 0 || result.dropped > 0 || absorbedChanged || migrating) {
|
|
1675
|
+
if (result.absorbed > 0 || result.dropped > 0 || absorbedChanged || migrating || healing) {
|
|
1169
1676
|
await options.beforePublish?.();
|
|
1170
1677
|
// Cheap early exits: lost the lock, someone published meanwhile, or a
|
|
1171
1678
|
// reset landed during the pass (counters written under the new epoch
|
|
@@ -1173,8 +1680,8 @@ export async function compactStatsShardsV7(
|
|
|
1173
1680
|
// a stale holder safe; these only avoid wasted work.
|
|
1174
1681
|
let concurrent =
|
|
1175
1682
|
!(await holdsStatsMaintenanceLock(options.lockToken)) ||
|
|
1176
|
-
((await listStatsArchiveGenerations(archivePath)).at(-1) ?? 0) !== base.
|
|
1177
|
-
(base.
|
|
1683
|
+
((await listStatsArchiveGenerations(archivePath)).at(-1) ?? 0) !== base.newestGeneration ||
|
|
1684
|
+
(base.newestGeneration === 0 && (await legacyArchiveIdentity(archivePath)) !== legacyIdentityAtRead) ||
|
|
1178
1685
|
(await readGlobalStatsEpochStrict()) !== globalEpoch;
|
|
1179
1686
|
for (const [key, epoch] of modelEpochs) {
|
|
1180
1687
|
if (concurrent) break;
|
|
@@ -1189,25 +1696,46 @@ export async function compactStatsShardsV7(
|
|
|
1189
1696
|
buckets: Array.from(buckets.values()),
|
|
1190
1697
|
routed: Array.from(routed.values()),
|
|
1191
1698
|
};
|
|
1192
|
-
|
|
1699
|
+
// Above every listed generation, readable or not, so a holder that read
|
|
1700
|
+
// an older one can never publish over an unreadable newer one.
|
|
1701
|
+
const generation = base.newestGeneration + 1;
|
|
1193
1702
|
const text = JSON.stringify(encodeStatsArchiveV2(next, generation, base.legacySha256)) + "\n";
|
|
1194
|
-
|
|
1703
|
+
const prepared = await prepareStatsArchiveGeneration(archivePath, generation, text);
|
|
1704
|
+
try {
|
|
1705
|
+
await options.beforeCommit?.();
|
|
1706
|
+
if (migrating && !(await moveLegacyArchiveAside(archivePath, base))) return skip();
|
|
1707
|
+
if (!(await commitPreparedGeneration(prepared))) return skip();
|
|
1708
|
+
} finally {
|
|
1709
|
+
await unlink(prepared.tempPath).catch(() => undefined);
|
|
1710
|
+
}
|
|
1195
1711
|
// A generation above ours means ours is invisible to readers (only
|
|
1196
1712
|
// possible after our base was superseded and cleaned up while this pass
|
|
1197
1713
|
// was suspended): it must not delete what its snapshot covers.
|
|
1198
1714
|
if (((await listStatsArchiveGenerations(archivePath)).at(-1) ?? 0) !== generation) return skip();
|
|
1715
|
+
// The in-place fallback is not atomic: its generation exists (empty or
|
|
1716
|
+
// partial) from the exclusive create until the write completes. A holder
|
|
1717
|
+
// suspended in between for longer than the stale-lock window has its
|
|
1718
|
+
// lock taken over, and the new holder may treat the old, still
|
|
1719
|
+
// unparseable generation as abandoned and build on the one below it.
|
|
1720
|
+
// If this writer then finished and deleted its absorbed shards before
|
|
1721
|
+
// the new holder listed the shard directory, they would be lost: the
|
|
1722
|
+
// new holder publishes above this generation and quarantines it. The new
|
|
1723
|
+
// holder takes the lock before reading anything, so a writer that still
|
|
1724
|
+
// holds it here cannot have been overtaken; one that lost it deletes
|
|
1725
|
+
// nothing (its absorbed files stay ignored and a later pass removes them).
|
|
1726
|
+
if (!(await holdsStatsMaintenanceLock(options.lockToken))) return skip();
|
|
1199
1727
|
result.generation = generation;
|
|
1200
1728
|
try {
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
});
|
|
1729
|
+
const path = statsArchiveGenerationPath(archivePath, generation);
|
|
1730
|
+
const info = await lstat(path);
|
|
1731
|
+
const view = new StatsArchiveView(next, true, false, generation, base.legacySha256);
|
|
1732
|
+
archiveFileCache.set(path, { ...statsFileIdentityOf(info), parsed: { kind: "ok", view } });
|
|
1733
|
+
lastGoodArchive.set(archivePath, view);
|
|
1207
1734
|
} catch {}
|
|
1735
|
+
for (const corrupt of base.corruptGenerations) await quarantineGeneration(archivePath, corrupt);
|
|
1208
1736
|
await removeSupersededGenerations(archivePath, generation);
|
|
1209
1737
|
}
|
|
1210
|
-
if (result.generation !== undefined || base.
|
|
1738
|
+
if (result.generation !== undefined || base.newestGeneration > 0) await ensureLegacyArchiveTombstone(archivePath, base.legacySha256);
|
|
1211
1739
|
await touchStatsMaintenanceLock(options.lockToken);
|
|
1212
1740
|
|
|
1213
1741
|
// Only now that the archive covering them is durable, delete the originals.
|
|
@@ -1244,7 +1772,7 @@ export async function maybeRunStatsMaintenance(now = Date.now()): Promise<StatsM
|
|
|
1244
1772
|
// Heartbeat so a long pass (a huge archive on a slow, scanned disk) is not
|
|
1245
1773
|
// taken over as stale. It stops while the process is suspended; the
|
|
1246
1774
|
// archive compare-and-swap covers that case.
|
|
1247
|
-
const heartbeat = setInterval(() => void touchStatsMaintenanceLock(lockToken), STATS_MAINTENANCE_HEARTBEAT_MS);
|
|
1775
|
+
const heartbeat = setInterval(() => void touchStatsMaintenanceLock(lockToken), testHooks.heartbeatMs ?? STATS_MAINTENANCE_HEARTBEAT_MS);
|
|
1248
1776
|
heartbeat.unref?.();
|
|
1249
1777
|
try {
|
|
1250
1778
|
if (cleanupDue) {
|
|
@@ -1252,17 +1780,26 @@ export async function maybeRunStatsMaintenance(now = Date.now()): Promise<StatsM
|
|
|
1252
1780
|
await writeMaintenanceMarker(SHARD_CLEANUP_MARKER_PATH, now);
|
|
1253
1781
|
}
|
|
1254
1782
|
if (compactionDue) {
|
|
1783
|
+
let failed = false;
|
|
1255
1784
|
try {
|
|
1256
1785
|
result.compaction = await compactStatsShardsV7(now, SHARD_FILES_DIR, SHARD_ARCHIVE_PATH, { lockToken });
|
|
1257
1786
|
if (result.compaction.skippedCorrupt) {
|
|
1258
|
-
console.warn(`${LOG_PREFIX}: stats archive ${SHARD_ARCHIVE_PATH} is
|
|
1787
|
+
console.warn(`${LOG_PREFIX}: stats archive ${SHARD_ARCHIVE_PATH} is in a newer format this version cannot read; keeping raw shards`);
|
|
1259
1788
|
}
|
|
1260
1789
|
} catch (error) {
|
|
1790
|
+
failed = true;
|
|
1261
1791
|
console.warn(`${LOG_PREFIX}: stats archive compaction failed`, error);
|
|
1262
1792
|
}
|
|
1263
1793
|
// A pass that lost a race published nothing; let the next startup retry
|
|
1264
|
-
// instead of waiting out the full interval.
|
|
1265
|
-
|
|
1794
|
+
// instead of waiting out the full interval. A pass that failed (a
|
|
1795
|
+
// transient Windows EPERM on an epoch or archive file, a full disk)
|
|
1796
|
+
// published nothing either: retry after a short backoff, not 6 hours
|
|
1797
|
+
// later, without retrying on every single startup.
|
|
1798
|
+
if (failed) {
|
|
1799
|
+
await writeMaintenanceMarker(SHARD_COMPACTION_MARKER_PATH, now, now - SHARD_CLEANUP_INTERVAL_MS + COMPACTION_RETRY_AFTER_FAILURE_MS);
|
|
1800
|
+
} else if (!result.compaction?.skippedConcurrent) {
|
|
1801
|
+
await writeMaintenanceMarker(SHARD_COMPACTION_MARKER_PATH, now);
|
|
1802
|
+
}
|
|
1266
1803
|
}
|
|
1267
1804
|
} finally {
|
|
1268
1805
|
clearInterval(heartbeat);
|