pi-cache-optimizer 2.9.1 → 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.
@@ -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
- * then replaces it with a directory of the same name. 2.9.0 treats that as an
37
- * unreadable archive: it never compacts (so it cannot overwrite or delete
38
- * anything) and its footer shows only history that is not archived, exactly
39
- * like the versions before 2.9.0. A file that 2.9.0 still manages to write
40
- * there afterwards (only if that directory is removed) is never counted: it is
41
- * set aside as `archive.legacy-<time>.json`, trading the counters it absorbed
42
- * for a guarantee against double counting.
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, rename, unlink, writeFile } from "node:fs/promises";
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,18 @@ 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
+ /** 2.9.0's archive.json while it is being migrated (see compactStatsShardsV7). */
189
+ const MIGRATION_PLACEHOLDER_PATTERN = /^archive\.migrating-[0-9a-f-]+\.json$/;
161
190
 
162
191
  export function emptyStatsArchive(now = Date.now()): StatsArchiveV1 {
163
192
  return { version: 1, kind: "pi-cache-optimizer-archive", updatedAt: now, absorbed: {}, buckets: [], routed: [] };
@@ -289,6 +318,7 @@ export function encodeStatsArchiveV2(archive: StatsArchiveV1, generation: number
289
318
  models,
290
319
  buckets,
291
320
  routed,
321
+ complete: true,
292
322
  };
293
323
  }
294
324
 
@@ -497,6 +527,19 @@ export class StatsArchiveView implements LoadedStatsArchive {
497
527
  private reduced?: ReducedArchive;
498
528
  private contributionCache?: { fingerprint: string; contribution: ShardContribution };
499
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
+
500
543
  constructor(
501
544
  readonly archive: StatsArchiveV1,
502
545
  readonly present: boolean,
@@ -504,10 +547,37 @@ export class StatsArchiveView implements LoadedStatsArchive {
504
547
  readonly generation: number,
505
548
  /** For 2.9.0's archive.json: its sha256; for a generation: the legacy file it was migrated from. */
506
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
+ } = {},
507
558
  ) {
508
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;
509
566
  }
510
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
+
511
581
  get shards(): readonly PersistedStatsShardV7[] {
512
582
  return this.syntheticShards ??= archiveToSyntheticShards(this.archive);
513
583
  }
@@ -710,54 +780,73 @@ export async function aggregateStatsWithArchiveV7(
710
780
  return aggregator.finish();
711
781
  }
712
782
 
713
- type ArchiveSource =
714
- | { kind: "none" }
715
- | { kind: "invalid"; path: string }
716
- | { kind: "legacy"; path: string; info: import("node:fs").Stats }
717
- | { kind: "generation"; path: string; generation: number; info: import("node:fs").Stats };
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
+ }
718
802
 
719
- const SOURCE_ATTEMPTS = 5;
803
+ type ArchiveListing = { generations: number[]; placeholders: string[] };
720
804
 
721
- /**
722
- * The newest snapshot: the highest generation, or 2.9.0's archive.json while
723
- * no generation exists. Retries when a listed generation disappears (it was
724
- * superseded and cleaned up meanwhile). Throws on other I/O errors.
725
- */
726
- async function locateArchiveSource(archivePath: string): Promise<ArchiveSource> {
727
- for (let attempt = 0; attempt < SOURCE_ATTEMPTS; attempt++) {
728
- const generations = await listStatsArchiveGenerations(archivePath);
729
- const generation = generations.at(-1);
730
- if (generation !== undefined) {
731
- const path = statsArchiveGenerationPath(archivePath, generation);
732
- try {
733
- const info = await lstat(path);
734
- if (!info.isFile() || info.isSymbolicLink()) return { kind: "invalid", path };
735
- return { kind: "generation", path, generation, info };
736
- } catch (error) {
737
- if (getErrorCode(error) === "ENOENT") continue;
738
- throw error;
739
- }
740
- }
741
- try {
742
- const info = await lstat(archivePath);
743
- if (info.isFile() && !info.isSymbolicLink()) return { kind: "legacy", path: archivePath, info };
744
- // The migration tombstone is created only after a generation exists:
745
- // the listing raced a cleanup, or the generations were removed by hand.
746
- if (info.isDirectory()) continue;
747
- return { kind: "invalid", path: archivePath };
748
- } catch (error) {
749
- if (getErrorCode(error) === "ENOENT") return { kind: "none" };
750
- throw error;
751
- }
805
+ async function listArchiveDirectory(archivePath: string): Promise<ArchiveListing> {
806
+ let names: string[];
807
+ try {
808
+ names = await readdir(dirname(archivePath));
809
+ } catch (error) {
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));
752
819
  }
753
- return { kind: "none" };
820
+ return { generations: generations.sort((a, b) => a - b), placeholders: placeholders.sort() };
754
821
  }
