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.
- package/README.md +4 -2
- package/README.zh-CN.md +4 -2
- package/index.ts +86 -9
- package/package.json +1 -1
- package/src/stats-archive.ts +622 -162
- package/src/stats-store.ts +75 -19
package/src/stats-archive.ts
CHANGED
|
@@ -31,18 +31,32 @@
|
|
|
31
31
|
* the highest generation; old generations are removed one generation late
|
|
32
32
|
* and readers retry when the file they listed is gone.
|
|
33
33
|
*
|
|
34
|
+
* Without hard links (FAT/exFAT, some NAS mounts) a generation is created
|
|
35
|
+
* exclusively in place (O_EXCL, the same compare-and-swap) and only counts
|
|
36
|
+
* once it parses completely.
|
|
37
|
+
*
|
|
38
|
+
* Recovery: every generation is self-consistent (its counters cover exactly
|
|
39
|
+
* the raw shards it lists as absorbed), so a generation that cannot be read
|
|
40
|
+
* (damaged on disk, truncated by a crash) is skipped for the previous one,
|
|
41
|
+
* which never double counts. The next compaction publishes above it and
|
|
42
|
+
* quarantines it as `archive.<generation>.corrupt-<time>.json`. A generation
|
|
43
|
+
* in a newer format is never skipped or overwritten.
|
|
44
|
+
*
|
|
34
45
|
* 2.9.0 compatibility: 2.9.0 kept a single `archive/archive.json`. It is still
|
|
35
46
|
* read while no generation exists and migrated by the next compaction, which
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
47
|
+
* moves it aside to a placeholder, verifies the moved bytes are the ones it
|
|
48
|
+
* read, replaces it with a directory of the same name, and then links the
|
|
49
|
+
* first generation. 2.9.0 treats that directory as an unreadable archive: it
|
|
50
|
+
* never compacts (so it cannot overwrite or delete anything) and its footer
|
|
51
|
+
* shows only history that is not archived, exactly like the versions before
|
|
52
|
+
* 2.9.0. A file that 2.9.0 still manages to write there afterwards (only if
|
|
53
|
+
* that directory is removed) is never counted: it is set aside as
|
|
54
|
+
* `archive.legacy-<time>.json`, trading the counters it absorbed for a
|
|
55
|
+
* guarantee against double counting. An unreadable archive.json is set aside
|
|
56
|
+
* the same way and the first generation starts over from the raw shards.
|
|
43
57
|
*/
|
|
44
58
|
import { createHash, randomUUID } from "node:crypto";
|
|
45
|
-
import { link, lstat, mkdir, open, readFile, readdir,
|
|
59
|
+
import { link, lstat, mkdir, open, readFile, readdir, rm, unlink, writeFile } from "node:fs/promises";
|
|
46
60
|
import { dirname, join } from "node:path";
|
|
47
61
|
import { LOG_PREFIX, asRecord, getErrorCode, isNonEmptyString, isProcessAlive } from "./common.ts";
|
|
48
62
|
import {
|
|
@@ -75,6 +89,10 @@ import {
|
|
|
75
89
|
readValidStatsShardsV7,
|
|
76
90
|
releaseStatsMaintenanceLock,
|
|
77
91
|
renameWithRetry,
|
|
92
|
+
sameStatsFileIdentity,
|
|
93
|
+
statsFileIdentityOf,
|
|
94
|
+
type StatsFileIdentity,
|
|
95
|
+
SHARD_CLEANUP_INTERVAL_MS,
|
|
78
96
|
touchStatsMaintenanceLock,
|
|
79
97
|
writeMaintenanceMarker,
|
|
80
98
|
} from "./stats-store.ts";
|
|
@@ -95,6 +113,9 @@ export const SHARD_COMPACTION_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-co
|
|
|
95
113
|
*/
|
|
96
114
|
export const ARCHIVE_ABANDONED_ACTIVE_MS = 24 * 60 * 60 * 1000;
|
|
97
115
|
|
|
116
|
+
/** How soon maintenance retries a compaction pass that failed with an error. */
|
|
117
|
+
export const COMPACTION_RETRY_AFTER_FAILURE_MS = 10 * 60 * 1000;
|
|
118
|
+
|
|
98
119
|
/** How often a running maintenance pass refreshes the lock's mtime. */
|
|
99
120
|
export const STATS_MAINTENANCE_HEARTBEAT_MS = 5 * 60 * 1000;
|
|
100
121
|
|
|
@@ -154,10 +175,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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
803
|
+
type ArchiveListing = { generations: number[]; placeholders: string[] };
|
|
720
804
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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 {
|
|
820
|
+
return { generations: generations.sort((a, b) => a - b), placeholders: placeholders.sort() };
|
|
754
821
|
}
|
|
755
822
|
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
872
|
+
if (kind === "legacy") {
|
|
778
873
|
const archive = parseStatsArchive(value);
|
|
779
|
-
|
|
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 !==
|
|
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
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
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
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
return previous && !previous.view.corrupt ? previous.view : corruptView();
|
|
805
|
-
};
|
|
806
|
-
for (let attempt = 0; attempt < SOURCE_ATTEMPTS; attempt++) {
|
|
807
|
-
let source: ArchiveSource;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
//
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
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
|
|
916
|
-
} catch (
|
|
917
|
-
if (getErrorCode(
|
|
918
|
-
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
*
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
* simply deleted. Anything else
|
|
951
|
-
*
|
|
952
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
972
|
-
} catch {
|
|
973
|
-
|
|
974
|
-
await unlink(retired).catch(() => undefined);
|
|
975
|
-
} else {
|
|
976
|
-
const orphan = join(dirname(archivePath), `archive.legacy-${Date.now()}.json`);
|
|
977
|
-
await rename(retired, orphan).catch(() => undefined);
|
|
978
|
-
console.warn(`${LOG_PREFIX}: ignored a stats archive written by an older pi-cache-optimizer after migration; kept as ${orphan}`);
|
|
1354
|
+
await renameWithRetry(archivePath, placeholder, LEGACY_ARCHIVE_RENAME_BUDGET_MS);
|
|
1355
|
+
} catch {
|
|
1356
|
+
return false;
|
|
979
1357
|
}
|
|
980
1358
|
}
|
|
981
1359
|
try {
|
|
982
|
-
await
|
|
983
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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.
|
|
1177
|
-
(base.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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);
|