@helipod/fleet 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 +1777 -0
- package/dist/index.js +2514 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2514 @@
|
|
|
1
|
+
// src/lease.ts
|
|
2
|
+
import { DEFAULT_SHARD } from "@helipod/id-codec";
|
|
3
|
+
var SHARD_ID = DEFAULT_SHARD;
|
|
4
|
+
var DEFAULT_LEASE_TTL_MS = 15e3;
|
|
5
|
+
var DEFAULT_RETRY_MS = 2e3;
|
|
6
|
+
var IDEMPOTENCY_VALUE_CAP_BYTES = 64 * 1024;
|
|
7
|
+
var IDEMPOTENCY_TTL_INTERVAL = "1 hour";
|
|
8
|
+
function toBigIntOrZero(v) {
|
|
9
|
+
if (v === null || v === void 0) return 0n;
|
|
10
|
+
return typeof v === "bigint" ? v : BigInt(v);
|
|
11
|
+
}
|
|
12
|
+
function isLockTimeoutError(e) {
|
|
13
|
+
if (e && typeof e === "object") {
|
|
14
|
+
if (e.code === "55P03") return true;
|
|
15
|
+
const msg = e.message;
|
|
16
|
+
if (typeof msg === "string" && /lock[_ ]?timeout|lock_not_available/i.test(msg)) return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
function rowToLeaseRow(row) {
|
|
21
|
+
return {
|
|
22
|
+
epoch: row.epoch,
|
|
23
|
+
writerUrl: row.writer_url,
|
|
24
|
+
writerAppName: row.writer_app_name ?? null,
|
|
25
|
+
expiresAt: row.expires_at,
|
|
26
|
+
frontierTs: toBigIntOrZero(row.frontier_ts),
|
|
27
|
+
prevTs: toBigIntOrZero(row.prev_ts)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
var LeaseManager = class {
|
|
31
|
+
client;
|
|
32
|
+
/** This node's advertised URL, recorded onto `shard_leases`/`fleet_nodes`. Public so the balancer
|
|
33
|
+
* can use it as this node's rendezvous identity (`ShardLeaseBalancer.myUrl`) without threading it
|
|
34
|
+
* separately through `startFleetNode`. */
|
|
35
|
+
advertiseUrl;
|
|
36
|
+
applicationName;
|
|
37
|
+
retryMs;
|
|
38
|
+
/** The lease TTL in ms this manager stamps onto `expires_at`. Public so the LeaseMonitor can
|
|
39
|
+
* derive its probe cadence from the SAME knob (see `startFleetNode`) — a live writer must renew
|
|
40
|
+
* well within the TTL. */
|
|
41
|
+
ttlMs;
|
|
42
|
+
/** `ttlMs` as a whole-ms integer, ready to interpolate into the `interval '<n> milliseconds'` SQL
|
|
43
|
+
* below. Integer-coerced (never a fraction/NaN) so the interpolation can't produce invalid SQL. */
|
|
44
|
+
ttlMsSql;
|
|
45
|
+
timer = null;
|
|
46
|
+
stopped = false;
|
|
47
|
+
/** Per-shard epoch map: `shardId → the epoch this node most recently acquired for it`. B1 tracked
|
|
48
|
+
* ONE `lastEpoch` (default shard only); B2a's writer holds N shards, each fenced against its own
|
|
49
|
+
* epoch, so the commit guard/heartbeat/idle-closer look up `currentEpoch(shardId)`. Updated on
|
|
50
|
+
* every successful per-shard `tryAcquire(shardId)` (including re-promotion's epoch bumps) so the
|
|
51
|
+
* guard always fences against the CURRENT epoch, not a boot snapshot. A shard absent from the
|
|
52
|
+
* map is one this node has never acquired. */
|
|
53
|
+
lastEpochByShard = /* @__PURE__ */ new Map();
|
|
54
|
+
constructor(client, opts) {
|
|
55
|
+
this.client = client;
|
|
56
|
+
this.advertiseUrl = opts.advertiseUrl;
|
|
57
|
+
this.applicationName = opts.applicationName ?? null;
|
|
58
|
+
this.retryMs = opts.retryMs ?? DEFAULT_RETRY_MS;
|
|
59
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
60
|
+
const rounded = Math.round(this.ttlMs);
|
|
61
|
+
this.ttlMsSql = Number.isFinite(rounded) && rounded > 0 ? rounded : DEFAULT_LEASE_TTL_MS;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Idempotent DDL: creates shard_leases if it doesn't already exist, and — when `shardIds` is
|
|
65
|
+
* non-empty — pre-seeds a row for every shard in the list (Fleet B3, D4: the concurrent-boot
|
|
66
|
+
* count-gate fix, reversing B2a's "no pre-seeding" call).
|
|
67
|
+
*
|
|
68
|
+
* B2a originally left this deliberately EMPTY (no pre-seeded rows): a bare `frontier_ts = 0` row
|
|
69
|
+
* created at DDL time, on a database that already held documents (a pre-`--fleet` upgrade), would
|
|
70
|
+
* be momentarily visible at `frontier_ts = 0` to a concurrently-booting sync node — `count(*) = N`
|
|
71
|
+
* AND `min(frontier_ts) = 0` would fake-report ready with an EMPTY replica (the F1×N hole,
|
|
72
|
+
* recurring ×N). That is why row existence used to be tied to a REAL, frontier-seeded acquisition
|
|
73
|
+
* (`tryAcquire`) rather than to `setup()`.
|
|
74
|
+
*
|
|
75
|
+
* The reversal here is safe ONLY because the seed now travels WITH the row: `documentsExist` (the
|
|
76
|
+
* caller's `documentsTableExists` probe, run BEFORE calling this) selects the exact same
|
|
77
|
+
* `frontierSeedExpr` fragment `tryAcquire` uses — `0` on a fresh database (no `documents` table
|
|
78
|
+
* yet: correct, there is no history to protect against), or `(SELECT COALESCE(MAX(ts), 0) FROM
|
|
79
|
+
* documents)` on an upgrade (`documents` already exists: correct, the row is born at the true
|
|
80
|
+
* high-water mark, never momentarily at 0). A pre-seeded row is created UNACQUIRED — `epoch = 0`,
|
|
81
|
+
* `writer_url = NULL`, already-expired `expires_at` — so the FIRST real `tryAcquire` still lands on
|
|
82
|
+
* its `ON CONFLICT` branch and bumps `epoch` from 0 to 1, byte-identical to a fresh INSERT's
|
|
83
|
+
* `epoch = 1`; every existing epoch/ownership assertion in the test suite is unaffected. Idempotent
|
|
84
|
+
* (`ON CONFLICT (shard_id) DO NOTHING`): a row already created by a peer's concurrent `setup()` or
|
|
85
|
+
* by a real acquisition is left untouched — this can only ever RAISE a row into existence, never
|
|
86
|
+
* clobber one. With every shard row present immediately, the tailer's `count(*) < NUM_SHARDS →
|
|
87
|
+
* not-ready` gate is satisfied the instant `setup()` returns — a concurrent multi-writer boot no
|
|
88
|
+
* longer stalls waiting for a writer to finish its acquire-all pass. See `node.ts`'s
|
|
89
|
+
* `prepareFleetNode` for the `documentsTableExists` probe + call site, and `replica-tailer.ts`'s
|
|
90
|
+
* `readFrontier` for the count gate.
|
|
91
|
+
*/
|
|
92
|
+
/** Run one DDL statement, swallowing the concurrent-boot duplicate-object race family
|
|
93
|
+
* (23505 unique_violation / 42P07 duplicate_table / 42710 duplicate_object — the third face
|
|
94
|
+
* is "type <table> already exists", the table's composite rowtype, which is what two nodes
|
|
95
|
+
* racing CREATE TABLE IF NOT EXISTS actually surface). The statement's objective — the
|
|
96
|
+
* object exists — is achieved when any of these fires; anything else propagates. Mirrors
|
|
97
|
+
* docstore-postgres setupSchema's discipline; added when the SIMULTANEOUS multi-writer boot
|
|
98
|
+
* E2E caught the unguarded race here (Plan A's extra boot DDL widened the window). */
|
|
99
|
+
async ddl(stmt) {
|
|
100
|
+
try {
|
|
101
|
+
await this.client.query(stmt);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
const code = e?.code;
|
|
104
|
+
if (code !== "23505" && code !== "42P07" && code !== "42710") throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async setup(shardIds = [], documentsExist = false) {
|
|
108
|
+
await this.ddl(`
|
|
109
|
+
CREATE TABLE IF NOT EXISTS shard_leases (
|
|
110
|
+
shard_id TEXT PRIMARY KEY,
|
|
111
|
+
epoch BIGINT NOT NULL,
|
|
112
|
+
writer_url TEXT,
|
|
113
|
+
writer_app_name TEXT,
|
|
114
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
115
|
+
frontier_ts BIGINT NOT NULL DEFAULT 0,
|
|
116
|
+
prev_ts BIGINT NOT NULL DEFAULT 0
|
|
117
|
+
)
|
|
118
|
+
`);
|
|
119
|
+
await this.ddl(`
|
|
120
|
+
CREATE TABLE IF NOT EXISTS fleet_nodes (
|
|
121
|
+
advertise_url TEXT PRIMARY KEY,
|
|
122
|
+
epoch BIGINT NOT NULL,
|
|
123
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
124
|
+
)
|
|
125
|
+
`);
|
|
126
|
+
await this.ddl(`
|
|
127
|
+
CREATE TABLE IF NOT EXISTS fleet_idempotency (
|
|
128
|
+
key TEXT PRIMARY KEY,
|
|
129
|
+
commit_ts BIGINT NOT NULL,
|
|
130
|
+
value_json TEXT,
|
|
131
|
+
oversized BOOLEAN NOT NULL DEFAULT false,
|
|
132
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
133
|
+
)
|
|
134
|
+
`);
|
|
135
|
+
if (shardIds.length === 0) return;
|
|
136
|
+
const frontierSeed = this.frontierSeedExpr(documentsExist);
|
|
137
|
+
const params = [];
|
|
138
|
+
const values = shardIds.map((shardId, i) => {
|
|
139
|
+
params.push(shardId);
|
|
140
|
+
return `($${i + 1}, 0, NULL, NULL, now(), ${frontierSeed}, 0)`;
|
|
141
|
+
});
|
|
142
|
+
await this.client.query(
|
|
143
|
+
`INSERT INTO shard_leases (shard_id, epoch, writer_url, writer_app_name, expires_at, frontier_ts, prev_ts)
|
|
144
|
+
VALUES ${values.join(", ")}
|
|
145
|
+
ON CONFLICT (shard_id) DO NOTHING`,
|
|
146
|
+
params
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The `frontier_ts` seed SQL fragment shared by `setup()`'s row pre-seed and `tryAcquire()`'s
|
|
151
|
+
* first-creation INSERT (Fleet B3, D4 — the one place this reasoning is written down): `0` when the
|
|
152
|
+
* caller has verified `documents` does not exist yet (a fresh database — no history to protect
|
|
153
|
+
* against), else `(SELECT COALESCE(MAX(ts), 0) FROM documents)` (an upgrade — the row is born at the
|
|
154
|
+
* store's true high-water mark, never momentarily visible below it). Both call sites gate this on
|
|
155
|
+
* the SAME `documentsTableExists` probe (`node.ts`), so a row is never seeded incorrectly regardless
|
|
156
|
+
* of which of the two code paths creates it first.
|
|
157
|
+
*/
|
|
158
|
+
frontierSeedExpr(documentsExist) {
|
|
159
|
+
return documentsExist ? `(SELECT COALESCE(MAX(ts), 0) FROM documents)` : `0`;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Upsert this node's `fleet_nodes` presence row (B2b, D3), extending `expires_at` by the lease TTL.
|
|
163
|
+
* Called at boot (BEFORE the writer-election `tryAcquire`, so a losing node is already visible when
|
|
164
|
+
* it boots sync) and on every balancer beat / writer probe — the node's liveness signal now. A node
|
|
165
|
+
* whose process dies stops heartbeating; its row expires after the TTL and it drops out of every
|
|
166
|
+
* survivor's `liveNodes()`, re-converging the rendezvous assignment. `epoch` increments per beat —
|
|
167
|
+
* carried only for observability/debugging (which incarnation of a node the row belongs to); the
|
|
168
|
+
* live-set membership check keys off `expires_at`, not `epoch`.
|
|
169
|
+
*/
|
|
170
|
+
async heartbeatPresence() {
|
|
171
|
+
await this.client.query(
|
|
172
|
+
`INSERT INTO fleet_nodes (advertise_url, epoch, expires_at)
|
|
173
|
+
VALUES ($1, 1, now() + interval '${this.ttlMsSql} milliseconds')
|
|
174
|
+
ON CONFLICT (advertise_url) DO UPDATE SET
|
|
175
|
+
epoch = fleet_nodes.epoch + 1,
|
|
176
|
+
expires_at = now() + interval '${this.ttlMsSql} milliseconds'`,
|
|
177
|
+
[this.advertiseUrl]
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The live fleet node set (B2b, D3): distinct unexpired `fleet_nodes` advertise URLs, UNION the live
|
|
182
|
+
* `shard_leases` writer URLs (belt-and-braces — a writer whose presence heartbeat momentarily lapsed
|
|
183
|
+
* but whose shard lease is still live is unambiguously alive). Every node computes rendezvous over
|
|
184
|
+
* this set, so they all derive the same shard→owner assignment. Expiry is `expires_at >= now()` per
|
|
185
|
+
* the DB's OWN clock, authoritative against host clock skew.
|
|
186
|
+
*/
|
|
187
|
+
async liveNodes() {
|
|
188
|
+
const rows = await this.client.query(
|
|
189
|
+
`SELECT advertise_url AS url FROM fleet_nodes WHERE expires_at >= now()
|
|
190
|
+
UNION
|
|
191
|
+
SELECT writer_url AS url FROM shard_leases WHERE writer_url IS NOT NULL AND expires_at >= now()`
|
|
192
|
+
);
|
|
193
|
+
return rows.map((r) => r.url).filter((u) => typeof u === "string" && u.length > 0);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Per-shard ownership snapshot for the balancer (B2b, D3): for each EXISTING `shard_leases` row, its
|
|
197
|
+
* `writer_url` (null = orphaned) and whether it has expired per the DB clock. A shard with no row is
|
|
198
|
+
* simply absent from the map — the balancer treats an absent shard as acquirable (never created, or
|
|
199
|
+
* fully reaped), the same as an orphaned one. Read-only; the balancer decides acquire/release from it.
|
|
200
|
+
*/
|
|
201
|
+
async readShardOwnership() {
|
|
202
|
+
const rows = await this.client.query(
|
|
203
|
+
`SELECT shard_id, writer_url, (expires_at < now()) AS expired FROM shard_leases`
|
|
204
|
+
);
|
|
205
|
+
const map = /* @__PURE__ */ new Map();
|
|
206
|
+
for (const r of rows) {
|
|
207
|
+
map.set(r.shard_id, {
|
|
208
|
+
writerUrl: r.writer_url ?? null,
|
|
209
|
+
expired: r.expired === true
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return map;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Self-fence a shard this node currently holds, for a GRACEFUL balancer release (B2b, D3): bump the
|
|
216
|
+
* epoch (so this node's own subsequent commit/heartbeat on the now-stale epoch fences cleanly),
|
|
217
|
+
* clear `writer_url` (so the shard reads as orphaned and its rightful rendezvous owner acquires it),
|
|
218
|
+
* and GREATEST-bump `frontier_ts` from the shared sequence (so F never regresses across the handoff).
|
|
219
|
+
* Predicated on this node's currently-held epoch — a silent no-op if the shard is already fenced/
|
|
220
|
+
* relinquished (`currentEpoch === null`). The caller runs this only when the shard is fully IDLE —
|
|
221
|
+
* `runtime.tryRunExclusiveOnShard` grants the mutex only if no commit holds it AND no group-commit
|
|
222
|
+
* batch is staged/flushing (Fleet B4), so no in-flight commit's OR batch's frontier write races the
|
|
223
|
+
* GREATEST-bump here; the release is deferred a beat if the shard is busy rather than fencing
|
|
224
|
+
* mid-flush (see `releaseShard` in `node.ts`). A mutation that begins mid-execute after the fence
|
|
225
|
+
* simply hits `FencedError` at its own commit (OCC-retryable, the forwarder re-routes the retry to
|
|
226
|
+
* the new owner). Mirrors `evictExpired`'s frontier-bump SQL, but unconditional on expiry (this is a
|
|
227
|
+
* voluntary handoff, not an eviction of a wedged peer).
|
|
228
|
+
*/
|
|
229
|
+
async selfFence(shardId) {
|
|
230
|
+
const epoch = this.currentEpoch(shardId);
|
|
231
|
+
if (epoch === null) return;
|
|
232
|
+
await this.client.query(
|
|
233
|
+
`UPDATE shard_leases SET
|
|
234
|
+
epoch = epoch + 1,
|
|
235
|
+
writer_url = NULL,
|
|
236
|
+
writer_app_name = NULL,
|
|
237
|
+
frontier_ts = GREATEST(frontier_ts, (SELECT nextval('helipod_ts')))
|
|
238
|
+
WHERE shard_id = $1 AND epoch = $2`,
|
|
239
|
+
[shardId, epoch]
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The epoch this node most recently acquired for `shardId` (via `tryAcquire`), or `null` if it
|
|
244
|
+
* never has. Read live — not a boot-time snapshot — by the commit guard and the heartbeat probe,
|
|
245
|
+
* so a re-promotion's epoch bump (another `tryAcquire` call) is picked up automatically with no
|
|
246
|
+
* extra threading between `prepareFleetNode`/`startFleetNode`/`promoteFleetNode`. Defaults to the
|
|
247
|
+
* default shard so B1 call sites (`currentEpoch()`) are unchanged.
|
|
248
|
+
*/
|
|
249
|
+
currentEpoch(shardId = SHARD_ID) {
|
|
250
|
+
return this.lastEpochByShard.get(shardId) ?? null;
|
|
251
|
+
}
|
|
252
|
+
/** The (shard, epoch) pairs this node currently holds — the input to the batched heartbeat,
|
|
253
|
+
* all-rows seed, and idle-shard closer (all fence per-row against the epoch on file). A snapshot
|
|
254
|
+
* of the live per-shard epoch map, so it reflects any re-promotion's epoch bumps. */
|
|
255
|
+
heldPairs() {
|
|
256
|
+
return [...this.lastEpochByShard].map(([shardId, epoch]) => ({ shardId, epoch }));
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Drop `shardId` from this node's held-epoch map (Fenced Frontier B2b, D2 — per-shard relinquish).
|
|
260
|
+
* After this call `currentEpoch(shardId)` returns `null`, so:
|
|
261
|
+
* - the commit guard's "no acquired epoch" branch fences any straggler commit on `shardId`
|
|
262
|
+
* cleanly (rather than a stale epoch happening to still match a row some other node has since
|
|
263
|
+
* re-fenced and moved on from);
|
|
264
|
+
* - `heldPairs()` (and therefore `heartbeatAll()`/`closeIdleFrontiers()`/`seedFrontierAll()`) stop
|
|
265
|
+
* including this shard.
|
|
266
|
+
* Idempotent: forgetting a shard this node doesn't currently hold is a silent no-op — the
|
|
267
|
+
* relinquish dispatcher (`node.ts`) relies on exactly this for ITS OWN idempotency, via
|
|
268
|
+
* `currentEpoch(shardId) === null` as its "already relinquished" check. Deliberately does NOT touch
|
|
269
|
+
* the `shard_leases` ROW — only the caller's advisory-lock release (`PgClient.releaseShardLock`) and
|
|
270
|
+
* this in-memory forget are relinquish's job; the row itself was already epoch-bumped by whoever
|
|
271
|
+
* fenced this node (or will lapse via TTL), and a future re-acquire (`tryAcquire`) is a fresh INSERT/
|
|
272
|
+
* ON CONFLICT UPDATE regardless of what this map remembers.
|
|
273
|
+
*/
|
|
274
|
+
forgetShard(shardId) {
|
|
275
|
+
this.lastEpochByShard.delete(shardId);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* One non-blocking attempt: takes the advisory lock (fast path); on success, runs the fencing
|
|
279
|
+
* upsert against `shard_leases` (bumping `epoch`, recording this node's URL/app-name, extending
|
|
280
|
+
* `expires_at`) and returns the new state. On failure to take the lock, returns null.
|
|
281
|
+
* `prev_ts` is seeded to 0 on first creation only, and `frontier_ts` to either 0 or the store's
|
|
282
|
+
* current `MAX(ts)` (see `seedFrontierFromDocuments`) — an `ON CONFLICT` re-acquisition (including
|
|
283
|
+
* promotion) leaves BOTH untouched, so the durable-commit chain survives across epochs (D3 depends
|
|
284
|
+
* on this: frontier must never reset just because the writer changed).
|
|
285
|
+
*
|
|
286
|
+
* `seedFrontierFromDocuments` (the F1×N residual-window fix): when a row is FIRST created, seed its
|
|
287
|
+
* `frontier_ts` to `MAX(ts)` from the `documents` log inside the same INSERT — atomically, so the
|
|
288
|
+
* row is NEVER momentarily visible at `frontier_ts = 0` on a pre-loaded store (which would let a
|
|
289
|
+
* concurrently-booting sync node pass its `count == N ∧ min-F` ready gate with an empty replica).
|
|
290
|
+
* Pass `true` only when the `documents` table is known to exist (post-`setupSchema`, or a pre-loaded
|
|
291
|
+
* store) — the caller guards this; `false` (the default, and the only safe value pre-DDL on a fresh
|
|
292
|
+
* database) creates the row at `frontier_ts = 0`, which is correct precisely because a fresh store
|
|
293
|
+
* holds no data. `seedFrontierAll` remains the idempotent belt-and-braces second pass.
|
|
294
|
+
*/
|
|
295
|
+
async tryAcquire(shardId = SHARD_ID, slot = 0, seedFrontierFromDocuments = false) {
|
|
296
|
+
const acquired = this.client.tryAcquireShardLock ? await this.client.tryAcquireShardLock(slot) : await this.client.tryAcquireWriterLock();
|
|
297
|
+
if (!acquired) return null;
|
|
298
|
+
const frontierSeed = this.frontierSeedExpr(seedFrontierFromDocuments);
|
|
299
|
+
const rows = await this.client.query(
|
|
300
|
+
`INSERT INTO shard_leases (shard_id, epoch, writer_url, writer_app_name, expires_at, frontier_ts, prev_ts)
|
|
301
|
+
VALUES ($1, 1, $2, $3, now() + interval '${this.ttlMsSql} milliseconds', ${frontierSeed}, 0)
|
|
302
|
+
ON CONFLICT (shard_id) DO UPDATE SET
|
|
303
|
+
epoch = shard_leases.epoch + 1,
|
|
304
|
+
writer_url = $2,
|
|
305
|
+
writer_app_name = $3,
|
|
306
|
+
expires_at = now() + interval '${this.ttlMsSql} milliseconds'
|
|
307
|
+
RETURNING epoch, writer_url, frontier_ts`,
|
|
308
|
+
[shardId, this.advertiseUrl, this.applicationName]
|
|
309
|
+
);
|
|
310
|
+
const row = rows[0];
|
|
311
|
+
if (!row) throw new Error("shard_leases upsert returned no row");
|
|
312
|
+
const state = {
|
|
313
|
+
epoch: row.epoch,
|
|
314
|
+
writerUrl: row.writer_url,
|
|
315
|
+
// On a re-acquire this is the frontier an interim owner left; on a fresh INSERT it's the seed
|
|
316
|
+
// (`frontierSeed` — store max or 0). Feeds `runtime.observeWriteTimestamp` at every acquisition.
|
|
317
|
+
frontierTs: toBigIntOrZero(row.frontier_ts)
|
|
318
|
+
};
|
|
319
|
+
this.lastEpochByShard.set(shardId, state.epoch);
|
|
320
|
+
return state;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Extend `expires_at` for this node's `epoch` — the LeaseMonitor's periodic probe (D2: one
|
|
324
|
+
* round-trip serves liveness-probe + TTL maintenance + fence verification). Returns the number
|
|
325
|
+
* of rows updated: 1 = still this node's epoch (TTL extended), 0 = fenced — some other node has
|
|
326
|
+
* bumped the epoch (a D4 eviction) and this node no longer holds the lease, even though its
|
|
327
|
+
* connection never dropped. Callers (see `node.ts`) treat 0 as definitive lease loss.
|
|
328
|
+
*/
|
|
329
|
+
async heartbeat(epoch, shardId = SHARD_ID) {
|
|
330
|
+
const rows = await this.client.query(
|
|
331
|
+
`UPDATE shard_leases SET expires_at = now() + interval '${this.ttlMsSql} milliseconds'
|
|
332
|
+
WHERE shard_id = $1 AND epoch = $2
|
|
333
|
+
RETURNING epoch`,
|
|
334
|
+
[shardId, epoch]
|
|
335
|
+
);
|
|
336
|
+
return rows.length;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Batched heartbeat over EVERY (shard, epoch) pair this node holds — one UPDATE per beat (B2a):
|
|
340
|
+
* the writer holds N leases and must renew all of them in a single round-trip, not N. Returns
|
|
341
|
+
* `{ updated, expected, fencedShardIds }` where `expected` is how many pairs this node believes it
|
|
342
|
+
* holds, `updated` is how many rows actually matched, and `fencedShardIds` (B2b, D2) is PRECISELY
|
|
343
|
+
* which held shards did NOT match — diffing the `RETURNING shard_id` rows against the held set —
|
|
344
|
+
* so the caller can relinquish exactly those shards rather than treat `updated < expected` as an
|
|
345
|
+
* undifferentiated whole-node signal. `updated < expected` (equivalently `fencedShardIds.length >
|
|
346
|
+
* 0`) means at least one shard's epoch was superseded; `fencedShardIds` is always `[]` when this
|
|
347
|
+
* node holds nothing (never a writer) — see the early return. The `(shard_id, epoch) IN ((..),(..))`
|
|
348
|
+
* tuple form fences per row.
|
|
349
|
+
*/
|
|
350
|
+
async heartbeatAll() {
|
|
351
|
+
const pairs = this.heldPairs();
|
|
352
|
+
if (pairs.length === 0) return { updated: 0, expected: 0, fencedShardIds: [] };
|
|
353
|
+
const { clause, params } = this.tupleInClause(pairs, 0);
|
|
354
|
+
const rows = await this.client.query(
|
|
355
|
+
`UPDATE shard_leases SET expires_at = now() + interval '${this.ttlMsSql} milliseconds'
|
|
356
|
+
WHERE (shard_id, epoch) IN (${clause})
|
|
357
|
+
RETURNING shard_id`,
|
|
358
|
+
params
|
|
359
|
+
);
|
|
360
|
+
const renewedIds = new Set(rows.map((r) => r.shard_id));
|
|
361
|
+
const fencedShardIds = pairs.filter((p) => !renewedIds.has(p.shardId)).map((p) => p.shardId);
|
|
362
|
+
return { updated: rows.length, expected: pairs.length, fencedShardIds };
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Read-only expiry check: does the lease row exist AND is `expires_at` in the past, per the DB's
|
|
366
|
+
* OWN clock? Used as the acquire loop's per-tick pre-gate before the heavier `evictExpired`
|
|
367
|
+
* transaction — a healthy (live) lease must NOT make every follower take the row lock every tick and
|
|
368
|
+
* contend with the writer's commit guard, so the common case stays a single cheap SELECT. The
|
|
369
|
+
* comparison is `now()` in SQL (not `read().expiresAt` in JS) so it's authoritative against clock
|
|
370
|
+
* skew between a follower's host and Postgres.
|
|
371
|
+
*/
|
|
372
|
+
async isExpired(shardId = SHARD_ID) {
|
|
373
|
+
const rows = await this.client.query(
|
|
374
|
+
`SELECT 1 FROM shard_leases WHERE shard_id = $1 AND expires_at < now()`,
|
|
375
|
+
[shardId]
|
|
376
|
+
);
|
|
377
|
+
return rows.length > 0;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Fencing-first eviction of an EXPIRED lease (Fenced Frontier B1, D4). Bumps `epoch` (fencing the
|
|
381
|
+
* wedged holder — its next commit guard / heartbeat now matches 0 rows), clears `writer_url`/
|
|
382
|
+
* `writer_app_name`, and advances `frontier_ts`, all predicated on the lease actually being
|
|
383
|
+
* expired. Returns `{ fenced: true, oldAppName }` capturing the evicted holder's `writer_app_name`
|
|
384
|
+
* (so the acquire loop can `pg_terminate_backend` its lingering connection); `{ fenced: false }`
|
|
385
|
+
* when the row is live (no-op) OR the row lock couldn't be taken within `lock_timeout` (retry next
|
|
386
|
+
* tick — never throws that to the loop).
|
|
387
|
+
*
|
|
388
|
+
* Why a `SELECT ... FOR UPDATE` then a separate `UPDATE`, NOT a single `UPDATE ... RETURNING (SELECT
|
|
389
|
+
* writer_app_name FROM cte)`: on Postgres, RETURNING — and the inlined CTE subquery it evaluates —
|
|
390
|
+
* sees the NEW (already-nulled) row, so a single statement reads back NULL for the old app name
|
|
391
|
+
* (verified on PGlite; a `MATERIALIZED` CTE with its own `FOR UPDATE` instead collides with the
|
|
392
|
+
* same-statement UPDATE's row lock and yields zero rows). The two-statement form captures the old
|
|
393
|
+
* value cleanly, and the `FOR UPDATE` takes the very row lock a concurrent commit-guard UPDATE
|
|
394
|
+
* contends on — serializing eviction against an in-flight commit. That contention is single-
|
|
395
|
+
* connection-untestable and covered E2E only (see the test header).
|
|
396
|
+
*/
|
|
397
|
+
async evictExpired(shardId = SHARD_ID) {
|
|
398
|
+
try {
|
|
399
|
+
return await this.client.transaction(async (tx) => {
|
|
400
|
+
await tx.query(`SET LOCAL lock_timeout = '2s'`);
|
|
401
|
+
const sel = await tx.query(
|
|
402
|
+
`SELECT writer_app_name FROM shard_leases WHERE shard_id = $1 AND expires_at < now() FOR UPDATE`,
|
|
403
|
+
[shardId]
|
|
404
|
+
);
|
|
405
|
+
if (sel.length === 0) return { fenced: false, oldAppName: null };
|
|
406
|
+
const oldAppName = sel[0].writer_app_name ?? null;
|
|
407
|
+
await tx.query(
|
|
408
|
+
`UPDATE shard_leases SET
|
|
409
|
+
epoch = epoch + 1,
|
|
410
|
+
writer_url = NULL,
|
|
411
|
+
writer_app_name = NULL,
|
|
412
|
+
frontier_ts = GREATEST(frontier_ts, (SELECT nextval('helipod_ts')))
|
|
413
|
+
WHERE shard_id = $1`,
|
|
414
|
+
[shardId]
|
|
415
|
+
);
|
|
416
|
+
return { fenced: true, oldAppName };
|
|
417
|
+
});
|
|
418
|
+
} catch (e) {
|
|
419
|
+
if (isLockTimeoutError(e)) return { fenced: false, oldAppName: null };
|
|
420
|
+
throw e;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Kill the evicted holder's lingering Postgres backend(s) by `application_name` so its session-level
|
|
425
|
+
* advisory writer lock is released and the NEXT acquire tick can win. Matches the fleet E2E's query
|
|
426
|
+
* shape exactly, including the `pid <> pg_backend_pid()` self-exclusion (the fencer never terminates
|
|
427
|
+
* its own connection — its app name differs anyway, so this is belt-and-braces). Best-effort: if the
|
|
428
|
+
* backend is already gone, the query simply affects zero rows.
|
|
429
|
+
*/
|
|
430
|
+
async terminateBackend(appName) {
|
|
431
|
+
await this.client.query(
|
|
432
|
+
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
|
433
|
+
WHERE (application_name = $1 OR application_name LIKE $1 || '-commit-%') AND pid <> pg_backend_pid()`,
|
|
434
|
+
[appName]
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* A tick observed the advisory-lock try FAIL — another backend still holds the writer lock. If the
|
|
439
|
+
* lease has ALSO expired, that holder is wedged (its heartbeat stopped) while its connection lingers
|
|
440
|
+
* (a hung/paused process still owns the advisory lock, so no other node can acquire). Fence it
|
|
441
|
+
* (`evictExpired` bumps the epoch) then terminate its backend to release the lock; the next tick's
|
|
442
|
+
* advisory try then succeeds → normal acquisition → promotion. No-op when the lease is still live.
|
|
443
|
+
*/
|
|
444
|
+
async maybeEvictWedged() {
|
|
445
|
+
if (!await this.isExpired()) return;
|
|
446
|
+
const { fenced, oldAppName } = await this.evictExpired();
|
|
447
|
+
if (fenced && oldAppName !== null) await this.terminateBackend(oldAppName);
|
|
448
|
+
}
|
|
449
|
+
/** Loop tryAcquire() every retryMs until it succeeds, then invoke onAcquired once. stop() cancels.
|
|
450
|
+
* On a tick where the advisory try fails AND the lease has expired, the wedged holder is fenced +
|
|
451
|
+
* its backend terminated (`maybeEvictWedged`) so a later tick can take over. */
|
|
452
|
+
acquireLoop(onAcquired) {
|
|
453
|
+
this.stopped = false;
|
|
454
|
+
const schedule = () => {
|
|
455
|
+
if (this.stopped) return;
|
|
456
|
+
this.timer = setTimeout(attempt, this.retryMs);
|
|
457
|
+
};
|
|
458
|
+
const attempt = () => {
|
|
459
|
+
if (this.stopped) return;
|
|
460
|
+
void (async () => {
|
|
461
|
+
try {
|
|
462
|
+
const state = await this.tryAcquire();
|
|
463
|
+
if (this.stopped) return;
|
|
464
|
+
if (state) {
|
|
465
|
+
onAcquired(state);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
await this.maybeEvictWedged();
|
|
469
|
+
} catch {
|
|
470
|
+
}
|
|
471
|
+
schedule();
|
|
472
|
+
})();
|
|
473
|
+
};
|
|
474
|
+
this.timer = setTimeout(attempt, this.retryMs);
|
|
475
|
+
}
|
|
476
|
+
/** Cancels any pending acquireLoop() retry. */
|
|
477
|
+
stop() {
|
|
478
|
+
this.stopped = true;
|
|
479
|
+
if (this.timer !== null) {
|
|
480
|
+
clearTimeout(this.timer);
|
|
481
|
+
this.timer = null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* F1 fix (Fenced Frontier B1 whole-branch review, BLOCKER): seed `frontier_ts` up to at least
|
|
486
|
+
* `maxTs` — the writer-BOOT step that closes the "pre-loaded database" hole. `tryAcquire()` only
|
|
487
|
+
* seeds `frontier_ts` to 0 on the row's FIRST creation (see above); a single-node Postgres `serve`
|
|
488
|
+
* that accumulates real data BEFORE `--fleet` is ever turned on has no `shard_leases` row at all,
|
|
489
|
+
* so the first `tryAcquire()` after enabling `--fleet` creates one at `frontier_ts=0` even though
|
|
490
|
+
* the store already holds history. Because the tailer targets `F=frontier_ts` (not
|
|
491
|
+
* `primary.maxTimestamp()`, see `replica-tailer.ts`'s D5), a fresh sync node's ready gate
|
|
492
|
+
* (`wm=0 < target=0`) is then a silent no-op — it reports ready with an EMPTY replica while the
|
|
493
|
+
* primary holds everything. Callers must invoke this at WRITER BOOT, after this node's own
|
|
494
|
+
* `setupSchema()` has definitely run (the `documents` table this reads may not exist before
|
|
495
|
+
* that — see `prepareFleetNode`/`startFleetNode`'s boot ordering) and BEFORE the node is reported
|
|
496
|
+
* ready; a later promotion does NOT need to call this — by then `frontier_ts` is already live,
|
|
497
|
+
* tracking every commit via the guard installed by `installCommitGuard`.
|
|
498
|
+
*
|
|
499
|
+
* `GREATEST` makes this a no-op on an already-live fleet (frontier already tracks every commit)
|
|
500
|
+
* and on re-acquisition — never regresses `frontier_ts`. Epoch-fenced like every other
|
|
501
|
+
* `shard_leases` write: a stale `epoch` here (this node lost the writer race to someone else
|
|
502
|
+
* between its `tryAcquire()` and this call) affects 0 rows rather than clobbering the new
|
|
503
|
+
* writer's state — that writer's own commit guard is the source of truth for `frontier_ts` going
|
|
504
|
+
* forward regardless. Deliberately does NOT touch `prev_ts`: this is a seed of the high-water
|
|
505
|
+
* mark, not a commit, so there is no new "previous commit" to record — the next REAL commit's
|
|
506
|
+
* guard sets `prev_ts := frontier_ts` (now the seeded value) exactly as it always does.
|
|
507
|
+
*/
|
|
508
|
+
async seedFrontier(epoch, maxTs, shardId = SHARD_ID) {
|
|
509
|
+
await this.client.query(
|
|
510
|
+
`UPDATE shard_leases SET frontier_ts = GREATEST(frontier_ts, $1) WHERE shard_id = $2 AND epoch = $3`,
|
|
511
|
+
[maxTs, shardId, epoch]
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* All-rows frontier seed (B2a — the F1×N fix): seed EVERY shard this node holds up to `maxTs` in
|
|
516
|
+
* ONE batched, per-row-epoch-fenced UPDATE, run at writer boot BEFORE the node reports ready. The
|
|
517
|
+
* same reasoning as single-shard `seedFrontier` applies to every row at once: any future commit on
|
|
518
|
+
* any shard takes a later `nextval`, so seeding all held frontiers to the store's current max can
|
|
519
|
+
* never over-shoot a real commit, and `GREATEST` keeps it a no-op on an already-live fleet.
|
|
520
|
+
* Epoch-fenced per pair (`(shard_id, epoch) IN (...)`), so a shard this node lost between acquiring
|
|
521
|
+
* it and this call is simply skipped rather than clobbered. See `node.ts`'s writer boot.
|
|
522
|
+
*/
|
|
523
|
+
async seedFrontierAll(maxTs) {
|
|
524
|
+
const pairs = this.heldPairs();
|
|
525
|
+
if (pairs.length === 0) return;
|
|
526
|
+
const { clause, params } = this.tupleInClause(pairs, 1);
|
|
527
|
+
await this.client.query(
|
|
528
|
+
`UPDATE shard_leases SET frontier_ts = GREATEST(frontier_ts, $1) WHERE (shard_id, epoch) IN (${clause})`,
|
|
529
|
+
[maxTs, ...params]
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Per-shard idle-shard frontier closing (B2a, D5 — needed the moment N>1: an idle shard pins the
|
|
534
|
+
* fleet's `F = min(frontier_ts)`). Allocate a single fresh `nextval` `N` from the shared
|
|
535
|
+
* `helipod_ts` sequence per beat, then for EACH held shard advance its frontier up to `N` —
|
|
536
|
+
* `UPDATE ... SET frontier_ts = GREATEST(frontier_ts, $N) WHERE shard_id=$s AND epoch=$e AND
|
|
537
|
+
* frontier_ts < $N` — but run each shard's UPDATE UNDER THAT SHARD'S COMMIT MUTEX
|
|
538
|
+
* (`runExclusiveOnShard`), skipping any shard currently mid-commit (mutex busy → left for the next
|
|
539
|
+
* beat). Returns the `nextval` used (the ceiling this beat closed idle shards up to).
|
|
540
|
+
*
|
|
541
|
+
* Why skip-if-busy, not the old bare batched UPDATE (the frontier-inversion fix): the commit
|
|
542
|
+
* guard writes `frontier_ts := commitTs` for a commit that drew its ts `T` from the SAME sequence,
|
|
543
|
+
* *inside* the commit transaction — which runs under the shard's commit mutex (single-commit) or at
|
|
544
|
+
* FLUSH time for a group-commit batch (Fleet B4). If the closer drew `N > T` and bare-wrote
|
|
545
|
+
* `frontier_ts = N` while that commit's/batch's rows had not yet landed, a tailer could read `F ≥
|
|
546
|
+
* N`, pull `(watermark, N]`, and MISS `T`'s rows when they land afterward (silent replica miss), or
|
|
547
|
+
* the guard's later write of `T` would trip a frontier-regression assert. `runExclusiveOnShard`
|
|
548
|
+
* treats a shard as available only when it is fully IDLE — mutex free AND no staged/flushing
|
|
549
|
+
* group-commit batch (`ShardWriter.hasInFlightWork()`): group commit runs the flush OFF the mutex,
|
|
550
|
+
* so a mid-flush batch (ts's already drawn, rows not yet landed) reads BUSY even though the mutex is
|
|
551
|
+
* free, and is skipped for the next beat. Given that, the closer and that shard's commits are
|
|
552
|
+
* mutually exclusive on this (single, writer-owned) node: any commit/batch STARTED before the draw
|
|
553
|
+
* either holds the mutex or is staged/flushing → skipped this beat; any commit/batch that STARTS
|
|
554
|
+
* after the closer's `nextval` draws its `T` at commit/flush time, AFTER `N`, so `T > N` and
|
|
555
|
+
* `GREATEST` keeps `frontier_ts ≥ N` with no regression and nothing in-flight ever sits below `N`.
|
|
556
|
+
* Cross-node closing of a held shard is impossible by design (only
|
|
557
|
+
* the lease holder closes its own held shards; orphan bumping via `evictExpired` targets
|
|
558
|
+
* writer-less rows, where no commit can be in flight). Epoch-fenced per pair. No-op (returns the
|
|
559
|
+
* allocated ts anyway) when this node holds nothing.
|
|
560
|
+
*/
|
|
561
|
+
async closeIdleFrontiers(runExclusiveOnShard) {
|
|
562
|
+
const pairs = this.heldPairs();
|
|
563
|
+
const tsRows = await this.client.query(`SELECT nextval('helipod_ts') AS ts`);
|
|
564
|
+
const newTs = toBigIntOrZero(tsRows[0]?.ts);
|
|
565
|
+
for (const { shardId, epoch } of pairs) {
|
|
566
|
+
await runExclusiveOnShard(shardId, async () => {
|
|
567
|
+
await this.client.query(
|
|
568
|
+
`UPDATE shard_leases SET frontier_ts = GREATEST(frontier_ts, $1)
|
|
569
|
+
WHERE shard_id = $2 AND epoch = $3 AND frontier_ts < $1`,
|
|
570
|
+
[newTs, shardId, epoch]
|
|
571
|
+
);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
return newTs;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Orphan frontier bumping (B2b, D4): advance the frontier of every WRITER-LESS shard row
|
|
578
|
+
* (`writer_url IS NULL`) that lags `ceiling`, so an unassigned/relinquished/expired shard never
|
|
579
|
+
* pins the fleet's `F = min(frontier_ts)` below the live commit position. Runs on the WRITER beat
|
|
580
|
+
* alongside `closeIdleFrontiers` (which only ever touches THIS node's OWN held rows via
|
|
581
|
+
* `heldPairs()` and so structurally cannot un-pin a shard nobody holds).
|
|
582
|
+
*
|
|
583
|
+
* Safe with NO commit mutex, unlike `closeIdleFrontiers`: a writer-less shard has no in-flight
|
|
584
|
+
* commit by construction — its last writer was fenced (epoch-bumped, `writer_url` nulled) BEFORE
|
|
585
|
+
* the row became orphaned, so nothing can be mid-commit writing `frontier_ts` on it. The `UPDATE`'s
|
|
586
|
+
* own row lock serializes any two writers racing this bump, and `GREATEST` + `frontier_ts < ceiling`
|
|
587
|
+
* keep it monotone regardless. Reuses the SAME `ceiling` (one `nextval` per beat) the idle closer
|
|
588
|
+
* drew, so a beat costs one sequence draw, not two.
|
|
589
|
+
*/
|
|
590
|
+
async bumpOrphanFrontiers(ceiling) {
|
|
591
|
+
await this.client.query(
|
|
592
|
+
`UPDATE shard_leases SET frontier_ts = GREATEST(frontier_ts, $1)
|
|
593
|
+
WHERE writer_url IS NULL AND frontier_ts < $1`,
|
|
594
|
+
[ceiling]
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
/** Reads the current lease row for `shardId` (discovery for forwarding, plus the full fencing/
|
|
598
|
+
* frontier state); null if none exists yet. Defaults to the default shard (B1 call sites). */
|
|
599
|
+
async read(shardId = SHARD_ID) {
|
|
600
|
+
const rows = await this.client.query(
|
|
601
|
+
`SELECT epoch, writer_url, writer_app_name, expires_at, frontier_ts, prev_ts
|
|
602
|
+
FROM shard_leases WHERE shard_id = $1`,
|
|
603
|
+
[shardId]
|
|
604
|
+
);
|
|
605
|
+
const row = rows[0];
|
|
606
|
+
if (!row) return null;
|
|
607
|
+
return rowToLeaseRow(row);
|
|
608
|
+
}
|
|
609
|
+
/** All shard rows' `(shard_id, frontier_ts)` — the fleet-wide frontier picture the writer's
|
|
610
|
+
* frontier-lag monitor reads to compute `min(frontier_ts)` + which shard is pinning it (D5's
|
|
611
|
+
* health observability). Ordered by frontier ascending so `rows[0]` is the pinning shard. */
|
|
612
|
+
async readAllFrontiers() {
|
|
613
|
+
const rows = await this.client.query(
|
|
614
|
+
`SELECT shard_id, frontier_ts FROM shard_leases ORDER BY frontier_ts ASC, shard_id ASC`
|
|
615
|
+
);
|
|
616
|
+
return rows.map((r) => ({ shardId: r.shard_id, frontierTs: toBigIntOrZero(r.frontier_ts) }));
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Read back `key`'s `fleet_idempotency` row for a replay decision (Fleet B3, D3), or `null` if no
|
|
620
|
+
* such key has ever committed (a genuine miss — proceed to run). Called by `packages/cli`'s
|
|
621
|
+
* `/_fleet/run` handler BOTH before running (the SELECT-first check) and after catching a
|
|
622
|
+
* unique_violation on this table (the concurrent-duplicate race's loser re-selecting the
|
|
623
|
+
* winner's row). See `IdempotencyReplay` for what `hasValue`/`oversized` distinguish.
|
|
624
|
+
*/
|
|
625
|
+
async lookupIdempotency(key) {
|
|
626
|
+
const rows = await this.client.query(
|
|
627
|
+
`SELECT commit_ts, value_json, oversized FROM fleet_idempotency WHERE key = $1`,
|
|
628
|
+
[key]
|
|
629
|
+
);
|
|
630
|
+
const row = rows[0];
|
|
631
|
+
if (!row) return null;
|
|
632
|
+
const raw = row.value_json;
|
|
633
|
+
return {
|
|
634
|
+
commitTs: toBigIntOrZero(row.commit_ts),
|
|
635
|
+
hasValue: raw !== null && raw !== void 0,
|
|
636
|
+
value: raw !== null && raw !== void 0 ? JSON.parse(raw) : null,
|
|
637
|
+
oversized: row.oversized === true
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Best-effort post-run recording of a forwarded mutation's return VALUE onto its already-committed
|
|
642
|
+
* `fleet_idempotency` row (Fleet B3, D3) — the value isn't known inside the commit transaction (the
|
|
643
|
+
* guard only sees `commitTs`), so this runs AFTER `runtime.run`/`runAction`/`runSystem` returns,
|
|
644
|
+
* from `packages/cli`'s `/_fleet/run` handler. Over `IDEMPOTENCY_VALUE_CAP_BYTES` → `value_json`
|
|
645
|
+
* stays/goes NULL and `oversized = true` instead of storing it (a replay then reports
|
|
646
|
+
* `valueMissing: true`, same as the crash-window case where this UPDATE never ran at all). A
|
|
647
|
+
* missing row (no matching `key` — e.g. a no-op mutation that staged nothing, so the guard never
|
|
648
|
+
* ran) silently affects 0 rows; the caller doesn't need to check for that itself.
|
|
649
|
+
*/
|
|
650
|
+
async recordIdempotencyValue(key, value) {
|
|
651
|
+
const json = JSON.stringify(value ?? null);
|
|
652
|
+
if (Buffer.byteLength(json, "utf8") > IDEMPOTENCY_VALUE_CAP_BYTES) {
|
|
653
|
+
await this.client.query(
|
|
654
|
+
`UPDATE fleet_idempotency SET value_json = NULL, oversized = true WHERE key = $1`,
|
|
655
|
+
[key]
|
|
656
|
+
);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
await this.client.query(
|
|
660
|
+
`UPDATE fleet_idempotency SET value_json = $1, oversized = false WHERE key = $2`,
|
|
661
|
+
[json, key]
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Reclaim `fleet_idempotency` rows older than the TTL (Fleet B3, D3) — a cheap indexed (PK-only
|
|
666
|
+
* table, no index needed at this scale) delete run on the balancer beat of every WRITER-ish node
|
|
667
|
+
* (see `ShardLeaseBalancerDeps.sweepIdempotency` / `node.ts`'s wiring). A retry arriving after a
|
|
668
|
+
* row has been swept simply re-executes (documented boundary — retries are seconds-scale, the
|
|
669
|
+
* sweep window is 1h).
|
|
670
|
+
*/
|
|
671
|
+
async sweepIdempotency() {
|
|
672
|
+
await this.client.query(`DELETE FROM fleet_idempotency WHERE created_at < now() - interval '${IDEMPOTENCY_TTL_INTERVAL}'`);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Build a `(shard_id, epoch) IN ((...),(...))` VALUES-list clause + its flat params array for a
|
|
676
|
+
* batched per-row-fenced statement, with placeholder numbers starting AFTER `offset` positional
|
|
677
|
+
* params the caller already consumed (e.g. a leading `$1 = maxTs`). Returns `{ clause, params }`
|
|
678
|
+
* where `params` is `[shardA, epochA, shardB, epochB, …]` in placeholder order.
|
|
679
|
+
*/
|
|
680
|
+
tupleInClause(pairs, offset) {
|
|
681
|
+
const tuples = [];
|
|
682
|
+
const params = [];
|
|
683
|
+
for (const { shardId, epoch } of pairs) {
|
|
684
|
+
const a = offset + params.length + 1;
|
|
685
|
+
const b = offset + params.length + 2;
|
|
686
|
+
tuples.push(`($${a}, $${b})`);
|
|
687
|
+
params.push(shardId, epoch);
|
|
688
|
+
}
|
|
689
|
+
return { clause: tuples.join(", "), params };
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
// src/lease-monitor.ts
|
|
694
|
+
var DEFAULT_PROBE_MS = 5e3;
|
|
695
|
+
var DEFAULT_MAX_MISSES = 3;
|
|
696
|
+
var LeaseMonitor = class {
|
|
697
|
+
probe;
|
|
698
|
+
onExit;
|
|
699
|
+
probeMs;
|
|
700
|
+
maxMisses;
|
|
701
|
+
probeTimeoutMs;
|
|
702
|
+
timer = null;
|
|
703
|
+
misses = 0;
|
|
704
|
+
inFlight = false;
|
|
705
|
+
exited = false;
|
|
706
|
+
stopped = false;
|
|
707
|
+
constructor(deps) {
|
|
708
|
+
this.probe = deps.probe;
|
|
709
|
+
this.onExit = deps.onExit;
|
|
710
|
+
this.probeMs = deps.probeMs ?? DEFAULT_PROBE_MS;
|
|
711
|
+
this.maxMisses = deps.maxMisses ?? DEFAULT_MAX_MISSES;
|
|
712
|
+
this.probeTimeoutMs = deps.probeTimeoutMs ?? this.probeMs;
|
|
713
|
+
}
|
|
714
|
+
/** Begin probing every `probeMs`. Idempotent; a no-op once stopped or after exit. */
|
|
715
|
+
start() {
|
|
716
|
+
if (this.timer !== null || this.stopped || this.exited) return;
|
|
717
|
+
this.timer = setInterval(() => void this.tick(), this.probeMs);
|
|
718
|
+
}
|
|
719
|
+
async tick() {
|
|
720
|
+
if (this.stopped || this.exited || this.inFlight) return;
|
|
721
|
+
this.inFlight = true;
|
|
722
|
+
let timeoutHandle = null;
|
|
723
|
+
try {
|
|
724
|
+
const probeSettled = Promise.resolve().then(() => this.probe()).then(() => "success");
|
|
725
|
+
const timedOutMarker = new Promise((resolve) => {
|
|
726
|
+
timeoutHandle = setTimeout(() => resolve("timeout"), this.probeTimeoutMs);
|
|
727
|
+
});
|
|
728
|
+
const result = await Promise.race([probeSettled, timedOutMarker]);
|
|
729
|
+
if (this.stopped || this.exited) return;
|
|
730
|
+
if (result === "timeout") {
|
|
731
|
+
this.recordMiss("timed out");
|
|
732
|
+
} else {
|
|
733
|
+
this.misses = 0;
|
|
734
|
+
}
|
|
735
|
+
} catch {
|
|
736
|
+
if (this.stopped || this.exited) return;
|
|
737
|
+
this.recordMiss("failed");
|
|
738
|
+
} finally {
|
|
739
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
740
|
+
this.inFlight = false;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
recordMiss(mode) {
|
|
744
|
+
this.misses += 1;
|
|
745
|
+
if (this.misses > this.maxMisses) {
|
|
746
|
+
this.fireExit(`writer lease probe ${mode} (${this.misses} consecutive times)`);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/** DEFINITIVE lease loss (pinned connection closed/errored) → exit immediately. */
|
|
750
|
+
connectionLost() {
|
|
751
|
+
if (this.stopped || this.exited) return;
|
|
752
|
+
this.fireExit("writer lease lost: database connection closed");
|
|
753
|
+
}
|
|
754
|
+
/** DEFINITIVE lease loss (epoch superseded — heartbeat or commit guard found 0 rows for this
|
|
755
|
+
* node's epoch) → exit immediately, mirroring `connectionLost()`. Idempotent/at-most-once via
|
|
756
|
+
* the same `fireExit` guard — safe to call from both the heartbeat probe and a commit guard. */
|
|
757
|
+
fenced(reason) {
|
|
758
|
+
if (this.stopped || this.exited) return;
|
|
759
|
+
this.fireExit(`writer lease fenced: ${reason ?? "epoch superseded by another node"}`);
|
|
760
|
+
}
|
|
761
|
+
fireExit(reason) {
|
|
762
|
+
if (this.exited) return;
|
|
763
|
+
this.exited = true;
|
|
764
|
+
this.clearTimer();
|
|
765
|
+
this.onExit(reason);
|
|
766
|
+
}
|
|
767
|
+
/** Halt all probing and disarm exit — graceful shutdown or promotion hand-off. */
|
|
768
|
+
stop() {
|
|
769
|
+
this.stopped = true;
|
|
770
|
+
this.clearTimer();
|
|
771
|
+
}
|
|
772
|
+
clearTimer() {
|
|
773
|
+
if (this.timer !== null) {
|
|
774
|
+
clearInterval(this.timer);
|
|
775
|
+
this.timer = null;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
// src/fenced-error.ts
|
|
781
|
+
var FencedError = class extends Error {
|
|
782
|
+
constructor(message) {
|
|
783
|
+
super(message);
|
|
784
|
+
this.name = "FencedError";
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
// src/commit-notifier.ts
|
|
789
|
+
var COMMIT_CHANNEL = "helipod_commits";
|
|
790
|
+
var NotifyingFanoutAdapter = class {
|
|
791
|
+
constructor(inner, client) {
|
|
792
|
+
this.inner = inner;
|
|
793
|
+
this.client = client;
|
|
794
|
+
}
|
|
795
|
+
inner;
|
|
796
|
+
client;
|
|
797
|
+
publish(payload) {
|
|
798
|
+
this.inner.publish(payload);
|
|
799
|
+
void this.client.notify(COMMIT_CHANNEL, String(payload.commitTs)).catch(() => {
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
subscribe(listener) {
|
|
803
|
+
return this.inner.subscribe(listener);
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
// src/forwarder.ts
|
|
808
|
+
import { DEFAULT_SHARD as DEFAULT_SHARD2 } from "@helipod/id-codec";
|
|
809
|
+
import { isHelipodError, helipodErrorFromJSON, NOT_SHARD_OWNER_CODE } from "@helipod/errors";
|
|
810
|
+
function trimTrailingSlash(url) {
|
|
811
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
812
|
+
}
|
|
813
|
+
var RYOW_WAIT_MS = 5e3;
|
|
814
|
+
var WriteForwarder = class {
|
|
815
|
+
constructor(lease, opts) {
|
|
816
|
+
this.lease = lease;
|
|
817
|
+
this.opts = opts;
|
|
818
|
+
}
|
|
819
|
+
lease;
|
|
820
|
+
opts;
|
|
821
|
+
tailer;
|
|
822
|
+
/** Guard each distinct malformed-response warning to once per process (independently — an absent
|
|
823
|
+
* commitTs and an unparseable one are different failure modes and each deserves its own log
|
|
824
|
+
* line) so a bad/old writer response doesn't spam the log on every subsequent forwarded write. */
|
|
825
|
+
warnedMissingCommitTs = false;
|
|
826
|
+
warnedUnparseableCommitTs = false;
|
|
827
|
+
/** Per-shard writer-URL cache (B2b, T2): populated on first forward for a shard, refreshed on a
|
|
828
|
+
* POST failure (transport error OR a not-the-owner rejection) and retried once. A node normally
|
|
829
|
+
* forwards the same shard repeatedly, so caching avoids a `shard_leases` read on every call. */
|
|
830
|
+
writerUrlCache = /* @__PURE__ */ new Map();
|
|
831
|
+
/** Attach the local `ReplicaTailer` this node reads off, enabling the read-your-own-writes wait
|
|
832
|
+
* in `forward()`. Fleet WRITER nodes never call this — they have no replica to wait on. */
|
|
833
|
+
attachTailer(t) {
|
|
834
|
+
this.tailer = t;
|
|
835
|
+
}
|
|
836
|
+
/** Called on promotion (sync → writer-ish). Releases any read-your-own-writes wait in flight —
|
|
837
|
+
* this node no longer serves reads off a replica for its own forwarded writes, so waiting on
|
|
838
|
+
* catch-up would only add latency. `isLocalWriter` itself needs no flip here: it already reads
|
|
839
|
+
* the live `LeaseManager` state directly (see below), which promotion's `tryAcquire` calls
|
|
840
|
+
* update as a side effect.
|
|
841
|
+
*
|
|
842
|
+
* NOTE (Fleet B3): a HYBRID promotion (multi-writer) does NOT call this — a hybrid keeps its
|
|
843
|
+
* replica + tailer running past promotion and STILL serves reads (and forwarded writes to shards
|
|
844
|
+
* it doesn't own) off the replica, so releasing the RYOW waits would be wrong. Only the
|
|
845
|
+
* single-writer failover promotion (`promoteFleetNode`, no replica read path afterward) calls it. */
|
|
846
|
+
promote() {
|
|
847
|
+
this.tailer?.release();
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Fleet B3 (D2) — the HYBRID own-commit RYOW gate. Wired as the runtime's `beforeNotify` drain
|
|
851
|
+
* hook: awaited before a LOCALLY-committed mutation's subscription re-runs fire, so those re-runs
|
|
852
|
+
* (which read this node's replica, via the hybrid query path) don't observe the commit's absence on
|
|
853
|
+
* a replica that hasn't applied it yet. Delegates to the SAME attached `ReplicaTailer.waitFor` the
|
|
854
|
+
* forwarded-write RYOW uses (`attachTailer`), so local and forwarded writes share one catch-up
|
|
855
|
+
* primitive. No-op when no tailer is attached (a single-writer node with no replica, or before the
|
|
856
|
+
* tailer exists) or when nothing committed (`0n`). A timeout is swallowed — reactivity is
|
|
857
|
+
* best-effort; the read path stays correct regardless (the re-run just fires against a replica that
|
|
858
|
+
* is still catching up, no worse than the pre-gate behavior).
|
|
859
|
+
*/
|
|
860
|
+
async waitForReplica(commitTs) {
|
|
861
|
+
if (!this.tailer || commitTs === 0n) return;
|
|
862
|
+
await this.tailer.waitFor(commitTs, RYOW_WAIT_MS);
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* True iff this node currently holds `shardId`'s write lease (B2b, D1: per-shard membership, not
|
|
866
|
+
* a whole-node binary role). `LeaseManager.currentEpoch(shardId) !== null` is the SAME live
|
|
867
|
+
* held-set accessor `relinquish()` uses as its own idempotency check (`node.ts`) — so this can
|
|
868
|
+
* never disagree with what the commit guard / relinquish dispatcher consider "held". Consulted
|
|
869
|
+
* fresh on every call (never cached): a shard acquired or relinquished mid-flight (balancer
|
|
870
|
+
* rebalance, failover, promotion) takes effect on the very next mutation.
|
|
871
|
+
*/
|
|
872
|
+
isLocalWriter(shardId = DEFAULT_SHARD2) {
|
|
873
|
+
return this.lease.currentEpoch(shardId) !== null;
|
|
874
|
+
}
|
|
875
|
+
async forward(kind, path, args, identity, shardId = DEFAULT_SHARD2, dedup) {
|
|
876
|
+
const body = {
|
|
877
|
+
path,
|
|
878
|
+
args,
|
|
879
|
+
identity,
|
|
880
|
+
kind,
|
|
881
|
+
shardId,
|
|
882
|
+
forwarded: true,
|
|
883
|
+
idempotencyKey: crypto.randomUUID(),
|
|
884
|
+
...dedup ? { clientId: dedup.clientId, seq: dedup.seq } : {}
|
|
885
|
+
};
|
|
886
|
+
const first = await this.writerUrlFor(shardId);
|
|
887
|
+
let result;
|
|
888
|
+
try {
|
|
889
|
+
result = await this.post(first, body);
|
|
890
|
+
} catch (firstErr) {
|
|
891
|
+
const shouldRetry = !isHelipodError(firstErr) || firstErr.code === NOT_SHARD_OWNER_CODE;
|
|
892
|
+
if (!shouldRetry) throw firstErr;
|
|
893
|
+
let second;
|
|
894
|
+
try {
|
|
895
|
+
second = await this.refreshWriterUrlFor(shardId);
|
|
896
|
+
} catch {
|
|
897
|
+
throw firstErr;
|
|
898
|
+
}
|
|
899
|
+
result = await this.post(second, body);
|
|
900
|
+
}
|
|
901
|
+
if (result.clientReplay) {
|
|
902
|
+
return { value: result.clientReplay.value ?? null, replay: result.clientReplay };
|
|
903
|
+
}
|
|
904
|
+
await this.waitForReplicaCatchUp(path, result.commitTs);
|
|
905
|
+
return {
|
|
906
|
+
value: result.value,
|
|
907
|
+
// `commitTs` is stringified over the wire (bigints don't survive JSON) — coerced to `number`
|
|
908
|
+
// here to match the `WriteRouter` seam's contract (consistent with how every other commitTs
|
|
909
|
+
// consumer at this precision layer already converts, e.g. `Number(r.oplog?.commitTs ?? 0)` in
|
|
910
|
+
// `runtime-embedded`'s sync path). The RYOW wait above already parsed the STRING via `BigInt`
|
|
911
|
+
// directly, so full precision was preserved for the one place that actually needs it.
|
|
912
|
+
commitTs: result.commitTs !== void 0 ? Number(result.commitTs) : void 0,
|
|
913
|
+
shardId: result.shardId
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
/** Discover shard `shardId`'s current writer URL — cached after the first read. */
|
|
917
|
+
async writerUrlFor(shardId) {
|
|
918
|
+
const cached = this.writerUrlCache.get(shardId);
|
|
919
|
+
if (cached !== void 0) return cached;
|
|
920
|
+
return this.refreshWriterUrlFor(shardId);
|
|
921
|
+
}
|
|
922
|
+
/** Force a fresh `shard_leases` read for `shardId`, overwriting any cached URL — the refresh half
|
|
923
|
+
* of `writerUrlFor`'s cache, also used directly by `forward()`'s retry-once path. */
|
|
924
|
+
async refreshWriterUrlFor(shardId) {
|
|
925
|
+
const state = await this.lease.read(shardId);
|
|
926
|
+
if (!state) throw new Error(`fleet: no writer lease found for shard '${shardId}' \u2014 cannot forward write`);
|
|
927
|
+
this.writerUrlCache.set(shardId, state.writerUrl);
|
|
928
|
+
return state.writerUrl;
|
|
929
|
+
}
|
|
930
|
+
async post(writerUrl, body) {
|
|
931
|
+
const res = await fetch(`${trimTrailingSlash(writerUrl)}/_fleet/run`, {
|
|
932
|
+
method: "POST",
|
|
933
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.opts.adminKey}` },
|
|
934
|
+
body: JSON.stringify(body)
|
|
935
|
+
});
|
|
936
|
+
const text = await res.text();
|
|
937
|
+
let parsed;
|
|
938
|
+
try {
|
|
939
|
+
parsed = text ? JSON.parse(text) : {};
|
|
940
|
+
} catch {
|
|
941
|
+
parsed = {};
|
|
942
|
+
}
|
|
943
|
+
if (!res.ok || parsed.error !== void 0) {
|
|
944
|
+
if (parsed.errorJson) throw helipodErrorFromJSON(parsed.errorJson);
|
|
945
|
+
throw new Error(parsed.error ?? `fleet: writer /_fleet/run returned HTTP ${res.status}`);
|
|
946
|
+
}
|
|
947
|
+
return { value: parsed.value ?? null, commitTs: parsed.commitTs, shardId: parsed.shardId, clientReplay: parsed.clientReplay };
|
|
948
|
+
}
|
|
949
|
+
/** Waits for the local replica to observe `commitTsStr`, when a tailer is attached. No-op on a
|
|
950
|
+
* fleet WRITER node (no tailer attached) or when the write committed nothing (`0`/absent). */
|
|
951
|
+
async waitForReplicaCatchUp(path, commitTsStr) {
|
|
952
|
+
if (!this.tailer) return;
|
|
953
|
+
if (commitTsStr === void 0) {
|
|
954
|
+
if (!this.warnedMissingCommitTs) {
|
|
955
|
+
this.warnedMissingCommitTs = true;
|
|
956
|
+
console.warn(
|
|
957
|
+
`fleet: writer's /_fleet/run response for ${path} had no commitTs \u2014 skipping read-your-own-writes wait`
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
let commitTs;
|
|
963
|
+
try {
|
|
964
|
+
commitTs = BigInt(commitTsStr);
|
|
965
|
+
} catch {
|
|
966
|
+
if (!this.warnedUnparseableCommitTs) {
|
|
967
|
+
this.warnedUnparseableCommitTs = true;
|
|
968
|
+
console.warn(
|
|
969
|
+
`fleet: writer's /_fleet/run response for ${path} had an unparseable commitTs ${JSON.stringify(commitTsStr)} \u2014 skipping read-your-own-writes wait`
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (commitTs === 0n) return;
|
|
975
|
+
const outcome = await this.tailer.waitFor(commitTs, RYOW_WAIT_MS);
|
|
976
|
+
if (outcome === "timeout") {
|
|
977
|
+
console.warn(
|
|
978
|
+
`fleet: read-your-own-writes wait timed out after ${RYOW_WAIT_MS}ms for ${path} at commitTs ${commitTs}`
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
// src/balancer.ts
|
|
985
|
+
import { DEFAULT_SHARD as DEFAULT_SHARD3, shardIdList } from "@helipod/id-codec";
|
|
986
|
+
|
|
987
|
+
// src/rendezvous.ts
|
|
988
|
+
var U64_MASK = 0xffffffffffffffffn;
|
|
989
|
+
var FNV_OFFSET_BASIS = 0xcbf29ce484222325n;
|
|
990
|
+
var FNV_PRIME = 0x100000001b3n;
|
|
991
|
+
var utf8 = new TextEncoder();
|
|
992
|
+
function fnv1a64(bytes) {
|
|
993
|
+
let h = FNV_OFFSET_BASIS;
|
|
994
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
995
|
+
h ^= BigInt(bytes[i]);
|
|
996
|
+
h = h * FNV_PRIME & U64_MASK;
|
|
997
|
+
}
|
|
998
|
+
return h;
|
|
999
|
+
}
|
|
1000
|
+
function rendezvousWeight(shardId, advertiseUrl) {
|
|
1001
|
+
return fnv1a64(utf8.encode(`${shardId}\0${advertiseUrl}`));
|
|
1002
|
+
}
|
|
1003
|
+
function rendezvousOwner(shardId, liveUrls) {
|
|
1004
|
+
let bestUrl = "";
|
|
1005
|
+
let bestWeight = -1n;
|
|
1006
|
+
for (const url of liveUrls) {
|
|
1007
|
+
const w = rendezvousWeight(shardId, url);
|
|
1008
|
+
if (w > bestWeight || w === bestWeight && url < bestUrl) {
|
|
1009
|
+
bestWeight = w;
|
|
1010
|
+
bestUrl = url;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return bestUrl;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// src/balancer.ts
|
|
1017
|
+
var DEFAULT_BEAT_MS = 2e3;
|
|
1018
|
+
var ShardLeaseBalancer = class {
|
|
1019
|
+
constructor(deps) {
|
|
1020
|
+
this.deps = deps;
|
|
1021
|
+
this.shards = shardIdList(deps.numShards);
|
|
1022
|
+
this.multiWriter = deps.multiWriter ?? false;
|
|
1023
|
+
this.beatMs = deps.beatMs ?? DEFAULT_BEAT_MS;
|
|
1024
|
+
this.log = deps.log ?? ((m) => console.error(m));
|
|
1025
|
+
}
|
|
1026
|
+
deps;
|
|
1027
|
+
shards;
|
|
1028
|
+
multiWriter;
|
|
1029
|
+
beatMs;
|
|
1030
|
+
log;
|
|
1031
|
+
timer = null;
|
|
1032
|
+
stopped = false;
|
|
1033
|
+
running = false;
|
|
1034
|
+
/** Canonical (sorted, comma-joined) live set from the PREVIOUS beat — damping compares against it:
|
|
1035
|
+
* a RELEASE only fires when the current live set equals this (identical for ≥2 consecutive beats). */
|
|
1036
|
+
previousLiveSet = null;
|
|
1037
|
+
/** Begin the periodic beat. The FIRST beat fires after `beatMs` (never synchronously) — prompt boot
|
|
1038
|
+
* acquisition is done explicitly via `acquireTargetsNow()`, not the periodic loop. Idempotent. */
|
|
1039
|
+
start() {
|
|
1040
|
+
if (this.timer !== null || this.stopped) return;
|
|
1041
|
+
this.timer = setInterval(() => void this.tick(), this.beatMs);
|
|
1042
|
+
}
|
|
1043
|
+
stop() {
|
|
1044
|
+
this.stopped = true;
|
|
1045
|
+
if (this.timer !== null) {
|
|
1046
|
+
clearInterval(this.timer);
|
|
1047
|
+
this.timer = null;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
/** Resolve the live candidate set for a beat: the DB's live nodes ∪ this node itself (a node is
|
|
1051
|
+
* always live to itself — belt-and-braces so its own boot never depends on its presence row having
|
|
1052
|
+
* landed/propagated yet). Returns both the URL array (rendezvous input) and its canonical form. */
|
|
1053
|
+
async liveCandidates() {
|
|
1054
|
+
const live = await this.deps.lease.liveNodes();
|
|
1055
|
+
const set = new Set(live);
|
|
1056
|
+
set.add(this.deps.myUrl);
|
|
1057
|
+
const urls = [...set];
|
|
1058
|
+
return { urls, canonical: [...set].sort().join(",") };
|
|
1059
|
+
}
|
|
1060
|
+
/** This node's target shards. In single-writer mode (the default) the sole writer's targets are ALL
|
|
1061
|
+
* shards — byte-identical to B2a's acquire-all — so a joining node never steals a share. In
|
|
1062
|
+
* multi-writer mode it is this node's rendezvous (HRW) share over `liveUrls`. */
|
|
1063
|
+
targetsFor(liveUrls) {
|
|
1064
|
+
if (!this.multiWriter) return new Set(this.shards);
|
|
1065
|
+
const targets = /* @__PURE__ */ new Set();
|
|
1066
|
+
for (const s of this.shards) {
|
|
1067
|
+
if (rendezvousOwner(s, liveUrls) === this.deps.myUrl) targets.add(s);
|
|
1068
|
+
}
|
|
1069
|
+
return targets;
|
|
1070
|
+
}
|
|
1071
|
+
/** A shard is acquirable iff it has no row (absent from the map), is orphaned (`writer_url NULL`), or
|
|
1072
|
+
* has expired — i.e. NOT held live by a peer. Acquisition only ever targets these; a live non-target
|
|
1073
|
+
* holder is never fenced by the balancer (that's failover's job). */
|
|
1074
|
+
acquirable(shardId, ownership) {
|
|
1075
|
+
const o = ownership.get(shardId);
|
|
1076
|
+
return o === void 0 || o.writerUrl === null || o.expired;
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Un-damped acquire pass over this node's CURRENT rendezvous targets — called at writer boot and on
|
|
1080
|
+
* promotion so the node holds its share PROMPTLY (before the ready line / before the first periodic
|
|
1081
|
+
* beat), rather than waiting out a 2s tick. In a single-node fleet the target set is EVERY shard, so
|
|
1082
|
+
* this acquires all N — byte-identical steady state to B2a's acquire-all. Seeds `previousLiveSet` so
|
|
1083
|
+
* the first periodic beat treats the boot membership as the damping baseline.
|
|
1084
|
+
*/
|
|
1085
|
+
async acquireTargetsNow() {
|
|
1086
|
+
const { urls, canonical } = await this.liveCandidates();
|
|
1087
|
+
const ownership = await this.deps.lease.readShardOwnership();
|
|
1088
|
+
const targets = this.targetsFor(urls);
|
|
1089
|
+
for (const s of this.shards) {
|
|
1090
|
+
if (targets.has(s) && !this.deps.isHeld(s) && this.acquirable(s, ownership)) {
|
|
1091
|
+
await this.deps.tryAcquireShard(s);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
this.previousLiveSet = canonical;
|
|
1095
|
+
}
|
|
1096
|
+
/** One balancer beat (see the class doc for the five steps). Never throws — a tick error is logged
|
|
1097
|
+
* and the loop continues; the next beat retries against fresh state. */
|
|
1098
|
+
async tick() {
|
|
1099
|
+
if (this.stopped || this.running) return;
|
|
1100
|
+
this.running = true;
|
|
1101
|
+
try {
|
|
1102
|
+
await this.deps.lease.heartbeatPresence();
|
|
1103
|
+
const { urls, canonical } = await this.liveCandidates();
|
|
1104
|
+
const stable = canonical === this.previousLiveSet;
|
|
1105
|
+
this.previousLiveSet = canonical;
|
|
1106
|
+
const ownership = await this.deps.lease.readShardOwnership();
|
|
1107
|
+
const targets = this.targetsFor(urls);
|
|
1108
|
+
if (!this.deps.isWriterish()) {
|
|
1109
|
+
if (this.multiWriter) {
|
|
1110
|
+
const wantsPromotion = [...targets].some(
|
|
1111
|
+
(s) => s !== DEFAULT_SHARD3 && this.acquirable(s, ownership)
|
|
1112
|
+
);
|
|
1113
|
+
if (wantsPromotion) await this.deps.requestPromotion();
|
|
1114
|
+
}
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
for (const s of this.shards) {
|
|
1118
|
+
if (targets.has(s) && !this.deps.isHeld(s) && this.acquirable(s, ownership)) {
|
|
1119
|
+
await this.deps.tryAcquireShard(s);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
if (this.multiWriter && stable) {
|
|
1123
|
+
for (const s of this.shards) {
|
|
1124
|
+
if (this.deps.isHeld(s) && !targets.has(s)) {
|
|
1125
|
+
await this.deps.releaseShard(s);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if (this.deps.sweepIdempotency) {
|
|
1130
|
+
try {
|
|
1131
|
+
await this.deps.sweepIdempotency();
|
|
1132
|
+
} catch (e) {
|
|
1133
|
+
this.log(`fleet: idempotency sweep failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
} catch (e) {
|
|
1137
|
+
this.log(`fleet: balancer tick failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1138
|
+
} finally {
|
|
1139
|
+
this.running = false;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
// src/node.ts
|
|
1145
|
+
import { rmSync } from "fs";
|
|
1146
|
+
import { join } from "path";
|
|
1147
|
+
import { NodePgClient, PostgresDocStore } from "@helipod/docstore-postgres";
|
|
1148
|
+
import { SqliteDocStore, NodeSqliteAdapter, BunSqliteAdapter } from "@helipod/docstore-sqlite";
|
|
1149
|
+
import { CommitGuardRejection } from "@helipod/errors";
|
|
1150
|
+
import { DEFAULT_SHARD as DEFAULT_SHARD4, shardIdList as shardIdList2 } from "@helipod/id-codec";
|
|
1151
|
+
import {
|
|
1152
|
+
InMemoryWriteFanoutAdapter,
|
|
1153
|
+
clientReceiptsGuard
|
|
1154
|
+
} from "@helipod/runtime-embedded";
|
|
1155
|
+
|
|
1156
|
+
// src/switchable-store.ts
|
|
1157
|
+
var SwitchableDocStore = class {
|
|
1158
|
+
delegate;
|
|
1159
|
+
constructor(initial) {
|
|
1160
|
+
this.delegate = initial;
|
|
1161
|
+
}
|
|
1162
|
+
/** Atomically repoint all future calls at `next`. Does not touch the outgoing delegate. */
|
|
1163
|
+
swapTo(next) {
|
|
1164
|
+
this.delegate = next;
|
|
1165
|
+
}
|
|
1166
|
+
/** The delegate current calls are being forwarded to. */
|
|
1167
|
+
current() {
|
|
1168
|
+
return this.delegate;
|
|
1169
|
+
}
|
|
1170
|
+
async setupSchema(options) {
|
|
1171
|
+
const d = this.delegate;
|
|
1172
|
+
return d.setupSchema(options);
|
|
1173
|
+
}
|
|
1174
|
+
async write(documents, indexUpdates, conflictStrategy, shardId) {
|
|
1175
|
+
const d = this.delegate;
|
|
1176
|
+
return d.write(documents, indexUpdates, conflictStrategy, shardId);
|
|
1177
|
+
}
|
|
1178
|
+
async commitWrite(documents, indexUpdates, shardId, opts) {
|
|
1179
|
+
const d = this.delegate;
|
|
1180
|
+
return d.commitWrite(documents, indexUpdates, shardId, opts);
|
|
1181
|
+
}
|
|
1182
|
+
async commitWriteBatch(units, shardId) {
|
|
1183
|
+
const d = this.delegate;
|
|
1184
|
+
return d.commitWriteBatch(units, shardId);
|
|
1185
|
+
}
|
|
1186
|
+
/** Forwards to the CURRENT delegate at call entry, same atomicity contract as every other
|
|
1187
|
+
* method here — but note the resulting registration is NOT itself re-forwarded on a later
|
|
1188
|
+
* `swapTo()`: the guard lands on whichever concrete store was `this.delegate` at the moment
|
|
1189
|
+
* `addCommitGuard` was called, and stays registered there even after the switchable repoints
|
|
1190
|
+
* elsewhere. In practice fleet code (`node.ts`) always calls `addCommitGuard` on the concrete
|
|
1191
|
+
* `PostgresDocStore` directly (never through this wrapper), so this pass-through exists purely
|
|
1192
|
+
* for `DocStore` interface conformance. */
|
|
1193
|
+
addCommitGuard(guard) {
|
|
1194
|
+
const d = this.delegate;
|
|
1195
|
+
return d.addCommitGuard(guard);
|
|
1196
|
+
}
|
|
1197
|
+
async get(id, readTimestamp) {
|
|
1198
|
+
const d = this.delegate;
|
|
1199
|
+
return d.get(id, readTimestamp);
|
|
1200
|
+
}
|
|
1201
|
+
async *index_scan(indexId, tableId, readTimestamp, interval, order, limit) {
|
|
1202
|
+
const d = this.delegate;
|
|
1203
|
+
yield* d.index_scan(indexId, tableId, readTimestamp, interval, order, limit);
|
|
1204
|
+
}
|
|
1205
|
+
async *load_documents(range, order, limit) {
|
|
1206
|
+
const d = this.delegate;
|
|
1207
|
+
yield* d.load_documents(range, order, limit);
|
|
1208
|
+
}
|
|
1209
|
+
async previous_revisions(queries) {
|
|
1210
|
+
const d = this.delegate;
|
|
1211
|
+
return d.previous_revisions(queries);
|
|
1212
|
+
}
|
|
1213
|
+
async scan(tableId, readTimestamp) {
|
|
1214
|
+
const d = this.delegate;
|
|
1215
|
+
return d.scan(tableId, readTimestamp);
|
|
1216
|
+
}
|
|
1217
|
+
async count(tableId) {
|
|
1218
|
+
const d = this.delegate;
|
|
1219
|
+
return d.count(tableId);
|
|
1220
|
+
}
|
|
1221
|
+
async maxTimestamp() {
|
|
1222
|
+
const d = this.delegate;
|
|
1223
|
+
return d.maxTimestamp();
|
|
1224
|
+
}
|
|
1225
|
+
async getGlobal(key) {
|
|
1226
|
+
const d = this.delegate;
|
|
1227
|
+
return d.getGlobal(key);
|
|
1228
|
+
}
|
|
1229
|
+
async writeGlobal(key, value) {
|
|
1230
|
+
const d = this.delegate;
|
|
1231
|
+
return d.writeGlobal(key, value);
|
|
1232
|
+
}
|
|
1233
|
+
async writeGlobalIfAbsent(key, value) {
|
|
1234
|
+
const d = this.delegate;
|
|
1235
|
+
return d.writeGlobalIfAbsent(key, value);
|
|
1236
|
+
}
|
|
1237
|
+
// ── Client mutation receipts (the Receipted Outbox, verdict §(c)) — pass-through ──────────────
|
|
1238
|
+
async getClientVerdict(identity, clientId, seq) {
|
|
1239
|
+
const d = this.delegate;
|
|
1240
|
+
return d.getClientVerdict(identity, clientId, seq);
|
|
1241
|
+
}
|
|
1242
|
+
async getClientFloor(identity, clientId) {
|
|
1243
|
+
const d = this.delegate;
|
|
1244
|
+
return d.getClientFloor(identity, clientId);
|
|
1245
|
+
}
|
|
1246
|
+
async recordClientVerdict(identity, clientId, seq, record) {
|
|
1247
|
+
const d = this.delegate;
|
|
1248
|
+
return d.recordClientVerdict(identity, clientId, seq, record);
|
|
1249
|
+
}
|
|
1250
|
+
async updateClientVerdictValue(identity, clientId, seq, value) {
|
|
1251
|
+
const d = this.delegate;
|
|
1252
|
+
return d.updateClientVerdictValue(identity, clientId, seq, value);
|
|
1253
|
+
}
|
|
1254
|
+
async pruneClientMutations(identity, clientId, opts) {
|
|
1255
|
+
const d = this.delegate;
|
|
1256
|
+
return d.pruneClientMutations(identity, clientId, opts);
|
|
1257
|
+
}
|
|
1258
|
+
async sweepExpiredClientMutations(beforeMs) {
|
|
1259
|
+
const d = this.delegate;
|
|
1260
|
+
return d.sweepExpiredClientMutations(beforeMs);
|
|
1261
|
+
}
|
|
1262
|
+
close() {
|
|
1263
|
+
const d = this.delegate;
|
|
1264
|
+
return d.close();
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
// src/replica-tailer.ts
|
|
1269
|
+
import { decodeStorageTableId, encodeStorageTableId } from "@helipod/id-codec";
|
|
1270
|
+
|
|
1271
|
+
// src/stable-prefix.ts
|
|
1272
|
+
function stablePrefixFromFrontier(raw) {
|
|
1273
|
+
return raw;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// src/replica-tailer.ts
|
|
1277
|
+
var COMMIT_CHANNEL2 = "helipod_commits";
|
|
1278
|
+
var DEFAULT_POLL_MS = 1e3;
|
|
1279
|
+
var DEFAULT_BATCH_SIZE = 1e3;
|
|
1280
|
+
var DEFAULT_NUM_SHARDS = 1;
|
|
1281
|
+
function docDedupeKey(id) {
|
|
1282
|
+
return `${encodeStorageTableId(id.tableNumber)}|${Buffer.from(id.internalId).toString("hex")}`;
|
|
1283
|
+
}
|
|
1284
|
+
function docLabel(id) {
|
|
1285
|
+
return `table=${encodeStorageTableId(id.tableNumber)} internalId=${Buffer.from(id.internalId).toString("hex")}`;
|
|
1286
|
+
}
|
|
1287
|
+
function toBigInt(v) {
|
|
1288
|
+
if (typeof v === "bigint") return v;
|
|
1289
|
+
if (typeof v === "number" || typeof v === "string") return BigInt(v);
|
|
1290
|
+
throw new Error(`fleet: expected shard_leases.frontier_ts to be a BIGINT-like value, got ${typeof v}`);
|
|
1291
|
+
}
|
|
1292
|
+
var DensityViolationError = class extends Error {
|
|
1293
|
+
constructor(docId, expectedPrevTs, actualHeadTs) {
|
|
1294
|
+
super(
|
|
1295
|
+
`fleet: replica density violation for doc (${docLabel(docId)}) \u2014 expected prev_ts ${expectedPrevTs === null ? "null (insert \u2014 replica must have no live head)" : String(expectedPrevTs)}, but the replica's actual head ts is ${actualHeadTs === null ? "null (no live head)" : String(actualHeadTs)}. A commit touching this document was skipped between the primary and this replica; delete <replica path>/fleet-replica.db to re-bootstrap.`
|
|
1296
|
+
);
|
|
1297
|
+
this.docId = docId;
|
|
1298
|
+
this.expectedPrevTs = expectedPrevTs;
|
|
1299
|
+
this.actualHeadTs = actualHeadTs;
|
|
1300
|
+
this.name = "DensityViolationError";
|
|
1301
|
+
}
|
|
1302
|
+
docId;
|
|
1303
|
+
expectedPrevTs;
|
|
1304
|
+
actualHeadTs;
|
|
1305
|
+
};
|
|
1306
|
+
var ReplicaTailer = class {
|
|
1307
|
+
constructor(client, primary, replica, opts) {
|
|
1308
|
+
this.client = client;
|
|
1309
|
+
this.primary = primary;
|
|
1310
|
+
this.replica = replica;
|
|
1311
|
+
this.mode = opts.mode ?? "replica";
|
|
1312
|
+
this.pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
|
1313
|
+
this.batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
1314
|
+
this.numShards = opts.numShards ?? DEFAULT_NUM_SHARDS;
|
|
1315
|
+
this.onInvalidation = opts.onInvalidation;
|
|
1316
|
+
}
|
|
1317
|
+
client;
|
|
1318
|
+
primary;
|
|
1319
|
+
replica;
|
|
1320
|
+
mode;
|
|
1321
|
+
pollMs;
|
|
1322
|
+
batchSize;
|
|
1323
|
+
numShards;
|
|
1324
|
+
onInvalidation;
|
|
1325
|
+
/** The tailer's own applied high-water mark — a `StablePrefixTs` (D6): only ever seeded from the
|
|
1326
|
+
* replica's own persisted `maxTimestamp()` (a prior run's watermark, on restart) or advanced to
|
|
1327
|
+
* a freshly-read/capped `F`. Never assigned a raw, un-branded `bigint` directly. */
|
|
1328
|
+
wm = stablePrefixFromFrontier(0n);
|
|
1329
|
+
/** The last-observed fenced frontier (D5) — tracked purely to assert F never regresses across
|
|
1330
|
+
* reads (`null` = no read yet, so the first `readFrontier()` establishes the baseline without
|
|
1331
|
+
* asserting anything). */
|
|
1332
|
+
lastF = null;
|
|
1333
|
+
timer;
|
|
1334
|
+
unlisten;
|
|
1335
|
+
stopped = true;
|
|
1336
|
+
/** Reentrancy guard: a NOTIFY wake and a poll tick can land back-to-back — only one
|
|
1337
|
+
* pull-apply-invalidate walk runs at a time, so `onInvalidation` never sees overlapping ranges,
|
|
1338
|
+
* and the bootstrap loop in `start()` never races a concurrent LISTEN-triggered tick. */
|
|
1339
|
+
draining = false;
|
|
1340
|
+
waiters = /* @__PURE__ */ new Set();
|
|
1341
|
+
/** Plain `bigint` on purpose (not the branded `StablePrefixTs`) — external callers (tests, the
|
|
1342
|
+
* `PromotionDeps` seam) only ever compare/print this value; widening away the internal brand at
|
|
1343
|
+
* the public boundary costs nothing and avoids forcing every caller to know about the brand. */
|
|
1344
|
+
watermark() {
|
|
1345
|
+
return this.wm;
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Reads the fenced frontier `F` (Fenced Frontier B1 D5, generalized to N shards in B2a) —
|
|
1349
|
+
* `F = min(frontier_ts)` over ALL `shard_leases` rows — via the same `CommitChannelClient` already
|
|
1350
|
+
* threaded in for LISTEN/NOTIFY. The min is the dense prefix the WHOLE fleet has durably fenced: a
|
|
1351
|
+
* replica may serve reads up to it and never past a commit that hasn't been fenced on every shard.
|
|
1352
|
+
*
|
|
1353
|
+
* Belt-and-braces (B2a — the F1×N hole otherwise recurs ×N): treat `count(*) < numShards` as
|
|
1354
|
+
* F=0-equivalent (NOT ready). A half-created `shard_leases` — some shards claimed+seeded, others not
|
|
1355
|
+
* yet — must not let `min` over the present rows fake a ready frontier; the writer's acquire-all +
|
|
1356
|
+
* seed-all completes (all N rows present, each seeded ≥ max) BEFORE it reports ready, so a real min
|
|
1357
|
+
* only appears once every shard is genuinely fenced. `count(*) = 0` (fresh fleet, pre-acquisition)
|
|
1358
|
+
* is the same F=0.
|
|
1359
|
+
*
|
|
1360
|
+
* Asserts F is monotonically non-decreasing across reads once the count gate is satisfied (D5's
|
|
1361
|
+
* defense-in-depth invariant) — a regression can only mean `shard_leases` was corrupted/hand-
|
|
1362
|
+
* tampered. The assertion is skipped while `count(*) < numShards` (F is a placeholder 0 there, so a
|
|
1363
|
+
* later real min is legitimately larger, not a regression).
|
|
1364
|
+
*/
|
|
1365
|
+
async readFrontier() {
|
|
1366
|
+
const rows = await this.client.query(
|
|
1367
|
+
`SELECT COALESCE(MIN(frontier_ts), 0) AS min_frontier, COUNT(*) AS n FROM shard_leases`
|
|
1368
|
+
);
|
|
1369
|
+
const row = rows[0];
|
|
1370
|
+
const count = row === void 0 ? 0 : Number(toBigInt(row.n));
|
|
1371
|
+
if (count < this.numShards) return stablePrefixFromFrontier(0n);
|
|
1372
|
+
const raw = row === void 0 ? 0n : toBigInt(row.min_frontier);
|
|
1373
|
+
const f = stablePrefixFromFrontier(raw);
|
|
1374
|
+
if (this.lastF !== null && f < this.lastF) {
|
|
1375
|
+
throw new Error(
|
|
1376
|
+
`fleet: frontier regression detected \u2014 min(shard_leases.frontier_ts) went from ${this.lastF} to ${f}. F must be monotonically non-decreasing; this indicates a lease row was corrupted or hand-tampered.`
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
this.lastF = f;
|
|
1380
|
+
return f;
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* @param seedWm ONLY consulted in `"invalidateOnly"` mode (ignored in `"replica"` mode, which always
|
|
1384
|
+
* seeds from the replica's own persisted high-water mark — see below). When provided, seeds the
|
|
1385
|
+
* watermark from THIS value instead of a fresh `readFrontier()` read.
|
|
1386
|
+
*
|
|
1387
|
+
* Fixes the PROMOTION-HANDOFF INVALIDATION GAP (B2b whole-branch review): a promoting node stops
|
|
1388
|
+
* its `ReplicaTailer` (sync mode) at some watermark `W`, then starts this derive-only listener.
|
|
1389
|
+
* Between the tailer's stop and the listener's start, a co-writer's commit can land and bump the
|
|
1390
|
+
* fenced frontier `F` past `W` — if the listener then seeds from a FRESH `readFrontier()` (i.e.
|
|
1391
|
+
* that same later `F`), it treats everything up to `F` as already-known and never derives/
|
|
1392
|
+
* invalidates the `(W, F]` range, even though nothing ever actually invalidated it: the sync
|
|
1393
|
+
* tailer was already stopped, and this listener starts past it. A subscription open across the
|
|
1394
|
+
* promotion goes silently stale for exactly those commits.
|
|
1395
|
+
*
|
|
1396
|
+
* The caller (`node.ts`'s `startWriterInvalidationListener`) closes this gap by passing the
|
|
1397
|
+
* OUTGOING tailer's own final `watermark()` (captured right after `tailer.stop()`) as `seedWm` on
|
|
1398
|
+
* promotion, so the listener picks up exactly where the tailer left off — contiguous coverage,
|
|
1399
|
+
* with the tailer's own last-applied range harmlessly re-derived (idempotent double-invalidation).
|
|
1400
|
+
* Writer BOOT (no prior tailer, so no gap by construction) omits `seedWm` and keeps the original
|
|
1401
|
+
* fresh-`readFrontier()` behavior.
|
|
1402
|
+
*/
|
|
1403
|
+
async start(seedWm) {
|
|
1404
|
+
this.stopped = false;
|
|
1405
|
+
if (this.mode === "invalidateOnly") {
|
|
1406
|
+
if (seedWm !== void 0) {
|
|
1407
|
+
this.wm = stablePrefixFromFrontier(seedWm);
|
|
1408
|
+
} else {
|
|
1409
|
+
this.wm = await this.readFrontier();
|
|
1410
|
+
}
|
|
1411
|
+
} else {
|
|
1412
|
+
this.wm = stablePrefixFromFrontier(await this.replica.maxTimestamp());
|
|
1413
|
+
const target = await this.readFrontier();
|
|
1414
|
+
while (!this.stopped && this.wm < target) {
|
|
1415
|
+
await this.tick();
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (this.stopped) return;
|
|
1419
|
+
try {
|
|
1420
|
+
this.unlisten = await this.client.listen(COMMIT_CHANNEL2, () => {
|
|
1421
|
+
void this.tick();
|
|
1422
|
+
});
|
|
1423
|
+
} catch {
|
|
1424
|
+
this.unlisten = void 0;
|
|
1425
|
+
}
|
|
1426
|
+
if (this.stopped) {
|
|
1427
|
+
if (this.unlisten !== void 0) {
|
|
1428
|
+
const unlisten = this.unlisten;
|
|
1429
|
+
this.unlisten = void 0;
|
|
1430
|
+
await unlisten();
|
|
1431
|
+
}
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
this.timer = setInterval(() => void this.tick(), this.pollMs);
|
|
1435
|
+
}
|
|
1436
|
+
async stop() {
|
|
1437
|
+
this.stopped = true;
|
|
1438
|
+
if (this.timer !== void 0) {
|
|
1439
|
+
clearInterval(this.timer);
|
|
1440
|
+
this.timer = void 0;
|
|
1441
|
+
}
|
|
1442
|
+
if (this.unlisten !== void 0) {
|
|
1443
|
+
const unlisten = this.unlisten;
|
|
1444
|
+
this.unlisten = void 0;
|
|
1445
|
+
await unlisten();
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
/** Resolves when `watermark() >= ts` (immediately if already true), or when `release()` fires.
|
|
1449
|
+
* Task 3's read-your-own-writes primitive: a client that just committed at `ts` can wait for
|
|
1450
|
+
* a follower's replica to have caught up before serving that client's next read from it. */
|
|
1451
|
+
waitFor(ts, timeoutMs) {
|
|
1452
|
+
if (this.wm >= ts) return Promise.resolve("reached");
|
|
1453
|
+
return new Promise((resolve) => {
|
|
1454
|
+
const waiter = {
|
|
1455
|
+
ts,
|
|
1456
|
+
settle: (outcome) => {
|
|
1457
|
+
clearTimeout(waiter.timer);
|
|
1458
|
+
this.waiters.delete(waiter);
|
|
1459
|
+
resolve(outcome);
|
|
1460
|
+
},
|
|
1461
|
+
timer: setTimeout(() => waiter.settle("timeout"), timeoutMs)
|
|
1462
|
+
};
|
|
1463
|
+
this.waiters.add(waiter);
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
/** Releases ALL pending `waitFor()`s with `"released"` (e.g. this node was just promoted to
|
|
1467
|
+
* writer, or is shutting down, and callers should stop waiting on replica catch-up). */
|
|
1468
|
+
release() {
|
|
1469
|
+
for (const w of [...this.waiters]) w.settle("released");
|
|
1470
|
+
}
|
|
1471
|
+
wakeSatisfiedWaiters() {
|
|
1472
|
+
for (const w of [...this.waiters]) {
|
|
1473
|
+
if (this.wm >= w.ts) w.settle("reached");
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
async tick() {
|
|
1477
|
+
if (this.stopped || this.draining) return;
|
|
1478
|
+
this.draining = true;
|
|
1479
|
+
try {
|
|
1480
|
+
await this.drainOnce();
|
|
1481
|
+
} catch (e) {
|
|
1482
|
+
if (e instanceof DensityViolationError) {
|
|
1483
|
+
console.error(e.message);
|
|
1484
|
+
await this.stop();
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
throw e;
|
|
1488
|
+
} finally {
|
|
1489
|
+
this.draining = false;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
/** One pull-apply-invalidate walk. Split out from `tick()` so `tick()` can wrap it with the
|
|
1493
|
+
* DensityViolationError halt handling without that catch swallowing the `draining` bookkeeping. */
|
|
1494
|
+
async drainOnce() {
|
|
1495
|
+
const F = await this.readFrontier();
|
|
1496
|
+
if (F <= this.wm) return;
|
|
1497
|
+
const { docs, cappedAt } = await this.pullDocs(this.wm, F);
|
|
1498
|
+
const appliedMax = stablePrefixFromFrontier(cappedAt ?? F);
|
|
1499
|
+
const indexRows = await this.client.query(
|
|
1500
|
+
`SELECT index_id, key, ts, table_id, internal_id, deleted FROM indexes WHERE ts > $1 AND ts <= $2 ORDER BY ts ASC`,
|
|
1501
|
+
[this.wm, appliedMax]
|
|
1502
|
+
);
|
|
1503
|
+
if (docs.length === 0 && indexRows.length === 0) {
|
|
1504
|
+
this.wm = appliedMax;
|
|
1505
|
+
this.wakeSatisfiedWaiters();
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if (this.mode === "replica") {
|
|
1509
|
+
const indexWrites = indexRows.map((r) => {
|
|
1510
|
+
const deleted = r.deleted;
|
|
1511
|
+
const value = deleted ? { type: "Deleted" } : {
|
|
1512
|
+
type: "NonClustered",
|
|
1513
|
+
docId: {
|
|
1514
|
+
tableNumber: decodeStorageTableId(r.table_id),
|
|
1515
|
+
internalId: r.internal_id
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
return {
|
|
1519
|
+
ts: r.ts,
|
|
1520
|
+
update: { indexId: r.index_id, key: r.key, value }
|
|
1521
|
+
};
|
|
1522
|
+
});
|
|
1523
|
+
await this.assertDensity(docs);
|
|
1524
|
+
await this.replica.write(docs, indexWrites, "Overwrite");
|
|
1525
|
+
}
|
|
1526
|
+
const tableIds = /* @__PURE__ */ new Set();
|
|
1527
|
+
for (const r of indexRows) if (r.table_id !== null) tableIds.add(String(r.table_id));
|
|
1528
|
+
const writtenTables = [...tableIds];
|
|
1529
|
+
const writtenKeys = indexRows.map((r) => ({ indexId: String(r.index_id), key: r.key }));
|
|
1530
|
+
const seenDocs = /* @__PURE__ */ new Set();
|
|
1531
|
+
const writtenDocs = [];
|
|
1532
|
+
for (const d of docs) {
|
|
1533
|
+
const dedupeKey = docDedupeKey(d.id);
|
|
1534
|
+
if (seenDocs.has(dedupeKey)) continue;
|
|
1535
|
+
seenDocs.add(dedupeKey);
|
|
1536
|
+
writtenDocs.push({ tableId: encodeStorageTableId(d.id.tableNumber), internalId: d.id.internalId });
|
|
1537
|
+
}
|
|
1538
|
+
await this.onInvalidation({ newMaxTs: appliedMax, writtenTables, writtenKeys, writtenDocs });
|
|
1539
|
+
this.wm = appliedMax;
|
|
1540
|
+
this.wakeSatisfiedWaiters();
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Density assertion (D5, defense-in-depth): for EVERY document entry in this batch, in
|
|
1544
|
+
* `DocumentLogEntry` order, the replica's head for that document IMMEDIATELY BEFORE this entry
|
|
1545
|
+
* lands must chain from `entry.prev_ts` exactly — `prev_ts !== null` requires a live head equal
|
|
1546
|
+
* to it; `prev_ts === null` (an insert) requires no live head at all. Throws `DensityViolationError`
|
|
1547
|
+
* on the first mismatch.
|
|
1548
|
+
*
|
|
1549
|
+
* IDEMPOTENT RE-APPLY exception: if the replica's head is ALREADY at this exact entry's own
|
|
1550
|
+
* `ts`, that's not a violation — it's this exact revision being re-applied (the whole class is
|
|
1551
|
+
* built around `"Overwrite"` idempotency, e.g. a restart re-walking an already-applied range).
|
|
1552
|
+
* Since `ts` is the log's globally unique per-shard commit position, a head landing on it can
|
|
1553
|
+
* only mean this precise commit already landed here, never an unrelated collision.
|
|
1554
|
+
*
|
|
1555
|
+
* A batch can carry more than one revision of the SAME document (it was written more than once
|
|
1556
|
+
* inside the pulled `(wm, F]` range), so "the head immediately before this entry" is a running
|
|
1557
|
+
* per-batch simulation — seeded from the replica's REAL state (`replica.get`) the first time a
|
|
1558
|
+
* doc is encountered, then advanced in-memory to each entry's own `ts` as it's validated —
|
|
1559
|
+
* rather than re-reading the replica for every entry (which would see later, not-yet-applied
|
|
1560
|
+
* entries' predecessor as still absent).
|
|
1561
|
+
*/
|
|
1562
|
+
async assertDensity(docs) {
|
|
1563
|
+
const headTs = /* @__PURE__ */ new Map();
|
|
1564
|
+
for (const d of docs) {
|
|
1565
|
+
const key = docDedupeKey(d.id);
|
|
1566
|
+
let head = headTs.get(key);
|
|
1567
|
+
if (head === void 0) {
|
|
1568
|
+
const existing = await this.replica.get(d.id);
|
|
1569
|
+
head = existing ? existing.ts : null;
|
|
1570
|
+
}
|
|
1571
|
+
if (head !== d.ts) {
|
|
1572
|
+
if (d.prev_ts !== null) {
|
|
1573
|
+
if (head === null || head !== d.prev_ts) throw new DensityViolationError(d.id, d.prev_ts, head);
|
|
1574
|
+
} else if (head !== null) {
|
|
1575
|
+
throw new DensityViolationError(d.id, null, head);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
headTs.set(key, d.ts);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Pull `DocumentLogEntry` rows for `(after, upTo]` from `load_documents`, capped at
|
|
1583
|
+
* `this.batchSize` — but if the cap lands mid-way through a group of rows sharing the same
|
|
1584
|
+
* commit `ts`, keep draining until that ts group is fully collected before stopping. A single
|
|
1585
|
+
* transaction shares exactly one commit `ts` across every document it wrote (see
|
|
1586
|
+
* `postgres-docstore.ts`'s `write()`), so cutting a batch mid-group would apply a transaction's
|
|
1587
|
+
* writes partially, which `load_documents`'s `(after, cap]`-shaped next-tick range would then
|
|
1588
|
+
* never revisit (the excluded rows share `ts === cap`, which the next tick's range excludes as
|
|
1589
|
+
* its own lower bound).
|
|
1590
|
+
*
|
|
1591
|
+
* `TimestampRange` is `{minInclusive, maxExclusive}` (see `packages/docstore/src/types.ts`) —
|
|
1592
|
+
* the half-open opposite skew from the `(after, upTo]` shape this tailer needs, so the bounds
|
|
1593
|
+
* are translated with a `+1n` offset on both sides (safe: `ts` is a monotonic integer log
|
|
1594
|
+
* position, never a real number needing density guarantees).
|
|
1595
|
+
*/
|
|
1596
|
+
async pullDocs(after, upTo) {
|
|
1597
|
+
const docs = [];
|
|
1598
|
+
let cappedAt = null;
|
|
1599
|
+
const gen = this.primary.load_documents({ minInclusive: after + 1n, maxExclusive: upTo + 1n }, "asc");
|
|
1600
|
+
for await (const entry of gen) {
|
|
1601
|
+
if (cappedAt !== null && entry.ts !== cappedAt) break;
|
|
1602
|
+
docs.push(entry);
|
|
1603
|
+
if (cappedAt === null && docs.length >= this.batchSize) cappedAt = entry.ts;
|
|
1604
|
+
}
|
|
1605
|
+
return { docs, cappedAt };
|
|
1606
|
+
}
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
// src/ranges.ts
|
|
1610
|
+
import { keyToPointRange, docKeyToPointRange } from "@helipod/id-codec";
|
|
1611
|
+
|
|
1612
|
+
// src/node.ts
|
|
1613
|
+
var REPLICA_DB_FILENAME = "fleet-replica.db";
|
|
1614
|
+
var DEFAULT_LEASE_TTL_MS2 = 15e3;
|
|
1615
|
+
var DEFAULT_NUM_SHARDS2 = 8;
|
|
1616
|
+
var FRONTIER_BEAT_MS = 100;
|
|
1617
|
+
var FRONTIER_COALESCE_MS = 10;
|
|
1618
|
+
var FRONTIER_LAG_WARN_MS = 5e3;
|
|
1619
|
+
var FrontierMonitor = class {
|
|
1620
|
+
constructor(lease, opts) {
|
|
1621
|
+
this.lease = lease;
|
|
1622
|
+
this.opts = opts;
|
|
1623
|
+
this.minSinceMs = (opts.now ?? Date.now)();
|
|
1624
|
+
}
|
|
1625
|
+
lease;
|
|
1626
|
+
opts;
|
|
1627
|
+
timer = null;
|
|
1628
|
+
coalesceTimer = null;
|
|
1629
|
+
stopped = false;
|
|
1630
|
+
running = false;
|
|
1631
|
+
cached = null;
|
|
1632
|
+
/** The last-observed `min(frontier_ts)` and the wall-clock time it was first seen — lag is the age
|
|
1633
|
+
* of the CURRENT min (how long it's been stuck), so both reset whenever min advances. */
|
|
1634
|
+
lastMin = null;
|
|
1635
|
+
minSinceMs;
|
|
1636
|
+
warned = false;
|
|
1637
|
+
/** D4 — replica-lag tracking: wall-clock time this node's OWN tailer watermark was first observed
|
|
1638
|
+
* BEHIND the fleet frontier F, or `null` while it's caught up. Mirrors `minSinceMs`/`lastMin`'s
|
|
1639
|
+
* "age of the current stuck condition" shape, but for the replica catch-up gap rather than F's own
|
|
1640
|
+
* advance. Reset the instant `wm` reaches `F` again — a transient blip re-arms silently. */
|
|
1641
|
+
replicaLagSinceMs = null;
|
|
1642
|
+
replicaLagWarned = false;
|
|
1643
|
+
get now() {
|
|
1644
|
+
return this.opts.now ?? Date.now;
|
|
1645
|
+
}
|
|
1646
|
+
start() {
|
|
1647
|
+
if (this.stopped || this.timer !== null) return;
|
|
1648
|
+
void this.beat();
|
|
1649
|
+
this.timer = setInterval(() => void this.beat(), this.opts.beatMs ?? FRONTIER_BEAT_MS);
|
|
1650
|
+
}
|
|
1651
|
+
/** Schedule a single coalesced beat `coalesceMs` out — called on each local commit so an idle
|
|
1652
|
+
* sibling shard un-pins F within ~10ms instead of waiting out the periodic beat. Multiple commits
|
|
1653
|
+
* inside the window collapse onto one beat. */
|
|
1654
|
+
triggerCoalesced() {
|
|
1655
|
+
if (this.stopped || this.coalesceTimer !== null) return;
|
|
1656
|
+
this.coalesceTimer = setTimeout(() => {
|
|
1657
|
+
this.coalesceTimer = null;
|
|
1658
|
+
void this.beat();
|
|
1659
|
+
}, this.opts.coalesceMs ?? FRONTIER_COALESCE_MS);
|
|
1660
|
+
}
|
|
1661
|
+
async beat() {
|
|
1662
|
+
if (this.stopped || this.running) return;
|
|
1663
|
+
this.running = true;
|
|
1664
|
+
try {
|
|
1665
|
+
if (this.opts.closeIdle) {
|
|
1666
|
+
if (!this.opts.runExclusiveOnShard) {
|
|
1667
|
+
throw new Error("fleet: FrontierMonitor closeIdle requires a runExclusiveOnShard seam (bug)");
|
|
1668
|
+
}
|
|
1669
|
+
const ceiling = await this.lease.closeIdleFrontiers(this.opts.runExclusiveOnShard);
|
|
1670
|
+
await this.lease.bumpOrphanFrontiers(ceiling);
|
|
1671
|
+
}
|
|
1672
|
+
const rows = await this.lease.readAllFrontiers();
|
|
1673
|
+
if (rows.length === 0) return;
|
|
1674
|
+
const min = rows[0].frontierTs;
|
|
1675
|
+
const pinningShard = rows[0].shardId;
|
|
1676
|
+
const nowMs = this.now();
|
|
1677
|
+
const minBefore = this.lastMin;
|
|
1678
|
+
if (this.lastMin === null || min > this.lastMin) {
|
|
1679
|
+
this.lastMin = min;
|
|
1680
|
+
this.minSinceMs = nowMs;
|
|
1681
|
+
this.warned = false;
|
|
1682
|
+
if (this.opts.closeIdle && minBefore !== null && this.opts.notifyOnAdvance) {
|
|
1683
|
+
try {
|
|
1684
|
+
this.opts.notifyOnAdvance(min);
|
|
1685
|
+
} catch {
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
const lagMs = nowMs - this.minSinceMs;
|
|
1690
|
+
this.cached = { frontier: min, lagMs, pinningShard };
|
|
1691
|
+
const lagWarnMs = this.opts.lagWarnMs ?? FRONTIER_LAG_WARN_MS;
|
|
1692
|
+
if (lagMs > lagWarnMs && !this.warned) {
|
|
1693
|
+
this.warned = true;
|
|
1694
|
+
(this.opts.warn ?? ((m) => console.warn(m)))(
|
|
1695
|
+
`fleet: frontier stuck for ${lagMs}ms at ${min} \u2014 shard '${pinningShard}' is pinning F`
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
if (this.opts.tailerWatermark) {
|
|
1699
|
+
const wm = this.opts.tailerWatermark();
|
|
1700
|
+
if (wm < min) {
|
|
1701
|
+
if (this.replicaLagSinceMs === null) this.replicaLagSinceMs = nowMs;
|
|
1702
|
+
const replicaLagMs = nowMs - this.replicaLagSinceMs;
|
|
1703
|
+
if (replicaLagMs > lagWarnMs && !this.replicaLagWarned) {
|
|
1704
|
+
this.replicaLagWarned = true;
|
|
1705
|
+
(this.opts.warn ?? ((m) => console.warn(m)))(
|
|
1706
|
+
`fleet: replica for '${this.lease.advertiseUrl}' lagging ${replicaLagMs}ms behind frontier ${min} (watermark ${wm})`
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
} else {
|
|
1710
|
+
this.replicaLagSinceMs = null;
|
|
1711
|
+
this.replicaLagWarned = false;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
} catch {
|
|
1715
|
+
} finally {
|
|
1716
|
+
this.running = false;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
/** The most recent frontier reading, or null if no beat has completed yet. `lagMs` is recomputed
|
|
1720
|
+
* against the current clock so a caller between beats still sees a fresh age. */
|
|
1721
|
+
stats() {
|
|
1722
|
+
if (this.cached === null) return null;
|
|
1723
|
+
return { ...this.cached, lagMs: this.now() - this.minSinceMs };
|
|
1724
|
+
}
|
|
1725
|
+
stop() {
|
|
1726
|
+
this.stopped = true;
|
|
1727
|
+
if (this.timer !== null) {
|
|
1728
|
+
clearInterval(this.timer);
|
|
1729
|
+
this.timer = null;
|
|
1730
|
+
}
|
|
1731
|
+
if (this.coalesceTimer !== null) {
|
|
1732
|
+
clearTimeout(this.coalesceTimer);
|
|
1733
|
+
this.coalesceTimer = null;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
function createAsyncChain() {
|
|
1738
|
+
let tail = Promise.resolve();
|
|
1739
|
+
return (op) => {
|
|
1740
|
+
const settled = tail.then(op, op);
|
|
1741
|
+
tail = settled.then(
|
|
1742
|
+
() => void 0,
|
|
1743
|
+
() => void 0
|
|
1744
|
+
);
|
|
1745
|
+
return settled;
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
function fleetProbeMs(ttlMs) {
|
|
1749
|
+
return Math.max(1, Math.round(ttlMs / 3));
|
|
1750
|
+
}
|
|
1751
|
+
function fleetAcquireRetryMs(ttlMs) {
|
|
1752
|
+
return Math.max(1, Math.round(ttlMs * 2 / 15));
|
|
1753
|
+
}
|
|
1754
|
+
var FLEET_WRITER_SESSION_TIMEOUTS = { idleInTransactionMs: 5e3, statementMs: 1e4 };
|
|
1755
|
+
var FLEET_DEPLOYMENT_ID_KEY = "fleet:deploymentId";
|
|
1756
|
+
function deleteReplicaFile(path) {
|
|
1757
|
+
for (const suffix of ["", "-wal", "-shm"]) rmSync(path + suffix, { force: true });
|
|
1758
|
+
}
|
|
1759
|
+
async function documentsTableExists(client) {
|
|
1760
|
+
const rows = await client.query(`SELECT to_regclass('documents') IS NOT NULL AS present`);
|
|
1761
|
+
return rows[0]?.present === true;
|
|
1762
|
+
}
|
|
1763
|
+
function fleetApplicationName(advertiseUrl) {
|
|
1764
|
+
let discriminator = advertiseUrl;
|
|
1765
|
+
try {
|
|
1766
|
+
const port = new URL(advertiseUrl).port;
|
|
1767
|
+
if (port) discriminator = port;
|
|
1768
|
+
} catch {
|
|
1769
|
+
}
|
|
1770
|
+
return `helipod-fleet-${discriminator}`;
|
|
1771
|
+
}
|
|
1772
|
+
function replicaAdapter(path) {
|
|
1773
|
+
const isBun = typeof globalThis.Bun !== "undefined";
|
|
1774
|
+
return isBun ? new BunSqliteAdapter({ path }) : new NodeSqliteAdapter({ path });
|
|
1775
|
+
}
|
|
1776
|
+
function fleetMultiWriterEnabled() {
|
|
1777
|
+
return /^(1|true|yes)$/i.test(process.env.HELIPOD_FLEET_MULTI_WRITER ?? "");
|
|
1778
|
+
}
|
|
1779
|
+
function groupCommitEnabled() {
|
|
1780
|
+
return /^(1|true|yes)$/i.test(process.env.HELIPOD_GROUP_COMMIT ?? "");
|
|
1781
|
+
}
|
|
1782
|
+
async function openReplicaFile(replicaPath) {
|
|
1783
|
+
try {
|
|
1784
|
+
const replica = new SqliteDocStore(replicaAdapter(replicaPath));
|
|
1785
|
+
await replica.setupSchema();
|
|
1786
|
+
return replica;
|
|
1787
|
+
} catch (e) {
|
|
1788
|
+
console.warn(
|
|
1789
|
+
`fleet: local replica at ${replicaPath} failed to open (${e instanceof Error ? e.message : String(e)}) \u2014 deleting and rebuilding from the primary`
|
|
1790
|
+
);
|
|
1791
|
+
deleteReplicaFile(replicaPath);
|
|
1792
|
+
const replica = new SqliteDocStore(replicaAdapter(replicaPath));
|
|
1793
|
+
await replica.setupSchema();
|
|
1794
|
+
return replica;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
async function openSyncReplica(replicaPath) {
|
|
1798
|
+
const replica = await openReplicaFile(replicaPath);
|
|
1799
|
+
return { replica, switchable: new SwitchableDocStore(replica) };
|
|
1800
|
+
}
|
|
1801
|
+
async function reconcileReplicaIdentity(deps) {
|
|
1802
|
+
const { pgStore, replicaPath } = deps;
|
|
1803
|
+
let primaryId = await pgStore.getGlobal(FLEET_DEPLOYMENT_ID_KEY);
|
|
1804
|
+
if (primaryId === null) {
|
|
1805
|
+
await pgStore.writeGlobalIfAbsent(FLEET_DEPLOYMENT_ID_KEY, crypto.randomUUID());
|
|
1806
|
+
primaryId = await pgStore.getGlobal(FLEET_DEPLOYMENT_ID_KEY);
|
|
1807
|
+
}
|
|
1808
|
+
const replicaId = await deps.replica.getGlobal(FLEET_DEPLOYMENT_ID_KEY);
|
|
1809
|
+
if (replicaId === primaryId) {
|
|
1810
|
+
return { replica: deps.replica, switchable: deps.switchable };
|
|
1811
|
+
}
|
|
1812
|
+
const hasData = await deps.replica.maxTimestamp() > 0n;
|
|
1813
|
+
if (replicaId === null && !hasData) {
|
|
1814
|
+
await deps.replica.writeGlobalIfAbsent(FLEET_DEPLOYMENT_ID_KEY, primaryId);
|
|
1815
|
+
return { replica: deps.replica, switchable: deps.switchable };
|
|
1816
|
+
}
|
|
1817
|
+
console.warn(
|
|
1818
|
+
`fleet: local replica at ${replicaPath} does not match the primary's deployment id (foreign replica, or predates identity stamping) \u2014 deleting and rebuilding from the primary`
|
|
1819
|
+
);
|
|
1820
|
+
await deps.replica.close();
|
|
1821
|
+
deleteReplicaFile(replicaPath);
|
|
1822
|
+
const freshReplica = await openReplicaFile(replicaPath);
|
|
1823
|
+
await freshReplica.writeGlobalIfAbsent(FLEET_DEPLOYMENT_ID_KEY, primaryId);
|
|
1824
|
+
deps.switchable.swapTo(freshReplica);
|
|
1825
|
+
return { replica: freshReplica, switchable: deps.switchable };
|
|
1826
|
+
}
|
|
1827
|
+
async function prepareFleetNode(deps) {
|
|
1828
|
+
const leaseTtlMs = deps.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS2;
|
|
1829
|
+
const numShards = deps.numShards ?? DEFAULT_NUM_SHARDS2;
|
|
1830
|
+
const shards = shardIdList2(numShards);
|
|
1831
|
+
const applicationName = fleetApplicationName(deps.advertiseUrl);
|
|
1832
|
+
const client = new NodePgClient({
|
|
1833
|
+
connectionString: deps.databaseUrl,
|
|
1834
|
+
applicationName,
|
|
1835
|
+
sessionTimeouts: FLEET_WRITER_SESSION_TIMEOUTS,
|
|
1836
|
+
// Per-shard commit-connection pool (B2a, D1): one dedicated connection per shard for `commitWrite`
|
|
1837
|
+
// transactions, so different shards' commits run as genuinely concurrent Postgres transactions
|
|
1838
|
+
// (the pinned connection keeps heartbeats/eviction/setup/queries + LISTEN). Each slot's advisory
|
|
1839
|
+
// lock is taken on its own connection, so a shard's connection death releases exactly that shard.
|
|
1840
|
+
commitPool: { shards }
|
|
1841
|
+
});
|
|
1842
|
+
const pgStore = new PostgresDocStore(client, { readOnly: true });
|
|
1843
|
+
const lease = new LeaseManager(client, {
|
|
1844
|
+
advertiseUrl: deps.advertiseUrl,
|
|
1845
|
+
applicationName,
|
|
1846
|
+
ttlMs: leaseTtlMs,
|
|
1847
|
+
retryMs: fleetAcquireRetryMs(leaseTtlMs)
|
|
1848
|
+
});
|
|
1849
|
+
const forwarder = new WriteForwarder(lease, { adminKey: deps.adminKey, selfUrl: deps.advertiseUrl });
|
|
1850
|
+
const fanoutAdapter = new NotifyingFanoutAdapter(new InMemoryWriteFanoutAdapter(), client);
|
|
1851
|
+
const documentsExist = await documentsTableExists(client);
|
|
1852
|
+
await lease.setup(shards, documentsExist);
|
|
1853
|
+
await lease.heartbeatPresence();
|
|
1854
|
+
const acquired = await lease.tryAcquire(DEFAULT_SHARD4, 0, documentsExist);
|
|
1855
|
+
const multiWriter = fleetMultiWriterEnabled();
|
|
1856
|
+
const replicaPath = join(deps.dataDir, REPLICA_DB_FILENAME);
|
|
1857
|
+
const beforeNotify = (commitTs) => forwarder.waitForReplica(commitTs);
|
|
1858
|
+
const groupCommit = groupCommitEnabled();
|
|
1859
|
+
const stablePrefix = async () => {
|
|
1860
|
+
const rows = await lease.readAllFrontiers();
|
|
1861
|
+
return rows.length > 0 ? rows[0].frontierTs : null;
|
|
1862
|
+
};
|
|
1863
|
+
if (acquired) {
|
|
1864
|
+
pgStore.setWritable();
|
|
1865
|
+
forwarder.promote();
|
|
1866
|
+
if (multiWriter) {
|
|
1867
|
+
const { replica: replica2, switchable: switchable2 } = await openSyncReplica(replicaPath);
|
|
1868
|
+
return {
|
|
1869
|
+
client,
|
|
1870
|
+
pgStore,
|
|
1871
|
+
replica: replica2,
|
|
1872
|
+
switchable: switchable2,
|
|
1873
|
+
replicaPath,
|
|
1874
|
+
lease,
|
|
1875
|
+
forwarder,
|
|
1876
|
+
role: "writer",
|
|
1877
|
+
numShards,
|
|
1878
|
+
runtimeOptions: {
|
|
1879
|
+
store: pgStore,
|
|
1880
|
+
writeRouter: forwarder,
|
|
1881
|
+
deferDrivers: false,
|
|
1882
|
+
fanoutAdapter,
|
|
1883
|
+
numShards,
|
|
1884
|
+
queryStore: switchable2,
|
|
1885
|
+
beforeNotify,
|
|
1886
|
+
groupCommit,
|
|
1887
|
+
stablePrefix,
|
|
1888
|
+
externalReceiptsGuard: true
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
return {
|
|
1893
|
+
client,
|
|
1894
|
+
pgStore,
|
|
1895
|
+
lease,
|
|
1896
|
+
forwarder,
|
|
1897
|
+
role: "writer",
|
|
1898
|
+
numShards,
|
|
1899
|
+
runtimeOptions: { store: pgStore, writeRouter: forwarder, deferDrivers: false, fanoutAdapter, numShards, groupCommit, stablePrefix, externalReceiptsGuard: true }
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
const { replica, switchable } = await openSyncReplica(replicaPath);
|
|
1903
|
+
if (multiWriter) {
|
|
1904
|
+
return {
|
|
1905
|
+
client,
|
|
1906
|
+
pgStore,
|
|
1907
|
+
replica,
|
|
1908
|
+
switchable,
|
|
1909
|
+
replicaPath,
|
|
1910
|
+
lease,
|
|
1911
|
+
forwarder,
|
|
1912
|
+
role: "sync",
|
|
1913
|
+
numShards,
|
|
1914
|
+
runtimeOptions: {
|
|
1915
|
+
// `receiptsStore: pgStore` — the Connect handshake classifies/prunes against the PRIMARY, not
|
|
1916
|
+
// the replica (which carries no receipts). Redundant here (`store` IS `pgStore` for a hybrid),
|
|
1917
|
+
// but set explicitly so the sync-node invariant is uniform across both sync shapes.
|
|
1918
|
+
store: pgStore,
|
|
1919
|
+
writeRouter: forwarder,
|
|
1920
|
+
deferDrivers: true,
|
|
1921
|
+
fanoutAdapter,
|
|
1922
|
+
numShards,
|
|
1923
|
+
queryStore: switchable,
|
|
1924
|
+
receiptsStore: pgStore,
|
|
1925
|
+
beforeNotify,
|
|
1926
|
+
groupCommit,
|
|
1927
|
+
stablePrefix,
|
|
1928
|
+
externalReceiptsGuard: true
|
|
1929
|
+
}
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
return {
|
|
1933
|
+
client,
|
|
1934
|
+
pgStore,
|
|
1935
|
+
replica,
|
|
1936
|
+
switchable,
|
|
1937
|
+
replicaPath,
|
|
1938
|
+
lease,
|
|
1939
|
+
forwarder,
|
|
1940
|
+
role: "sync",
|
|
1941
|
+
numShards,
|
|
1942
|
+
runtimeOptions: { store: switchable, writeRouter: forwarder, deferDrivers: true, fanoutAdapter, numShards, receiptsStore: pgStore, groupCommit, stablePrefix, externalReceiptsGuard: true }
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
function defaultFleetExit(reason) {
|
|
1946
|
+
console.error(`fleet: ${reason} \u2014 exiting so this node restarts and rejoins as a sync replica`);
|
|
1947
|
+
process.exit(1);
|
|
1948
|
+
}
|
|
1949
|
+
async function runPromotion(deps) {
|
|
1950
|
+
try {
|
|
1951
|
+
await deps.promote();
|
|
1952
|
+
deps.startMonitor();
|
|
1953
|
+
deps.firePromoted();
|
|
1954
|
+
} catch (e) {
|
|
1955
|
+
deps.onExit(`promotion failed (${e instanceof Error ? e.message : String(e)})`);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
async function promoteFleetNode(deps) {
|
|
1959
|
+
deps.runtime.observeTimestamp(await deps.pgStore.maxTimestamp());
|
|
1960
|
+
deps.pgStore.setWritable();
|
|
1961
|
+
deps.switchable.swapTo(deps.pgStore);
|
|
1962
|
+
deps.forwarder.promote();
|
|
1963
|
+
await deps.tailer.stop();
|
|
1964
|
+
await deps.replica.close();
|
|
1965
|
+
await deps.runtime.startDrivers();
|
|
1966
|
+
}
|
|
1967
|
+
function installCommitGuard(pgStore, lease, onFenced) {
|
|
1968
|
+
return pgStore.addCommitGuard(async (q, units, shardId) => {
|
|
1969
|
+
const epoch = lease.currentEpoch(shardId);
|
|
1970
|
+
if (epoch === null) {
|
|
1971
|
+
onFenced(shardId, `commit guard invoked with no acquired epoch for shard '${shardId}'`);
|
|
1972
|
+
throw new FencedError(`commit fenced: this node has not acquired a shard_leases epoch for shard '${shardId}'`);
|
|
1973
|
+
}
|
|
1974
|
+
const tsN = units[units.length - 1].ts;
|
|
1975
|
+
const rows = await q.query(
|
|
1976
|
+
`UPDATE shard_leases SET prev_ts = frontier_ts, frontier_ts = GREATEST(frontier_ts, $1) WHERE shard_id = $2 AND epoch = $3 RETURNING epoch`,
|
|
1977
|
+
[tsN, shardId, epoch]
|
|
1978
|
+
);
|
|
1979
|
+
if (rows.length === 0) {
|
|
1980
|
+
onFenced(shardId, `commit guard found 0 rows for shard '${shardId}' epoch ${epoch} \u2014 superseded by another writer`);
|
|
1981
|
+
throw new FencedError(`commit fenced: epoch no longer current for shard '${shardId}'`);
|
|
1982
|
+
}
|
|
1983
|
+
for (let i = 0; i < units.length; i++) {
|
|
1984
|
+
const unit = units[i];
|
|
1985
|
+
if (unit.meta?.idempotencyKey) {
|
|
1986
|
+
try {
|
|
1987
|
+
await q.query(`INSERT INTO fleet_idempotency (key, commit_ts) VALUES ($1, $2)`, [
|
|
1988
|
+
unit.meta.idempotencyKey,
|
|
1989
|
+
unit.ts
|
|
1990
|
+
]);
|
|
1991
|
+
} catch (e) {
|
|
1992
|
+
if (e.code === "23505") {
|
|
1993
|
+
throw new CommitGuardRejection(i, "FLEET_IDEMPOTENCY_CONFLICT", `key=${unit.meta.idempotencyKey}`, {
|
|
1994
|
+
cause: e
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
throw e;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
function relinquish(deps, shardId, reason, opts = {}) {
|
|
2004
|
+
if (deps.lease.currentEpoch(shardId) === null) return;
|
|
2005
|
+
deps.lease.forgetShard(shardId);
|
|
2006
|
+
const log = deps.log ?? ((m) => console.error(m));
|
|
2007
|
+
log(
|
|
2008
|
+
`fleet: relinquish shard='${shardId}' reason='${reason}'` + (opts.connectionLost ? " (via commit-connection loss \u2014 its slot lock is already gone)" : "")
|
|
2009
|
+
);
|
|
2010
|
+
if (shardId === DEFAULT_SHARD4) {
|
|
2011
|
+
log(`fleet: relinquished the default shard \u2014 stopping drivers (scheduler/workflow/cron/reaper)`);
|
|
2012
|
+
deps.onDefaultRelinquished?.();
|
|
2013
|
+
}
|
|
2014
|
+
if (opts.connectionLost) return;
|
|
2015
|
+
const slot = deps.shards.indexOf(shardId);
|
|
2016
|
+
if (slot < 0 || !deps.client.releaseShardLock) return;
|
|
2017
|
+
void deps.client.releaseShardLock(slot).catch((e) => {
|
|
2018
|
+
log(
|
|
2019
|
+
`fleet: releaseShardLock(${slot}) for shard '${shardId}' failed: ${e instanceof Error ? e.message : String(e)}`
|
|
2020
|
+
);
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
async function acquireShardAsWriter(lease, shardId, slot, retryMs, seedFrontierFromDocuments = false) {
|
|
2024
|
+
const deadline = Date.now() + Math.max(1, retryMs) * 75;
|
|
2025
|
+
for (; ; ) {
|
|
2026
|
+
const state = await lease.tryAcquire(shardId, slot, seedFrontierFromDocuments);
|
|
2027
|
+
if (state) return;
|
|
2028
|
+
if (await lease.isExpired(shardId)) {
|
|
2029
|
+
const { fenced, oldAppName } = await lease.evictExpired(shardId);
|
|
2030
|
+
if (fenced && oldAppName !== null) await lease.terminateBackend(oldAppName);
|
|
2031
|
+
}
|
|
2032
|
+
if (Date.now() > deadline) {
|
|
2033
|
+
throw new Error(`fleet: could not acquire shard '${shardId}' (slot ${slot}) lease within the deadline`);
|
|
2034
|
+
}
|
|
2035
|
+
await new Promise((r) => setTimeout(r, Math.max(1, retryMs)));
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
function deriveFlushesPerSec(prevReadMs, prevFlushCount, nowMs, nowFlushCount) {
|
|
2039
|
+
if (prevReadMs === null || nowMs <= prevReadMs) return 0;
|
|
2040
|
+
return Math.max(0, (nowFlushCount - prevFlushCount) / ((nowMs - prevReadMs) / 1e3));
|
|
2041
|
+
}
|
|
2042
|
+
async function startFleetNode(deps) {
|
|
2043
|
+
const { client, pgStore, runtime, lease, forwarder, switchable, replicaPath } = deps;
|
|
2044
|
+
const numShards = deps.numShards ?? 1;
|
|
2045
|
+
const shards = shardIdList2(numShards);
|
|
2046
|
+
let replica = deps.replica;
|
|
2047
|
+
const onExit = deps.onExit ?? defaultFleetExit;
|
|
2048
|
+
const promotedCbs = [];
|
|
2049
|
+
let frontierMonitor = null;
|
|
2050
|
+
let unsubscribeCommits = null;
|
|
2051
|
+
let unregisterCommitGuard = null;
|
|
2052
|
+
let unregisterReceiptsGuard = null;
|
|
2053
|
+
let lastGroupCommitReadMs = null;
|
|
2054
|
+
let lastFlushCount = 0;
|
|
2055
|
+
const groupCommitStats = () => {
|
|
2056
|
+
const stats = runtime.groupCommitStats();
|
|
2057
|
+
const now = Date.now();
|
|
2058
|
+
const flushesPerSec = deriveFlushesPerSec(lastGroupCommitReadMs, lastFlushCount, now, stats.flushCount);
|
|
2059
|
+
lastGroupCommitReadMs = now;
|
|
2060
|
+
lastFlushCount = stats.flushCount;
|
|
2061
|
+
return { ...stats, flushesPerSec };
|
|
2062
|
+
};
|
|
2063
|
+
const firePromoted = () => {
|
|
2064
|
+
for (const cb of promotedCbs) {
|
|
2065
|
+
try {
|
|
2066
|
+
cb();
|
|
2067
|
+
} catch {
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2071
|
+
let writerish = forwarder.isLocalWriter();
|
|
2072
|
+
let promoting = false;
|
|
2073
|
+
let doPromote = () => {
|
|
2074
|
+
};
|
|
2075
|
+
const chainDriverOp = createAsyncChain();
|
|
2076
|
+
const startDriversChained = () => chainDriverOp(() => runtime.startDrivers());
|
|
2077
|
+
const stopDriversOnlyChained = () => chainDriverOp(() => runtime.stopDriversOnly());
|
|
2078
|
+
const relinquishDeps = {
|
|
2079
|
+
lease,
|
|
2080
|
+
client,
|
|
2081
|
+
shards,
|
|
2082
|
+
onDefaultRelinquished: () => void stopDriversOnlyChained()
|
|
2083
|
+
};
|
|
2084
|
+
const tryAcquireShard = async (shardId) => {
|
|
2085
|
+
const slot = shards.indexOf(shardId);
|
|
2086
|
+
if (slot < 0) return false;
|
|
2087
|
+
let state = await lease.tryAcquire(shardId, slot, true);
|
|
2088
|
+
if (!state && await lease.isExpired(shardId)) {
|
|
2089
|
+
const { fenced, oldAppName } = await lease.evictExpired(shardId);
|
|
2090
|
+
if (fenced && oldAppName !== null) await lease.terminateBackend(oldAppName);
|
|
2091
|
+
state = await lease.tryAcquire(shardId, slot, true);
|
|
2092
|
+
}
|
|
2093
|
+
if (!state) return false;
|
|
2094
|
+
runtime.observeWriteTimestamp(state.frontierTs);
|
|
2095
|
+
if (shardId === DEFAULT_SHARD4) void startDriversChained();
|
|
2096
|
+
return true;
|
|
2097
|
+
};
|
|
2098
|
+
const releaseShard = async (shardId) => {
|
|
2099
|
+
const fenced = await runtime.tryRunExclusiveOnShard(shardId, async () => {
|
|
2100
|
+
await lease.selfFence(shardId);
|
|
2101
|
+
});
|
|
2102
|
+
if (!fenced) return;
|
|
2103
|
+
relinquish(relinquishDeps, shardId, "balancer graceful release (no longer a rendezvous target)");
|
|
2104
|
+
};
|
|
2105
|
+
const multiWriter = fleetMultiWriterEnabled();
|
|
2106
|
+
const balancer = new ShardLeaseBalancer({
|
|
2107
|
+
lease,
|
|
2108
|
+
myUrl: lease.advertiseUrl,
|
|
2109
|
+
numShards,
|
|
2110
|
+
multiWriter,
|
|
2111
|
+
isHeld: (shardId) => lease.currentEpoch(shardId) !== null,
|
|
2112
|
+
isWriterish: () => writerish,
|
|
2113
|
+
tryAcquireShard,
|
|
2114
|
+
releaseShard,
|
|
2115
|
+
requestPromotion: async () => {
|
|
2116
|
+
doPromote();
|
|
2117
|
+
},
|
|
2118
|
+
// Fleet B3, D3: reclaim expired `fleet_idempotency` rows on every writer-ish beat (cheap indexed
|
|
2119
|
+
// delete) — see `LeaseManager.sweepIdempotency`. The balancer runs `sweepIdempotency` only while
|
|
2120
|
+
// `isWriterish()`; a pure sync node holds no writer-side state to sweep.
|
|
2121
|
+
sweepIdempotency: () => lease.sweepIdempotency(),
|
|
2122
|
+
beatMs: fleetAcquireRetryMs(lease.ttlMs)
|
|
2123
|
+
});
|
|
2124
|
+
const invalidationSink = async (inv) => {
|
|
2125
|
+
try {
|
|
2126
|
+
runtime.observeTimestamp(inv.newMaxTs);
|
|
2127
|
+
const ranges = [
|
|
2128
|
+
...inv.writtenKeys.map((k) => keyToPointRange(k.indexId, k.key)),
|
|
2129
|
+
...inv.writtenDocs.map((d) => docKeyToPointRange(d.tableId, d.internalId))
|
|
2130
|
+
];
|
|
2131
|
+
const commitTs = Number(inv.newMaxTs);
|
|
2132
|
+
await runtime.handler.notifyWrites({
|
|
2133
|
+
tables: inv.writtenTables,
|
|
2134
|
+
ranges,
|
|
2135
|
+
commitTs
|
|
2136
|
+
});
|
|
2137
|
+
runtime.notifyExternalCommit({ tables: inv.writtenTables, ranges, commitTs });
|
|
2138
|
+
} catch (e) {
|
|
2139
|
+
console.error("fleet: replica invalidation failed", e);
|
|
2140
|
+
}
|
|
2141
|
+
};
|
|
2142
|
+
const startHybridTailer = async () => {
|
|
2143
|
+
if (!replica || !switchable || !replicaPath) {
|
|
2144
|
+
throw new Error(
|
|
2145
|
+
"fleet: hybrid boot requires a replica + switchable store + replicaPath (bug: prepareFleetNode multi-writer path must provide them)"
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
await pgStore.setupSchema();
|
|
2149
|
+
const reconciled2 = await reconcileReplicaIdentity({ pgStore, replica, switchable, replicaPath });
|
|
2150
|
+
replica = reconciled2.replica;
|
|
2151
|
+
const t = new ReplicaTailer(client, pgStore, replica, { numShards, onInvalidation: invalidationSink });
|
|
2152
|
+
await t.start();
|
|
2153
|
+
forwarder.attachTailer(t);
|
|
2154
|
+
return t;
|
|
2155
|
+
};
|
|
2156
|
+
let hybridTailer = null;
|
|
2157
|
+
let monitor = null;
|
|
2158
|
+
const startWriterMonitor = () => {
|
|
2159
|
+
monitor = new LeaseMonitor({
|
|
2160
|
+
// Heartbeat-as-probe (Fenced Frontier B1, D2): one round-trip serves liveness-probe + TTL
|
|
2161
|
+
// maintenance + fence verification — NEVER pg_try_advisory_lock (re-entrant on the holding
|
|
2162
|
+
// session). `lease.currentEpoch()` is read live so a re-promotion's epoch bump is honored.
|
|
2163
|
+
probe: async () => {
|
|
2164
|
+
await lease.heartbeatPresence();
|
|
2165
|
+
if (lease.heldPairs().length === 0) return;
|
|
2166
|
+
const { fencedShardIds } = await lease.heartbeatAll();
|
|
2167
|
+
for (const fencedShardId of fencedShardIds) {
|
|
2168
|
+
relinquish(
|
|
2169
|
+
relinquishDeps,
|
|
2170
|
+
fencedShardId,
|
|
2171
|
+
"batched heartbeat found a superseded epoch"
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
},
|
|
2175
|
+
onExit: (reason) => onExit(`writer lease lost: ${reason}`),
|
|
2176
|
+
// Probe cadence scales with the lease TTL (same knob the LeaseManager stamps expires_at from),
|
|
2177
|
+
// so a live writer always renews several times per TTL — a shortened TTL (e.g. the wedged-writer
|
|
2178
|
+
// E2E's 4000ms) must not let a HEALTHY writer's own lease expire between probes. At the default
|
|
2179
|
+
// 15000ms TTL this is exactly the historical 5000ms probe.
|
|
2180
|
+
probeMs: fleetProbeMs(lease.ttlMs)
|
|
2181
|
+
});
|
|
2182
|
+
monitor.start();
|
|
2183
|
+
};
|
|
2184
|
+
client.onConnectionLost?.(() => monitor?.connectionLost());
|
|
2185
|
+
client.onShardConnectionLost?.(
|
|
2186
|
+
(shardId) => relinquish(relinquishDeps, shardId, "commit connection lost", { connectionLost: true })
|
|
2187
|
+
);
|
|
2188
|
+
const armWriter = async (seed) => {
|
|
2189
|
+
await balancer.acquireTargetsNow();
|
|
2190
|
+
if (seed) await lease.seedFrontierAll(await pgStore.maxTimestamp());
|
|
2191
|
+
unregisterReceiptsGuard?.();
|
|
2192
|
+
const receiptsGuard = clientReceiptsGuard();
|
|
2193
|
+
unregisterReceiptsGuard = pgStore.addCommitGuard(async (q, units, shardId) => {
|
|
2194
|
+
await receiptsGuard(q, units, shardId);
|
|
2195
|
+
});
|
|
2196
|
+
unregisterCommitGuard?.();
|
|
2197
|
+
unregisterCommitGuard = installCommitGuard(
|
|
2198
|
+
pgStore,
|
|
2199
|
+
lease,
|
|
2200
|
+
(fencedShardId, reason) => relinquish(relinquishDeps, fencedShardId, reason)
|
|
2201
|
+
);
|
|
2202
|
+
const closeIdle = numShards > 1;
|
|
2203
|
+
frontierMonitor = new FrontierMonitor(lease, {
|
|
2204
|
+
closeIdle,
|
|
2205
|
+
runExclusiveOnShard: (shardId, fn) => runtime.tryRunExclusiveOnShard(shardId, fn),
|
|
2206
|
+
// D4 — replica-lag warning: a hybrid writer keeps a local read-replica tailer (`hybridTailer`,
|
|
2207
|
+
// already started above/on the prior sync boot by the time `armWriter` runs) even though it
|
|
2208
|
+
// commits to the primary — watch ITS watermark against F too. A non-hybrid writer has no
|
|
2209
|
+
// replica (reads the primary directly), so this is omitted there — nothing to lag.
|
|
2210
|
+
tailerWatermark: multiWriter ? () => hybridTailer.watermark() : void 0,
|
|
2211
|
+
// T3.5: wake every follower's LISTEN the instant an idle/orphan shard close (silently, by bare
|
|
2212
|
+
// UPDATE) actually un-pins F — reusing the SAME channel `NotifyingFanoutAdapter` NOTIFYs on a
|
|
2213
|
+
// real commit, so a `ReplicaTailer` armed on either wakes identically. Fire-and-forget +
|
|
2214
|
+
// swallow, exactly like `NotifyingFanoutAdapter.publish`'s own NOTIFY: a transient connection
|
|
2215
|
+
// hiccup here must not break this beat — the poll fallback remains the correctness backstop.
|
|
2216
|
+
notifyOnAdvance: (ceiling) => {
|
|
2217
|
+
void client.notify(COMMIT_CHANNEL, String(ceiling)).catch(() => {
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
});
|
|
2221
|
+
frontierMonitor.start();
|
|
2222
|
+
if (closeIdle) {
|
|
2223
|
+
unsubscribeCommits = runtime.writeFanoutAdapter?.subscribe(() => frontierMonitor?.triggerCoalesced()) ?? null;
|
|
2224
|
+
}
|
|
2225
|
+
};
|
|
2226
|
+
if (forwarder.isLocalWriter()) {
|
|
2227
|
+
if (multiWriter) hybridTailer = await startHybridTailer();
|
|
2228
|
+
await armWriter(true);
|
|
2229
|
+
startWriterMonitor();
|
|
2230
|
+
balancer.start();
|
|
2231
|
+
await pgStore.writeGlobalIfAbsent(FLEET_DEPLOYMENT_ID_KEY, crypto.randomUUID());
|
|
2232
|
+
return {
|
|
2233
|
+
role: () => "writer",
|
|
2234
|
+
writerUrl: async () => (await lease.read())?.writerUrl ?? "",
|
|
2235
|
+
onPromoted: (cb) => promotedCbs.push(cb),
|
|
2236
|
+
frontierStats: () => frontierMonitor?.stats() ?? null,
|
|
2237
|
+
groupCommitStats,
|
|
2238
|
+
isLocalWriter: (shardId) => forwarder.isLocalWriter(shardId),
|
|
2239
|
+
idempotencyLookup: (key) => lease.lookupIdempotency(key),
|
|
2240
|
+
idempotencyRecordValue: (key, value) => lease.recordIdempotencyValue(key, value),
|
|
2241
|
+
stop: async () => {
|
|
2242
|
+
monitor?.stop();
|
|
2243
|
+
balancer.stop();
|
|
2244
|
+
frontierMonitor?.stop();
|
|
2245
|
+
unsubscribeCommits?.();
|
|
2246
|
+
unregisterCommitGuard?.();
|
|
2247
|
+
lease.stop();
|
|
2248
|
+
await hybridTailer?.stop();
|
|
2249
|
+
if (hybridTailer) await switchable?.close();
|
|
2250
|
+
}
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
if (!replica || !switchable || !replicaPath) {
|
|
2254
|
+
throw new Error(
|
|
2255
|
+
"fleet: sync node start requires a replica + switchable store + replicaPath (bug: prepareFleetNode sync boot must provide them)"
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
await pgStore.setupSchema();
|
|
2259
|
+
const reconciled = await reconcileReplicaIdentity({ pgStore, replica, switchable, replicaPath });
|
|
2260
|
+
replica = reconciled.replica;
|
|
2261
|
+
const tailer = new ReplicaTailer(client, pgStore, replica, {
|
|
2262
|
+
// B2a: the ready gate is F = min(frontier_ts) over all N shard rows, and the tailer refuses to
|
|
2263
|
+
// treat a partial `shard_leases` (count < numShards) as ready.
|
|
2264
|
+
numShards,
|
|
2265
|
+
// Same sink the writer-ish derive-only listener uses (D5/T5-c) — observe the ts + fan invalidation
|
|
2266
|
+
// into the sync handler; only the replica-apply (this tailer's default mode) differs.
|
|
2267
|
+
onInvalidation: invalidationSink
|
|
2268
|
+
});
|
|
2269
|
+
await tailer.start();
|
|
2270
|
+
forwarder.attachTailer(tailer);
|
|
2271
|
+
if (multiWriter) hybridTailer = tailer;
|
|
2272
|
+
frontierMonitor = new FrontierMonitor(lease, { closeIdle: false, tailerWatermark: () => tailer.watermark() });
|
|
2273
|
+
frontierMonitor.start();
|
|
2274
|
+
const triggerPromotion = () => {
|
|
2275
|
+
if (promoting) return;
|
|
2276
|
+
promoting = true;
|
|
2277
|
+
void runPromotion({
|
|
2278
|
+
// The lease row (default shard) is already upserted by the tryAcquire() inside acquireLoop when
|
|
2279
|
+
// the election path fires; the balancer path promotes first, then acquires its targets below.
|
|
2280
|
+
promote: async () => {
|
|
2281
|
+
if (multiWriter) {
|
|
2282
|
+
pgStore.setWritable();
|
|
2283
|
+
frontierMonitor?.stop();
|
|
2284
|
+
unsubscribeCommits?.();
|
|
2285
|
+
unsubscribeCommits = null;
|
|
2286
|
+
await armWriter(false);
|
|
2287
|
+
writerish = true;
|
|
2288
|
+
if (lease.currentEpoch(DEFAULT_SHARD4) === null) void stopDriversOnlyChained();
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
await promoteFleetNode({
|
|
2292
|
+
// D4: `startDrivers` routed through the SAME driver chain as every other call site, so this
|
|
2293
|
+
// promotion's step 7 (`await runtime.startDrivers()`) is ordered against any overlapping
|
|
2294
|
+
// `stopDriversOnly()`/`startDrivers()` fired from the balancer or a relinquish, rather than
|
|
2295
|
+
// racing the runtime's own bare call directly.
|
|
2296
|
+
runtime: { observeTimestamp: (ts) => runtime.observeTimestamp(ts), startDrivers: startDriversChained },
|
|
2297
|
+
pgStore,
|
|
2298
|
+
switchable,
|
|
2299
|
+
forwarder,
|
|
2300
|
+
tailer,
|
|
2301
|
+
replica
|
|
2302
|
+
});
|
|
2303
|
+
frontierMonitor?.stop();
|
|
2304
|
+
unsubscribeCommits?.();
|
|
2305
|
+
unsubscribeCommits = null;
|
|
2306
|
+
await armWriter(false);
|
|
2307
|
+
writerish = true;
|
|
2308
|
+
if (lease.currentEpoch(DEFAULT_SHARD4) === null) void stopDriversOnlyChained();
|
|
2309
|
+
},
|
|
2310
|
+
startMonitor: startWriterMonitor,
|
|
2311
|
+
firePromoted,
|
|
2312
|
+
onExit
|
|
2313
|
+
});
|
|
2314
|
+
};
|
|
2315
|
+
doPromote = triggerPromotion;
|
|
2316
|
+
lease.acquireLoop((state) => {
|
|
2317
|
+
runtime.observeWriteTimestamp(state.frontierTs);
|
|
2318
|
+
triggerPromotion();
|
|
2319
|
+
void startDriversChained();
|
|
2320
|
+
});
|
|
2321
|
+
balancer.start();
|
|
2322
|
+
return {
|
|
2323
|
+
role: () => forwarder.isLocalWriter() ? "writer" : "sync",
|
|
2324
|
+
writerUrl: async () => (await lease.read())?.writerUrl ?? "",
|
|
2325
|
+
onPromoted: (cb) => promotedCbs.push(cb),
|
|
2326
|
+
frontierStats: () => frontierMonitor?.stats() ?? null,
|
|
2327
|
+
groupCommitStats,
|
|
2328
|
+
isLocalWriter: (shardId) => forwarder.isLocalWriter(shardId),
|
|
2329
|
+
idempotencyLookup: (key) => lease.lookupIdempotency(key),
|
|
2330
|
+
idempotencyRecordValue: (key, value) => lease.recordIdempotencyValue(key, value),
|
|
2331
|
+
stop: async () => {
|
|
2332
|
+
monitor?.stop();
|
|
2333
|
+
balancer.stop();
|
|
2334
|
+
frontierMonitor?.stop();
|
|
2335
|
+
unsubscribeCommits?.();
|
|
2336
|
+
unregisterCommitGuard?.();
|
|
2337
|
+
lease.stop();
|
|
2338
|
+
await tailer.stop();
|
|
2339
|
+
if (multiWriter) await switchable?.close();
|
|
2340
|
+
await pgStore.close();
|
|
2341
|
+
}
|
|
2342
|
+
};
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// src/reshard.ts
|
|
2346
|
+
import { DEFAULT_SHARD as DEFAULT_SHARD5, shardIdList as shardIdList3 } from "@helipod/id-codec";
|
|
2347
|
+
var NUM_SHARDS_GLOBAL_KEY = "fleet:numShards";
|
|
2348
|
+
var ReshardFleetLiveError = class extends Error {
|
|
2349
|
+
liveUrls;
|
|
2350
|
+
constructor(liveUrls) {
|
|
2351
|
+
super(
|
|
2352
|
+
`refusing to reshard: ${liveUrls.length} node(s) still live: ${liveUrls.join(", ")} \u2014 scale the fleet to zero first`
|
|
2353
|
+
);
|
|
2354
|
+
this.name = "ReshardFleetLiveError";
|
|
2355
|
+
this.liveUrls = liveUrls;
|
|
2356
|
+
}
|
|
2357
|
+
};
|
|
2358
|
+
var ReshardVerificationError = class extends Error {
|
|
2359
|
+
constructor(message) {
|
|
2360
|
+
super(message);
|
|
2361
|
+
this.name = "ReshardVerificationError";
|
|
2362
|
+
}
|
|
2363
|
+
};
|
|
2364
|
+
var ReshardNotAFleetError = class extends Error {
|
|
2365
|
+
constructor() {
|
|
2366
|
+
super(
|
|
2367
|
+
"this database is not a fleet store (no shard_leases/fleet_nodes tables) \u2014 a fleet is initialized by `helipod serve --fleet`; run the fleet at least once before resharding"
|
|
2368
|
+
);
|
|
2369
|
+
this.name = "ReshardNotAFleetError";
|
|
2370
|
+
}
|
|
2371
|
+
};
|
|
2372
|
+
function toBigIntOrZero2(v) {
|
|
2373
|
+
if (v === null || v === void 0) return 0n;
|
|
2374
|
+
return typeof v === "bigint" ? v : BigInt(v);
|
|
2375
|
+
}
|
|
2376
|
+
async function documentsTableExists2(q) {
|
|
2377
|
+
const rows = await q.query(`SELECT to_regclass('documents') IS NOT NULL AS exists`);
|
|
2378
|
+
return rows[0]?.exists === true;
|
|
2379
|
+
}
|
|
2380
|
+
async function maxDocumentsTs(q) {
|
|
2381
|
+
if (!await documentsTableExists2(q)) return 0n;
|
|
2382
|
+
const rows = await q.query(`SELECT COALESCE(MAX(ts), 0) AS m FROM documents`);
|
|
2383
|
+
return toBigIntOrZero2(rows[0]?.m);
|
|
2384
|
+
}
|
|
2385
|
+
async function readNumShardsGlobal(q) {
|
|
2386
|
+
const rows = await q.query(`SELECT value FROM persistence_globals WHERE key = $1`, [NUM_SHARDS_GLOBAL_KEY]);
|
|
2387
|
+
const row = rows[0];
|
|
2388
|
+
if (!row) return null;
|
|
2389
|
+
return Number(JSON.parse(row.value));
|
|
2390
|
+
}
|
|
2391
|
+
function shardIdSet(rows) {
|
|
2392
|
+
return new Set(rows.map((r) => r.shard_id));
|
|
2393
|
+
}
|
|
2394
|
+
async function reshardFleet(client, opts) {
|
|
2395
|
+
const { targetShards } = opts;
|
|
2396
|
+
if (!Number.isInteger(targetShards) || targetShards < 1) {
|
|
2397
|
+
throw new RangeError(`reshardFleet: targetShards must be an integer >= 1, got ${targetShards}`);
|
|
2398
|
+
}
|
|
2399
|
+
const fleetProbe = await client.query(
|
|
2400
|
+
`SELECT to_regclass('shard_leases') IS NOT NULL AND to_regclass('fleet_nodes') IS NOT NULL AS is_fleet`
|
|
2401
|
+
);
|
|
2402
|
+
if (fleetProbe[0]?.is_fleet !== true) {
|
|
2403
|
+
throw new ReshardNotAFleetError();
|
|
2404
|
+
}
|
|
2405
|
+
const liveRows = await client.query(
|
|
2406
|
+
`SELECT advertise_url AS url FROM fleet_nodes WHERE expires_at >= now()
|
|
2407
|
+
UNION
|
|
2408
|
+
SELECT writer_url AS url FROM shard_leases WHERE writer_url IS NOT NULL AND expires_at >= now()`
|
|
2409
|
+
);
|
|
2410
|
+
const liveUrls = liveRows.map((r) => r.url).filter((u) => typeof u === "string" && u.length > 0);
|
|
2411
|
+
if (liveUrls.length > 0) throw new ReshardFleetLiveError(liveUrls);
|
|
2412
|
+
const laneRows = await client.query(`SELECT shard_id FROM shard_leases`);
|
|
2413
|
+
const currentLanes = shardIdSet(laneRows);
|
|
2414
|
+
const persistedNumShards = await readNumShardsGlobal(client);
|
|
2415
|
+
const previousShards = persistedNumShards ?? currentLanes.size;
|
|
2416
|
+
const target = shardIdList3(targetShards);
|
|
2417
|
+
const targetSet = new Set(target);
|
|
2418
|
+
const created = target.filter((id) => !currentLanes.has(id));
|
|
2419
|
+
const deleted = [...currentLanes].filter((id) => !targetSet.has(id));
|
|
2420
|
+
if (deleted.includes(DEFAULT_SHARD5)) {
|
|
2421
|
+
throw new ReshardVerificationError(`internal invariant violation: "${DEFAULT_SHARD5}" lane marked for deletion`);
|
|
2422
|
+
}
|
|
2423
|
+
await client.transaction(async (tx) => {
|
|
2424
|
+
const seedFragment = await documentsTableExists2(tx) ? `(SELECT COALESCE(MAX(ts), 0) FROM documents)` : `0`;
|
|
2425
|
+
await tx.query(
|
|
2426
|
+
`INSERT INTO persistence_globals (key, value) VALUES ($1, $2)
|
|
2427
|
+
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
|
2428
|
+
[NUM_SHARDS_GLOBAL_KEY, JSON.stringify(String(targetShards))]
|
|
2429
|
+
);
|
|
2430
|
+
for (const shardId of created) {
|
|
2431
|
+
await tx.query(
|
|
2432
|
+
`INSERT INTO shard_leases (shard_id, epoch, writer_url, writer_app_name, expires_at, frontier_ts, prev_ts)
|
|
2433
|
+
VALUES ($1, 0, NULL, NULL, now(), ${seedFragment}, 0)`,
|
|
2434
|
+
[shardId]
|
|
2435
|
+
);
|
|
2436
|
+
}
|
|
2437
|
+
for (const shardId of deleted) {
|
|
2438
|
+
await tx.query(`DELETE FROM shard_leases WHERE shard_id = $1`, [shardId]);
|
|
2439
|
+
}
|
|
2440
|
+
await tx.query(`UPDATE shard_leases SET frontier_ts = GREATEST(frontier_ts, ${seedFragment})`);
|
|
2441
|
+
});
|
|
2442
|
+
const postLaneRows = await client.query(`SELECT shard_id, frontier_ts FROM shard_leases`);
|
|
2443
|
+
const postLaneIds = postLaneRows.map((r) => r.shard_id);
|
|
2444
|
+
const postSet = new Set(postLaneIds);
|
|
2445
|
+
const setMatches = postLaneIds.length === targetShards && target.every((id) => postSet.has(id));
|
|
2446
|
+
if (!setMatches) {
|
|
2447
|
+
throw new ReshardVerificationError(
|
|
2448
|
+
`reshard post-verify failed: expected shard_leases set ${JSON.stringify(target)}, got ${JSON.stringify(postLaneIds)}`
|
|
2449
|
+
);
|
|
2450
|
+
}
|
|
2451
|
+
const frontierValues = postLaneRows.map((r) => toBigIntOrZero2(r.frontier_ts));
|
|
2452
|
+
const minFrontier = frontierValues.reduce((a, b) => b < a ? b : a);
|
|
2453
|
+
const maxTs = await maxDocumentsTs(client);
|
|
2454
|
+
if (minFrontier < maxTs) {
|
|
2455
|
+
throw new ReshardVerificationError(
|
|
2456
|
+
`reshard post-verify failed: min(frontier_ts)=${minFrontier} < MAX(ts)=${maxTs} (the F1 floor)`
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
const globalNumShards = await readNumShardsGlobal(client);
|
|
2460
|
+
if (globalNumShards !== targetShards) {
|
|
2461
|
+
throw new ReshardVerificationError(
|
|
2462
|
+
`reshard post-verify failed: fleet:numShards reads back ${globalNumShards}, expected ${targetShards}`
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
return {
|
|
2466
|
+
previousShards,
|
|
2467
|
+
newShards: targetShards,
|
|
2468
|
+
created,
|
|
2469
|
+
deleted,
|
|
2470
|
+
frontierFloor: minFrontier.toString()
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
// src/index.ts
|
|
2475
|
+
var FLEET_VERSION = "0.0.0";
|
|
2476
|
+
export {
|
|
2477
|
+
DEFAULT_NUM_SHARDS2 as DEFAULT_NUM_SHARDS,
|
|
2478
|
+
DensityViolationError,
|
|
2479
|
+
FLEET_VERSION,
|
|
2480
|
+
FLEET_WRITER_SESSION_TIMEOUTS,
|
|
2481
|
+
FencedError,
|
|
2482
|
+
FrontierMonitor,
|
|
2483
|
+
IDEMPOTENCY_VALUE_CAP_BYTES,
|
|
2484
|
+
LeaseManager,
|
|
2485
|
+
LeaseMonitor,
|
|
2486
|
+
NUM_SHARDS_GLOBAL_KEY,
|
|
2487
|
+
NotifyingFanoutAdapter,
|
|
2488
|
+
REPLICA_DB_FILENAME,
|
|
2489
|
+
ReplicaTailer,
|
|
2490
|
+
ReshardFleetLiveError,
|
|
2491
|
+
ReshardNotAFleetError,
|
|
2492
|
+
ReshardVerificationError,
|
|
2493
|
+
ShardLeaseBalancer,
|
|
2494
|
+
SwitchableDocStore,
|
|
2495
|
+
WriteForwarder,
|
|
2496
|
+
acquireShardAsWriter,
|
|
2497
|
+
createAsyncChain,
|
|
2498
|
+
deriveFlushesPerSec,
|
|
2499
|
+
docKeyToPointRange,
|
|
2500
|
+
fleetApplicationName,
|
|
2501
|
+
fleetMultiWriterEnabled,
|
|
2502
|
+
groupCommitEnabled,
|
|
2503
|
+
installCommitGuard,
|
|
2504
|
+
keyToPointRange,
|
|
2505
|
+
prepareFleetNode,
|
|
2506
|
+
relinquish,
|
|
2507
|
+
rendezvousOwner,
|
|
2508
|
+
rendezvousWeight,
|
|
2509
|
+
reshardFleet,
|
|
2510
|
+
runPromotion,
|
|
2511
|
+
stablePrefixFromFrontier,
|
|
2512
|
+
startFleetNode
|
|
2513
|
+
};
|
|
2514
|
+
//# sourceMappingURL=index.js.map
|