755
822
 
756
- type CachedArchive = { path: string; ino: number | bigint; size: number; mtimeMs: number; view: StatsArchiveView };
757
- const archiveCache = new Map<string, CachedArchive>();
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>();
758
846
 
759
847
  export function clearStatsArchiveCache(): void {
760
- archiveCache.clear();
848
+ archiveFileCache.clear();
849
+ lastGoodArchive.clear();
761
850
  }
762
851
 
763
852
  const emptyView = (): StatsArchiveView => new StatsArchiveView(emptyStatsArchive(0), false, false, 0);
@@ -767,87 +856,262 @@ function sha256(text: string | Buffer): string {
767
856
  return createHash("sha256").update(text).digest("hex");
768
857
  }
769
858
 
770
- function parseArchiveFile(source: Extract<ArchiveSource, { kind: "legacy" | "generation" }>, bytes: Buffer): StatsArchiveView {
859
+ function parseArchiveBytes(kind: "legacy" | "generation", generation: number, path: string, bytes: Buffer): ParsedArchiveFile {
860
+ const invalid = (): ParsedArchiveFile => ({ kind: "invalid", sha256: sha256(bytes) });
771
861
  let value: unknown;
772
862
  try {
773
863
  value = JSON.parse(bytes.toString("utf8"));
774
864
  } catch {
775
- return corruptView();
865
+ return invalid();
866
+ }
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" };
776
871
  }
777
- if (source.kind === "legacy") {
872
+ if (kind === "legacy") {
778
873
  const archive = parseStatsArchive(value);
779
- return archive ? new StatsArchiveView(archive, true, false, 0, sha256(bytes)) : corruptView();
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();
780
878
  }
781
879
  const parsed = parseStatsArchiveV2(value);
782
- if (!parsed || parsed.generation !== source.generation) return corruptView();
783
- return new StatsArchiveView(parsed.archive, true, false, parsed.generation, parsed.legacySha256);
880
+ if (!parsed || parsed.generation !== generation) return invalid();
881
+ return { kind: "ok", view: new StatsArchiveView(parsed.archive, true, false, parsed.generation, parsed.legacySha256) };
784
882
  }
785
883
 
884
+ class ArchiveRestart extends Error {}
885
+
886
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
887
+
786
888
  export type ReadStatsArchiveOptions = {
787
889
  /** Throw on a transient I/O failure instead of serving the last good snapshot (compaction). */
788
890
  strict?: boolean;
789
891
  };
790
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
+ };
902
+
791
903
  /**
792
- * Read the newest archive snapshot, re-parsing only when the file (path,
793
- * inode, size, mtime) changed. No archive is an empty one; an invalid one is
794
- * reported as corrupt and treated as empty, so raw shards stay the source of
795
- * truth. A transient I/O failure (Windows EPERM/EBUSY while another process
796
- * or a scanner holds the file) is never cached: the last good snapshot is
797
- * returned when there is one, otherwise an uncached corrupt result, so the
798
- * next refresh retries.
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).
799
923
  */
