instar 1.3.522 → 1.3.524
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/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +56 -0
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +13 -1
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/CredentialIdentityOracle.d.ts +59 -0
- package/dist/core/CredentialIdentityOracle.d.ts.map +1 -0
- package/dist/core/CredentialIdentityOracle.js +89 -0
- package/dist/core/CredentialIdentityOracle.js.map +1 -0
- package/dist/core/CredentialLocationLedger.d.ts +187 -0
- package/dist/core/CredentialLocationLedger.d.ts.map +1 -0
- package/dist/core/CredentialLocationLedger.js +351 -0
- package/dist/core/CredentialLocationLedger.js.map +1 -0
- package/dist/core/CredentialWriteFunnel.d.ts +77 -0
- package/dist/core/CredentialWriteFunnel.d.ts.map +1 -0
- package/dist/core/CredentialWriteFunnel.js +128 -0
- package/dist/core/CredentialWriteFunnel.js.map +1 -0
- package/dist/core/MeshRpc.d.ts +5 -0
- package/dist/core/MeshRpc.d.ts.map +1 -1
- package/dist/core/MeshRpc.js +8 -0
- package/dist/core/MeshRpc.js.map +1 -1
- package/dist/core/StoreSnapshot.d.ts +455 -0
- package/dist/core/StoreSnapshot.d.ts.map +1 -0
- package/dist/core/StoreSnapshot.js +786 -0
- package/dist/core/StoreSnapshot.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +5 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/stateSyncConfig.js +1 -1
- package/dist/core/stateSyncConfig.js.map +1 -1
- package/dist/core/storeSnapshotBuild.worker.d.ts +2 -0
- package/dist/core/storeSnapshotBuild.worker.d.ts.map +1 -0
- package/dist/core/storeSnapshotBuild.worker.js +32 -0
- package/dist/core/storeSnapshotBuild.worker.js.map +1 -0
- package/dist/core/types.d.ts +35 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/package.json +1 -1
- package/scripts/lint-no-direct-llm-http.js +6 -0
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/1.3.523.md +31 -0
- package/upgrades/1.3.524.md +31 -0
- package/upgrades/side-effects/hlc-step3-snapshot-tail.md +133 -0
- package/upgrades/side-effects/live-credential-repointing-increment-a-foundation.md +75 -0
- package/upgrades/side-effects/live-credential-repointing-increment-a-funnel-primitive.md +66 -0
- package/upgrades/side-effects/live-credential-repointing-increment-a-ledger.md +71 -0
- package/upgrades/side-effects/live-credential-repointing-increment-a-oracle.md +68 -0
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StoreSnapshot — single-origin snapshot-then-tail machinery (WS2 replicated-store
|
|
3
|
+
* foundation, Component 4 / build-order step 3).
|
|
4
|
+
*
|
|
5
|
+
* Spec: docs/specs/multi-machine-replicated-store-foundation.md §6 (snapshot-then-
|
|
6
|
+
* tail), §6.1 (single-origin anti-forgery), §6.2 (snapshot format + watermark
|
|
7
|
+
* VECTOR), §6.3 (the cutover apply path), §6.4 (HLC demoted to SECONDARY dedup),
|
|
8
|
+
* §6.5 (tombstone high-water seed), §6.6 (why a per-(origin,kind) seq-watermark
|
|
9
|
+
* vector, not a scalar), §8.2 (snapshot-cache fixed ceiling).
|
|
10
|
+
*
|
|
11
|
+
* GENERIC foundation machinery ONLY — there is NO concrete store kind here
|
|
12
|
+
* (preferences/relationships are later consumers, WS2.1+). Everything ships DARK
|
|
13
|
+
* behind `multiMachine.stateSync.<store>.enabled` (default false); a single-machine
|
|
14
|
+
* agent (no peers to pull from) is a strict no-op.
|
|
15
|
+
*
|
|
16
|
+
* This module is PURE LOGIC. The 67MB-class whole-store materialization happens OFF
|
|
17
|
+
* the event loop in `storeSnapshotBuild.worker.ts` (the instar#1069 requirement,
|
|
18
|
+
* mirroring CartographerSweepEngine / cartographerDetect.worker.ts) — this file
|
|
19
|
+
* exports the pure `materializeSnapshot()` the worker dispatches to, plus the
|
|
20
|
+
* non-worker cache/breaker/cutover seams that run on the main thread but do only
|
|
21
|
+
* bounded, O(records-in-one-batch) work. No fs, no Date directly, no network.
|
|
22
|
+
*
|
|
23
|
+
* THE CORRECTNESS BORROW (§6.3): the whole no-gap/no-double-apply guarantee is
|
|
24
|
+
* inherited from the EXISTING seq-contiguous transport. This module:
|
|
25
|
+
* 1. builds a single-origin snapshot (origin === serving machine, §6.1),
|
|
26
|
+
* 2. computes a per-(origin,kind) seq-watermark VECTOR from the entries that
|
|
27
|
+
* actually materialized (§6.2 — it cannot lie),
|
|
28
|
+
* 3. seeds `PeerMeta.kinds[kind].lastHeldSeq = snapshotSeq` (§6.3 step 3),
|
|
29
|
+
* 4. then TAILS via the UNCHANGED `buildServeBatch(kind, snapshotSeq, M)` — the
|
|
30
|
+
* existing applier seq-contiguity (lastHeldSeq+1) does the gap/dup work.
|
|
31
|
+
* There is NO new gap-detection here. HLC is a SECONDARY dedup hint only (§6.4).
|
|
32
|
+
*/
|
|
33
|
+
import { Worker } from 'node:worker_threads';
|
|
34
|
+
import { HybridLogicalClock, coerceHlc, serializeHlcKey, } from './HybridLogicalClock.js';
|
|
35
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Pure materializer (§6.1 + §6.2) — dispatched by the worker, OFF the event loop
|
|
37
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
38
|
+
/** Defensive re-narrow of one entry's envelope fields. Returns null to drop. */
|
|
39
|
+
function narrowEnvelope(data) {
|
|
40
|
+
const recordKey = data.recordKey;
|
|
41
|
+
if (typeof recordKey !== 'string' || recordKey.length === 0)
|
|
42
|
+
return null;
|
|
43
|
+
const op = data.op;
|
|
44
|
+
if (op !== 'put' && op !== 'delete')
|
|
45
|
+
return null;
|
|
46
|
+
const origin = data.origin;
|
|
47
|
+
if (typeof origin !== 'string' || origin.length === 0)
|
|
48
|
+
return null;
|
|
49
|
+
let hlc;
|
|
50
|
+
try {
|
|
51
|
+
hlc = coerceHlc(data.hlc);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// @silent-fallback-ok: a malformed hlc on an entry that already passed the
|
|
55
|
+
// applier's schema is a corrupt on-disk row; the materializer DROPS it
|
|
56
|
+
// (counted as malformedDropped + surfaced in the result) rather than crashing
|
|
57
|
+
// the whole snapshot build. The drop is the safe direction — a record with no
|
|
58
|
+
// total-order position cannot participate in an HLC-max merge.
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return { recordKey, hlc, op, origin };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Materialize a single-origin snapshot (§6.1 + §6.2). PURE — no I/O, no Date, no
|
|
65
|
+
* network. This is the function the worker dispatches to (the heavy walk runs on
|
|
66
|
+
* the worker thread).
|
|
67
|
+
*
|
|
68
|
+
* Algorithm:
|
|
69
|
+
* 1. For every contributing kind, fold the own-stream entries into a per-recordKey
|
|
70
|
+
* LATEST record (HLC-max via HybridLogicalClock.compare). A cross-origin entry
|
|
71
|
+
* (`entry.machine !== origin`) is DROPPED + counted — the §6.1 anti-forgery
|
|
72
|
+
* invariant enforced at materialization, not trusted to the caller.
|
|
73
|
+
* 2. Track the per-(origin,kind) `snapshotSeq` = the highest origin-authored
|
|
74
|
+
* `entry.seq` reflected (§6.2 — computed from what materialized, cannot lie).
|
|
75
|
+
* 3. Track the per-recordKey delete high-water (§6.5) for the seed.
|
|
76
|
+
* 4. Emit the materialized set (latest per key, deletes tombstoned) + the
|
|
77
|
+
* watermark vector + the secondary maxHlc.
|
|
78
|
+
*
|
|
79
|
+
* Boundedness (instar#1069): the per-key fold is O(entries); the result is the
|
|
80
|
+
* live key count (≤ entries). `maxSnapshotBytes` deterministically truncates a
|
|
81
|
+
* pathologically-large materialization (highest-seq-first kept).
|
|
82
|
+
*/
|
|
83
|
+
export function materializeSnapshot(input) {
|
|
84
|
+
const { store, origin, entriesByKind } = input;
|
|
85
|
+
let crossOriginDropped = 0;
|
|
86
|
+
let malformedDropped = 0;
|
|
87
|
+
// recordKey → the winning (highest-HLC) record so far, plus the kind it rode +
|
|
88
|
+
// the seq it came from (for the watermark vector + the delete high-water).
|
|
89
|
+
const winners = new Map();
|
|
90
|
+
const kindWatermarks = {};
|
|
91
|
+
const deleteWatermarks = {};
|
|
92
|
+
let maxHlc = null;
|
|
93
|
+
for (const [kind, entries] of Object.entries(entriesByKind)) {
|
|
94
|
+
let snapshotSeq = 0;
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
// §6.1 ANTI-FORGERY: a single-origin snapshot serves ONLY records the
|
|
97
|
+
// serving machine authored. A cross-origin entry is dropped + counted —
|
|
98
|
+
// never materialized into another origin's snapshot.
|
|
99
|
+
if (entry.machine !== origin) {
|
|
100
|
+
crossOriginDropped++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const env = narrowEnvelope(entry.data);
|
|
104
|
+
if (env === null) {
|
|
105
|
+
malformedDropped++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// origin field must also equal the serving machine (defense-in-depth: the
|
|
109
|
+
// envelope `origin` is authoritative, but a row whose data.origin disagrees
|
|
110
|
+
// with entry.machine is corrupt — drop it).
|
|
111
|
+
if (env.origin !== origin) {
|
|
112
|
+
crossOriginDropped++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// §6.2 watermark: the highest origin-authored seq reflected for this kind.
|
|
116
|
+
if (typeof entry.seq === 'number' && Number.isFinite(entry.seq) && entry.seq > snapshotSeq) {
|
|
117
|
+
snapshotSeq = entry.seq;
|
|
118
|
+
}
|
|
119
|
+
// Track the global max HLC (secondary dedup hint, §6.4).
|
|
120
|
+
if (maxHlc === null || HybridLogicalClock.compare(env.hlc, maxHlc) > 0) {
|
|
121
|
+
maxHlc = env.hlc;
|
|
122
|
+
}
|
|
123
|
+
// Store-specific data = the entry data minus the reserved envelope fields.
|
|
124
|
+
const storeData = stripEnvelopeFields(entry.data);
|
|
125
|
+
const rec = {
|
|
126
|
+
recordKey: env.recordKey,
|
|
127
|
+
hlc: env.hlc,
|
|
128
|
+
op: env.op,
|
|
129
|
+
origin,
|
|
130
|
+
data: storeData,
|
|
131
|
+
};
|
|
132
|
+
const prior = winners.get(env.recordKey);
|
|
133
|
+
if (!prior || HybridLogicalClock.compare(env.hlc, prior.rec.hlc) > 0) {
|
|
134
|
+
winners.set(env.recordKey, { rec });
|
|
135
|
+
}
|
|
136
|
+
// §6.5 delete high-water seed: per key, the highest-HLC delete reflected.
|
|
137
|
+
if (env.op === 'delete') {
|
|
138
|
+
const dw = deleteWatermarks[env.recordKey];
|
|
139
|
+
if (!dw || HybridLogicalClock.compare(env.hlc, dw) > 0) {
|
|
140
|
+
deleteWatermarks[env.recordKey] = env.hlc;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
kindWatermarks[kind] = { snapshotSeq };
|
|
145
|
+
}
|
|
146
|
+
// A delete-watermark entry is only meaningful while the WINNING record for that
|
|
147
|
+
// key is still the tombstone (a later put for the same key — a legitimate
|
|
148
|
+
// re-create — supersedes it and clears the high-water seed; the always-on
|
|
149
|
+
// receiver guard re-derives the live floor, §6.5 guard 1). Clear a seed whose
|
|
150
|
+
// winner is now a put with a strictly-greater HLC.
|
|
151
|
+
for (const [key, dw] of Object.entries(deleteWatermarks)) {
|
|
152
|
+
const w = winners.get(key);
|
|
153
|
+
if (w && w.rec.op === 'put' && HybridLogicalClock.compare(w.rec.hlc, dw) > 0) {
|
|
154
|
+
delete deleteWatermarks[key];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Emit the materialized records (deterministic order: by recordKey for stable
|
|
158
|
+
// serialization → stable cache key bytes).
|
|
159
|
+
let records = [...winners.values()]
|
|
160
|
+
.map((w) => w.rec)
|
|
161
|
+
.sort((a, b) => (a.recordKey < b.recordKey ? -1 : a.recordKey > b.recordKey ? 1 : 0));
|
|
162
|
+
// Deterministic byte-bounded truncation (instar#1069): if the materialized set
|
|
163
|
+
// exceeds maxSnapshotBytes, keep the highest-HLC records first and FLAG truncated.
|
|
164
|
+
// We deliberately keep the FULL watermark (we do NOT lower snapshotSeq) — lowering
|
|
165
|
+
// it to match the kept records is impossible to do correctly across a multi-kind
|
|
166
|
+
// vector. Instead, `truncated` is a HARD REFUSAL signal, NOT a "partial is fine"
|
|
167
|
+
// hint: StoreSnapshotEngine.serveSnapshot returns `build-truncated` (never caches
|
|
168
|
+
// or serves it) and applySnapshotCutover THROWS on a truncated snapshot — so a
|
|
169
|
+
// truncated build can never seed `lastHeldSeq` past the dropped records (the silent
|
|
170
|
+
// sub-watermark gap trap). The caller falls back to a from-genesis tail; the real
|
|
171
|
+
// fix is a store whose materialized state fits its bound (§8 retention).
|
|
172
|
+
let truncated = false;
|
|
173
|
+
const cap = input.maxSnapshotBytes ?? 0;
|
|
174
|
+
if (cap > 0) {
|
|
175
|
+
const full = Buffer.byteLength(JSON.stringify(records), 'utf-8');
|
|
176
|
+
if (full > cap) {
|
|
177
|
+
truncated = true;
|
|
178
|
+
// Keep highest-HLC records until the cap; the rest are excluded from THIS
|
|
179
|
+
// build. The caller treats `truncated` as "this store is too large to
|
|
180
|
+
// snapshot under the current cap" — a bounded, surfaced condition, never a
|
|
181
|
+
// silent partial.
|
|
182
|
+
const byHlcDesc = [...records].sort((a, b) => HybridLogicalClock.compare(b.hlc, a.hlc));
|
|
183
|
+
const kept = [];
|
|
184
|
+
let bytes = 2; // "[]"
|
|
185
|
+
for (const r of byHlcDesc) {
|
|
186
|
+
const rb = Buffer.byteLength(JSON.stringify(r), 'utf-8') + 1;
|
|
187
|
+
if (kept.length > 0 && bytes + rb > cap)
|
|
188
|
+
break;
|
|
189
|
+
kept.push(r);
|
|
190
|
+
bytes += rb;
|
|
191
|
+
}
|
|
192
|
+
records = kept.sort((a, b) => (a.recordKey < b.recordKey ? -1 : a.recordKey > b.recordKey ? 1 : 0));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const watermark = {
|
|
196
|
+
origin,
|
|
197
|
+
kinds: kindWatermarks,
|
|
198
|
+
maxHlc: maxHlc ?? { physical: 0, logical: 0, node: origin },
|
|
199
|
+
};
|
|
200
|
+
const sizeBytes = Buffer.byteLength(JSON.stringify(records), 'utf-8');
|
|
201
|
+
const snapshot = {
|
|
202
|
+
store,
|
|
203
|
+
origin,
|
|
204
|
+
records,
|
|
205
|
+
watermark,
|
|
206
|
+
deleteWatermarks,
|
|
207
|
+
sizeBytes,
|
|
208
|
+
truncated,
|
|
209
|
+
};
|
|
210
|
+
return { snapshot, crossOriginDropped, malformedDropped, truncated };
|
|
211
|
+
}
|
|
212
|
+
/** The reserved replicated-record envelope field names (kept local to avoid a
|
|
213
|
+
* cyclic import with ReplicatedRecordEnvelope; asserted equal by a unit test). */
|
|
214
|
+
const RESERVED_ENVELOPE_FIELDS = ['recordKey', 'hlc', 'op', 'origin', 'observed'];
|
|
215
|
+
/** Return the store-specific portion of a journal `data` (envelope fields stripped). */
|
|
216
|
+
function stripEnvelopeFields(data) {
|
|
217
|
+
const reserved = new Set(RESERVED_ENVELOPE_FIELDS);
|
|
218
|
+
const out = {};
|
|
219
|
+
for (const [k, v] of Object.entries(data)) {
|
|
220
|
+
if (!reserved.has(k))
|
|
221
|
+
out[k] = v;
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
226
|
+
// Snapshot cache — a FIXED-ceiling LRU ring (§8.2)
|
|
227
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
228
|
+
/** A cache key (§8.2): keyed by (origin, store, maxHlc). Deterministic string. */
|
|
229
|
+
export function snapshotCacheKey(origin, store, maxHlc) {
|
|
230
|
+
return `${origin}${store}${serializeHlcKey(maxHlc)}`;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The snapshot cache — a FIXED-ceiling ring (§8.2), NOT pool-size-scaled. Bounded
|
|
234
|
+
* by BOTH `maxCachedSnapshots` (count) AND `maxCacheBytes` (bytes), whichever
|
|
235
|
+
* binds first; LRU eviction with a monotonic `cacheLossCounter` (mirroring the
|
|
236
|
+
* quarantine ring's lossCounter). An evicted snapshot is just rebuilt on next
|
|
237
|
+
* demand (breaker-gated, §6.3) — eviction is a recompute, never a correctness
|
|
238
|
+
* loss; the counter makes the recompute visible in degradation.
|
|
239
|
+
*
|
|
240
|
+
* A stale entry (its source stream advanced past the cached maxHlc) is dropped on
|
|
241
|
+
* the next put for the same (origin, store) — the cache key embeds maxHlc, so a
|
|
242
|
+
* fresher build is a DIFFERENT key; `invalidateOlder` drops the superseded ones.
|
|
243
|
+
*/
|
|
244
|
+
export class SnapshotCache {
|
|
245
|
+
maxCount;
|
|
246
|
+
maxBytes;
|
|
247
|
+
entries = new Map();
|
|
248
|
+
clock = 0;
|
|
249
|
+
bytes = 0;
|
|
250
|
+
_cacheLossCounter = 0;
|
|
251
|
+
constructor(opts) {
|
|
252
|
+
if (!(opts.maxCachedSnapshots > 0))
|
|
253
|
+
throw new Error('SnapshotCache: maxCachedSnapshots must be > 0');
|
|
254
|
+
if (!(opts.maxCacheBytes > 0))
|
|
255
|
+
throw new Error('SnapshotCache: maxCacheBytes must be > 0');
|
|
256
|
+
this.maxCount = Math.floor(opts.maxCachedSnapshots);
|
|
257
|
+
this.maxBytes = Math.floor(opts.maxCacheBytes);
|
|
258
|
+
}
|
|
259
|
+
/** Monotonic count of LRU evictions (§8.2 — surfaced in degradation). */
|
|
260
|
+
get cacheLossCounter() {
|
|
261
|
+
return this._cacheLossCounter;
|
|
262
|
+
}
|
|
263
|
+
/** Current live entry count. */
|
|
264
|
+
get size() {
|
|
265
|
+
return this.entries.size;
|
|
266
|
+
}
|
|
267
|
+
/** Current total cached bytes. */
|
|
268
|
+
get byteSize() {
|
|
269
|
+
return this.bytes;
|
|
270
|
+
}
|
|
271
|
+
/** Get a cached snapshot by key (refreshes its LRU position). */
|
|
272
|
+
get(key) {
|
|
273
|
+
const e = this.entries.get(key);
|
|
274
|
+
if (!e)
|
|
275
|
+
return undefined;
|
|
276
|
+
e.lastUsed = ++this.clock;
|
|
277
|
+
return e.snapshot;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Put a freshly-built snapshot. Drops any STALE entry for the same (origin,
|
|
281
|
+
* store) whose maxHlc is older than this one (the source stream advanced past
|
|
282
|
+
* it, §8.2) — those are superseded, not LRU-evicted (no lossCounter bump). Then
|
|
283
|
+
* enforces the count + byte ceilings via LRU eviction (lossCounter bumped).
|
|
284
|
+
*/
|
|
285
|
+
put(snapshot) {
|
|
286
|
+
const key = snapshotCacheKey(snapshot.origin, snapshot.store, snapshot.watermark.maxHlc);
|
|
287
|
+
// Idempotent re-put: replace in place (refresh bytes + LRU).
|
|
288
|
+
const existing = this.entries.get(key);
|
|
289
|
+
if (existing) {
|
|
290
|
+
this.bytes -= existing.snapshot.sizeBytes;
|
|
291
|
+
existing.snapshot = snapshot;
|
|
292
|
+
existing.lastUsed = ++this.clock;
|
|
293
|
+
this.bytes += snapshot.sizeBytes;
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
// Drop superseded entries for the same (origin, store) with an OLDER maxHlc.
|
|
297
|
+
this.invalidateOlder(snapshot);
|
|
298
|
+
this.entries.set(key, { key, snapshot, lastUsed: ++this.clock });
|
|
299
|
+
this.bytes += snapshot.sizeBytes;
|
|
300
|
+
}
|
|
301
|
+
this.evictToBounds();
|
|
302
|
+
}
|
|
303
|
+
/** Drop cached entries for the same (origin, store) with a strictly-older maxHlc. */
|
|
304
|
+
invalidateOlder(fresh) {
|
|
305
|
+
for (const [k, e] of this.entries) {
|
|
306
|
+
if (e.snapshot.origin === fresh.origin && e.snapshot.store === fresh.store) {
|
|
307
|
+
if (HybridLogicalClock.compare(e.snapshot.watermark.maxHlc, fresh.watermark.maxHlc) < 0) {
|
|
308
|
+
this.bytes -= e.snapshot.sizeBytes;
|
|
309
|
+
this.entries.delete(k);
|
|
310
|
+
// NOT a loss — superseded by a fresher build for the same target, not
|
|
311
|
+
// an LRU eviction. A subsequent demand uses the fresher entry.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** Evict LRU entries until BOTH the count and byte ceilings hold (§8.2). */
|
|
317
|
+
evictToBounds() {
|
|
318
|
+
while (this.entries.size > this.maxCount || this.bytes > this.maxBytes) {
|
|
319
|
+
// Find the LRU entry.
|
|
320
|
+
let lru = null;
|
|
321
|
+
for (const e of this.entries.values()) {
|
|
322
|
+
if (lru === null || e.lastUsed < lru.lastUsed)
|
|
323
|
+
lru = e;
|
|
324
|
+
}
|
|
325
|
+
if (lru === null)
|
|
326
|
+
break;
|
|
327
|
+
this.bytes -= lru.snapshot.sizeBytes;
|
|
328
|
+
this.entries.delete(lru.key);
|
|
329
|
+
this._cacheLossCounter++;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** Iterate cached snapshots for a given (origin, store) — bounded by maxCount.
|
|
333
|
+
* Does NOT touch LRU (a read-only scan; the caller touches LRU on a real serve). */
|
|
334
|
+
*entriesFor(origin, store) {
|
|
335
|
+
for (const e of this.entries.values()) {
|
|
336
|
+
if (e.snapshot.origin === origin && e.snapshot.store === store)
|
|
337
|
+
yield e.snapshot;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** Drop ALL cached entries for an origin (the §7.4 rollback-unmerge hook). */
|
|
341
|
+
dropOrigin(origin) {
|
|
342
|
+
for (const [k, e] of this.entries) {
|
|
343
|
+
if (e.snapshot.origin === origin) {
|
|
344
|
+
this.bytes -= e.snapshot.sizeBytes;
|
|
345
|
+
this.entries.delete(k);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Per-peer snapshot-build-frequency breaker (§6.3). Prevents rebuild storms from a
|
|
352
|
+
* flapping peer: within `minRebuildIntervalMs` the cached snapshot is served; more
|
|
353
|
+
* than `maxRebuildsPerWindow` actual rebuilds in `windowMs` trips a breaker that
|
|
354
|
+
* stays open for `cooldownMs`. Bounded (No-Unbounded-Loops). PURE-ish: the clock
|
|
355
|
+
* is injected so it is unit-testable across simulated windows.
|
|
356
|
+
*
|
|
357
|
+
* Keyed by (peer, origin, store) so one peer flapping on store A does not throttle
|
|
358
|
+
* its store-B rebuilds, and two peers requesting the same snapshot are independent.
|
|
359
|
+
*/
|
|
360
|
+
export class SnapshotRebuildBreaker {
|
|
361
|
+
minIntervalMs;
|
|
362
|
+
maxPerWindow;
|
|
363
|
+
windowMs;
|
|
364
|
+
cooldownMs;
|
|
365
|
+
now;
|
|
366
|
+
/** Per key: the recent rebuild timestamps (bounded by the window) + breaker state.
|
|
367
|
+
* `lastRebuildAt === null` means "never rebuilt" — distinct from a real now()=0
|
|
368
|
+
* timestamp, so the min-interval check is correct even when the clock starts at 0. */
|
|
369
|
+
state = new Map();
|
|
370
|
+
constructor(opts) {
|
|
371
|
+
this.minIntervalMs = opts.minRebuildIntervalMs ?? 30_000;
|
|
372
|
+
this.maxPerWindow = opts.maxRebuildsPerWindow ?? 5;
|
|
373
|
+
this.windowMs = opts.windowMs ?? 5 * 60_000;
|
|
374
|
+
this.cooldownMs = opts.cooldownMs ?? 60_000;
|
|
375
|
+
this.now = opts.now;
|
|
376
|
+
}
|
|
377
|
+
keyOf(peer, origin, store) {
|
|
378
|
+
return `${peer}${origin}${store}`;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Decide whether a fresh rebuild is allowed RIGHT NOW for (peer, origin, store).
|
|
382
|
+
* Returns `{ allow: true }` to build; otherwise a refusal with `serveCache`
|
|
383
|
+
* (true = serve the cached snapshot instead of building). This does NOT record
|
|
384
|
+
* the rebuild — call `recordRebuild()` after an ACTUAL build completes, so a
|
|
385
|
+
* cache-served request never counts against the frequency cap.
|
|
386
|
+
*/
|
|
387
|
+
shouldRebuild(peer, origin, store) {
|
|
388
|
+
const key = this.keyOf(peer, origin, store);
|
|
389
|
+
const now = this.now();
|
|
390
|
+
const s = this.state.get(key);
|
|
391
|
+
if (!s)
|
|
392
|
+
return { allow: true };
|
|
393
|
+
// Breaker open?
|
|
394
|
+
if (s.openUntil > now) {
|
|
395
|
+
return { allow: false, reason: 'breaker-open', serveCache: true };
|
|
396
|
+
}
|
|
397
|
+
// Minimum-rebuild window: within it, serve the cache.
|
|
398
|
+
if (s.lastRebuildAt !== null && now - s.lastRebuildAt < this.minIntervalMs) {
|
|
399
|
+
return { allow: false, reason: 'within-min-interval', serveCache: true };
|
|
400
|
+
}
|
|
401
|
+
// Frequency cap: prune the window, then check.
|
|
402
|
+
const recent = s.rebuilds.filter((t) => now - t < this.windowMs);
|
|
403
|
+
if (recent.length >= this.maxPerWindow) {
|
|
404
|
+
// Trip the breaker.
|
|
405
|
+
s.openUntil = now + this.cooldownMs;
|
|
406
|
+
s.rebuilds = recent;
|
|
407
|
+
this.state.set(key, s);
|
|
408
|
+
return { allow: false, reason: 'breaker-open', serveCache: true };
|
|
409
|
+
}
|
|
410
|
+
return { allow: true };
|
|
411
|
+
}
|
|
412
|
+
/** Record that an actual rebuild completed for (peer, origin, store). */
|
|
413
|
+
recordRebuild(peer, origin, store) {
|
|
414
|
+
const key = this.keyOf(peer, origin, store);
|
|
415
|
+
const now = this.now();
|
|
416
|
+
const s = this.state.get(key) ?? { rebuilds: [], openUntil: 0, lastRebuildAt: null };
|
|
417
|
+
s.rebuilds = s.rebuilds.filter((t) => now - t < this.windowMs);
|
|
418
|
+
s.rebuilds.push(now);
|
|
419
|
+
s.lastRebuildAt = now;
|
|
420
|
+
this.state.set(key, s);
|
|
421
|
+
}
|
|
422
|
+
/** Is the breaker currently open for (peer, origin, store)? (for tests/observability). */
|
|
423
|
+
isOpen(peer, origin, store) {
|
|
424
|
+
const s = this.state.get(this.keyOf(peer, origin, store));
|
|
425
|
+
return !!s && s.openUntil > this.now();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Apply a single-origin snapshot at cutover (§6.3 steps 3–5 + §6.4 + §6.5). This
|
|
430
|
+
* is the load-bearing seam: it (a) applies each record via HLC-max merge through
|
|
431
|
+
* the injected applier, (b) seeds the deleted-keys high-water from the snapshot,
|
|
432
|
+
* (c) seeds `lastHeldSeq = snapshotSeq` per contributing kind so the NEXT ordinary
|
|
433
|
+
* tail is already in-contiguity, and (d) uses the §6.4 secondary HLC-identity set
|
|
434
|
+
* as a belt-and-suspenders dedup.
|
|
435
|
+
*
|
|
436
|
+
* It does NOT do the tail — that is the UNCHANGED `buildServeBatch(kind,
|
|
437
|
+
* snapshotSeq, M)` path the caller drives next. There is NO gap-detection here;
|
|
438
|
+
* the seq contiguity already there does the work (§6.3 step 4).
|
|
439
|
+
*
|
|
440
|
+
* Idempotency (§6.3 step 5): re-running is safe — re-seeding the cursor to the
|
|
441
|
+
* same snapshotSeq + re-applying is a per-recordKey HLC-max merge (an
|
|
442
|
+
* already-present newer record is not overwritten) and the seq tail drops
|
|
443
|
+
* at-or-below entries as duplicates.
|
|
444
|
+
*
|
|
445
|
+
* PURE-ish: all state mutation is funnelled through the injected seams.
|
|
446
|
+
*/
|
|
447
|
+
export function applySnapshotCutover(snapshot, seams) {
|
|
448
|
+
// STRUCTURAL REFUSAL of a truncated snapshot (the sub-watermark gap trap, §6.3):
|
|
449
|
+
// a truncated snapshot seeds the full `snapshotSeq` but is MISSING records
|
|
450
|
+
// at-or-below it, so seeding `lastHeldSeq = snapshotSeq` + tailing `seq >
|
|
451
|
+
// snapshotSeq` would silently never replay them — a gap the seq-contiguity cannot
|
|
452
|
+
// catch. A truncated snapshot must NEVER be applied; the caller falls back to a
|
|
453
|
+
// from-genesis tail. We throw (a programmer error: serveSnapshot already refuses a
|
|
454
|
+
// truncated build, so a truncated snapshot should never reach the cutover) rather
|
|
455
|
+
// than silently under-seed — the loud, safe direction.
|
|
456
|
+
if (snapshot.truncated) {
|
|
457
|
+
throw new Error(`applySnapshotCutover: refusing a TRUNCATED snapshot for store="${snapshot.store}" origin="${snapshot.origin}" — ` +
|
|
458
|
+
`applying it would seed lastHeldSeq past dropped records and create a silent sub-watermark gap. ` +
|
|
459
|
+
`The caller must fall back to a from-genesis tail (StoreSnapshotEngine.serveSnapshot returns 'build-truncated').`);
|
|
460
|
+
}
|
|
461
|
+
const tally = { applied: 0, dedupSkipped: 0, notWinner: 0 };
|
|
462
|
+
// (b) Seed the deleted-keys high-water FIRST (§6.5) so a put record in the same
|
|
463
|
+
// snapshot that is below a delete high-water is dropped as a resurrection by the
|
|
464
|
+
// applier's guard during step (a).
|
|
465
|
+
for (const [recordKey, hlc] of Object.entries(snapshot.deleteWatermarks)) {
|
|
466
|
+
seams.seedDeleteWatermark(snapshot.store, snapshot.origin, recordKey, hlc);
|
|
467
|
+
}
|
|
468
|
+
// (a) Apply each materialized record via HLC-max merge, with the §6.4 secondary
|
|
469
|
+
// HLC-identity dedup as a belt-and-suspenders net.
|
|
470
|
+
for (const rec of snapshot.records) {
|
|
471
|
+
const isNew = seams.recordHlcIdentity(snapshot.store, rec.recordKey, rec.origin, rec.hlc);
|
|
472
|
+
if (!isNew) {
|
|
473
|
+
tally.dedupSkipped++;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const landed = seams.applySnapshotRecord(snapshot.store, snapshot.origin, rec);
|
|
477
|
+
if (landed)
|
|
478
|
+
tally.applied++;
|
|
479
|
+
else
|
|
480
|
+
tally.notWinner++;
|
|
481
|
+
}
|
|
482
|
+
// (c) Seed lastHeldSeq = snapshotSeq per contributing (origin, kind) stream —
|
|
483
|
+
// the load-bearing cursor placement (§6.3 step 3) so the next tail is contiguous.
|
|
484
|
+
for (const [kind, wm] of Object.entries(snapshot.watermark.kinds)) {
|
|
485
|
+
seams.seedLastHeldSeq(snapshot.origin, kind, wm.snapshotSeq);
|
|
486
|
+
}
|
|
487
|
+
return tally;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Compute the tail request cursor for a contributing (origin, kind) AFTER a
|
|
491
|
+
* cutover (§6.3 step 4). The tail rides the UNCHANGED transport:
|
|
492
|
+
* `buildServeBatch(kind, snapshotSeq, origin)`. This helper is the single place
|
|
493
|
+
* that names the contract — the cursor is the snapshot's per-kind `snapshotSeq`,
|
|
494
|
+
* NOTHING HLC-derived (HLC is demoted to secondary dedup, §6.4).
|
|
495
|
+
*/
|
|
496
|
+
export function tailCursorAfterCutover(snapshot, kind) {
|
|
497
|
+
return snapshot.watermark.kinds[kind]?.snapshotSeq ?? 0;
|
|
498
|
+
}
|
|
499
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
500
|
+
// Wire serialization (the mesh verb carries these) — bounded, validated
|
|
501
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
502
|
+
/** Validate + narrow an untrusted wire snapshot (the receiver side of the mesh
|
|
503
|
+
* verb). Returns null to REJECT the whole snapshot (single-origin violated, a
|
|
504
|
+
* malformed record, etc.) — the §6.1 anti-forgery + §6.2 format gate at the door.
|
|
505
|
+
* `expectedOrigin` is the AUTHENTICATED sender (the mesh layer proved it); a
|
|
506
|
+
* snapshot whose `origin` or any record `origin` disagrees is rejected wholesale
|
|
507
|
+
* (the cross-origin-snapshot attack, §6.1). */
|
|
508
|
+
export function validateWireSnapshot(raw, expectedOrigin) {
|
|
509
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
510
|
+
return null;
|
|
511
|
+
const o = raw;
|
|
512
|
+
if (typeof o.store !== 'string' || o.store.length === 0)
|
|
513
|
+
return null;
|
|
514
|
+
if (typeof o.origin !== 'string' || o.origin.length === 0)
|
|
515
|
+
return null;
|
|
516
|
+
// §6.1: single-origin — origin MUST equal the authenticated sender.
|
|
517
|
+
if (o.origin !== expectedOrigin)
|
|
518
|
+
return null;
|
|
519
|
+
if (!Array.isArray(o.records))
|
|
520
|
+
return null;
|
|
521
|
+
const records = [];
|
|
522
|
+
for (const r of o.records) {
|
|
523
|
+
if (!r || typeof r !== 'object')
|
|
524
|
+
return null;
|
|
525
|
+
const rr = r;
|
|
526
|
+
if (typeof rr.recordKey !== 'string' || rr.recordKey.length === 0)
|
|
527
|
+
return null;
|
|
528
|
+
if (rr.op !== 'put' && rr.op !== 'delete')
|
|
529
|
+
return null;
|
|
530
|
+
// §6.1 anti-forgery: EVERY record's origin MUST equal the serving machine. A
|
|
531
|
+
// single foreign-origin record rejects the WHOLE snapshot (quarantined as
|
|
532
|
+
// untrusted-origin by the caller) — never landed.
|
|
533
|
+
if (rr.origin !== expectedOrigin)
|
|
534
|
+
return null;
|
|
535
|
+
let hlc;
|
|
536
|
+
try {
|
|
537
|
+
hlc = coerceHlc(rr.hlc);
|
|
538
|
+
}
|
|
539
|
+
catch { /* @silent-fallback-ok: a malformed hlc on an UNTRUSTED wire record REJECTS the whole snapshot (return null → the caller quarantines it as untrusted-origin/malformed, §6.1) — the safe direction; nothing is silently defaulted or applied. */
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
const data = rr.data && typeof rr.data === 'object' && !Array.isArray(rr.data)
|
|
543
|
+
? rr.data
|
|
544
|
+
: {};
|
|
545
|
+
records.push({ recordKey: rr.recordKey, hlc, op: rr.op, origin: expectedOrigin, data: stripEnvelopeFields(data) });
|
|
546
|
+
}
|
|
547
|
+
// Watermark.
|
|
548
|
+
const wmRaw = o.watermark;
|
|
549
|
+
if (!wmRaw || typeof wmRaw !== 'object')
|
|
550
|
+
return null;
|
|
551
|
+
const wm = wmRaw;
|
|
552
|
+
if (wm.origin !== expectedOrigin)
|
|
553
|
+
return null;
|
|
554
|
+
const kindsRaw = wm.kinds;
|
|
555
|
+
if (!kindsRaw || typeof kindsRaw !== 'object')
|
|
556
|
+
return null;
|
|
557
|
+
const kinds = {};
|
|
558
|
+
for (const [k, v] of Object.entries(kindsRaw)) {
|
|
559
|
+
const vv = v;
|
|
560
|
+
if (!vv || typeof vv.snapshotSeq !== 'number' || !Number.isFinite(vv.snapshotSeq) || vv.snapshotSeq < 0)
|
|
561
|
+
return null;
|
|
562
|
+
kinds[k] = { snapshotSeq: vv.snapshotSeq };
|
|
563
|
+
}
|
|
564
|
+
let maxHlc;
|
|
565
|
+
try {
|
|
566
|
+
maxHlc = coerceHlc(wm.maxHlc);
|
|
567
|
+
}
|
|
568
|
+
catch { /* @silent-fallback-ok: a malformed watermark maxHlc on an UNTRUSTED wire snapshot REJECTS the whole snapshot (return null) — the safe direction; never a silent default. */
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
// Delete watermarks.
|
|
572
|
+
const dwRaw = o.deleteWatermarks;
|
|
573
|
+
const deleteWatermarks = {};
|
|
574
|
+
if (dwRaw && typeof dwRaw === 'object' && !Array.isArray(dwRaw)) {
|
|
575
|
+
for (const [k, v] of Object.entries(dwRaw)) {
|
|
576
|
+
try {
|
|
577
|
+
deleteWatermarks[k] = coerceHlc(v);
|
|
578
|
+
}
|
|
579
|
+
catch { /* @silent-fallback-ok: a malformed delete-watermark hlc on an UNTRUSTED wire snapshot REJECTS the whole snapshot (return null) — the safe direction; never a silent default. */
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const sizeBytes = Buffer.byteLength(JSON.stringify(records), 'utf-8');
|
|
585
|
+
// Preserve the truncated flag off the wire (a holder that built a truncated
|
|
586
|
+
// snapshot must NOT have served it — serveSnapshot refuses build-truncated — but
|
|
587
|
+
// we carry the flag honestly so applySnapshotCutover's structural refusal is the
|
|
588
|
+
// backstop even against a buggy/old holder that serves one anyway).
|
|
589
|
+
const truncated = o.truncated === true;
|
|
590
|
+
return { store: o.store, origin: expectedOrigin, records, watermark: { origin: expectedOrigin, kinds, maxHlc }, deleteWatermarks, sizeBytes, truncated };
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* StoreSnapshotEngine — the §6.3 serve orchestrator. For a (peer, origin, store)
|
|
594
|
+
* request it:
|
|
595
|
+
* 1. asks the rebuild breaker whether a fresh build is allowed (a flapping peer
|
|
596
|
+
* is served the cached snapshot; a tripped breaker refuses — §6.3),
|
|
597
|
+
* 2. on a build: loads the own-stream entries (single-origin), runs the
|
|
598
|
+
* materializer OFF THE EVENT LOOP in a worker thread (instar#1069), bounded
|
|
599
|
+
* by `buildTimeoutMs`, caches the result (LRU + cacheLossCounter, §8.2), and
|
|
600
|
+
* records the rebuild against the breaker,
|
|
601
|
+
* 3. on a within-window / breaker-open decision: serves the cached snapshot if
|
|
602
|
+
* present, else refuses (the caller falls back / retries later).
|
|
603
|
+
*
|
|
604
|
+
* The HEAVY work (whole-store materialization) is the worker's; the engine itself
|
|
605
|
+
* does only bounded orchestration on the main thread.
|
|
606
|
+
*/
|
|
607
|
+
export class StoreSnapshotEngine {
|
|
608
|
+
cache;
|
|
609
|
+
breaker;
|
|
610
|
+
seams;
|
|
611
|
+
buildTimeoutMs;
|
|
612
|
+
workerHeapMb;
|
|
613
|
+
maxSnapshotBytes;
|
|
614
|
+
log;
|
|
615
|
+
runInline;
|
|
616
|
+
constructor(opts) {
|
|
617
|
+
this.cache = opts.cache;
|
|
618
|
+
this.breaker = opts.breaker;
|
|
619
|
+
this.seams = opts.seams;
|
|
620
|
+
this.buildTimeoutMs = opts.buildTimeoutMs ?? 120_000;
|
|
621
|
+
this.workerHeapMb = opts.workerHeapMb ?? 1536;
|
|
622
|
+
this.maxSnapshotBytes = opts.maxSnapshotBytes ?? 0;
|
|
623
|
+
this.log = opts.log ?? (() => { });
|
|
624
|
+
this.runInline = opts.runInline === true;
|
|
625
|
+
}
|
|
626
|
+
/** The snapshot cache (for observability / the rollback-unmerge dropOrigin hook). */
|
|
627
|
+
getCache() {
|
|
628
|
+
return this.cache;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Serve a single-origin snapshot of (origin, store) to `peer` (§6.3). Builds off
|
|
632
|
+
* the event loop, reuses the cache within the rebuild window, breaker-gated.
|
|
633
|
+
*/
|
|
634
|
+
async serveSnapshot(peer, origin, store) {
|
|
635
|
+
const started = this.seams.now();
|
|
636
|
+
const decision = this.breaker.shouldRebuild(peer, origin, store);
|
|
637
|
+
if (!decision.allow) {
|
|
638
|
+
// Serve the cached snapshot if we have one (§6.3 flapping-peer reuse).
|
|
639
|
+
if (decision.serveCache) {
|
|
640
|
+
const cached = this.mostRecentCached(origin, store);
|
|
641
|
+
if (cached) {
|
|
642
|
+
return { ok: true, snapshot: cached, source: 'cache', durationMs: this.seams.now() - started, truncated: false };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// No cache to serve + a tripped breaker ⇒ refuse (bounded; the caller retries).
|
|
646
|
+
if (decision.reason === 'breaker-open') {
|
|
647
|
+
return { ok: false, reason: 'breaker-open', durationMs: this.seams.now() - started };
|
|
648
|
+
}
|
|
649
|
+
// within-min-interval but no cache yet (first request in the window) — fall
|
|
650
|
+
// through to a build (we have nothing to serve and the breaker is not open).
|
|
651
|
+
}
|
|
652
|
+
const entriesByKind = this.seams.loadOwnEntries(store, origin);
|
|
653
|
+
const total = Object.values(entriesByKind).reduce((n, arr) => n + arr.length, 0);
|
|
654
|
+
if (total === 0) {
|
|
655
|
+
return { ok: false, reason: 'no-entries', durationMs: this.seams.now() - started };
|
|
656
|
+
}
|
|
657
|
+
const input = {
|
|
658
|
+
store,
|
|
659
|
+
origin,
|
|
660
|
+
entriesByKind,
|
|
661
|
+
...(this.maxSnapshotBytes > 0 ? { maxSnapshotBytes: this.maxSnapshotBytes } : {}),
|
|
662
|
+
};
|
|
663
|
+
const built = await this.runBuild(input);
|
|
664
|
+
if (!built.ok) {
|
|
665
|
+
this.log('snapshot-build-failed', { store, origin, peer, reason: built.reason });
|
|
666
|
+
return { ok: false, reason: built.reason, durationMs: this.seams.now() - started };
|
|
667
|
+
}
|
|
668
|
+
// STRUCTURAL REFUSAL of a truncated build (the sub-watermark gap trap, §6.3):
|
|
669
|
+
// a truncated snapshot would seed lastHeldSeq past dropped records — a silent
|
|
670
|
+
// gap. We do NOT cache it and do NOT serve it; the caller falls back to a
|
|
671
|
+
// from-genesis tail (the complete path). We DO record the rebuild against the
|
|
672
|
+
// breaker (a build happened — its cost is real, and an immediate retry would
|
|
673
|
+
// re-truncate), and surface the condition loudly. The structural answer is the
|
|
674
|
+
// store's per-kind retention bound (§8): a store whose materialized state
|
|
675
|
+
// exceeds maxSnapshotBytes is mis-bounded and must shrink its window, not be
|
|
676
|
+
// served a silent partial.
|
|
677
|
+
if (built.result.truncated) {
|
|
678
|
+
this.breaker.recordRebuild(peer, origin, store);
|
|
679
|
+
this.log('snapshot-build-truncated', {
|
|
680
|
+
store,
|
|
681
|
+
origin,
|
|
682
|
+
peer,
|
|
683
|
+
recordCount: built.result.snapshot.records.length,
|
|
684
|
+
sizeBytes: built.result.snapshot.sizeBytes,
|
|
685
|
+
});
|
|
686
|
+
return { ok: false, reason: 'build-truncated', durationMs: this.seams.now() - started };
|
|
687
|
+
}
|
|
688
|
+
this.cache.put(built.result.snapshot);
|
|
689
|
+
this.breaker.recordRebuild(peer, origin, store);
|
|
690
|
+
if (built.result.crossOriginDropped > 0) {
|
|
691
|
+
this.log('snapshot-build-anomaly', {
|
|
692
|
+
store,
|
|
693
|
+
origin,
|
|
694
|
+
crossOriginDropped: built.result.crossOriginDropped,
|
|
695
|
+
malformedDropped: built.result.malformedDropped,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
ok: true,
|
|
700
|
+
snapshot: built.result.snapshot,
|
|
701
|
+
source: 'built',
|
|
702
|
+
durationMs: this.seams.now() - started,
|
|
703
|
+
truncated: false,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
/** The most-recent cached snapshot for (origin, store), if any. */
|
|
707
|
+
mostRecentCached(origin, store) {
|
|
708
|
+
// The cache key embeds maxHlc; the cache.invalidateOlder already keeps only the
|
|
709
|
+
// freshest per (origin, store). Scan for a match (bounded by maxCachedSnapshots).
|
|
710
|
+
// We re-build the key from a probe is impossible (we don't know maxHlc), so we
|
|
711
|
+
// scan — the cache holds at most maxCachedSnapshots entries.
|
|
712
|
+
let best;
|
|
713
|
+
// SnapshotCache does not expose iteration; we add a helper there. Use it:
|
|
714
|
+
for (const snap of this.cache.entriesFor(origin, store)) {
|
|
715
|
+
if (!best || HybridLogicalClock.compare(snap.watermark.maxHlc, best.watermark.maxHlc) > 0) {
|
|
716
|
+
best = snap;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (best) {
|
|
720
|
+
// Touch its LRU position via a get on the canonical key.
|
|
721
|
+
this.cache.get(snapshotCacheKey(best.origin, best.store, best.watermark.maxHlc));
|
|
722
|
+
}
|
|
723
|
+
return best;
|
|
724
|
+
}
|
|
725
|
+
/** Run the materializer — off the event loop (worker), or inline for tests. */
|
|
726
|
+
async runBuild(input) {
|
|
727
|
+
if (this.runInline) {
|
|
728
|
+
try {
|
|
729
|
+
return { ok: true, result: materializeSnapshot(input) };
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
return { ok: false, reason: 'build-failed' };
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return this.runWorker(input);
|
|
736
|
+
}
|
|
737
|
+
/** Minimal env allowlist for a spawned worker — NEVER the parent process.env. */
|
|
738
|
+
workerEnv() {
|
|
739
|
+
const env = {};
|
|
740
|
+
for (const k of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'SystemRoot', 'TEMP', 'TMP']) {
|
|
741
|
+
if (process.env[k])
|
|
742
|
+
env[k] = process.env[k];
|
|
743
|
+
}
|
|
744
|
+
return env;
|
|
745
|
+
}
|
|
746
|
+
/** Spawn the build worker, await its single message, bound by buildTimeoutMs. */
|
|
747
|
+
runWorker(input) {
|
|
748
|
+
const heapMb = this.workerHeapMb;
|
|
749
|
+
const timeoutMs = this.buildTimeoutMs;
|
|
750
|
+
return new Promise((resolve) => {
|
|
751
|
+
let worker;
|
|
752
|
+
try {
|
|
753
|
+
const workerUrl = new URL('./storeSnapshotBuild.worker.js', import.meta.url);
|
|
754
|
+
worker = new Worker(workerUrl, {
|
|
755
|
+
workerData: input,
|
|
756
|
+
resourceLimits: { maxOldGenerationSizeMb: heapMb },
|
|
757
|
+
env: this.workerEnv(),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
resolve({ ok: false, reason: 'build-failed' });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
let settled = false;
|
|
765
|
+
const done = (r) => {
|
|
766
|
+
if (settled)
|
|
767
|
+
return;
|
|
768
|
+
settled = true;
|
|
769
|
+
clearTimeout(timer);
|
|
770
|
+
worker.terminate().catch(() => { });
|
|
771
|
+
resolve(r);
|
|
772
|
+
};
|
|
773
|
+
const timer = setTimeout(() => done({ ok: false, reason: 'build-timeout' }), timeoutMs);
|
|
774
|
+
worker.once('message', (msg) => {
|
|
775
|
+
if (msg.ok && msg.result)
|
|
776
|
+
done({ ok: true, result: msg.result });
|
|
777
|
+
else
|
|
778
|
+
done({ ok: false, reason: 'build-failed' });
|
|
779
|
+
});
|
|
780
|
+
worker.once('error', () => done({ ok: false, reason: 'build-failed' }));
|
|
781
|
+
worker.once('exit', () => { if (!settled)
|
|
782
|
+
done({ ok: false, reason: 'build-failed' }); });
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
//# sourceMappingURL=StoreSnapshot.js.map
|