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.
@@ -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,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
- 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 };
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
- const SOURCE_ATTEMPTS = 5;
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 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.
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 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" };
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
- return { kind: "none" };
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
- type CachedArchive = { path: string; ino: number | bigint; size: number; mtimeMs: number; view: StatsArchiveView };
757
- const archiveCache = new Map<string, CachedArchive>();
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
- archiveCache.clear();
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 parseArchiveFile(source: Extract<ArchiveSource, { kind: "legacy" | "generation" }>, bytes: Buffer): StatsArchiveView {
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 corruptView();
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 (source.kind === "legacy") {
917
+ if (kind === "legacy") {
778
918
  const archive = parseStatsArchive(value);
779
- return archive ? new StatsArchiveView(archive, true, false, 0, sha256(bytes)) : corruptView();
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 !== source.generation) return corruptView();
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, 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.
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 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;
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
- source = await locateArchiveSource(archivePath);
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
- return transientFailure(error);
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
- if (source.kind === "none") {
814
- archiveCache.delete(archivePath);
815
- return emptyView();
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
- if (source.kind === "invalid") {
818
- archiveCache.delete(archivePath);
819
- return corruptView();
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
- 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;
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
- 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);
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
- 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;
1163
+ return previous;
849
1164
  }
850
- return transientFailure(new Error(`stats archive ${archivePath} kept changing while being read`));
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
- * 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> {
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
- await hooks.beforeCommit?.();
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 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;
1272
+ handle = await open(prepared.target, "wx");
1273
+ } catch (openError) {
1274
+ if (getErrorCode(openError) === "EEXIST") return false;
1275
+ throw openError;
921
1276
  }
922
- return true;
923
- } finally {
924
- await unlink(tempPath).catch(() => undefined);
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
- * 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.
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
- let sha: string | undefined;
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
- 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}`);
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 mkdir(archivePath);
983
- await writeFile(join(archivePath, "README.txt"), LEGACY_TOMBSTONE_README, "utf8");
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
- // 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;
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.generation ||
1177
- (base.generation === 0 && (await legacyArchiveIdentity(archivePath)) !== legacyIdentityAtRead) ||
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
- const generation = base.generation + 1;
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
- if (!(await commitStatsArchiveGeneration(archivePath, generation, text, options))) return skip();
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 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
- });
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.generation > 0) await ensureLegacyArchiveTombstone(archivePath, base.legacySha256);
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 unreadable; keeping raw shards`);
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
- if (!result.compaction?.skippedConcurrent) await writeMaintenanceMarker(SHARD_COMPACTION_MARKER_PATH, now);
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);