800
- export async function readStatsArchiveView(archivePath: string = SHARD_ARCHIVE_PATH, options: ReadStatsArchiveOptions = {}): Promise<StatsArchiveView> {
801
- const transientFailure = (error: unknown): StatsArchiveView => {
802
- if (options.strict) throw error;
803
- const previous = archiveCache.get(archivePath);
804
- return previous && !previous.view.corrupt ? previous.view : corruptView();
805
- };
806
- for (let attempt = 0; attempt < SOURCE_ATTEMPTS; attempt++) {
807
- let source: ArchiveSource;
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++) {
808
928
  try {
809
- source = await locateArchiveSource(archivePath);
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 };
810
934
  } catch (error) {
811
- return transientFailure(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;
812
942
  }
813
- if (source.kind === "none") {
814
- archiveCache.delete(archivePath);
815
- return emptyView();
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();
816
965
  }
817
- if (source.kind === "invalid") {
818
- archiveCache.delete(archivePath);
819
- return corruptView();
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;
820
985
  }
821
- const { info } = source;
822
- const cached = archiveCache.get(archivePath);
823
- // Generations are immutable, so (path, identity) is a safe cache key. 2.9.0
824
- // rewrites archive.json in place of the old one, and on filesystems with
825
- // ino 0 and coarse mtimes (FAT/SMB on Windows) a rewrite of the same size
826
- // keeps the same identity. Compaction migrates from it and then deletes
827
- // shards, so it always re-reads that file instead of trusting the cache.
828
- const cacheable = !(options.strict && source.kind === "legacy");
829
- if (cacheable && cached && cached.path === source.path && cached.ino === info.ino && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
830
- return cached.view;
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;
994
+ try {
995
+ await testHooks.beforeLstat?.(archivePath);
996
+ info = await lstat(archivePath);
997
+ } catch (error) {
998
+ if (getErrorCode(error) !== "ENOENT") throw error;
999
+ }
1000
+ if (info?.isFile() && !info.isSymbolicLink()) {
1001
+ const parsed = await readArchiveFile(archivePath, "legacy", 0, options, restartIfNewer);
1002
+ if (!parsed) throw new ArchiveRestart();
1003
+ if (parsed.parsed.kind === "ok") return parsed.parsed.view;
1004
+ if (parsed.parsed.kind === "unsupported") return corruptView();
1005
+ return legacyCorruptView(archivePath, parsed.parsed.sha256);
1006
+ }
1007
+ if (info && !info.isDirectory()) return legacyCorruptView(archivePath, undefined);
1008
+ const placeholder = listing.placeholders.at(-1);
1009
+ if (placeholder) {
1010
+ const parsed = await readArchiveFile(placeholder, "legacy", 0, options, restartIfNewer);
1011
+ if (!parsed) throw new ArchiveRestart();
1012
+ if (parsed.parsed.kind === "ok") return parsed.parsed.view;
1013
+ if (parsed.parsed.kind === "unsupported") return corruptView();
1014
+ return legacyCorruptView(placeholder, parsed.parsed.sha256);
1015
+ }
1016
+ // archive.json is missing or is the migration tombstone, which exists only
1017
+ // once a generation does (or while a migration moves archive.json aside):
1018
+ // the listing raced a migration or a cleanup. List again before concluding
1019
+ // there is no archive.
1020
+ if (attempt < 2) return undefined;
1021
+ return emptyView();
1022
+ }
1023
+
1024
+ function legacyCorruptView(path: string, sha: string | undefined): StatsArchiveView {
1025
+ return new StatsArchiveView(emptyStatsArchive(0), true, false, 0, undefined, { legacySourcePath: path, legacySourceSha256: sha, legacyCorrupt: true });
1026
+ }
1027
+
1028
+ /** lstat + cached parse of one archive file; undefined when it is not a regular file. */
1029
+ async function readArchiveFile(
1030
+ path: string,
1031
+ kind: "legacy" | "generation",
1032
+ generation: number,
1033
+ options: ReadStatsArchiveOptions,
1034
+ onError: (generation: number, error: unknown) => Promise<never>,
1035
+ ): Promise<{ parsed: ParsedArchiveFile; mtimeMs: number } | undefined> {
1036
+ let info: import("node:fs").Stats;
1037
+ try {
1038
+ await testHooks.beforeLstat?.(path);
1039
+ info = await lstat(path);
1040
+ } catch (error) {
1041
+ return onError(generation, error);
1042
+ }
1043
+ if (!info.isFile() || info.isSymbolicLink()) return undefined;
1044
+ const cached = archiveFileCache.get(path);
1045
+ // Generations are immutable, so their identity is a safe cache key. 2.9.0
1046
+ // rewrites archive.json in place, and on filesystems with ino 0 and coarse
1047
+ // mtimes (FAT/SMB on Windows) a rewrite of the same size may keep the same
1048
+ // identity. Compaction migrates from it and then deletes shards, so it
1049
+ // always re-reads that file instead of trusting the cache.
1050
+ const cacheable = !(options.strict && kind === "legacy");
1051
+ if (cacheable && cached && sameStatsFileIdentity(cached, info)) return { parsed: cached.parsed, mtimeMs: info.mtimeMs };
1052
+ let bytes: Buffer;
1053
+ try {
1054
+ await testHooks.beforeReadFile?.(path);
1055
+ bytes = await readFile(path);
1056
+ } catch (error) {
1057
+ archiveFileCache.delete(path);
1058
+ return onError(generation, error);
1059
+ }
1060
+ const parsed = parseArchiveBytes(kind, generation, path, bytes);
1061
+ // A young unparseable generation may still be written in place: do not
1062
+ // cache it. An old one stays unreadable until compaction quarantines it
1063
+ // (possibly hours later); cache it like any other file so readers do not
1064
+ // re-read and re-parse it on every refresh. Completing a write would
1065
+ // change its size, so the identity check still notices that.
1066
+ const young = Math.abs(Date.now() - info.mtimeMs) < ARCHIVE_PENDING_GENERATION_MS;
1067
+ if (!(parsed.kind === "invalid" && kind === "generation" && young)) archiveFileCache.set(path, { ...statsFileIdentityOf(info), parsed });
1068
+ else archiveFileCache.delete(path);
1069
+ if (archiveFileCache.size > 16) {
1070
+ for (const key of archiveFileCache.keys()) {
1071
+ if (archiveFileCache.size <= 16) break;
1072
+ if (key !== path) archiveFileCache.delete(key);
831
1073
  }
832
- let bytes: Buffer;
833
- try {
834
- bytes = await readFile(source.path);
835
- } catch (error) {
836
- // Superseded and cleaned up (or migrated) between listing and reading.
837
- if (getErrorCode(error) === "ENOENT") continue;
838
- // On Windows a generation that is being deleted while someone holds it
839
- // open fails with EPERM instead; a newer one exists by then.
840
- if (source.kind === "generation") {
841
- const newest = await listStatsArchiveGenerations(archivePath).then((list) => list.at(-1), () => undefined);
842
- if (newest !== undefined && newest > source.generation) continue;
843
- }
844
- return transientFailure(error);
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 };
845
1111
  }
846
- const view = parseArchiveFile(source, bytes);
847
- archiveCache.set(archivePath, { path: source.path, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, view });
848
- return view;
1112
+ return previous;
849
1113
  }
850
- return transientFailure(new Error(`stats archive ${archivePath} kept changing while being read`));
1114
+ return { rawShards, archive: view };
851
1115
  }
852
1116
 
853
1117
  /** Reference-form read (used by tests and tools): the snapshot with synthetic shards available. */
@@ -887,18 +1151,30 @@ async function legacyArchiveIdentity(archivePath: string): Promise<string | unde
887
1151
 
888
1152
  /** Test hooks for the publish protocol; production never sets them. */
889
1153
  export type ArchivePublishHooks = {
890
- /** Runs after every publish check passed, right before the new generation becomes visible. */
1154
+ /** Runs after every publish check passed, right before the new generation becomes visible (and before a migration moves archive.json). */
891
1155
  beforeCommit?: () => Promise<void>;
892
1156
  };
893
1157
 
894
- /**
895
- * Publish `generation` atomically and exclusively: the snapshot is written
896
- * and flushed to a private temp file, then hard-linked to its final name.
897
- * link() fails with EEXIST when that generation already exists, on Windows
898
- * and POSIX alike, so of all writers that read generation N exactly one can
899
- * publish N+1. Resolves false when another writer won.
900
- */
901
- async function commitStatsArchiveGeneration(archivePath: string, generation: number, text: string, hooks: ArchivePublishHooks): Promise<boolean> {
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> {
902
1178
  await mkdir(dirname(archivePath), { recursive: true });
903
1179
  const target = statsArchiveGenerationPath(archivePath, generation);
904
1180
  const tempPath = `${target}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
@@ -910,19 +1186,57 @@ async function commitStatsArchiveGeneration(archivePath: string, generation: num
910
1186
  } finally {
911
1187
  await handle.close();
912
1188
  }
913
- await hooks.beforeCommit?.();
1189
+ } catch (error) {
1190
+ await unlink(tempPath).catch(() => undefined);
1191
+ throw error;
1192
+ }
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;
914
1220
  try {
915
- await link(tempPath, target);
916
- } catch (error) {
917
- if (getErrorCode(error) === "EEXIST") return false;
918
- // A filesystem without hard links (FAT/exFAT) cannot publish safely;
919
- // the caller reports it and raw shards simply stay where they are.
920
- throw error;
1221
+ handle = await open(prepared.target, "wx");
1222
+ } catch (openError) {
1223
+ if (getErrorCode(openError) === "EEXIST") return false;
1224
+ throw openError;
921
1225
  }
922
- return true;
923
- } finally {
924
- await unlink(tempPath).catch(() => undefined);
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();
925
1237
  }
1238
+ await fsyncDirectory(dirname(prepared.target));
1239
+ return true;
926
1240
  }
927
1241
 
928
1242
  /**
@@ -937,21 +1251,63 @@ async function removeSupersededGenerations(archivePath: string, current: number)
937
1251
  }
938
1252
  }
939
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
+
940
1269
  const LEGACY_TOMBSTONE_README =
941
1270
  "pi-cache-optimizer stores its stats archive in the archive.<generation>.json files next to this directory.\n" +
942
1271
  "This directory replaces the single archive.json of version 2.9.0 so that 2.9.0 can no longer write it\n" +
943
1272
  "(it would publish without a compare-and-swap). 2.9.0 reports the archive as unreadable and keeps raw shards.\n" +
944
1273
  "Do not remove it while any Pi process might still run pi-cache-optimizer 2.9.0.\n";
945
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
+
946
1289
  /**
947
- * After migrating (or whenever generations exist), make sure 2.9.0 cannot
948
- * write archive.json: move a file there aside and put a directory in its
949
- * place. A file whose content is the one this archive was migrated from is
950
- * simply deleted. Anything else was written by 2.9.0 after the migration
951
- * (only possible if the directory was removed); it is never counted, because
952
- * merging it could double count, and is kept for inspection.
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.
953
1298
  */
954
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
+
955
1311
  let info: import("node:fs").Stats | undefined;
956
1312
  try {
957
1313
  info = await lstat(archivePath);
@@ -962,26 +1318,67 @@ async function ensureLegacyArchiveTombstone(archivePath: string, migratedFromSha
962
1318
  if (info) {
963
1319
  const retired = `${archivePath}.${process.pid}.${randomUUID()}.retired.tmp`;
964
1320
  try {
965
- await renameWithRetry(archivePath, retired);
1321
+ await renameWithRetry(archivePath, retired, LEGACY_ARCHIVE_RENAME_BUDGET_MS);
966
1322
  } catch {
967
1323
  return; // retried by the next pass; readers ignore archive.json once a generation exists
968
1324
  }
969
- let sha: string | undefined;
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`);
970
1353
  try {
971
- sha = sha256(await readFile(retired));
972
- } catch {}
973
- if (sha !== undefined && sha === migratedFromSha256) {
974
- await unlink(retired).catch(() => undefined);
975
- } else {
976
- const orphan = join(dirname(archivePath), `archive.legacy-${Date.now()}.json`);
977
- await rename(retired, orphan).catch(() => undefined);
978
- console.warn(`${LOG_PREFIX}: ignored a stats archive written by an older pi-cache-optimizer after migration; kept as ${orphan}`);
1354
+ await renameWithRetry(archivePath, placeholder, LEGACY_ARCHIVE_RENAME_BUDGET_MS);
1355
+ } catch {
1356
+ return false;
979
1357
  }
980
1358
  }
981
1359
  try {
982
- await mkdir(archivePath);
983
- await writeFile(join(archivePath, "README.txt"), LEGACY_TOMBSTONE_README, "utf8");
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));
984
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;
985
1382
  }
