@helipod/objectstore-substrate 0.1.0
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/LICENSE +11 -0
- package/dist/index.d.ts +1114 -0
- package/dist/index.js +1584 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1584 @@
|
|
|
1
|
+
// src/segment.ts
|
|
2
|
+
import { convexToJson, jsonToConvex } from "@helipod/values";
|
|
3
|
+
function bytesToBase64(bytes) {
|
|
4
|
+
let binary = "";
|
|
5
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
6
|
+
return btoa(binary);
|
|
7
|
+
}
|
|
8
|
+
function base64ToBytes(b64) {
|
|
9
|
+
const binary = atob(b64);
|
|
10
|
+
const out = new Uint8Array(binary.length);
|
|
11
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
function encodeId(id) {
|
|
15
|
+
return { tableNumber: id.tableNumber, internalId: bytesToBase64(id.internalId) };
|
|
16
|
+
}
|
|
17
|
+
function decodeId(id) {
|
|
18
|
+
return { tableNumber: id.tableNumber, internalId: base64ToBytes(id.internalId) };
|
|
19
|
+
}
|
|
20
|
+
function encodeResolvedDocument(doc) {
|
|
21
|
+
return { id: encodeId(doc.id), value: convexToJson(doc.value) };
|
|
22
|
+
}
|
|
23
|
+
function decodeResolvedDocument(doc) {
|
|
24
|
+
return { id: decodeId(doc.id), value: jsonToConvex(doc.value) };
|
|
25
|
+
}
|
|
26
|
+
function encodeDocumentLogEntry(entry) {
|
|
27
|
+
return {
|
|
28
|
+
ts: entry.ts.toString(),
|
|
29
|
+
id: encodeId(entry.id),
|
|
30
|
+
value: entry.value === null ? null : encodeResolvedDocument(entry.value),
|
|
31
|
+
prev_ts: entry.prev_ts === null ? null : entry.prev_ts.toString()
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function decodeDocumentLogEntry(entry) {
|
|
35
|
+
return {
|
|
36
|
+
ts: BigInt(entry.ts),
|
|
37
|
+
id: decodeId(entry.id),
|
|
38
|
+
value: entry.value === null ? null : decodeResolvedDocument(entry.value),
|
|
39
|
+
prev_ts: entry.prev_ts === null ? null : BigInt(entry.prev_ts)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function encodeIndexValue(value) {
|
|
43
|
+
return value.type === "Deleted" ? { type: "Deleted" } : { type: "NonClustered", docId: encodeId(value.docId) };
|
|
44
|
+
}
|
|
45
|
+
function decodeIndexValue(value) {
|
|
46
|
+
return value.type === "Deleted" ? { type: "Deleted" } : { type: "NonClustered", docId: decodeId(value.docId) };
|
|
47
|
+
}
|
|
48
|
+
function encodeIndexUpdate(update) {
|
|
49
|
+
return { indexId: update.indexId, key: bytesToBase64(update.key), value: encodeIndexValue(update.value) };
|
|
50
|
+
}
|
|
51
|
+
function decodeIndexUpdate(update) {
|
|
52
|
+
return { indexId: update.indexId, key: base64ToBytes(update.key), value: decodeIndexValue(update.value) };
|
|
53
|
+
}
|
|
54
|
+
function encodeIndexWrite(write) {
|
|
55
|
+
return { ts: write.ts.toString(), update: encodeIndexUpdate(write.update) };
|
|
56
|
+
}
|
|
57
|
+
function decodeIndexWrite(write) {
|
|
58
|
+
return { ts: BigInt(write.ts), update: decodeIndexUpdate(write.update) };
|
|
59
|
+
}
|
|
60
|
+
function encodeDocumentLogEntries(documents) {
|
|
61
|
+
return documents.map(encodeDocumentLogEntry);
|
|
62
|
+
}
|
|
63
|
+
function decodeDocumentLogEntries(wire) {
|
|
64
|
+
return wire.map(decodeDocumentLogEntry);
|
|
65
|
+
}
|
|
66
|
+
function encodeIndexWrites(writes) {
|
|
67
|
+
return writes.map(encodeIndexWrite);
|
|
68
|
+
}
|
|
69
|
+
function decodeIndexWrites(wire) {
|
|
70
|
+
return wire.map(decodeIndexWrite);
|
|
71
|
+
}
|
|
72
|
+
function encodeSegment(payload) {
|
|
73
|
+
const wire = {
|
|
74
|
+
documents: encodeDocumentLogEntries(payload.documents),
|
|
75
|
+
indexUpdates: encodeIndexWrites(payload.indexUpdates)
|
|
76
|
+
};
|
|
77
|
+
return new TextEncoder().encode(JSON.stringify(wire));
|
|
78
|
+
}
|
|
79
|
+
function decodeSegment(bytes) {
|
|
80
|
+
const wire = JSON.parse(new TextDecoder().decode(bytes));
|
|
81
|
+
return {
|
|
82
|
+
documents: decodeDocumentLogEntries(wire.documents),
|
|
83
|
+
indexUpdates: decodeIndexWrites(wire.indexUpdates)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/manifest.ts
|
|
88
|
+
function manifestKey(shard) {
|
|
89
|
+
return `s${shard}/manifest`;
|
|
90
|
+
}
|
|
91
|
+
function emptyManifest() {
|
|
92
|
+
return { epoch: 0, frontierTs: "0", tsCounter: "0", segments: [], nextSeqno: 0, writerId: "", leaseExpiresAt: "0" };
|
|
93
|
+
}
|
|
94
|
+
async function readManifest(os, shard) {
|
|
95
|
+
const entry = await os.get(manifestKey(shard));
|
|
96
|
+
if (entry === null) return null;
|
|
97
|
+
const manifest = JSON.parse(new TextDecoder().decode(entry.body));
|
|
98
|
+
return { manifest, etag: entry.etag };
|
|
99
|
+
}
|
|
100
|
+
async function createManifest(os, shard) {
|
|
101
|
+
const manifest = emptyManifest();
|
|
102
|
+
const { etag } = await os.casPut(manifestKey(shard), new TextEncoder().encode(JSON.stringify(manifest)), null);
|
|
103
|
+
return { manifest, etag };
|
|
104
|
+
}
|
|
105
|
+
async function casManifest(os, shard, next, ifMatch) {
|
|
106
|
+
return os.casPut(manifestKey(shard), new TextEncoder().encode(JSON.stringify(next)), ifMatch);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/object-doc-store.ts
|
|
110
|
+
import { isCasConflict as isCasConflict2 } from "@helipod/objectstore";
|
|
111
|
+
|
|
112
|
+
// src/snapshot.ts
|
|
113
|
+
function encodeSnapshot(payload) {
|
|
114
|
+
const wire = {
|
|
115
|
+
frontierTs: payload.frontierTs,
|
|
116
|
+
segBase: payload.segBase,
|
|
117
|
+
documents: encodeDocumentLogEntries(payload.documents),
|
|
118
|
+
indexUpdates: encodeIndexWrites(payload.indexUpdates)
|
|
119
|
+
};
|
|
120
|
+
return new TextEncoder().encode(JSON.stringify(wire));
|
|
121
|
+
}
|
|
122
|
+
function decodeSnapshot(bytes) {
|
|
123
|
+
const wire = JSON.parse(new TextDecoder().decode(bytes));
|
|
124
|
+
return {
|
|
125
|
+
frontierTs: wire.frontierTs,
|
|
126
|
+
segBase: wire.segBase,
|
|
127
|
+
documents: decodeDocumentLogEntries(wire.documents),
|
|
128
|
+
indexUpdates: decodeIndexWrites(wire.indexUpdates)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function snapshotKey(shard, ts) {
|
|
132
|
+
return `s${shard}/snap/${ts}`;
|
|
133
|
+
}
|
|
134
|
+
async function writeSnapshot(os, shard, payload) {
|
|
135
|
+
await os.putImmutable(snapshotKey(shard, payload.frontierTs), encodeSnapshot(payload));
|
|
136
|
+
}
|
|
137
|
+
async function readSnapshot(os, shard, ts) {
|
|
138
|
+
const entry = await os.get(snapshotKey(shard, ts));
|
|
139
|
+
if (entry === null) return null;
|
|
140
|
+
return decodeSnapshot(entry.body);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/fenced-error.ts
|
|
144
|
+
var FencedError = class extends Error {
|
|
145
|
+
constructor(message) {
|
|
146
|
+
super(message);
|
|
147
|
+
this.name = "FencedError";
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// src/consumers.ts
|
|
152
|
+
import { isCasConflict } from "@helipod/objectstore";
|
|
153
|
+
function consumersPrefix(shard) {
|
|
154
|
+
return `s${shard}/consumers/`;
|
|
155
|
+
}
|
|
156
|
+
function consumerKey(shard, consumerId) {
|
|
157
|
+
return `${consumersPrefix(shard)}${consumerId}`;
|
|
158
|
+
}
|
|
159
|
+
async function publishConsumerWatermark(os, shard, consumerId, watermark) {
|
|
160
|
+
const key = consumerKey(shard, consumerId);
|
|
161
|
+
const body = new TextEncoder().encode(JSON.stringify({ appliedSeqno: watermark.appliedSeqno }));
|
|
162
|
+
const MAX_ATTEMPTS = 5;
|
|
163
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
164
|
+
const existing = await os.get(key);
|
|
165
|
+
try {
|
|
166
|
+
await os.casPut(key, body, existing === null ? null : existing.etag);
|
|
167
|
+
return;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (!isCasConflict(e)) throw e;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
throw new Error(
|
|
173
|
+
`objectstore-substrate: publishConsumerWatermark exhausted ${MAX_ATTEMPTS} retries for '${key}' \u2014 unexpected sustained contention (watermarks are meant to be single-writer-per-(shard, consumerId))`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
async function readConsumerWatermarks(os, shard) {
|
|
177
|
+
const prefix = consumersPrefix(shard);
|
|
178
|
+
const keys = await os.list(prefix);
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const key of keys) {
|
|
181
|
+
const entry = await os.get(key);
|
|
182
|
+
if (entry === null) continue;
|
|
183
|
+
const parsed = JSON.parse(new TextDecoder().decode(entry.body));
|
|
184
|
+
out.push({ consumerId: key.slice(prefix.length), appliedSeqno: parsed.appliedSeqno });
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
async function removeConsumer(os, shard, consumerId) {
|
|
189
|
+
await os.delete(consumerKey(shard, consumerId));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/apply-snapshot.ts
|
|
193
|
+
import { documentIdKey } from "@helipod/id-codec";
|
|
194
|
+
async function applySnapshotState(local, snap, frontierTs) {
|
|
195
|
+
const snapshotIds = new Set(snap.documents.map((d) => documentIdKey(d.id)));
|
|
196
|
+
const currentState = await local.dumpCurrentState();
|
|
197
|
+
const dropped = currentState.documents.filter((d) => !snapshotIds.has(documentIdKey(d.id)));
|
|
198
|
+
let deletedDocs = [];
|
|
199
|
+
if (dropped.length > 0) {
|
|
200
|
+
deletedDocs = dropped.map((d) => ({ ts: frontierTs, id: d.id, value: null, prev_ts: d.ts }));
|
|
201
|
+
await local.write(deletedDocs, [], "Overwrite");
|
|
202
|
+
}
|
|
203
|
+
await local.write(snap.documents, snap.indexUpdates, "Overwrite");
|
|
204
|
+
return { deletedDocs };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/object-doc-store.ts
|
|
208
|
+
function segmentKey(shard, seqno) {
|
|
209
|
+
return `s${shard}/seg/${seqno}`;
|
|
210
|
+
}
|
|
211
|
+
var SNAPSHOT_EVERY = 8;
|
|
212
|
+
var ObjectStoreDocStore = class _ObjectStoreDocStore {
|
|
213
|
+
objectStore;
|
|
214
|
+
shard;
|
|
215
|
+
local;
|
|
216
|
+
/** The last manifest state this process successfully CAS'd (or bootstrapped from) + its etag —
|
|
217
|
+
* the read-half of the next `casManifest`'s optimistic-concurrency check. Updated ONLY after a
|
|
218
|
+
* successful CAS (never speculatively), under `mutex`. */
|
|
219
|
+
cached;
|
|
220
|
+
/** The next free segment seqno for THIS process — advanced by one on every successful commit CAS
|
|
221
|
+
* (dense in the common case), AND durably burned forward by one extra on every successful
|
|
222
|
+
* TAKEOVER `acquire()` (Task 4.6, superseding the earlier in-process-only skip-one of Task 4.5 —
|
|
223
|
+
* see `acquire()`'s doc) to fence out the seqno the immediate predecessor may have orphaned. The
|
|
224
|
+
* burn lives in the DURABLE `manifest.nextSeqno` itself (not just this in-process field) so it
|
|
225
|
+
* survives across a CHAIN of stalled takeovers with no successful commit in between — see
|
|
226
|
+
* `acquire()`. Non-dense-by-one-per-takeover is expected and harmless: never read as `[0..n]` —
|
|
227
|
+
* `materializeTo`/GC always iterate the manifest's own explicit `segments`/`nextSeqno` fields. */
|
|
228
|
+
nextSeqno;
|
|
229
|
+
/** Set when a post-CAS local apply fails: the commit is durable but the local materialization is
|
|
230
|
+
* inconsistent, so further commits are refused until the store is re-opened (re-bootstrapped). */
|
|
231
|
+
poisoned = false;
|
|
232
|
+
/** The lease this instance currently holds (Tier 3 Slice 4), or `null` if it has never
|
|
233
|
+
* `acquire()`'d (or was fenced — see `heartbeat`/`commitWriteBatch`). `commitWriteBatch` refuses to
|
|
234
|
+
* run without this set; `epoch` is the CAS-bumped value `acquire()` claimed, checked against
|
|
235
|
+
* `this.cached.manifest.epoch` before every commit as a defense-in-depth fence check. */
|
|
236
|
+
held = null;
|
|
237
|
+
/** Serializes `commitWrite`/`commitWriteBatch` — see class doc. */
|
|
238
|
+
mutex = Promise.resolve();
|
|
239
|
+
/** Committed segments since the last successful snapshot (or since `open`) — `maybeSnapshot`'s
|
|
240
|
+
* cadence trigger. Reset to 0 only on a successful `snapshot()`. */
|
|
241
|
+
committedSegmentsSinceSnapshot = 0;
|
|
242
|
+
constructor(objectStore, shard, local, cached, nextSeqno) {
|
|
243
|
+
this.objectStore = objectStore;
|
|
244
|
+
this.shard = shard;
|
|
245
|
+
this.local = local;
|
|
246
|
+
this.cached = cached;
|
|
247
|
+
this.nextSeqno = nextSeqno;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Open (or initialize) a shard's object-storage-backed store: read-or-create the manifest, then
|
|
251
|
+
* BOOTSTRAP (materialize) the local store — via `materializeTo`, see its doc for the snapshot +
|
|
252
|
+
* tail-replay algorithm. This claims NO ownership (Tier 3 Slice 4): a writer must additionally
|
|
253
|
+
* `acquire()` before it may commit; a replica (Slice 5) opens without ever acquiring. A fresh
|
|
254
|
+
* (empty) bucket bootstraps to an empty local store; a bucket with prior commits reconstructs the
|
|
255
|
+
* IDENTICAL current state either way.
|
|
256
|
+
*/
|
|
257
|
+
static async open(opts) {
|
|
258
|
+
const { objectStore, shard, local } = opts;
|
|
259
|
+
await local.setupSchema();
|
|
260
|
+
let cached = await readManifest(objectStore, shard);
|
|
261
|
+
if (cached === null) {
|
|
262
|
+
try {
|
|
263
|
+
cached = await createManifest(objectStore, shard);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
if (!isCasConflict2(e)) throw e;
|
|
266
|
+
cached = await readManifest(objectStore, shard);
|
|
267
|
+
if (cached === null) throw e;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const store = new _ObjectStoreDocStore(objectStore, shard, local, cached, 0);
|
|
271
|
+
await store.materializeTo(cached.manifest);
|
|
272
|
+
return store;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Materialize `this.local`/`this.nextSeqno` up to `manifest`'s frontier by applying whatever this
|
|
276
|
+
* instance hasn't already applied: if `manifest` references a snapshot this instance hasn't yet
|
|
277
|
+
* covered (`snapshotSegBase` at or beyond `this.nextSeqno`), restore it first
|
|
278
|
+
* (`write(..., "Overwrite")` of its current-state dump), then replay every remaining referenced
|
|
279
|
+
* segment in order via the same explicit-ts `write(..., "Overwrite")` path — the identical primitive
|
|
280
|
+
* a post-CAS commit applies with, and the one a replica tailer would use (design record §7).
|
|
281
|
+
*
|
|
282
|
+
* Two callers: `open()` on a brand-new instance (`this.nextSeqno === 0`, so this is a full
|
|
283
|
+
* bootstrap — snapshot-if-any + every segment), and `acquire()` on an ALREADY-materialized instance
|
|
284
|
+
* that may be behind the CURRENT manifest (a re-acquiring/challenging writer) — in that case
|
|
285
|
+
* skipping already-applied segments (and skipping a snapshot restore if this instance is already
|
|
286
|
+
* past its `segBase`) matters both for correctness (never double-apply, though `Overwrite` is
|
|
287
|
+
* idempotent) and so a challenger that's only slightly behind doesn't needlessly re-read a snapshot
|
|
288
|
+
* object. Restoring the snapshot when we're behind it is also what makes catch-up correct even if
|
|
289
|
+
* the fencing owner's `gc()` has since deleted the pre-snapshot segments we'd otherwise be missing.
|
|
290
|
+
*
|
|
291
|
+
* Not gated on `runExclusive` itself — both callers already hold it.
|
|
292
|
+
*/
|
|
293
|
+
async materializeTo(manifest) {
|
|
294
|
+
let appliedThrough = this.nextSeqno - 1;
|
|
295
|
+
if (manifest.snapshotTs !== void 0 && manifest.snapshotSegBase !== void 0 && manifest.snapshotSegBase > appliedThrough) {
|
|
296
|
+
const snap = await readSnapshot(this.objectStore, this.shard, manifest.snapshotTs);
|
|
297
|
+
if (snap === null) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`objectstore-substrate: missing snapshot '${manifest.snapshotTs}' referenced by manifest for shard '${this.shard}' (torn state)`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
await applySnapshotState(this.local, snap, BigInt(snap.frontierTs));
|
|
303
|
+
appliedThrough = manifest.snapshotSegBase;
|
|
304
|
+
}
|
|
305
|
+
for (const seqno of manifest.segments) {
|
|
306
|
+
if (seqno <= appliedThrough) continue;
|
|
307
|
+
const entry = await this.objectStore.get(segmentKey(this.shard, seqno));
|
|
308
|
+
if (entry === null) {
|
|
309
|
+
throw new Error(`objectstore-substrate: missing segment '${segmentKey(this.shard, seqno)}' referenced by manifest for shard '${this.shard}'`);
|
|
310
|
+
}
|
|
311
|
+
const payload = decodeSegment(entry.body);
|
|
312
|
+
await this.local.write(payload.documents, payload.indexUpdates, "Overwrite");
|
|
313
|
+
appliedThrough = seqno;
|
|
314
|
+
}
|
|
315
|
+
this.nextSeqno = manifest.nextSeqno;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Claim ownership of this shard for `opts.writerId` (Tier 3 Slice 4, Task 4.2) — the manifest CAS
|
|
319
|
+
* IS the fence: a successful acquire bumps `epoch`, which invalidates any prior owner's cached
|
|
320
|
+
* etag, so its next `commitWriteBatch`/`heartbeat` fails loudly (`FencedError` + poison).
|
|
321
|
+
*
|
|
322
|
+
* Re-reads the manifest FRESH (not `this.cached` — this instance may be stale or may never have
|
|
323
|
+
* committed before). If the lease is currently LIVE and held by a DIFFERENT writer
|
|
324
|
+
* (`now <= leaseExpiresAt`), refuses: `{acquired: false, heldBy, expiresAt}` — this is what
|
|
325
|
+
* prevents two live writers ping-ponging (a heartbeating owner's lease never expires). Otherwise
|
|
326
|
+
* (unowned, or the current lease is EXPIRED, or `opts.writerId` already owns it and is re-claiming)
|
|
327
|
+
* catches this instance's local store up to the just-read manifest via `materializeTo` — REQUIRED so
|
|
328
|
+
* a challenger that was behind (or a fresh instance that only `open()`'d) is fully materialized to
|
|
329
|
+
* the manifest's frontier BEFORE it starts committing under the claimed lease — then CAS-bumps
|
|
330
|
+
* `epoch` and stamps `writerId`/`leaseExpiresAt`.
|
|
331
|
+
*
|
|
332
|
+
* A `CasConflict` (lost the acquire race to a concurrent challenger) re-reads and returns
|
|
333
|
+
* `{acquired: false, ...}` — the caller may retry. Any OTHER `casManifest` failure is ambiguous
|
|
334
|
+
* (may have landed despite throwing — same lost-response concern as the commit path's C1 fix) and
|
|
335
|
+
* poisons this instance exactly like a commit/heartbeat CAS failure does (global constraint: lease
|
|
336
|
+
* CAS failures poison like commit CAS failures).
|
|
337
|
+
*/
|
|
338
|
+
async acquire(opts) {
|
|
339
|
+
return this.runExclusive(async () => {
|
|
340
|
+
const fresh = await readManifest(this.objectStore, this.shard);
|
|
341
|
+
if (fresh === null) {
|
|
342
|
+
throw new Error(`objectstore-substrate: acquire() found no manifest for shard '${this.shard}' \u2014 open() must initialize it first`);
|
|
343
|
+
}
|
|
344
|
+
const { manifest, etag } = fresh;
|
|
345
|
+
if (manifest.writerId !== "" && opts.now <= Number(manifest.leaseExpiresAt) && manifest.writerId !== opts.writerId) {
|
|
346
|
+
return { acquired: false, heldBy: manifest.writerId, expiresAt: Number(manifest.leaseExpiresAt) };
|
|
347
|
+
}
|
|
348
|
+
await this.materializeTo(manifest);
|
|
349
|
+
const burn = manifest.epoch > 0;
|
|
350
|
+
const next = {
|
|
351
|
+
...manifest,
|
|
352
|
+
epoch: manifest.epoch + 1,
|
|
353
|
+
writerId: opts.writerId,
|
|
354
|
+
leaseExpiresAt: String(opts.now + opts.leaseTtlMs),
|
|
355
|
+
nextSeqno: burn ? manifest.nextSeqno + 1 : manifest.nextSeqno
|
|
356
|
+
};
|
|
357
|
+
try {
|
|
358
|
+
const { etag: newEtag } = await casManifest(this.objectStore, this.shard, next, etag);
|
|
359
|
+
this.cached = { manifest: next, etag: newEtag };
|
|
360
|
+
this.held = { epoch: next.epoch, writerId: opts.writerId };
|
|
361
|
+
this.poisoned = false;
|
|
362
|
+
this.nextSeqno = next.nextSeqno;
|
|
363
|
+
return { acquired: true };
|
|
364
|
+
} catch (e) {
|
|
365
|
+
if (!isCasConflict2(e)) {
|
|
366
|
+
this.poisoned = true;
|
|
367
|
+
throw e;
|
|
368
|
+
}
|
|
369
|
+
const reread = await readManifest(this.objectStore, this.shard);
|
|
370
|
+
if (reread === null) throw e;
|
|
371
|
+
return { acquired: false, heldBy: reread.manifest.writerId, expiresAt: Number(reread.manifest.leaseExpiresAt) };
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Renew the currently-held lease's `leaseExpiresAt` (Tier 3 Slice 4, Task 4.2) — `epoch`/`writerId`
|
|
377
|
+
* are carried forward UNCHANGED (a heartbeat is not a re-claim). Requires `this.held` (throws a
|
|
378
|
+
* clear error if `acquire()` was never called) and refuses on an already-`poisoned` instance.
|
|
379
|
+
*
|
|
380
|
+
* ANY `casManifest` failure means the manifest moved out from under this instance — a challenger
|
|
381
|
+
* bumped `epoch` (fenced us) or is mid-fence — so this ALWAYS poisons and throws `FencedError`,
|
|
382
|
+
* unlike the commit path's CasConflict-vs-ambiguous-error distinction: a heartbeat's whole job is to
|
|
383
|
+
* detect exactly this condition, so there is no "retry" case to special-case.
|
|
384
|
+
*/
|
|
385
|
+
async heartbeat(opts) {
|
|
386
|
+
return this.runExclusive(async () => {
|
|
387
|
+
if (this.held === null) {
|
|
388
|
+
throw new FencedError(
|
|
389
|
+
`heartbeat on a store with no held lease for shard '${this.shard}' \u2014 ownership is lost (never acquired or already fenced)`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (this.poisoned) {
|
|
393
|
+
throw new FencedError(
|
|
394
|
+
`heartbeat on a poisoned store for shard '${this.shard}' (fenced or a prior apply failed) \u2014 ownership is lost`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const next = { ...this.cached.manifest, leaseExpiresAt: String(opts.now + opts.leaseTtlMs) };
|
|
398
|
+
try {
|
|
399
|
+
const { etag } = await casManifest(this.objectStore, this.shard, next, this.cached.etag);
|
|
400
|
+
this.cached = { manifest: next, etag };
|
|
401
|
+
} catch (e) {
|
|
402
|
+
this.poisoned = true;
|
|
403
|
+
this.held = null;
|
|
404
|
+
throw new FencedError(
|
|
405
|
+
`heartbeat fenced: manifest for shard '${this.shard}' moved (stale etag) \u2014 this writer is no longer current and is now poisoned (re-acquire to continue). Cause: ${e?.message ?? String(e)}`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/** Voluntarily give up the held lease WITHOUT touching the bucket — the lease simply expires on its
|
|
411
|
+
* own at `leaseExpiresAt` (Tier 3 Slice 4, Task 4.2). After this, `commitWriteBatch` refuses until
|
|
412
|
+
* a fresh `acquire()`. IN-PROCESS ONLY: a challenger's `acquire()` still has to wait out the full
|
|
413
|
+
* remaining TTL, since nothing in the bucket changed. See `relinquish()` — the graceful-shutdown
|
|
414
|
+
* variant that ALSO clears the lease in the bucket itself, so a challenger can take over
|
|
415
|
+
* immediately instead of waiting for expiry (Tier 3 Slice 6, Task 6.5). */
|
|
416
|
+
release() {
|
|
417
|
+
this.held = null;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Graceful-shutdown variant of `release()` (Tier 3 Slice 6, Task 6.5): best-effort CAS the manifest
|
|
421
|
+
* to clear the lease (`writerId: "", leaseExpiresAt: "0"`) so a challenger's very next `acquire()`
|
|
422
|
+
* — even at `now: 0` — sees an unowned/already-expired lease and takes over IMMEDIATELY, instead of
|
|
423
|
+
* having to wait out this writer's full remaining `leaseTtlMs`. This closes the production gap the
|
|
424
|
+
* Task 6.4 review found: every graceful rolling-deploy stop/start pair otherwise ate a full-TTL
|
|
425
|
+
* write outage.
|
|
426
|
+
*
|
|
427
|
+
* Deliberately best-effort: a CAS failure here means either (a) we were already fenced — someone
|
|
428
|
+
* else owns the shard now, which is exactly the state we wanted anyway, or (b) a transient blip —
|
|
429
|
+
* the lease still expires naturally, falling back to the pre-6.5 behavior. Neither case should
|
|
430
|
+
* block or fail a clean shutdown, so any CAS error is swallowed. Does NOT bump `epoch` — clearing
|
|
431
|
+
* `writerId`/`leaseExpiresAt` is sufficient for a challenger's refusal check to pass trivially; the
|
|
432
|
+
* challenger's own `acquire()` bumps `epoch` and fences any stale in-process state on ITS claim, the
|
|
433
|
+
* same as an expiry-based takeover always has. Does NOT poison on a swallowed CAS failure — this is
|
|
434
|
+
* a clean voluntary exit, not a fence.
|
|
435
|
+
*
|
|
436
|
+
* Runs under `runExclusive` (serializes with commits/heartbeat/acquire, same as every other
|
|
437
|
+
* lease/commit operation on this instance). A no-op if this instance never held the lease
|
|
438
|
+
* (`this.held === null` — already fenced, or `acquire()` was never called): nothing to relinquish.
|
|
439
|
+
* Always demotes `this.held` to `null` at the end, regardless of the CAS outcome.
|
|
440
|
+
*/
|
|
441
|
+
async relinquish() {
|
|
442
|
+
return this.runExclusive(async () => {
|
|
443
|
+
if (this.held === null) return;
|
|
444
|
+
const next = { ...this.cached.manifest, writerId: "", leaseExpiresAt: "0" };
|
|
445
|
+
try {
|
|
446
|
+
const { etag } = await casManifest(this.objectStore, this.shard, next, this.cached.etag);
|
|
447
|
+
this.cached = { manifest: next, etag };
|
|
448
|
+
} catch {
|
|
449
|
+
}
|
|
450
|
+
this.held = null;
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
/** Chain `fn` onto the mutex so commits from this process serialize (see class doc). */
|
|
454
|
+
runExclusive(fn) {
|
|
455
|
+
const result = this.mutex.then(fn, fn);
|
|
456
|
+
this.mutex = result.then(
|
|
457
|
+
() => void 0,
|
|
458
|
+
() => void 0
|
|
459
|
+
);
|
|
460
|
+
return result;
|
|
461
|
+
}
|
|
462
|
+
// ── Commit path (intercepted, object-first) ─────────────────────────────────────────────────
|
|
463
|
+
async commitWriteBatch(units, shardId) {
|
|
464
|
+
const tsList = await this.runExclusive(async () => {
|
|
465
|
+
if (this.held === null) {
|
|
466
|
+
throw new Error(`ObjectStoreDocStore for shard '${this.shard}': not the lease owner \u2014 call acquire() before committing`);
|
|
467
|
+
}
|
|
468
|
+
if (units.length === 0) return [];
|
|
469
|
+
if (this.poisoned) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
`ObjectStoreDocStore for shard '${this.shard}' is poisoned (a prior post-commit local apply failed); it must be re-opened before further use`
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
if (this.cached.manifest.epoch !== this.held.epoch) {
|
|
475
|
+
this.poisoned = true;
|
|
476
|
+
throw new FencedError(
|
|
477
|
+
`commit fenced: shard '${this.shard}' cached epoch (${this.cached.manifest.epoch}) diverges from the held lease epoch (${this.held.epoch}) \u2014 this writer was fenced by another acquirer and is now poisoned (re-acquire to continue)`
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
const localMax = await this.local.maxTimestamp();
|
|
481
|
+
const cachedTsCounter = BigInt(this.cached.manifest.tsCounter);
|
|
482
|
+
const floor = cachedTsCounter > localMax ? cachedTsCounter : localMax;
|
|
483
|
+
const stampedUnits = [];
|
|
484
|
+
const allDocuments = [];
|
|
485
|
+
const allIndexUpdates = [];
|
|
486
|
+
const tsList2 = [];
|
|
487
|
+
for (let i = 0; i < units.length; i++) {
|
|
488
|
+
const unit = units[i];
|
|
489
|
+
const ts = floor + BigInt(i) + 1n;
|
|
490
|
+
tsList2.push(ts);
|
|
491
|
+
const stampedDocs = unit.documents.map((d) => ({ ...d, ts }));
|
|
492
|
+
const stampedIdx = unit.indexUpdates.map((w) => ({ ...w, ts }));
|
|
493
|
+
stampedUnits.push({ documents: stampedDocs, indexUpdates: stampedIdx });
|
|
494
|
+
allDocuments.push(...stampedDocs);
|
|
495
|
+
allIndexUpdates.push(...stampedIdx);
|
|
496
|
+
}
|
|
497
|
+
const maxTs = tsList2[tsList2.length - 1];
|
|
498
|
+
const seqno = this.nextSeqno;
|
|
499
|
+
const payload = { documents: allDocuments, indexUpdates: allIndexUpdates };
|
|
500
|
+
await this.objectStore.putImmutable(segmentKey(this.shard, seqno), encodeSegment(payload));
|
|
501
|
+
const next = {
|
|
502
|
+
...this.cached.manifest,
|
|
503
|
+
epoch: this.held.epoch,
|
|
504
|
+
frontierTs: maxTs.toString(),
|
|
505
|
+
tsCounter: maxTs.toString(),
|
|
506
|
+
segments: [...this.cached.manifest.segments, seqno],
|
|
507
|
+
nextSeqno: seqno + 1
|
|
508
|
+
};
|
|
509
|
+
let etag;
|
|
510
|
+
try {
|
|
511
|
+
({ etag } = await casManifest(this.objectStore, this.shard, next, this.cached.etag));
|
|
512
|
+
} catch (e) {
|
|
513
|
+
this.poisoned = true;
|
|
514
|
+
if (isCasConflict2(e)) {
|
|
515
|
+
this.held = null;
|
|
516
|
+
throw new FencedError(
|
|
517
|
+
`commit fenced: manifest for shard '${this.shard}' moved (stale etag) \u2014 this writer is no longer current and is now poisoned (re-open to continue)`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
throw e;
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
for (const u of stampedUnits) {
|
|
524
|
+
await this.local.write(u.documents, u.indexUpdates, "Overwrite", shardId);
|
|
525
|
+
}
|
|
526
|
+
} catch (e) {
|
|
527
|
+
this.poisoned = true;
|
|
528
|
+
throw new Error(
|
|
529
|
+
`post-commit local apply failed after a DURABLE commit to shard '${this.shard}' (seqno ${seqno}); the commit is durable but this ObjectStoreDocStore's local materialization is inconsistent and must be re-opened. Cause: ${e?.message ?? String(e)}`
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
this.cached = { manifest: next, etag };
|
|
533
|
+
this.nextSeqno = seqno + 1;
|
|
534
|
+
this.committedSegmentsSinceSnapshot++;
|
|
535
|
+
return tsList2;
|
|
536
|
+
});
|
|
537
|
+
await this.#maybeSnapshotBestEffort();
|
|
538
|
+
return tsList;
|
|
539
|
+
}
|
|
540
|
+
async commitWrite(documents, indexUpdates, shardId, opts) {
|
|
541
|
+
const out = await this.commitWriteBatch([{ documents, indexUpdates, meta: opts?.meta }], shardId);
|
|
542
|
+
return out[0];
|
|
543
|
+
}
|
|
544
|
+
// ── Snapshots (Tier 3 Slice 3, Task 3.2) — object-first, under the commit mutex ─────────────
|
|
545
|
+
/**
|
|
546
|
+
* Take a snapshot of the local store's current state and CAS the manifest to reference it, so a
|
|
547
|
+
* future `open` can bootstrap in O(state + tail) instead of replaying the whole segment log.
|
|
548
|
+
* Serializes against commits via `runExclusive` — same reasoning as `commitWriteBatch`: it reads
|
|
549
|
+
* `cached`/`nextSeqno` and CAS's the manifest, so it must not race a concurrent commit's CAS
|
|
550
|
+
* against the same cached etag.
|
|
551
|
+
*
|
|
552
|
+
* DEADLOCK HAZARD: `runExclusive` is NOT reentrant. Never call `snapshot()`/`maybeSnapshot()` from
|
|
553
|
+
* INSIDE another `runExclusive` body (e.g. the commit path's own exclusive block) — chain it AFTER
|
|
554
|
+
* that block resolves instead (see `commitWriteBatch`'s `#maybeSnapshotBestEffort` call site).
|
|
555
|
+
*
|
|
556
|
+
* BENIGN DIVERGENCE (whole-branch review, Minor #2): if this snapshot's boundary (`frontierTs`) is
|
|
557
|
+
* a DELETE's ts, the dump it captures (`dumpCurrentState` excludes tombstones) has no row at that
|
|
558
|
+
* ts — see the matching note at `open()`'s snapshot-restore site for the full explanation and why
|
|
559
|
+
* it's safe (the `tsCounter` commit floor, not `local.maxTimestamp()`, is what guards ts reuse).
|
|
560
|
+
*/
|
|
561
|
+
async snapshot() {
|
|
562
|
+
return this.runExclusive(async () => {
|
|
563
|
+
if (this.poisoned) {
|
|
564
|
+
throw new Error(
|
|
565
|
+
`ObjectStoreDocStore for shard '${this.shard}' is poisoned (a prior post-commit local apply failed); it must be re-opened before further use`
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
if (this.nextSeqno === 0) return;
|
|
569
|
+
const dump = await this.local.dumpCurrentState();
|
|
570
|
+
const frontierTs = this.cached.manifest.frontierTs;
|
|
571
|
+
const segBase = this.nextSeqno - 1;
|
|
572
|
+
const payload = { frontierTs, segBase, documents: dump.documents, indexUpdates: dump.indexUpdates };
|
|
573
|
+
await writeSnapshot(this.objectStore, this.shard, payload);
|
|
574
|
+
const next = {
|
|
575
|
+
...this.cached.manifest,
|
|
576
|
+
snapshotTs: frontierTs,
|
|
577
|
+
snapshotSegBase: segBase,
|
|
578
|
+
segments: this.cached.manifest.segments.filter((s) => s > segBase)
|
|
579
|
+
};
|
|
580
|
+
try {
|
|
581
|
+
const { etag } = await casManifest(this.objectStore, this.shard, next, this.cached.etag);
|
|
582
|
+
this.cached = { manifest: next, etag };
|
|
583
|
+
} catch (e) {
|
|
584
|
+
this.poisoned = true;
|
|
585
|
+
if (isCasConflict2(e)) {
|
|
586
|
+
throw new FencedError(
|
|
587
|
+
`snapshot fenced: manifest for shard '${this.shard}' moved (stale etag) \u2014 this writer is no longer current and is now poisoned (re-open to continue)`
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
throw e;
|
|
591
|
+
}
|
|
592
|
+
this.committedSegmentsSinceSnapshot = 0;
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/** Take a snapshot if `SNAPSHOT_EVERY` segments have committed since the last one. No-op
|
|
596
|
+
* (including a poisoned instance — the next commit/explicit `snapshot()` call will surface that
|
|
597
|
+
* loudly) otherwise. */
|
|
598
|
+
async maybeSnapshot() {
|
|
599
|
+
if (this.poisoned) return;
|
|
600
|
+
if (this.committedSegmentsSinceSnapshot < SNAPSHOT_EVERY) return;
|
|
601
|
+
await this.snapshot();
|
|
602
|
+
}
|
|
603
|
+
/** `maybeSnapshot()`, swallowing any error — called from OUTSIDE the commit's exclusive block
|
|
604
|
+
* (see `commitWriteBatch`). A snapshot failure must never fail an already-durable commit; if it
|
|
605
|
+
* poisoned this instance, the next commit's own poisoned-check surfaces that loudly instead. */
|
|
606
|
+
async #maybeSnapshotBestEffort() {
|
|
607
|
+
try {
|
|
608
|
+
await this.maybeSnapshot();
|
|
609
|
+
} catch {
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// ── GC (Tier 3 Slice 3, Task 3.3) — reclaim what the latest snapshot has superseded ─────────
|
|
613
|
+
/**
|
|
614
|
+
* Reclaim durable objects the CURRENT manifest's snapshot (and every registered consumer's
|
|
615
|
+
* watermark) has superseded: every segment with `seqno <= min(snapshotSegBase, W_min)` (where
|
|
616
|
+
* `W_min` is the SLOWEST published `s{shard}/consumers/{id}` watermark's `appliedSeqno` — `+Infinity` when
|
|
617
|
+
* no consumers are registered, so the floor collapses back to plain `snapshotSegBase`, byte-for-
|
|
618
|
+
* byte the Slice 3 behavior) and every snapshot object except the current one (`snapshotTs`) — the
|
|
619
|
+
* newest snapshot is always kept regardless of `W_min` (a replica re-materializing after falling
|
|
620
|
+
* behind the tail restores the NEWEST snapshot via `materializeTo`'s fallback path, never an older
|
|
621
|
+
* one, so keeping only the newest is correct independent of any watermark).
|
|
622
|
+
*
|
|
623
|
+
* The watermark floor is the Tier 3 Slice 5, Task 5.2 addition (closing the Slice 3/4 GC-under-
|
|
624
|
+
* replicas deferral): a lagging replica's `ObjectStoreReplicaTailer` publishes its own
|
|
625
|
+
* `appliedSeqno` via `publishConsumerWatermark`; as long as it hasn't fallen behind
|
|
626
|
+
* `snapshotSegBase` itself (its own snapshot-fallback backstop covers that case — see
|
|
627
|
+
* `replica-tailer.ts`), `gc()` here will never delete a segment `(W_min, snapshotSegBase]` that
|
|
628
|
+
* replica still needs to tail.
|
|
629
|
+
*
|
|
630
|
+
* Runs under `runExclusive` — it must not race a concurrent commit/snapshot/acquire on THIS
|
|
631
|
+
* instance. Never touches the manifest itself or `this.cached`/`nextSeqno` (beyond adopting a
|
|
632
|
+
* freshly re-read manifest when the epoch check below passes) — GC is purely subtractive over
|
|
633
|
+
* objects the manifest (and consumers) no longer need.
|
|
634
|
+
*
|
|
635
|
+
* PERFORMANCE NOTE (whole-branch review, Minor #4): because it runs under `runExclusive`, `gc()`
|
|
636
|
+
* blocks `commitWriteBatch`/`snapshot()` on this instance for the ENTIRE list+delete sweep (now
|
|
637
|
+
* also a `consumers/` list+get sweep) — fine for a manual, occasional, single-node call, but revisit
|
|
638
|
+
* the locking granularity if GC's cadence ever needs to tighten.
|
|
639
|
+
*
|
|
640
|
+
* GC-FENCING (Tier 3 Slice 7, Task 7.1 — closes the Slice-4/5/6 deferral above): `gc()` used to
|
|
641
|
+
* trust `this.cached.manifest` — a snapshot pointer that can be ARBITRARILY STALE the instant a
|
|
642
|
+
* challenger fences this writer (bumps `epoch`) without this instance having attempted a
|
|
643
|
+
* commit/heartbeat since. A stale writer's cached `snapshotTs` then names an OLD snapshot, and the
|
|
644
|
+
* pre-Slice-7 "delete every `snap/*` except my cached `snapshotTs`" predicate would delete the NEW
|
|
645
|
+
* owner's live snapshot — sinking that owner's next bootstrap. Fixed by two changes, in order:
|
|
646
|
+
* 1. `this.held === null` (never an owner — a replica, or an already-demoted/fenced writer) is a
|
|
647
|
+
* harmless no-op: never GC.
|
|
648
|
+
* 2. RE-READ the manifest fresh (never trust `this.cached`) and compare its `epoch` against
|
|
649
|
+
* `this.held.epoch`. A mismatch means a challenger's `acquire()` landed since we last knew —
|
|
650
|
+
* we've been fenced RIGHT NOW, even though nothing told us yet — so we poison + demote and
|
|
651
|
+
* delete NOTHING. Only once the epoch matches do we adopt the fresh manifest as current truth
|
|
652
|
+
* and compute the delete floor/keepSnap FROM IT (never from a value read before this check).
|
|
653
|
+
* This closes the TOCTOU window at the READ side, but a fence could still land in the gap BETWEEN
|
|
654
|
+
* this re-read and the deletes below (the sweep isn't atomic with the epoch check) — so the delete
|
|
655
|
+
* PREDICATES themselves must independently tolerate a same-gap fence:
|
|
656
|
+
* - Segments: `seqno <= floor` (unchanged) — a new owner's commits only ever land at HIGHER
|
|
657
|
+
* seqnos than the frontier this floor was computed from, so this predicate can never reach one.
|
|
658
|
+
* - Snapshots: **`BigInt(ts) < BigInt(keepSnap)`** (strictly older), not "every snapshot except
|
|
659
|
+
* keepSnap" — a new owner racing a snapshot into the gap produces a `ts` NEWER than our
|
|
660
|
+
* `keepSnap` (ts's are monotone `frontierTs` values), which `<` by construction never matches.
|
|
661
|
+
* `keepSnap` itself is also never deleted (not `<` itself). Compared as `bigint`, not string —
|
|
662
|
+
* ts's are decimal-string bigints, and a naive string compare orders `"9"` after `"10"`.
|
|
663
|
+
* Together: a fenced/stale writer's `gc()` deletes nothing (the epoch check catches the common case,
|
|
664
|
+
* and the TOCTOU-safe predicates catch the rest), while the happy-path owner's behavior is unchanged
|
|
665
|
+
* (its own re-read always matches its own held epoch).
|
|
666
|
+
*/
|
|
667
|
+
async gc() {
|
|
668
|
+
return this.runExclusive(async () => {
|
|
669
|
+
if (this.poisoned) {
|
|
670
|
+
throw new Error(
|
|
671
|
+
`ObjectStoreDocStore for shard '${this.shard}' is poisoned (a prior post-commit local apply failed); it must be re-opened before further use`
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
if (this.held === null) {
|
|
675
|
+
return { deletedSegments: 0, deletedSnapshots: 0 };
|
|
676
|
+
}
|
|
677
|
+
const fresh = await readManifest(this.objectStore, this.shard);
|
|
678
|
+
if (fresh === null) {
|
|
679
|
+
throw new Error(`objectstore-substrate: gc() found no manifest for shard '${this.shard}' \u2014 open() must initialize it first`);
|
|
680
|
+
}
|
|
681
|
+
if (fresh.manifest.epoch !== this.held.epoch) {
|
|
682
|
+
this.poisoned = true;
|
|
683
|
+
this.held = null;
|
|
684
|
+
return { deletedSegments: 0, deletedSnapshots: 0 };
|
|
685
|
+
}
|
|
686
|
+
this.cached = fresh;
|
|
687
|
+
if (fresh.manifest.snapshotTs === void 0) {
|
|
688
|
+
return { deletedSegments: 0, deletedSnapshots: 0 };
|
|
689
|
+
}
|
|
690
|
+
const segBase = fresh.manifest.snapshotSegBase;
|
|
691
|
+
const keepSnap = fresh.manifest.snapshotTs;
|
|
692
|
+
const keepSnapBig = BigInt(keepSnap);
|
|
693
|
+
const watermarks = await readConsumerWatermarks(this.objectStore, this.shard);
|
|
694
|
+
const wMin = watermarks.length > 0 ? Math.min(...watermarks.map((w) => w.appliedSeqno)) : Number.POSITIVE_INFINITY;
|
|
695
|
+
const floor = Math.min(segBase, wMin);
|
|
696
|
+
const segPrefix = `s${this.shard}/seg/`;
|
|
697
|
+
const segKeys = await this.objectStore.list(segPrefix);
|
|
698
|
+
let deletedSegments = 0;
|
|
699
|
+
for (const key of segKeys) {
|
|
700
|
+
const suffix = key.slice(key.lastIndexOf("/") + 1);
|
|
701
|
+
const seqno = Number(suffix);
|
|
702
|
+
if (!Number.isInteger(seqno)) continue;
|
|
703
|
+
if (seqno <= floor) {
|
|
704
|
+
await this.objectStore.delete(key);
|
|
705
|
+
deletedSegments++;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const snapPrefix = `s${this.shard}/snap/`;
|
|
709
|
+
const snapKeys = await this.objectStore.list(snapPrefix);
|
|
710
|
+
let deletedSnapshots = 0;
|
|
711
|
+
for (const key of snapKeys) {
|
|
712
|
+
const ts = key.slice(key.lastIndexOf("/") + 1);
|
|
713
|
+
if (!/^\d+$/.test(ts)) continue;
|
|
714
|
+
if (BigInt(ts) < keepSnapBig) {
|
|
715
|
+
await this.objectStore.delete(key);
|
|
716
|
+
deletedSnapshots++;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return { deletedSegments, deletedSnapshots };
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
// ── Everything else: forward to the local materialized store ───────────────────────────────
|
|
723
|
+
setupSchema(options) {
|
|
724
|
+
return this.local.setupSchema(options);
|
|
725
|
+
}
|
|
726
|
+
write(documents, indexUpdates, conflictStrategy, shardId) {
|
|
727
|
+
return this.local.write(documents, indexUpdates, conflictStrategy, shardId);
|
|
728
|
+
}
|
|
729
|
+
/** The materialized current state (Slice 5 — migration export). Delegates to the local SQLite
|
|
730
|
+
* store, which already holds the full materialized image the object log reduces to — the same
|
|
731
|
+
* source `snapshot()` dumps from. */
|
|
732
|
+
dumpCurrentState() {
|
|
733
|
+
return this.local.dumpCurrentState();
|
|
734
|
+
}
|
|
735
|
+
addCommitGuard(guard) {
|
|
736
|
+
return this.local.addCommitGuard(guard);
|
|
737
|
+
}
|
|
738
|
+
get(id, readTimestamp) {
|
|
739
|
+
return this.local.get(id, readTimestamp);
|
|
740
|
+
}
|
|
741
|
+
index_scan(indexId, tableId, readTimestamp, interval, order, limit) {
|
|
742
|
+
return this.local.index_scan(indexId, tableId, readTimestamp, interval, order, limit);
|
|
743
|
+
}
|
|
744
|
+
load_documents(range, order, limit) {
|
|
745
|
+
return this.local.load_documents(range, order, limit);
|
|
746
|
+
}
|
|
747
|
+
previous_revisions(queries) {
|
|
748
|
+
return this.local.previous_revisions(queries);
|
|
749
|
+
}
|
|
750
|
+
scan(tableId, readTimestamp) {
|
|
751
|
+
return this.local.scan(tableId, readTimestamp);
|
|
752
|
+
}
|
|
753
|
+
count(tableId) {
|
|
754
|
+
return this.local.count(tableId);
|
|
755
|
+
}
|
|
756
|
+
maxTimestamp() {
|
|
757
|
+
return this.local.maxTimestamp();
|
|
758
|
+
}
|
|
759
|
+
getGlobal(key) {
|
|
760
|
+
return this.local.getGlobal(key);
|
|
761
|
+
}
|
|
762
|
+
writeGlobal(key, value) {
|
|
763
|
+
return this.local.writeGlobal(key, value);
|
|
764
|
+
}
|
|
765
|
+
writeGlobalIfAbsent(key, value) {
|
|
766
|
+
return this.local.writeGlobalIfAbsent(key, value);
|
|
767
|
+
}
|
|
768
|
+
getClientVerdict(identity, clientId, seq) {
|
|
769
|
+
return this.local.getClientVerdict(identity, clientId, seq);
|
|
770
|
+
}
|
|
771
|
+
getClientFloor(identity, clientId) {
|
|
772
|
+
return this.local.getClientFloor(identity, clientId);
|
|
773
|
+
}
|
|
774
|
+
recordClientVerdict(identity, clientId, seq, record) {
|
|
775
|
+
return this.local.recordClientVerdict(identity, clientId, seq, record);
|
|
776
|
+
}
|
|
777
|
+
updateClientVerdictValue(identity, clientId, seq, value) {
|
|
778
|
+
return this.local.updateClientVerdictValue(identity, clientId, seq, value);
|
|
779
|
+
}
|
|
780
|
+
pruneClientMutations(identity, clientId, opts) {
|
|
781
|
+
return this.local.pruneClientMutations(identity, clientId, opts);
|
|
782
|
+
}
|
|
783
|
+
sweepExpiredClientMutations(beforeMs) {
|
|
784
|
+
return this.local.sweepExpiredClientMutations(beforeMs);
|
|
785
|
+
}
|
|
786
|
+
close() {
|
|
787
|
+
return this.local.close();
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
// src/sharded-object-doc-store.ts
|
|
792
|
+
import { DEFAULT_SHARD } from "@helipod/id-codec";
|
|
793
|
+
|
|
794
|
+
// src/merge-sorted.ts
|
|
795
|
+
function compareBytesLex(a, b) {
|
|
796
|
+
const len = Math.min(a.length, b.length);
|
|
797
|
+
for (let i = 0; i < len; i++) {
|
|
798
|
+
const diff = a[i] - b[i];
|
|
799
|
+
if (diff !== 0) return diff;
|
|
800
|
+
}
|
|
801
|
+
return a.length - b.length;
|
|
802
|
+
}
|
|
803
|
+
function compareBigint(a, b) {
|
|
804
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
805
|
+
}
|
|
806
|
+
async function* mergeSortedAsyncGenerators(sources, keyCompare, order, limit) {
|
|
807
|
+
const cursors = sources.map((gen) => ({ gen, current: void 0, done: false }));
|
|
808
|
+
async function advance(c) {
|
|
809
|
+
const r = await c.gen.next();
|
|
810
|
+
if (r.done) {
|
|
811
|
+
c.done = true;
|
|
812
|
+
c.current = void 0;
|
|
813
|
+
} else {
|
|
814
|
+
c.current = r.value;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
await Promise.all(cursors.map((c) => advance(c)));
|
|
819
|
+
let yielded = 0;
|
|
820
|
+
for (; ; ) {
|
|
821
|
+
if (limit !== void 0 && yielded >= limit) return;
|
|
822
|
+
let bestIdx = -1;
|
|
823
|
+
for (let i = 0; i < cursors.length; i++) {
|
|
824
|
+
const c = cursors[i];
|
|
825
|
+
if (c.done) continue;
|
|
826
|
+
if (bestIdx === -1) {
|
|
827
|
+
bestIdx = i;
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const cmp = keyCompare(c.current, cursors[bestIdx].current);
|
|
831
|
+
if (order === "asc" ? cmp < 0 : cmp > 0) bestIdx = i;
|
|
832
|
+
}
|
|
833
|
+
if (bestIdx === -1) return;
|
|
834
|
+
const winner = cursors[bestIdx];
|
|
835
|
+
yield winner.current;
|
|
836
|
+
yielded++;
|
|
837
|
+
await advance(winner);
|
|
838
|
+
}
|
|
839
|
+
} finally {
|
|
840
|
+
await Promise.all(
|
|
841
|
+
cursors.filter((c) => !c.done).map(async (c) => {
|
|
842
|
+
try {
|
|
843
|
+
await c.gen.return(void 0);
|
|
844
|
+
} catch {
|
|
845
|
+
}
|
|
846
|
+
})
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// src/sharded-object-doc-store.ts
|
|
852
|
+
var ShardedObjectStoreDocStore = class {
|
|
853
|
+
lanes;
|
|
854
|
+
defaultShard;
|
|
855
|
+
constructor(lanes, opts) {
|
|
856
|
+
if (lanes.size === 0) {
|
|
857
|
+
throw new Error("ShardedObjectStoreDocStore: at least one lane is required");
|
|
858
|
+
}
|
|
859
|
+
const defaultShard = opts?.defaultShard ?? DEFAULT_SHARD;
|
|
860
|
+
if (!lanes.has(defaultShard)) {
|
|
861
|
+
throw new Error(
|
|
862
|
+
`ShardedObjectStoreDocStore: defaultShard '${defaultShard}' is not one of the composed lanes (${[...lanes.keys()].join(", ")})`
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
this.lanes = lanes;
|
|
866
|
+
this.defaultShard = defaultShard;
|
|
867
|
+
}
|
|
868
|
+
lane(shardId) {
|
|
869
|
+
const l = this.lanes.get(shardId);
|
|
870
|
+
if (!l) {
|
|
871
|
+
throw new Error(
|
|
872
|
+
`ShardedObjectStoreDocStore: unknown shard '${shardId}' \u2014 composed lanes are (${[...this.lanes.keys()].join(", ")})`
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
return l;
|
|
876
|
+
}
|
|
877
|
+
laneList() {
|
|
878
|
+
return [...this.lanes.values()];
|
|
879
|
+
}
|
|
880
|
+
// ── Writes — route by the caller-supplied shardId (the ShardedTransactor always passes one for
|
|
881
|
+
// commits; `write`'s replica/bootstrap callers do too — see `ObjectStoreDocStore.materializeTo`) ─
|
|
882
|
+
setupSchema(options) {
|
|
883
|
+
return Promise.all(this.laneList().map((l) => l.setupSchema(options))).then(() => void 0);
|
|
884
|
+
}
|
|
885
|
+
// NOTE: these three are deliberately `async` (not a bare `return this.lane(...)...`) so an
|
|
886
|
+
// unknown-shardId lookup failure — thrown SYNCHRONOUSLY by `this.lane()` — surfaces as a REJECTED
|
|
887
|
+
// promise, matching every other `DocStore` method's async contract, rather than throwing
|
|
888
|
+
// synchronously out of the call before the caller ever gets a promise to await/catch.
|
|
889
|
+
async write(documents, indexUpdates, conflictStrategy, shardId) {
|
|
890
|
+
return this.lane(shardId ?? this.defaultShard).write(documents, indexUpdates, conflictStrategy, shardId);
|
|
891
|
+
}
|
|
892
|
+
async commitWrite(documents, indexUpdates, shardId, opts) {
|
|
893
|
+
return this.lane(shardId ?? this.defaultShard).commitWrite(documents, indexUpdates, shardId, opts);
|
|
894
|
+
}
|
|
895
|
+
async commitWriteBatch(units, shardId) {
|
|
896
|
+
return this.lane(shardId ?? this.defaultShard).commitWriteBatch(units, shardId);
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Fan the SAME guard out to every lane (one registration per lane) — a guard for the sharded case
|
|
900
|
+
* therefore fences/effects PER LANE, not atomically across the whole sharded store. This matches
|
|
901
|
+
* `ObjectStoreDocStore`'s own single-lane note ("guard atomicity + effectively-once forwarding are a
|
|
902
|
+
* LATER slice") — composing N of them doesn't add a NEW atomicity gap, it just makes the existing
|
|
903
|
+
* per-lane-only scope explicit at the sharded layer too.
|
|
904
|
+
*/
|
|
905
|
+
addCommitGuard(guard) {
|
|
906
|
+
const unregs = this.laneList().map((l) => l.addCommitGuard(guard));
|
|
907
|
+
return () => {
|
|
908
|
+
for (const unreg of unregs) unreg();
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
// ── Reads — MERGE across every lane (the transactor never tells us which shard a read is for) ────
|
|
912
|
+
async get(id, readTimestamp) {
|
|
913
|
+
const hits = await Promise.all(this.laneList().map((l) => l.get(id, readTimestamp)));
|
|
914
|
+
for (const hit of hits) if (hit !== null) return hit;
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
async *index_scan(indexId, tableId, readTimestamp, interval, order, limit) {
|
|
918
|
+
const generators = this.laneList().map((l) => l.index_scan(indexId, tableId, readTimestamp, interval, order, limit));
|
|
919
|
+
yield* mergeSortedAsyncGenerators(generators, (a, b) => compareBytesLex(a[0], b[0]), order, limit);
|
|
920
|
+
}
|
|
921
|
+
async *load_documents(range, order, limit) {
|
|
922
|
+
const generators = this.laneList().map((l) => l.load_documents(range, order, limit));
|
|
923
|
+
yield* mergeSortedAsyncGenerators(generators, (a, b) => compareBigint(a.ts, b.ts), order, limit);
|
|
924
|
+
}
|
|
925
|
+
async previous_revisions(queries) {
|
|
926
|
+
const perLane = await Promise.all(this.laneList().map((l) => l.previous_revisions(queries)));
|
|
927
|
+
const merged = /* @__PURE__ */ new Map();
|
|
928
|
+
for (const laneResult of perLane) {
|
|
929
|
+
for (const [key, entry] of laneResult) merged.set(key, entry);
|
|
930
|
+
}
|
|
931
|
+
return merged;
|
|
932
|
+
}
|
|
933
|
+
async scan(tableId, readTimestamp) {
|
|
934
|
+
const perLane = await Promise.all(this.laneList().map((l) => l.scan(tableId, readTimestamp)));
|
|
935
|
+
return perLane.flat();
|
|
936
|
+
}
|
|
937
|
+
async count(tableId) {
|
|
938
|
+
const perLane = await Promise.all(this.laneList().map((l) => l.count(tableId)));
|
|
939
|
+
return perLane.reduce((sum, n) => sum + n, 0);
|
|
940
|
+
}
|
|
941
|
+
async maxTimestamp() {
|
|
942
|
+
const perLane = await Promise.all(this.laneList().map((l) => l.maxTimestamp()));
|
|
943
|
+
let max = 0n;
|
|
944
|
+
for (const ts of perLane) if (ts > max) max = ts;
|
|
945
|
+
return max;
|
|
946
|
+
}
|
|
947
|
+
// ── Deployment-level bookkeeping — route to ONE lane (the default shard) consistently, never
|
|
948
|
+
// fan out: these are single-source-of-truth concerns (fleet identity, per-client receipts), not
|
|
949
|
+
// per-shard data. Routing every deployment ever to the SAME lane means a client's receipts/floor
|
|
950
|
+
// are never split across lanes regardless of which table its mutations touched. ────────────────
|
|
951
|
+
getGlobal(key) {
|
|
952
|
+
return this.lane(this.defaultShard).getGlobal(key);
|
|
953
|
+
}
|
|
954
|
+
writeGlobal(key, value) {
|
|
955
|
+
return this.lane(this.defaultShard).writeGlobal(key, value);
|
|
956
|
+
}
|
|
957
|
+
writeGlobalIfAbsent(key, value) {
|
|
958
|
+
return this.lane(this.defaultShard).writeGlobalIfAbsent(key, value);
|
|
959
|
+
}
|
|
960
|
+
getClientVerdict(identity, clientId, seq) {
|
|
961
|
+
return this.lane(this.defaultShard).getClientVerdict(identity, clientId, seq);
|
|
962
|
+
}
|
|
963
|
+
getClientFloor(identity, clientId) {
|
|
964
|
+
return this.lane(this.defaultShard).getClientFloor(identity, clientId);
|
|
965
|
+
}
|
|
966
|
+
recordClientVerdict(identity, clientId, seq, record) {
|
|
967
|
+
return this.lane(this.defaultShard).recordClientVerdict(identity, clientId, seq, record);
|
|
968
|
+
}
|
|
969
|
+
updateClientVerdictValue(identity, clientId, seq, value) {
|
|
970
|
+
return this.lane(this.defaultShard).updateClientVerdictValue(identity, clientId, seq, value);
|
|
971
|
+
}
|
|
972
|
+
pruneClientMutations(identity, clientId, opts) {
|
|
973
|
+
return this.lane(this.defaultShard).pruneClientMutations(identity, clientId, opts);
|
|
974
|
+
}
|
|
975
|
+
sweepExpiredClientMutations(beforeMs) {
|
|
976
|
+
return this.lane(this.defaultShard).sweepExpiredClientMutations(beforeMs);
|
|
977
|
+
}
|
|
978
|
+
async close() {
|
|
979
|
+
await Promise.all(this.laneList().map((l) => l.close()));
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
// src/reshard.ts
|
|
984
|
+
import { shardIdList, shardIdForKeyValue, DEFAULT_SHARD as DEFAULT_SHARD2, documentIdKey as documentIdKey2 } from "@helipod/id-codec";
|
|
985
|
+
|
|
986
|
+
// src/globals.ts
|
|
987
|
+
import { isCasConflict as isCasConflict3 } from "@helipod/objectstore";
|
|
988
|
+
var GLOBALS_KEY = "globals";
|
|
989
|
+
async function readGlobals(os) {
|
|
990
|
+
const entry = await os.get(GLOBALS_KEY);
|
|
991
|
+
if (entry === null) return null;
|
|
992
|
+
return JSON.parse(new TextDecoder().decode(entry.body));
|
|
993
|
+
}
|
|
994
|
+
async function createGlobals(os, globals) {
|
|
995
|
+
await os.casPut(GLOBALS_KEY, new TextEncoder().encode(JSON.stringify(globals)), null);
|
|
996
|
+
return globals;
|
|
997
|
+
}
|
|
998
|
+
async function ensureGlobals(os, globals) {
|
|
999
|
+
const existing = await readGlobals(os);
|
|
1000
|
+
if (existing !== null) return existing;
|
|
1001
|
+
try {
|
|
1002
|
+
return await createGlobals(os, globals);
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
if (!isCasConflict3(e)) throw e;
|
|
1005
|
+
const winner = await readGlobals(os);
|
|
1006
|
+
if (winner === null) {
|
|
1007
|
+
throw new Error("objectstore-substrate: globals CasConflict but re-read found no globals object");
|
|
1008
|
+
}
|
|
1009
|
+
return winner;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
async function writeGlobals(os, globals) {
|
|
1013
|
+
const bytes = new TextEncoder().encode(JSON.stringify(globals));
|
|
1014
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1015
|
+
const existing = await os.get(GLOBALS_KEY);
|
|
1016
|
+
try {
|
|
1017
|
+
await os.casPut(GLOBALS_KEY, bytes, existing === null ? null : existing.etag);
|
|
1018
|
+
return;
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
if (!isCasConflict3(e)) throw e;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
throw new Error("objectstore-substrate: writeGlobals exhausted retries on a moving globals object");
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/reshard.ts
|
|
1027
|
+
var ReshardObjectStoreLiveError = class extends Error {
|
|
1028
|
+
constructor(message) {
|
|
1029
|
+
super(message);
|
|
1030
|
+
this.name = "ReshardObjectStoreLiveError";
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
function laneIdsFor(numShards) {
|
|
1034
|
+
return numShards === 1 ? ["0"] : [...shardIdList(numShards)];
|
|
1035
|
+
}
|
|
1036
|
+
function pushTo(map, key, value) {
|
|
1037
|
+
const arr = map.get(key);
|
|
1038
|
+
if (arr) arr.push(value);
|
|
1039
|
+
else map.set(key, [value]);
|
|
1040
|
+
}
|
|
1041
|
+
async function reshardObjectStore(opts) {
|
|
1042
|
+
const { objectStore: os, toShards, now, shardKeyFor, makeLocal } = opts;
|
|
1043
|
+
if (!Number.isInteger(toShards) || toShards < 1) {
|
|
1044
|
+
throw new RangeError(`reshardObjectStore: toShards must be a positive integer, got ${toShards}`);
|
|
1045
|
+
}
|
|
1046
|
+
const globals = await readGlobals(os);
|
|
1047
|
+
if (globals === null) {
|
|
1048
|
+
throw new Error("reshardObjectStore: no `globals` object \u2014 this bucket is not an object-storage deployment");
|
|
1049
|
+
}
|
|
1050
|
+
const fromShards = globals.numShards;
|
|
1051
|
+
if (fromShards === toShards) {
|
|
1052
|
+
return { fromShards, toShards, movedDocs: 0, perLaneCounts: {} };
|
|
1053
|
+
}
|
|
1054
|
+
const sourceLaneIds = laneIdsFor(fromShards);
|
|
1055
|
+
const targetLaneIds = laneIdsFor(toShards);
|
|
1056
|
+
const toBucketLane = (engineShard) => toShards === 1 ? "0" : engineShard;
|
|
1057
|
+
for (const shard of sourceLaneIds) {
|
|
1058
|
+
const m = await readManifest(os, shard);
|
|
1059
|
+
if (m !== null && m.manifest.writerId !== "" && now <= Number(m.manifest.leaseExpiresAt)) {
|
|
1060
|
+
throw new ReshardObjectStoreLiveError(
|
|
1061
|
+
`reshardObjectStore: refusing \u2014 lane '${shard}' has a live lease held by '${m.manifest.writerId}' (expires at ${m.manifest.leaseExpiresAt}, now ${now}). Stop the deployment (scale to zero / let leases expire) first.`
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
const allDocs = [];
|
|
1066
|
+
const allIndex = [];
|
|
1067
|
+
const oldLaneOfDoc = /* @__PURE__ */ new Map();
|
|
1068
|
+
for (const shard of sourceLaneIds) {
|
|
1069
|
+
const local = makeLocal();
|
|
1070
|
+
await ObjectStoreDocStore.open({ objectStore: os, shard, local });
|
|
1071
|
+
const state = await local.dumpCurrentState();
|
|
1072
|
+
for (const d of state.documents) {
|
|
1073
|
+
allDocs.push(d);
|
|
1074
|
+
oldLaneOfDoc.set(documentIdKey2(d.id), shard);
|
|
1075
|
+
}
|
|
1076
|
+
for (const iw of state.indexUpdates) allIndex.push(iw);
|
|
1077
|
+
await local.close();
|
|
1078
|
+
}
|
|
1079
|
+
const laneToDocs = /* @__PURE__ */ new Map();
|
|
1080
|
+
const newLaneOfDoc = /* @__PURE__ */ new Map();
|
|
1081
|
+
let movedDocs = 0;
|
|
1082
|
+
for (const d of allDocs) {
|
|
1083
|
+
const shardKey = shardKeyFor(d.id.tableNumber);
|
|
1084
|
+
const fields = d.value?.value;
|
|
1085
|
+
const engineShard = shardKey !== null && fields !== void 0 ? shardIdForKeyValue(fields[shardKey], toShards) : DEFAULT_SHARD2;
|
|
1086
|
+
const newLane = toBucketLane(engineShard);
|
|
1087
|
+
const key = documentIdKey2(d.id);
|
|
1088
|
+
newLaneOfDoc.set(key, newLane);
|
|
1089
|
+
if (oldLaneOfDoc.get(key) !== newLane) movedDocs++;
|
|
1090
|
+
pushTo(laneToDocs, newLane, d);
|
|
1091
|
+
}
|
|
1092
|
+
const laneToIndex = /* @__PURE__ */ new Map();
|
|
1093
|
+
for (const iw of allIndex) {
|
|
1094
|
+
if (iw.update.value.type !== "NonClustered") continue;
|
|
1095
|
+
const lane = newLaneOfDoc.get(documentIdKey2(iw.update.value.docId));
|
|
1096
|
+
if (lane === void 0) continue;
|
|
1097
|
+
pushTo(laneToIndex, lane, iw);
|
|
1098
|
+
}
|
|
1099
|
+
const lanesToClear = /* @__PURE__ */ new Set([...sourceLaneIds, ...targetLaneIds]);
|
|
1100
|
+
for (const shard of lanesToClear) {
|
|
1101
|
+
for (const k of await os.list(`s${shard}/`)) await os.delete(k);
|
|
1102
|
+
}
|
|
1103
|
+
const perLaneCounts = {};
|
|
1104
|
+
for (const shard of targetLaneIds) {
|
|
1105
|
+
const docs = laneToDocs.get(shard) ?? [];
|
|
1106
|
+
const index = laneToIndex.get(shard) ?? [];
|
|
1107
|
+
perLaneCounts[shard] = docs.length;
|
|
1108
|
+
const local = makeLocal();
|
|
1109
|
+
const store = await ObjectStoreDocStore.open({ objectStore: os, shard, local });
|
|
1110
|
+
await store.acquire({ writerId: `reshard-${shard}`, leaseTtlMs: 6e4, now });
|
|
1111
|
+
if (docs.length > 0 || index.length > 0) {
|
|
1112
|
+
await store.commitWriteBatch([{ documents: docs, indexUpdates: index }], shard);
|
|
1113
|
+
}
|
|
1114
|
+
await store.relinquish();
|
|
1115
|
+
await local.close();
|
|
1116
|
+
}
|
|
1117
|
+
await writeGlobals(os, { deploymentId: globals.deploymentId, numShards: toShards });
|
|
1118
|
+
return { fromShards, toShards, movedDocs, perLaneCounts };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// src/heartbeat-driver.ts
|
|
1122
|
+
function leaseHeartbeatDriver(store, opts) {
|
|
1123
|
+
const { leaseTtlMs, heartbeatMs, onFenced } = opts;
|
|
1124
|
+
if (heartbeatMs >= leaseTtlMs) {
|
|
1125
|
+
throw new Error(
|
|
1126
|
+
`objectstore-substrate: leaseHeartbeatDriver requires heartbeatMs (${heartbeatMs}) < leaseTtlMs (${leaseTtlMs}) \u2014 a heartbeat cadence at or slower than the lease TTL can let the lease lapse before a renewal ever lands`
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
let ctx;
|
|
1130
|
+
let timer = null;
|
|
1131
|
+
let stopped = false;
|
|
1132
|
+
async function tick() {
|
|
1133
|
+
if (stopped) return;
|
|
1134
|
+
await store.heartbeat({ now: ctx.now(), leaseTtlMs });
|
|
1135
|
+
}
|
|
1136
|
+
function armTimer() {
|
|
1137
|
+
if (stopped) return;
|
|
1138
|
+
if (timer !== null) {
|
|
1139
|
+
ctx.clearTimer(timer);
|
|
1140
|
+
timer = null;
|
|
1141
|
+
}
|
|
1142
|
+
timer = ctx.setTimer(ctx.now() + heartbeatMs, wake);
|
|
1143
|
+
}
|
|
1144
|
+
function wake() {
|
|
1145
|
+
if (stopped) return;
|
|
1146
|
+
tick().then(
|
|
1147
|
+
() => {
|
|
1148
|
+
armTimer();
|
|
1149
|
+
},
|
|
1150
|
+
(e) => {
|
|
1151
|
+
if (stopped) return;
|
|
1152
|
+
if (e instanceof FencedError) {
|
|
1153
|
+
stopped = true;
|
|
1154
|
+
console.error(
|
|
1155
|
+
`[objectstore-substrate] FATAL: lease heartbeat fenced \u2014 this node no longer owns its shard's write lease and must stop serving writes. Cause: ${e.message}`
|
|
1156
|
+
);
|
|
1157
|
+
try {
|
|
1158
|
+
onFenced?.(e);
|
|
1159
|
+
} catch (callbackError) {
|
|
1160
|
+
console.error("[objectstore-substrate] lease heartbeat: onFenced callback threw:", callbackError);
|
|
1161
|
+
}
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
console.error("[objectstore-substrate] lease heartbeat: renewal attempt failed (will retry):", e);
|
|
1165
|
+
armTimer();
|
|
1166
|
+
}
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
return {
|
|
1170
|
+
name: "leaseHeartbeat",
|
|
1171
|
+
start(c) {
|
|
1172
|
+
ctx = c;
|
|
1173
|
+
armTimer();
|
|
1174
|
+
},
|
|
1175
|
+
stop() {
|
|
1176
|
+
stopped = true;
|
|
1177
|
+
if (timer !== null) {
|
|
1178
|
+
ctx.clearTimer(timer);
|
|
1179
|
+
timer = null;
|
|
1180
|
+
}
|
|
1181
|
+
},
|
|
1182
|
+
// Test seam: runs one heartbeat pass and awaits its real completion, letting a `FencedError` (or
|
|
1183
|
+
// any other error) propagate to the caller — unlike `wake()`, used by the timer path, which
|
|
1184
|
+
// catches and branches instead. Does NOT itself set `stopped`/call `onFenced` on a fence; a test
|
|
1185
|
+
// exercising that behavior should drive it through the timer callback captured by a fake
|
|
1186
|
+
// `DriverContext.setTimer`, same as `receiptsReaper`'s own tests do for its policy.
|
|
1187
|
+
__tick: () => tick()
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// src/gc-driver.ts
|
|
1192
|
+
function gcDriver(store, opts) {
|
|
1193
|
+
const { sweepMs } = opts;
|
|
1194
|
+
let ctx;
|
|
1195
|
+
let timer = null;
|
|
1196
|
+
let stopped = false;
|
|
1197
|
+
async function tick() {
|
|
1198
|
+
if (stopped) return { deletedSegments: 0, deletedSnapshots: 0 };
|
|
1199
|
+
return store.gc();
|
|
1200
|
+
}
|
|
1201
|
+
function armTimer() {
|
|
1202
|
+
if (stopped) return;
|
|
1203
|
+
if (timer !== null) {
|
|
1204
|
+
ctx.clearTimer(timer);
|
|
1205
|
+
timer = null;
|
|
1206
|
+
}
|
|
1207
|
+
timer = ctx.setTimer(ctx.now() + sweepMs, wake);
|
|
1208
|
+
}
|
|
1209
|
+
function wake() {
|
|
1210
|
+
if (stopped) return;
|
|
1211
|
+
tick().catch((e) => {
|
|
1212
|
+
console.error("[objectstore-substrate] gc-driver: sweep pass failed (will retry):", e);
|
|
1213
|
+
}).finally(() => {
|
|
1214
|
+
armTimer();
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
return {
|
|
1218
|
+
name: "objectStoreGc",
|
|
1219
|
+
start(c) {
|
|
1220
|
+
ctx = c;
|
|
1221
|
+
armTimer();
|
|
1222
|
+
},
|
|
1223
|
+
stop() {
|
|
1224
|
+
stopped = true;
|
|
1225
|
+
if (timer !== null) {
|
|
1226
|
+
ctx.clearTimer(timer);
|
|
1227
|
+
timer = null;
|
|
1228
|
+
}
|
|
1229
|
+
},
|
|
1230
|
+
// Test seam: runs one gc() pass and awaits its real completion, letting any error propagate
|
|
1231
|
+
// (unlike `wake()`, used by the timer path, which swallows+logs) — see the interface doc above.
|
|
1232
|
+
__tick: () => tick()
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// src/frontier.ts
|
|
1237
|
+
async function readGlobalFrontier(os, shards) {
|
|
1238
|
+
if (shards.length === 0) return 0n;
|
|
1239
|
+
let min = null;
|
|
1240
|
+
for (const shard of shards) {
|
|
1241
|
+
const entry = await readManifest(os, shard);
|
|
1242
|
+
if (entry === null) return 0n;
|
|
1243
|
+
const ts = BigInt(entry.manifest.frontierTs);
|
|
1244
|
+
if (min === null || ts < min) min = ts;
|
|
1245
|
+
}
|
|
1246
|
+
return min;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// src/replica-tailer.ts
|
|
1250
|
+
import { encodeStorageTableId, internalIdToHex } from "@helipod/id-codec";
|
|
1251
|
+
var MAX_MISSING_SEGMENT_RETRIES = 8;
|
|
1252
|
+
var ObjectStoreReplicaTailer = class {
|
|
1253
|
+
objectStore;
|
|
1254
|
+
shard;
|
|
1255
|
+
local;
|
|
1256
|
+
onInvalidation;
|
|
1257
|
+
pollMs;
|
|
1258
|
+
/** The highest segment seqno this tailer has itself applied (or correlated `local` to — see
|
|
1259
|
+
* `#ensureInitialized`/the "nothing new" opportunistic-seed note in `#tickOnce`). `-1` means
|
|
1260
|
+
* "not yet correlated to any manifest state" — the sentinel that makes `#materializeRound`'s
|
|
1261
|
+
* snapshot-fallback check and tail-pull loop naturally perform a FULL catch-up (every segment,
|
|
1262
|
+
* or the newest snapshot + its tail) the first time this tailer actually has new work to do,
|
|
1263
|
+
* which is the correct, safe behavior for a `local` this tailer hasn't yet correlated to a
|
|
1264
|
+
* known-caught-up point (re-applying already-present rows via `write(..., "Overwrite")` is an
|
|
1265
|
+
* idempotent no-op on `local`'s actual state; the only cost is a possibly-oversized first
|
|
1266
|
+
* invalidation batch, never a missed one). */
|
|
1267
|
+
appliedSeqnoValue = -1;
|
|
1268
|
+
/** The ts through which `local` is known to be caught up. Lazily seeded from
|
|
1269
|
+
* `local.maxTimestamp()` on the first `tick()` — see `#ensureInitialized`. */
|
|
1270
|
+
appliedMaxTsValue = 0n;
|
|
1271
|
+
initialized = false;
|
|
1272
|
+
timer;
|
|
1273
|
+
/** Reentrancy guard — a manual `tick()` call racing the `start()`-armed poll timer must not run
|
|
1274
|
+
* two overlapping apply rounds against the same `local`/cursor state. */
|
|
1275
|
+
draining = false;
|
|
1276
|
+
waiters = /* @__PURE__ */ new Set();
|
|
1277
|
+
constructor(opts) {
|
|
1278
|
+
this.objectStore = opts.objectStore;
|
|
1279
|
+
this.shard = opts.shard;
|
|
1280
|
+
this.local = opts.local;
|
|
1281
|
+
this.onInvalidation = opts.onInvalidation;
|
|
1282
|
+
this.pollMs = opts.pollMs ?? 1e3;
|
|
1283
|
+
}
|
|
1284
|
+
get appliedSeqno() {
|
|
1285
|
+
return this.appliedSeqnoValue;
|
|
1286
|
+
}
|
|
1287
|
+
get appliedMaxTs() {
|
|
1288
|
+
return this.appliedMaxTsValue;
|
|
1289
|
+
}
|
|
1290
|
+
/** Arms a `setInterval(tick, pollMs)`. Tick errors are swallowed (logged) — mirrors the fleet
|
|
1291
|
+
* tailer's fire-and-forget poll posture: a transient failure must not crash the caller's process,
|
|
1292
|
+
* and the NEXT tick retries from the same (unadvanced) watermark regardless. No-op if already
|
|
1293
|
+
* started. */
|
|
1294
|
+
start() {
|
|
1295
|
+
if (this.timer !== void 0) return;
|
|
1296
|
+
this.timer = setInterval(() => {
|
|
1297
|
+
void this.tick().catch((e) => {
|
|
1298
|
+
console.error(`objectstore-substrate: replica tailer tick failed for shard '${this.shard}'`, e);
|
|
1299
|
+
});
|
|
1300
|
+
}, this.pollMs);
|
|
1301
|
+
}
|
|
1302
|
+
stop() {
|
|
1303
|
+
if (this.timer !== void 0) {
|
|
1304
|
+
clearInterval(this.timer);
|
|
1305
|
+
this.timer = void 0;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
/** Resolves when `appliedMaxTs >= ts` (immediately if already true). Poll-driven: nothing here
|
|
1309
|
+
* itself advances the watermark — either `start()` must be armed, or the caller must drive
|
|
1310
|
+
* `tick()` itself (e.g. in a test loop). Rejects on `timeoutMs` elapsing first. */
|
|
1311
|
+
waitFor(ts, timeoutMs) {
|
|
1312
|
+
if (this.appliedMaxTsValue >= ts) return Promise.resolve();
|
|
1313
|
+
return new Promise((resolve, reject) => {
|
|
1314
|
+
const waiter = {
|
|
1315
|
+
ts,
|
|
1316
|
+
resolve: () => {
|
|
1317
|
+
clearTimeout(waiter.timer);
|
|
1318
|
+
this.waiters.delete(waiter);
|
|
1319
|
+
resolve();
|
|
1320
|
+
},
|
|
1321
|
+
timer: setTimeout(() => {
|
|
1322
|
+
this.waiters.delete(waiter);
|
|
1323
|
+
reject(
|
|
1324
|
+
new Error(
|
|
1325
|
+
`ObjectStoreReplicaTailer.waitFor timed out after ${timeoutMs}ms waiting for ts >= ${ts} (currently at ${this.appliedMaxTsValue})`
|
|
1326
|
+
)
|
|
1327
|
+
);
|
|
1328
|
+
}, timeoutMs)
|
|
1329
|
+
};
|
|
1330
|
+
this.waiters.add(waiter);
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
#wakeSatisfiedWaiters() {
|
|
1334
|
+
for (const w of [...this.waiters]) {
|
|
1335
|
+
if (this.appliedMaxTsValue >= w.ts) w.resolve();
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
/** One poll round: returns `true` if anything was applied (or the watermark otherwise advanced),
|
|
1339
|
+
* `false` if there was nothing new. Reentrancy-guarded (see `draining`'s doc) — a call landing
|
|
1340
|
+
* while another is already in flight is a no-op `false`, not queued; the next timer tick (or the
|
|
1341
|
+
* caller's own retry) picks up whatever was missed. */
|
|
1342
|
+
async tick() {
|
|
1343
|
+
if (this.draining) return false;
|
|
1344
|
+
this.draining = true;
|
|
1345
|
+
try {
|
|
1346
|
+
return await this.#tickOnce();
|
|
1347
|
+
} finally {
|
|
1348
|
+
this.draining = false;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
/** Seeds `appliedMaxTsValue` from `local.maxTimestamp()` on the FIRST tick only — this must be
|
|
1352
|
+
* lazy (not done in the constructor) because `DocStore.maxTimestamp()` is async and constructors
|
|
1353
|
+
* can't await. Deliberately does NOT try to also seed `appliedSeqnoValue` here: at this point we
|
|
1354
|
+
* have no manifest read yet to correlate a ts to a seqno cursor against, so `appliedSeqnoValue`
|
|
1355
|
+
* stays at its conservative `-1` sentinel — `#tickOnce`'s "nothing new" branch opportunistically
|
|
1356
|
+
* seeds it instead, the first time it reads a manifest whose `frontierTs` this exact
|
|
1357
|
+
* `appliedMaxTsValue` already covers (see that branch's doc for why that correlation is safe). */
|
|
1358
|
+
async #ensureInitialized() {
|
|
1359
|
+
if (this.initialized) return;
|
|
1360
|
+
this.appliedMaxTsValue = await this.local.maxTimestamp();
|
|
1361
|
+
this.initialized = true;
|
|
1362
|
+
}
|
|
1363
|
+
async #tickOnce() {
|
|
1364
|
+
await this.#ensureInitialized();
|
|
1365
|
+
const fresh = await readManifest(this.objectStore, this.shard);
|
|
1366
|
+
if (fresh === null) return false;
|
|
1367
|
+
const manifest = fresh.manifest;
|
|
1368
|
+
const frontierTs = BigInt(manifest.frontierTs);
|
|
1369
|
+
if (frontierTs <= this.appliedMaxTsValue) {
|
|
1370
|
+
if (this.appliedSeqnoValue < manifest.nextSeqno - 1) this.appliedSeqnoValue = manifest.nextSeqno - 1;
|
|
1371
|
+
return false;
|
|
1372
|
+
}
|
|
1373
|
+
const round = await this.#materializeRound(manifest);
|
|
1374
|
+
const newMaxTs = BigInt(round.manifest.frontierTs);
|
|
1375
|
+
if (round.documents.length === 0 && round.indexUpdates.length === 0) {
|
|
1376
|
+
this.appliedSeqnoValue = round.appliedSeqno;
|
|
1377
|
+
this.appliedMaxTsValue = newMaxTs;
|
|
1378
|
+
this.#wakeSatisfiedWaiters();
|
|
1379
|
+
return true;
|
|
1380
|
+
}
|
|
1381
|
+
const inv = this.#buildInvalidation(round.documents, round.indexUpdates, newMaxTs);
|
|
1382
|
+
await this.onInvalidation(inv);
|
|
1383
|
+
this.appliedSeqnoValue = round.appliedSeqno;
|
|
1384
|
+
this.appliedMaxTsValue = newMaxTs;
|
|
1385
|
+
this.#wakeSatisfiedWaiters();
|
|
1386
|
+
return true;
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Pulls + applies everything between `this.appliedSeqnoValue` and `manifest`'s frontier, mutating
|
|
1390
|
+
* `local` as it goes (idempotent — a segment/snapshot already reflected in `local` is a safe
|
|
1391
|
+
* no-op re-`Overwrite`), and returns every row applied THIS round (for `#buildInvalidation`), the
|
|
1392
|
+
* final manifest revision actually reached, and the seqno cursor the round advanced to.
|
|
1393
|
+
*
|
|
1394
|
+
* Deliberately does NOT mutate `this.appliedSeqnoValue` — only `#tickOnce` may do that, and only
|
|
1395
|
+
* AFTER its `onInvalidation` sink resolves (see that method's doc). This method runs entirely
|
|
1396
|
+
* against a LOCAL `appliedSeqno` variable seeded from the instance field, so a caller that discards
|
|
1397
|
+
* this round's result (sink threw) leaves the instance cursor exactly where it started, and a retry
|
|
1398
|
+
* re-runs this same method from the same starting point — re-applying idempotently and rebuilding
|
|
1399
|
+
* the identical returned batch.
|
|
1400
|
+
*
|
|
1401
|
+
* Mirrors `object-doc-store.ts`'s `materializeTo` — same snapshot-restore-then-replay-tail shape —
|
|
1402
|
+
* with one addition `materializeTo` doesn't need: a MISSING segment (`objectStore.get` returns
|
|
1403
|
+
* `null`) means a lagging replica lost a race against `gc()`, which only ever deletes a segment
|
|
1404
|
+
* once a NEWER snapshot supersedes it. The safe recovery is therefore always available: re-read the
|
|
1405
|
+
* manifest (it must now reference a snapshot covering the missing seqno, or GC could not have
|
|
1406
|
+
* deleted it) and restart the round against that fresher manifest — looping rather than recursing,
|
|
1407
|
+
* so partial progress already applied (and already reflected in the local `appliedSeqno`) is never
|
|
1408
|
+
* discarded, and the accumulated `documents`/`indexUpdates` carry across the restart intact.
|
|
1409
|
+
*/
|
|
1410
|
+
async #materializeRound(initialManifest) {
|
|
1411
|
+
const documents = [];
|
|
1412
|
+
const indexUpdates = [];
|
|
1413
|
+
let manifest = initialManifest;
|
|
1414
|
+
let appliedSeqno = this.appliedSeqnoValue;
|
|
1415
|
+
let missingSegmentRetries = 0;
|
|
1416
|
+
for (; ; ) {
|
|
1417
|
+
if (manifest.snapshotTs !== void 0 && manifest.snapshotSegBase !== void 0 && manifest.snapshotSegBase > appliedSeqno) {
|
|
1418
|
+
const snap = await readSnapshot(this.objectStore, this.shard, manifest.snapshotTs);
|
|
1419
|
+
if (snap === null) {
|
|
1420
|
+
throw new Error(
|
|
1421
|
+
`objectstore-substrate: replica tailer for shard '${this.shard}' \u2014 missing snapshot '${manifest.snapshotTs}' referenced by the manifest (torn state)`
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
const snapFrontierTs = BigInt(snap.frontierTs);
|
|
1425
|
+
const { deletedDocs } = await applySnapshotState(this.local, snap, snapFrontierTs);
|
|
1426
|
+
if (deletedDocs.length > 0) {
|
|
1427
|
+
documents.push(...deletedDocs);
|
|
1428
|
+
}
|
|
1429
|
+
documents.push(...snap.documents);
|
|
1430
|
+
indexUpdates.push(...snap.indexUpdates);
|
|
1431
|
+
appliedSeqno = manifest.snapshotSegBase;
|
|
1432
|
+
}
|
|
1433
|
+
let missedSegment = false;
|
|
1434
|
+
for (const seqno of manifest.segments) {
|
|
1435
|
+
if (seqno <= appliedSeqno) continue;
|
|
1436
|
+
const entry = await this.objectStore.get(segmentKey(this.shard, seqno));
|
|
1437
|
+
if (entry === null) {
|
|
1438
|
+
if (++missingSegmentRetries > MAX_MISSING_SEGMENT_RETRIES) {
|
|
1439
|
+
throw new Error(
|
|
1440
|
+
`objectstore-substrate: replica tailer for shard '${this.shard}' \u2014 missing segment '${segmentKey(this.shard, seqno)}' did not resolve via snapshot fallback after ${MAX_MISSING_SEGMENT_RETRIES} retries`
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
const refreshed = await readManifest(this.objectStore, this.shard);
|
|
1444
|
+
if (refreshed === null) {
|
|
1445
|
+
throw new Error(
|
|
1446
|
+
`objectstore-substrate: replica tailer for shard '${this.shard}' \u2014 manifest disappeared mid-round`
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
manifest = refreshed.manifest;
|
|
1450
|
+
missedSegment = true;
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
const payload = decodeSegment(entry.body);
|
|
1454
|
+
await this.local.write(payload.documents, payload.indexUpdates, "Overwrite");
|
|
1455
|
+
documents.push(...payload.documents);
|
|
1456
|
+
indexUpdates.push(...payload.indexUpdates);
|
|
1457
|
+
appliedSeqno = seqno;
|
|
1458
|
+
}
|
|
1459
|
+
if (!missedSegment) return { documents, indexUpdates, manifest, appliedSeqno };
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/** `${tableId}|${internalIdHex}` — the doc-identity key `#buildInvalidation`'s dedupe uses to
|
|
1463
|
+
* distinguish applied documents by `(tableId, internalId)`. (The snapshot-restore diff this
|
|
1464
|
+
* method used to also serve, Finding 1, now lives in the shared `applySnapshotState` helper,
|
|
1465
|
+
* keyed by `@helipod/id-codec`'s own `documentIdKey` — see `apply-snapshot.ts`.) */
|
|
1466
|
+
#docKey(id) {
|
|
1467
|
+
return `${encodeStorageTableId(id.tableNumber)}|${internalIdToHex(id.internalId)}`;
|
|
1468
|
+
}
|
|
1469
|
+
/** Builds the round's `AppliedInvalidation` — mirrors the fleet tailer's derivation
|
|
1470
|
+
* (`replica-tailer.ts` ~:476-488): `writtenDocs` DISTINCT-deduped by `(tableId, internalId)` from
|
|
1471
|
+
* the applied documents, `writtenKeys` straight from the applied index writes, `writtenTables`
|
|
1472
|
+
* DISTINCT across both sources. */
|
|
1473
|
+
#buildInvalidation(documents, indexUpdates, newMaxTs) {
|
|
1474
|
+
const tables = /* @__PURE__ */ new Set();
|
|
1475
|
+
const seenDocs = /* @__PURE__ */ new Set();
|
|
1476
|
+
const writtenDocs = [];
|
|
1477
|
+
for (const d of documents) {
|
|
1478
|
+
const tableId = encodeStorageTableId(d.id.tableNumber);
|
|
1479
|
+
tables.add(tableId);
|
|
1480
|
+
const dedupeKey = this.#docKey(d.id);
|
|
1481
|
+
if (seenDocs.has(dedupeKey)) continue;
|
|
1482
|
+
seenDocs.add(dedupeKey);
|
|
1483
|
+
writtenDocs.push({ tableId, internalId: d.id.internalId });
|
|
1484
|
+
}
|
|
1485
|
+
const writtenKeys = indexUpdates.map((w) => ({ indexId: w.update.indexId, key: w.update.key }));
|
|
1486
|
+
for (const w of indexUpdates) {
|
|
1487
|
+
if (w.update.value.type === "NonClustered") tables.add(encodeStorageTableId(w.update.value.docId.tableNumber));
|
|
1488
|
+
}
|
|
1489
|
+
return { newMaxTs, writtenTables: [...tables], writtenKeys, writtenDocs };
|
|
1490
|
+
}
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
// src/replica-wiring.ts
|
|
1494
|
+
import { keyToPointRange, docKeyToPointRange } from "@helipod/id-codec";
|
|
1495
|
+
function startReplicaReactiveTailer(opts) {
|
|
1496
|
+
const { runtime, objectStore, shard, local, consumerId, pollMs } = opts;
|
|
1497
|
+
const interval = pollMs ?? 1e3;
|
|
1498
|
+
const onInvalidation = async (inv) => {
|
|
1499
|
+
runtime.observeTimestamp(inv.newMaxTs);
|
|
1500
|
+
const ranges = [
|
|
1501
|
+
...inv.writtenKeys.map((k) => keyToPointRange(k.indexId, k.key)),
|
|
1502
|
+
...inv.writtenDocs.map((d) => docKeyToPointRange(d.tableId, d.internalId))
|
|
1503
|
+
];
|
|
1504
|
+
const commitTs = Number(inv.newMaxTs);
|
|
1505
|
+
await runtime.handler.notifyWrites({ tables: inv.writtenTables, ranges, commitTs });
|
|
1506
|
+
runtime.notifyExternalCommit({ tables: inv.writtenTables, ranges, commitTs });
|
|
1507
|
+
};
|
|
1508
|
+
const tailer = new ObjectStoreReplicaTailer({ objectStore, shard, local, onInvalidation, pollMs });
|
|
1509
|
+
let lastPublishedSeqno = -1;
|
|
1510
|
+
let timer;
|
|
1511
|
+
let stopped = false;
|
|
1512
|
+
let inflight;
|
|
1513
|
+
async function pump() {
|
|
1514
|
+
if (stopped) return;
|
|
1515
|
+
await tailer.tick();
|
|
1516
|
+
if (stopped) return;
|
|
1517
|
+
if (tailer.appliedSeqno !== lastPublishedSeqno) {
|
|
1518
|
+
await publishConsumerWatermark(objectStore, shard, consumerId, { appliedSeqno: tailer.appliedSeqno });
|
|
1519
|
+
lastPublishedSeqno = tailer.appliedSeqno;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
function armTimer() {
|
|
1523
|
+
if (stopped) return;
|
|
1524
|
+
timer = setTimeout(wake, interval);
|
|
1525
|
+
}
|
|
1526
|
+
function wake() {
|
|
1527
|
+
if (stopped) return;
|
|
1528
|
+
const round = pump().catch((e) => {
|
|
1529
|
+
console.error(`objectstore-substrate: replica reactive tailer wiring pump failed for shard '${shard}'`, e);
|
|
1530
|
+
});
|
|
1531
|
+
inflight = round;
|
|
1532
|
+
void round.finally(() => {
|
|
1533
|
+
if (inflight === round) inflight = void 0;
|
|
1534
|
+
armTimer();
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
armTimer();
|
|
1538
|
+
return {
|
|
1539
|
+
async stop() {
|
|
1540
|
+
stopped = true;
|
|
1541
|
+
if (timer !== void 0) {
|
|
1542
|
+
clearTimeout(timer);
|
|
1543
|
+
timer = void 0;
|
|
1544
|
+
}
|
|
1545
|
+
await inflight;
|
|
1546
|
+
tailer.stop();
|
|
1547
|
+
},
|
|
1548
|
+
__pump: pump
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
export {
|
|
1552
|
+
FencedError,
|
|
1553
|
+
ObjectStoreDocStore,
|
|
1554
|
+
ObjectStoreReplicaTailer,
|
|
1555
|
+
ReshardObjectStoreLiveError,
|
|
1556
|
+
ShardedObjectStoreDocStore,
|
|
1557
|
+
casManifest,
|
|
1558
|
+
compareBigint,
|
|
1559
|
+
compareBytesLex,
|
|
1560
|
+
createGlobals,
|
|
1561
|
+
createManifest,
|
|
1562
|
+
decodeSegment,
|
|
1563
|
+
decodeSnapshot,
|
|
1564
|
+
encodeSegment,
|
|
1565
|
+
encodeSnapshot,
|
|
1566
|
+
ensureGlobals,
|
|
1567
|
+
gcDriver,
|
|
1568
|
+
leaseHeartbeatDriver,
|
|
1569
|
+
mergeSortedAsyncGenerators,
|
|
1570
|
+
publishConsumerWatermark,
|
|
1571
|
+
readConsumerWatermarks,
|
|
1572
|
+
readGlobalFrontier,
|
|
1573
|
+
readGlobals,
|
|
1574
|
+
readManifest,
|
|
1575
|
+
readSnapshot,
|
|
1576
|
+
removeConsumer,
|
|
1577
|
+
reshardObjectStore,
|
|
1578
|
+
segmentKey,
|
|
1579
|
+
snapshotKey,
|
|
1580
|
+
startReplicaReactiveTailer,
|
|
1581
|
+
writeGlobals,
|
|
1582
|
+
writeSnapshot
|
|
1583
|
+
};
|
|
1584
|
+
//# sourceMappingURL=index.js.map
|