986
1383
 
987
1384
  /** Instance ids of every shard-named file in `directory`, readable or not. */
@@ -996,9 +1393,31 @@ async function listShardInstanceIds(directory: string): Promise<Set<string>> {
996
1393
  return new Set(names.filter((name) => /^[0-9a-f-]+\.json$/i.test(name)).map((name) => name.slice(0, -".json".length)));
997
1394
  }
998
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;
1410
+ }
1411
+
999
1412
  /** A shard maintenance may fold away: from a past day and no longer written to. */
1000
1413
  export function isArchivableShard(shard: PersistedStatsShardV7, now: number, today: string): boolean {
1001
- 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;
1002
1421
  if (now - shard.lifecycle.updatedAt >= SHARD_RETENTION_MS) return false; // retention deletes it instead
1003
1422
  if (shard.lifecycle.state === "closed") return true;
1004
1423
  return now - shard.lifecycle.updatedAt >= ARCHIVE_ABANDONED_ACTIVE_MS && !isProcessAlive(shard.process.pid);
@@ -1066,12 +1485,23 @@ export async function compactStatsShardsV7(
1066
1485
  // compare-and-swap against exactly this generation.
1067
1486
  const base = await readStatsArchiveView(archivePath, { strict: true });
1068
1487
  if (base.corrupt) {
1488
+ // A newer format this version cannot read: never overwrite it.
1069
1489
  result.skippedCorrupt = true;
1070
1490
  return result;
1071
1491
  }
1072
- // 2.9.0's archive.json: publish it as the first generation even when
1073
- // nothing else changed, so 2.9.0 can be locked out of writing it.
1074
- const migrating = base.present && base.generation === 0;
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;
1075
1505
  const previous = base.archive;
1076
1506
  const today = currentLocalDay();
1077
1507
  // Strict reads: compaction permanently drops counters whose epoch differs,
@@ -1165,7 +1595,7 @@ export async function compactStatsShardsV7(
1165
1595
  result.dropped = 0;
1166
1596
  return result;
1167
1597
  };
1168
- if (result.absorbed > 0 || result.dropped > 0 || absorbedChanged || migrating) {
1598
+ if (result.absorbed > 0 || result.dropped > 0 || absorbedChanged || migrating || healing) {
1169
1599
  await options.beforePublish?.();
1170
1600
  // Cheap early exits: lost the lock, someone published meanwhile, or a
1171
1601
  // reset landed during the pass (counters written under the new epoch
@@ -1173,8 +1603,8 @@ export async function compactStatsShardsV7(
1173
1603
  // a stale holder safe; these only avoid wasted work.
1174
1604
  let concurrent =
1175
1605
  !(await holdsStatsMaintenanceLock(options.lockToken)) ||
1176
- ((await listStatsArchiveGenerations(archivePath)).at(-1) ?? 0) !== base.generation ||
1177
- (base.generation === 0 && (await legacyArchiveIdentity(archivePath)) !== legacyIdentityAtRead) ||
1606
+ ((await listStatsArchiveGenerations(archivePath)).at(-1) ?? 0) !== base.newestGeneration ||
1607
+ (base.newestGeneration === 0 && (await legacyArchiveIdentity(archivePath)) !== legacyIdentityAtRead) ||
1178
1608
  (await readGlobalStatsEpochStrict()) !== globalEpoch;
1179
1609
  for (const [key, epoch] of modelEpochs) {
1180
1610
  if (concurrent) break;
@@ -1189,25 +1619,46 @@ export async function compactStatsShardsV7(
1189
1619
  buckets: Array.from(buckets.values()),
1190
1620
  routed: Array.from(routed.values()),
1191
1621
  };
1192
- const generation = base.generation + 1;
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;
1193
1625
  const text = JSON.stringify(encodeStatsArchiveV2(next, generation, base.legacySha256)) + "\n";
1194
- if (!(await commitStatsArchiveGeneration(archivePath, generation, text, options))) return skip();
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
+ }
1195
1634
  // A generation above ours means ours is invisible to readers (only
1196
1635
  // possible after our base was superseded and cleaned up while this pass
1197
1636
  // was suspended): it must not delete what its snapshot covers.
1198
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();
1199
1650
  result.generation = generation;
1200
1651
  try {
1201
- const info = await lstat(statsArchiveGenerationPath(archivePath, generation));
1202
- archiveCache.set(archivePath, {
1203
- path: statsArchiveGenerationPath(archivePath, generation),
1204
- ino: info.ino, size: info.size, mtimeMs: info.mtimeMs,
1205
- view: new StatsArchiveView(next, true, false, generation, base.legacySha256),
1206
- });
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);
1207
1657
  } catch {}
1658
+ for (const corrupt of base.corruptGenerations) await quarantineGeneration(archivePath, corrupt);
1208
1659
  await removeSupersededGenerations(archivePath, generation);
1209
1660
  }
1210
- if (result.generation !== undefined || base.generation > 0) await ensureLegacyArchiveTombstone(archivePath, base.legacySha256);
1661
+ if (result.generation !== undefined || base.newestGeneration > 0) await ensureLegacyArchiveTombstone(archivePath, base.legacySha256);
1211
1662
  await touchStatsMaintenanceLock(options.lockToken);
1212
1663
 
1213
1664
  // Only now that the archive covering them is durable, delete the originals.
@@ -1244,7 +1695,7 @@ export async function maybeRunStatsMaintenance(now = Date.now()): Promise<StatsM
1244
1695
  // Heartbeat so a long pass (a huge archive on a slow, scanned disk) is not
1245
1696
  // taken over as stale. It stops while the process is suspended; the
1246
1697
  // archive compare-and-swap covers that case.
1247
- const heartbeat = setInterval(() => void touchStatsMaintenanceLock(lockToken), STATS_MAINTENANCE_HEARTBEAT_MS);
1698
+ const heartbeat = setInterval(() => void touchStatsMaintenanceLock(lockToken), testHooks.heartbeatMs ?? STATS_MAINTENANCE_HEARTBEAT_MS);
1248
1699
  heartbeat.unref?.();
1249
1700
  try {
1250
1701
  if (cleanupDue) {
@@ -1252,17 +1703,26 @@ export async function maybeRunStatsMaintenance(now = Date.now()): Promise<StatsM
1252
1703
  await writeMaintenanceMarker(SHARD_CLEANUP_MARKER_PATH, now);
1253
1704
  }
1254
1705
  if (compactionDue) {
1706
+ let failed = false;
1255
1707
  try {
1256
1708
  result.compaction = await compactStatsShardsV7(now, SHARD_FILES_DIR, SHARD_ARCHIVE_PATH, { lockToken });
1257
1709
  if (result.compaction.skippedCorrupt) {
1258
- console.warn(`${LOG_PREFIX}: stats archive ${SHARD_ARCHIVE_PATH} is unreadable; keeping raw shards`);
1710
+ console.warn(`${LOG_PREFIX}: stats archive ${SHARD_ARCHIVE_PATH} is in a newer format this version cannot read; keeping raw shards`);
1259
1711
  }
1260
1712
  } catch (error) {
1713
+ failed = true;
1261
1714
  console.warn(`${LOG_PREFIX}: stats archive compaction failed`, error);
1262
1715
  }
1263
1716
  // A pass that lost a race published nothing; let the next startup retry
1264
- // instead of waiting out the full interval.
1265
- if (!result.compaction?.skippedConcurrent) await writeMaintenanceMarker(SHARD_COMPACTION_MARKER_PATH, now);
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
+ }
1266
1726
  }
1267
1727
  } finally {
1268
1728
  clearInterval(heartbeat);