@astilba/cache 0.0.0-stage → 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 +21 -0
- package/README.md +14 -2
- package/dist/adapters/cloudflare/index.d.ts +299 -0
- package/dist/adapters/cloudflare/index.js +1699 -0
- package/dist/adapters/react-router/index.d.ts +92 -0
- package/dist/adapters/react-router/index.js +272 -0
- package/dist/cache-YWn0VZpn.d.ts +930 -0
- package/dist/cache-Z4cJzPmm.js +3528 -0
- package/dist/index.d.ts +131 -0
- package/dist/index.js +2 -0
- package/package.json +96 -3
|
@@ -0,0 +1,1699 @@
|
|
|
1
|
+
import { a as replicationPollerOf, b as isRegId, c as isRetriableHttp, d as memory, f as readFrame, m as safeEmit, p as TELEMETRY_EVENTS, r as createCache, t as aliasCacheIdentity, u as isStoreWriteError, v as DEFAULT_REG_ID, x as mirrorKeys, y as assertRegId } from "../../cache-Z4cJzPmm.js";
|
|
2
|
+
import { DurableObject } from "cloudflare:workers";
|
|
3
|
+
/**
|
|
4
|
+
* App-specific WebSocket close codes (the 4000–4999 private range). Each is EXPLICIT so a
|
|
5
|
+
* subscriber can tell WHY it was cut and knows the remedy is the same everywhere: redial → the
|
|
6
|
+
* `reset` → `hello` handshake → KV resync (cold protocol).
|
|
7
|
+
* - `DELIVERY_DOUBT` — the SERVER closed a subscriber on any delivery doubt (a throwing `send`, a
|
|
8
|
+
* non-OPEN socket): doBus never risks a silent gap, it closes.
|
|
9
|
+
* - `SCOPE_MISMATCH` — the CLIENT closed because the DO's `hello` echoed a `regId` ≠ its own
|
|
10
|
+
* configured scope: it read a foreign channel, so it fails closed loud.
|
|
11
|
+
* - `PROTOCOL` — the CLIENT closed on an undecodable message (malformed / unknown kind / wrong
|
|
12
|
+
* version): never dispatch a partial event, never guess a future version.
|
|
13
|
+
*/
|
|
14
|
+
const CLOSE_DELIVERY_DOUBT = 4001;
|
|
15
|
+
const CLOSE_SCOPE_MISMATCH = 4002;
|
|
16
|
+
const CLOSE_PROTOCOL = 4003;
|
|
17
|
+
/** Narrow an `unknown` to an index-readable record, or `undefined`. Every field is `typeof`-checked
|
|
18
|
+
* at the call site below before use, so the record view is sound. */
|
|
19
|
+
function asRecord(value) {
|
|
20
|
+
if (typeof value !== "object" || value === null) return;
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Encode one wire event to a single WS-message string. The `frame` payload is
|
|
25
|
+
* `JSON.stringify(frame)` — the canonical bytes, carried UNCHANGED as a nested JSON string, so
|
|
26
|
+
* a frame survives the wire byte-identical (the canonical-bytes teeth pin it). Keys are emitted in
|
|
27
|
+
* a fixed order so the envelope bytes are themselves deterministic.
|
|
28
|
+
*/
|
|
29
|
+
function encode(event) {
|
|
30
|
+
switch (event.kind) {
|
|
31
|
+
case "reset": return JSON.stringify({
|
|
32
|
+
kind: "reset",
|
|
33
|
+
v: 1
|
|
34
|
+
});
|
|
35
|
+
case "hello": return JSON.stringify({
|
|
36
|
+
head: event.head,
|
|
37
|
+
kind: "hello",
|
|
38
|
+
regId: event.regId,
|
|
39
|
+
v: 1
|
|
40
|
+
});
|
|
41
|
+
case "gap": return JSON.stringify({
|
|
42
|
+
head: event.head,
|
|
43
|
+
kind: "gap",
|
|
44
|
+
v: 1
|
|
45
|
+
});
|
|
46
|
+
case "frame": return JSON.stringify({
|
|
47
|
+
frame: JSON.stringify(event.frame),
|
|
48
|
+
kind: "frame",
|
|
49
|
+
v: 1
|
|
50
|
+
});
|
|
51
|
+
default: throw new Error(`unknown wire event: ${String(event)}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Decode one WS-message string, STRICTLY. Any of — non-JSON bytes, a non-object, a `v`
|
|
56
|
+
* that is not exactly `WIRE_VERSION` (a higher version is NEVER guessed; a lower one is refused
|
|
57
|
+
* too), an unknown `kind`, or a malformed payload (a non-number `head`/absent `regId`, or a frame
|
|
58
|
+
* the `readFrame` rejects) — yields `{ok:false}`, a CLOSE-and-reconnect signal. A well-formed
|
|
59
|
+
* message yields `{ok:true, event}`; the frame is validated through the reader's own `readFrame`,
|
|
60
|
+
* so a corrupt wire change fails closed exactly like a corrupt `__delta` byte.
|
|
61
|
+
*/
|
|
62
|
+
function decode(bytes) {
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(bytes);
|
|
66
|
+
} catch {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
reason: "malformed json"
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const env = asRecord(parsed);
|
|
73
|
+
if (!env) return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: "envelope is not an object"
|
|
76
|
+
};
|
|
77
|
+
if (env.v !== 1) return {
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: `unsupported wire version ${String(env.v)}`
|
|
80
|
+
};
|
|
81
|
+
switch (env.kind) {
|
|
82
|
+
case "reset": return {
|
|
83
|
+
event: { kind: "reset" },
|
|
84
|
+
ok: true
|
|
85
|
+
};
|
|
86
|
+
case "hello":
|
|
87
|
+
if (typeof env.head !== "number" || typeof env.regId !== "string") return {
|
|
88
|
+
ok: false,
|
|
89
|
+
reason: "malformed hello"
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
event: {
|
|
93
|
+
head: env.head,
|
|
94
|
+
kind: "hello",
|
|
95
|
+
regId: env.regId
|
|
96
|
+
},
|
|
97
|
+
ok: true
|
|
98
|
+
};
|
|
99
|
+
case "gap":
|
|
100
|
+
if (typeof env.head !== "number") return {
|
|
101
|
+
ok: false,
|
|
102
|
+
reason: "malformed gap"
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
event: {
|
|
106
|
+
head: env.head,
|
|
107
|
+
kind: "gap"
|
|
108
|
+
},
|
|
109
|
+
ok: true
|
|
110
|
+
};
|
|
111
|
+
case "frame": {
|
|
112
|
+
if (typeof env.frame !== "string") return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: "malformed frame payload"
|
|
115
|
+
};
|
|
116
|
+
const frame = readFrame(env.frame);
|
|
117
|
+
if (!frame) return {
|
|
118
|
+
ok: false,
|
|
119
|
+
reason: "frame failed F12 parse"
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
event: {
|
|
123
|
+
frame,
|
|
124
|
+
kind: "frame"
|
|
125
|
+
},
|
|
126
|
+
ok: true
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
default: return {
|
|
130
|
+
ok: false,
|
|
131
|
+
reason: `unknown wire kind ${String(env.kind)}`
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/adapters/cloudflare/bus-client.ts
|
|
137
|
+
/** Is `value` a thenable (a promise-returning dial vs a synchronous one)? */
|
|
138
|
+
function isThenable(value) {
|
|
139
|
+
return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
|
|
140
|
+
}
|
|
141
|
+
var DoBus = class {
|
|
142
|
+
#dial;
|
|
143
|
+
#regId;
|
|
144
|
+
#onClose;
|
|
145
|
+
constructor(dial, options) {
|
|
146
|
+
this.#dial = dial;
|
|
147
|
+
this.#regId = options.regId;
|
|
148
|
+
this.#onClose = options.onClose;
|
|
149
|
+
}
|
|
150
|
+
subscribe(onEvent) {
|
|
151
|
+
const state = {
|
|
152
|
+
closed: false,
|
|
153
|
+
onEvent,
|
|
154
|
+
reported: false,
|
|
155
|
+
socket: null
|
|
156
|
+
};
|
|
157
|
+
const attach = (socket) => {
|
|
158
|
+
if (state.closed) {
|
|
159
|
+
this.#safeSocketClose(socket, 1e3, "astilba/cache: unsubscribed");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
state.socket = socket;
|
|
163
|
+
socket.addEventListener("message", (event) => {
|
|
164
|
+
this.#onMessage(event.data, onEvent, state);
|
|
165
|
+
});
|
|
166
|
+
socket.addEventListener("close", (event) => {
|
|
167
|
+
this.#report(state, {
|
|
168
|
+
code: event.code,
|
|
169
|
+
local: false,
|
|
170
|
+
reason: event.reason
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
const dialed = this.#dial();
|
|
175
|
+
if (isThenable(dialed)) (async () => {
|
|
176
|
+
try {
|
|
177
|
+
attach(await dialed);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
this.#report(state, {
|
|
180
|
+
code: CLOSE_PROTOCOL,
|
|
181
|
+
local: true,
|
|
182
|
+
reason: `astilba/cache: dial failed — ${String(error)}`
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
else attach(dialed);
|
|
187
|
+
return { close: () => {
|
|
188
|
+
if (state.closed) return;
|
|
189
|
+
state.closed = true;
|
|
190
|
+
state.onEvent = null;
|
|
191
|
+
if (state.socket) {
|
|
192
|
+
if (!this.#safeSocketClose(state.socket, 1e3, "astilba/cache: unsubscribed")) return;
|
|
193
|
+
}
|
|
194
|
+
state.socket = null;
|
|
195
|
+
} };
|
|
196
|
+
}
|
|
197
|
+
/** Decode one message and dispatch it, or CLOSE loud on a protocol/scope violation (rulings). */
|
|
198
|
+
#onMessage(data, onEvent, state) {
|
|
199
|
+
if (state.closed) return;
|
|
200
|
+
if (typeof data !== "string") {
|
|
201
|
+
this.#localClose(state, CLOSE_PROTOCOL, "astilba/cache: non-string frame");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const result = decode(data);
|
|
205
|
+
if (!result.ok) {
|
|
206
|
+
this.#localClose(state, CLOSE_PROTOCOL, `astilba/cache: undecodable frame — ${result.reason}`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const { event } = result;
|
|
210
|
+
switch (event.kind) {
|
|
211
|
+
case "hello":
|
|
212
|
+
if (event.regId !== this.#regId) {
|
|
213
|
+
this.#localClose(state, CLOSE_SCOPE_MISMATCH, `astilba/cache: hello regId "${event.regId}" != configured "${this.#regId}"`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
onEvent({
|
|
217
|
+
head: event.head,
|
|
218
|
+
kind: "hello"
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
case "reset":
|
|
222
|
+
onEvent({ kind: "reset" });
|
|
223
|
+
return;
|
|
224
|
+
case "gap":
|
|
225
|
+
onEvent({
|
|
226
|
+
head: event.head,
|
|
227
|
+
kind: "gap"
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
case "frame":
|
|
231
|
+
onEvent({
|
|
232
|
+
frame: event.frame,
|
|
233
|
+
kind: "frame"
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
default: {
|
|
237
|
+
const unreachable = event;
|
|
238
|
+
this.#localClose(state, CLOSE_PROTOCOL, `astilba/cache: unreachable wire kind ${String(unreachable)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** The client initiates a close (protocol/scope) and reports it as `local`. */
|
|
243
|
+
#localClose(state, code, reason) {
|
|
244
|
+
if (state.closed) return;
|
|
245
|
+
state.closed = true;
|
|
246
|
+
if (state.socket) this.#safeSocketClose(state.socket, code, reason);
|
|
247
|
+
this.#report(state, {
|
|
248
|
+
code,
|
|
249
|
+
local: true,
|
|
250
|
+
reason
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Fire the status report at most once per subscription, and DECLARE the loss to the subscriber.
|
|
255
|
+
*The declaration is what keeps a dead channel from reading as a quiet one: `reset`/`hello`
|
|
256
|
+
* are the only other things this client dispatches, so without a `lost` the reader's evidence for
|
|
257
|
+
* "the bus never came up" and "the bus has nothing to say" is identical — namely nothing.
|
|
258
|
+
*
|
|
259
|
+
* The subscriber is notified BEFORE the app's `onClose`, and its throw cannot eat the report (nor
|
|
260
|
+
* the reverse): each is guarded separately, because the two consumers are independent and neither
|
|
261
|
+
* one's failure is the other's business.
|
|
262
|
+
*/
|
|
263
|
+
#report(state, info) {
|
|
264
|
+
if (state.reported) return;
|
|
265
|
+
state.reported = true;
|
|
266
|
+
state.closed = true;
|
|
267
|
+
const { onEvent } = state;
|
|
268
|
+
if (onEvent) try {
|
|
269
|
+
onEvent({ kind: "lost" });
|
|
270
|
+
} catch {}
|
|
271
|
+
this.#onClose?.(info);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Close the socket, tolerating a platform refusal — WITHOUT claiming success.
|
|
275
|
+
*
|
|
276
|
+
* The refusal is real and routine: this socket may have been minted in an earlier request, and the
|
|
277
|
+
* platform refuses `close()` from a different one exactly as it refuses every other operation on
|
|
278
|
+
* a foreign handle. Swallowing that and marking the subscription closed anyway was the shape of
|
|
279
|
+
* the lie: local state said "closed", the socket stayed open, and the server kept a subscriber it
|
|
280
|
+
* would fan out to forever.
|
|
281
|
+
*
|
|
282
|
+
* SO THE RETURN VALUE IS THE HONESTY. `false` means the close did NOT happen and the socket is
|
|
283
|
+
* still out there; the caller records that rather than overwriting it with an assumption. This may
|
|
284
|
+
* DEFER a close (the leak below), and that is a cost; it may not claim one that never occurred.
|
|
285
|
+
*
|
|
286
|
+
* THE LEAK'S BOUND, STATED PLAINLY. A socket this cannot close is not unbounded: the server closes
|
|
287
|
+
* on doubt at its next fan-out (a send to a socket whose client is gone fails, and the DO drops
|
|
288
|
+
* it), and isolate eviction takes the rest. So the residue is one socket per refused close, for at
|
|
289
|
+
* most as long as the Coordinator takes to speak again — priced and finite, which a silent
|
|
290
|
+
* divergence between local state and the wire is not.
|
|
291
|
+
*/
|
|
292
|
+
#safeSocketClose(socket, code, reason) {
|
|
293
|
+
try {
|
|
294
|
+
socket.close(code, reason);
|
|
295
|
+
return true;
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
/**
|
|
302
|
+
* Build a `doBus` client `Bus`. `dial` obtains an OPEN socket (synchronously in the
|
|
303
|
+
* conformance pair; a `stub.fetch` → `accept()` promise in workerd). `options.regId` is the scope
|
|
304
|
+
* the DO's `hello` must echo; `options.onClose` receives the socket's close (
|
|
305
|
+
* NO self-reconnect). The reader subscribes to the returned `Bus`; the client decodes each message
|
|
306
|
+
* and dispatches the `BusEvent`, closing loud on any protocol/scope violation.
|
|
307
|
+
*/
|
|
308
|
+
function doBus(dial, options) {
|
|
309
|
+
return new DoBus(dial, options);
|
|
310
|
+
}
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/registry/alarm.ts
|
|
313
|
+
/**
|
|
314
|
+
* The NEAREST of a currently-armed alarm and a newly-desired deadline. A `null`
|
|
315
|
+
* existing means nothing is armed yet, so the desired deadline wins outright. Never pushes an armed
|
|
316
|
+
* alarm LATER — the min guarantees a sooner deadline (a due flush) always survives a later one (the
|
|
317
|
+
* idle heartbeat cadence), and re-arming with the sooner value is idempotent.
|
|
318
|
+
*/
|
|
319
|
+
function earliestAlarm(existing, desired) {
|
|
320
|
+
return existing === null ? desired : Math.min(existing, desired);
|
|
321
|
+
}
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region src/registry/execute.ts
|
|
324
|
+
/**
|
|
325
|
+
* Write an immutable object THROUGH the non-retryable-write belt. The
|
|
326
|
+
* executor-side backstop for the delta/snapshot SIZE bound, distinct from the machine's snapshot
|
|
327
|
+
* skip: a NON-retryable classified rejection — an oversize immutable (KV `too_large`, 25 MiB, the
|
|
328
|
+
* code `cloudflareKV` raises), the pathological bound no retry can clear — emits a
|
|
329
|
+
* `flush_failed_nonretryable{code}` telemetry event so the failure is OBSERVABLE, then PROPAGATES
|
|
330
|
+
* (the flush fails whole; the terminal `__state` bless never runs, so no pointer references an
|
|
331
|
+
* object that was never written). Retryable rejections (`throttled`/`unavailable`) propagate
|
|
332
|
+
* UNTOUCHED — the alarm retries them, exactly as before.
|
|
333
|
+
*/
|
|
334
|
+
async function putImmutableWithBelt(sink, key, bytes, kind) {
|
|
335
|
+
try {
|
|
336
|
+
await sink.putImmutable(key, bytes, kind);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (isStoreWriteError(error) && !error.retryable) await sink.telemetry({
|
|
339
|
+
code: error.code,
|
|
340
|
+
event: "flush_failed_nonretryable"
|
|
341
|
+
});
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Execute a flush's effect plan against a host sink. The plan already carries
|
|
347
|
+
* the once-only terminal `advance-pointer` last, so this never issues two pointer puts in one
|
|
348
|
+
* flush. It does NOT catch a sink rejection: a failed `putPointer` fails the flush whole.
|
|
349
|
+
*
|
|
350
|
+
* `regId` scopes every key through `mirrorKeys(regId)` — the SAME formatter the reader
|
|
351
|
+
* addresses with — so the writer and reader can never re-drift to different bytes. The
|
|
352
|
+
* host passes its machine's `regId`; that same id is echoed in the pointer payload the machine
|
|
353
|
+
* minted, so the physical key and the payload agree by construction.
|
|
354
|
+
*/
|
|
355
|
+
async function executeFlushEffects(effects, sink, regId) {
|
|
356
|
+
const keys = mirrorKeys(regId);
|
|
357
|
+
for (const effect of effects) switch (effect.kind) {
|
|
358
|
+
case "persist-batch":
|
|
359
|
+
await putImmutableWithBelt(sink, keys.deltaKey(effect.seq), JSON.stringify(effect.frame), "delta");
|
|
360
|
+
break;
|
|
361
|
+
case "persist-snapshot":
|
|
362
|
+
await putImmutableWithBelt(sink, keys.snapKey(effect.seq), JSON.stringify(effect.frame), "snap");
|
|
363
|
+
break;
|
|
364
|
+
case "broadcast-frame":
|
|
365
|
+
await sink.broadcast(effect.frame);
|
|
366
|
+
break;
|
|
367
|
+
case "telemetry":
|
|
368
|
+
await sink.telemetry({
|
|
369
|
+
bytes: effect.bytes,
|
|
370
|
+
ceiling: effect.ceiling,
|
|
371
|
+
event: effect.event
|
|
372
|
+
});
|
|
373
|
+
break;
|
|
374
|
+
case "advance-pointer":
|
|
375
|
+
await sink.putPointer(keys.stateKey, JSON.stringify(effect.pointer));
|
|
376
|
+
break;
|
|
377
|
+
default: throw new Error(`unknown flush effect: ${String(effect)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/registry/machine.ts
|
|
382
|
+
/** Default snapshot cadence: checkpoint every 500 batches OR every 6h. */
|
|
383
|
+
const DEFAULT_SNAPSHOT_EVERY_BATCHES = 500;
|
|
384
|
+
const DEFAULT_SNAPSHOT_INTERVAL_MS = 216e5;
|
|
385
|
+
/** Default snapshot SIZE ceiling: the KV 25 MiB per-value profile. A due
|
|
386
|
+
* snapshot whose canonical shipped bytes exceed this is SKIPPED (never truncated, never a phantom
|
|
387
|
+
* bless) — the machine-owned gate above the executor-side delta bound. */
|
|
388
|
+
const DEFAULT_MAX_SNAPSHOT_BYTES = 26214400;
|
|
389
|
+
/**
|
|
390
|
+
* Fold a coalescing window's buffered changes into the CANONICAL batch form:
|
|
391
|
+
* - every tag appears EXACTLY ONCE, carrying its max soft/hard epoch over the window
|
|
392
|
+
* (any duplicate tags folded via max-on-write), and
|
|
393
|
+
* - the changes are ordered LEXICOGRAPHICALLY by tag (UTF-16 code-unit order).
|
|
394
|
+
* Only an axis actually touched in the window is emitted — an untouched axis has no delta
|
|
395
|
+
* over the (fromEpoch, toEpoch] range. Content-addressing makes a batch a pure
|
|
396
|
+
* function of durable state over its range, so it has one serialization; the kernel applies
|
|
397
|
+
* changes via max-on-write, which is commutative and idempotent, so dedup + lex-sort
|
|
398
|
+
* carry the SAME knowledge as the raw concatenation (INV-14/19/20 preserved). The emitted
|
|
399
|
+
* key order is alphabetical (hardEpoch, softEpoch, tag) — the repo's stable wire convention —
|
|
400
|
+
* so a canonical change has exactly one serialization on every host.
|
|
401
|
+
*/
|
|
402
|
+
function canonicalBatch(pending) {
|
|
403
|
+
const folded = /* @__PURE__ */ new Map();
|
|
404
|
+
for (const change of pending) {
|
|
405
|
+
const acc = folded.get(change.tag) ?? {};
|
|
406
|
+
if (change.softEpoch !== void 0) acc.softEpoch = Math.max(acc.softEpoch ?? change.softEpoch, change.softEpoch);
|
|
407
|
+
if (change.hardEpoch !== void 0) acc.hardEpoch = Math.max(acc.hardEpoch ?? change.hardEpoch, change.hardEpoch);
|
|
408
|
+
folded.set(change.tag, acc);
|
|
409
|
+
}
|
|
410
|
+
return [...folded.keys()].toSorted().map((tag) => {
|
|
411
|
+
const acc = folded.get(tag) ?? {};
|
|
412
|
+
return {
|
|
413
|
+
...acc.hardEpoch === void 0 ? {} : { hardEpoch: acc.hardEpoch },
|
|
414
|
+
...acc.softEpoch === void 0 ? {} : { softEpoch: acc.softEpoch },
|
|
415
|
+
tag
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The registry state machine. Holds dual watermark epochs per tag, a monotone epoch
|
|
421
|
+
* counter, a durable batch sequence, the monotone GC-horizon high-water, the snapshot
|
|
422
|
+
* cadence watermark (`snapBatch`/`lastSnapMs`), and the un-flushed (coalescing) change
|
|
423
|
+
* buffer. Every method is synchronous and side-effect free with respect to the outside
|
|
424
|
+
* world: mutations update machine state and return effect descriptions; queries return
|
|
425
|
+
* values.
|
|
426
|
+
*/
|
|
427
|
+
var RegistryMachine = class {
|
|
428
|
+
#watermarks = /* @__PURE__ */ new Map();
|
|
429
|
+
#epoch = 0;
|
|
430
|
+
#batchSeq = 0;
|
|
431
|
+
#gcHorizonMs = 0;
|
|
432
|
+
#pendingFrom = null;
|
|
433
|
+
#pendingChanges = [];
|
|
434
|
+
#snapBatch = 0;
|
|
435
|
+
#lastSnapMs = null;
|
|
436
|
+
#snapshotEveryBatches = DEFAULT_SNAPSHOT_EVERY_BATCHES;
|
|
437
|
+
#snapshotIntervalMs = DEFAULT_SNAPSHOT_INTERVAL_MS;
|
|
438
|
+
#maxSnapshotBytes = DEFAULT_MAX_SNAPSHOT_BYTES;
|
|
439
|
+
#regId = DEFAULT_REG_ID;
|
|
440
|
+
constructor(options = {}) {
|
|
441
|
+
this.configure(options);
|
|
442
|
+
}
|
|
443
|
+
/** The current Coordinator epoch (the correctness clock, machine state). */
|
|
444
|
+
get currentEpoch() {
|
|
445
|
+
return this.#epoch;
|
|
446
|
+
}
|
|
447
|
+
/** The batch seq of the last emitted snapshot (`__state.snapBatch`), machine state. */
|
|
448
|
+
get snapBatch() {
|
|
449
|
+
return this.#snapBatch;
|
|
450
|
+
}
|
|
451
|
+
/** The last durably-emitted batch seq (`lastBatch`), machine state — both the flush and the heartbeat pointer effects carry it; a host may also read it directly. */
|
|
452
|
+
get lastBatch() {
|
|
453
|
+
return this.#batchSeq;
|
|
454
|
+
}
|
|
455
|
+
/** The effective machine config, machine state — the shell diffs `env` against this to decide whether to journal a fresh `configure` (Ruling B). */
|
|
456
|
+
get effectiveConfig() {
|
|
457
|
+
return {
|
|
458
|
+
maxSnapshotBytes: this.#maxSnapshotBytes,
|
|
459
|
+
regId: this.#regId,
|
|
460
|
+
snapshotEveryBatches: this.#snapshotEveryBatches,
|
|
461
|
+
snapshotIntervalMs: this.#snapshotIntervalMs
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/** The mirror-key scoping id, machine state — the host reads it to build the
|
|
465
|
+
* scoped keyspace it writes the flush effects to (`mirrorKeys(regId)`). */
|
|
466
|
+
get regId() {
|
|
467
|
+
return this.#regId;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Apply an effective snapshot cadence as machine STATE. A JOURNALED command (Ruling B):
|
|
471
|
+
* `env` may AUTHOR this — it never parameterizes the machine directly — so a replay reproduces
|
|
472
|
+
* the cadence AT ITS JOURNAL POSITION, prospective by construction. Unset knobs fall to the
|
|
473
|
+
* defaults, exactly as the constructor seed does. Never retroactive: it changes only how future
|
|
474
|
+
* flushes trip the cadence, so `snapBatch` stays monotone across a config change.
|
|
475
|
+
*/
|
|
476
|
+
configure(options) {
|
|
477
|
+
this.#snapshotEveryBatches = options.snapshotEveryBatches ?? DEFAULT_SNAPSHOT_EVERY_BATCHES;
|
|
478
|
+
this.#snapshotIntervalMs = options.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS;
|
|
479
|
+
this.#maxSnapshotBytes = options.maxSnapshotBytes ?? DEFAULT_MAX_SNAPSHOT_BYTES;
|
|
480
|
+
const regId = options.regId ?? "default";
|
|
481
|
+
assertRegId(regId);
|
|
482
|
+
this.#regId = regId;
|
|
483
|
+
}
|
|
484
|
+
/** The monotone high-water GC horizon in ms — never retracts. */
|
|
485
|
+
get gcHorizonMs() {
|
|
486
|
+
return this.#gcHorizonMs;
|
|
487
|
+
}
|
|
488
|
+
/** Authoritative dual watermark for a tag; absent tags read as a zero watermark. */
|
|
489
|
+
watermarkOf(tag) {
|
|
490
|
+
const w = this.#watermarks.get(tag);
|
|
491
|
+
return {
|
|
492
|
+
hardEpoch: w?.hardEpoch ?? 0,
|
|
493
|
+
softEpoch: w?.softEpoch ?? 0
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Live watermark knowledge for these tags. A primary read is always authoritative
|
|
498
|
+
* and known; an untouched tag reads as a known zero watermark, never as unknown.
|
|
499
|
+
*/
|
|
500
|
+
knowledgeOf(tags) {
|
|
501
|
+
const through = this.#epoch;
|
|
502
|
+
return tags.map((tag) => {
|
|
503
|
+
const w = this.#watermarks.get(tag);
|
|
504
|
+
return {
|
|
505
|
+
hardEpoch: w?.hardEpoch ?? 0,
|
|
506
|
+
known: true,
|
|
507
|
+
softEpoch: w?.softEpoch ?? 0,
|
|
508
|
+
throughEpoch: through
|
|
509
|
+
};
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
/** Soft purge: max-on-write bump of `softEpoch` for each tag. */
|
|
513
|
+
expire(tags, nowMs) {
|
|
514
|
+
return this.#mutate(tags, "soft", nowMs);
|
|
515
|
+
}
|
|
516
|
+
/** Hard purge: max-on-write bump of `hardEpoch` for each tag. */
|
|
517
|
+
delete(tags, nowMs) {
|
|
518
|
+
return this.#mutate(tags, "hard", nowMs);
|
|
519
|
+
}
|
|
520
|
+
/** Register a retention lease; the GC horizon rises to the high-water. */
|
|
521
|
+
registerRetention(retentionMs) {
|
|
522
|
+
this.#gcHorizonMs = Math.max(this.#gcHorizonMs, retentionMs);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Describe the GC horizon at `nowMs`. Pure: the machine collects nothing;
|
|
526
|
+
* a host uses `collectBeforeMs` to decide what to reclaim. Uncertainty extends, never
|
|
527
|
+
* shortens, the horizon — it is the monotone high-water retention.
|
|
528
|
+
*/
|
|
529
|
+
gc(nowMs) {
|
|
530
|
+
return {
|
|
531
|
+
collectBeforeMs: nowMs - this.#gcHorizonMs,
|
|
532
|
+
horizonMs: this.#gcHorizonMs
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Flush the coalescing window into a persist-batch + broadcast-frame, plus a
|
|
537
|
+
* persist-snapshot when the cadence is due. Emits nothing when no epoch has advanced
|
|
538
|
+
* since the last flush. The batch takes the next durable seq (`lastBatch + 1`) and spans
|
|
539
|
+
* `(fromEpoch, toEpoch)` — content-addressed so replays are byte-identical. The
|
|
540
|
+
* buffered changes are canonicalized (each tag once at its max epoch, lex-sorted) so the
|
|
541
|
+
* batch has exactly ONE serialization across hosts (`canonicalBatch` — behavior-preserving
|
|
542
|
+
* under max-on-write).
|
|
543
|
+
*
|
|
544
|
+
* SNAPSHOT CADENCE: emit a snapshot when `seq − snapBatch ≥ snapshotEveryBatches`
|
|
545
|
+
* OR `nowMs − lastSnapMs ≥ snapshotIntervalMs`. The seq clause is replay-safe (a pure
|
|
546
|
+
* function of the command journal); the time clause reads the `nowMs` PARAMETER, so a host
|
|
547
|
+
* that replays flushes with the recorded `nowMs` reproduces `snapBatch` exactly.
|
|
548
|
+
*
|
|
549
|
+
* SIZE GATE: before emitting a due snapshot, measure its CANONICAL SHIPPED
|
|
550
|
+
* bytes; if they exceed `maxSnapshotBytes`, SKIP it — emit a `snapshot-skipped-oversize`
|
|
551
|
+
* telemetry effect INSTEAD of `persist-snapshot`, leave `#snapBatch` UNADVANCED (no phantom
|
|
552
|
+
* checkpoint blessed), and let batch/broadcast/pointer proceed normally. `#lastSnapMs` advances
|
|
553
|
+
* so the time clause does not re-fire every flush, but the batch clause stays tripped — a loud
|
|
554
|
+
* re-attempt on each subsequent flush. The gate is journaled (replay-deterministic).
|
|
555
|
+
*
|
|
556
|
+
* Emission order is batch → snapshot → broadcast → advance-pointer, so a host writes every
|
|
557
|
+
* immutable object (`__delta:<seq>`, and `__snap:<seq>` when the cadence fires) BEFORE the
|
|
558
|
+
* ONE terminal `advance-pointer` blesses `__state` (write-then-bless). The pointer is atomic
|
|
559
|
+
* across `lastBatch` AND `snapBatch` — there is no stale intermediate pointer, so no host can
|
|
560
|
+
* issue two sub-second `__state` puts in one flush.
|
|
561
|
+
*/
|
|
562
|
+
flush(nowMs) {
|
|
563
|
+
if (this.#pendingFrom === null) return [];
|
|
564
|
+
const fromEpoch = this.#pendingFrom;
|
|
565
|
+
const toEpoch = this.#epoch;
|
|
566
|
+
const changes = canonicalBatch(this.#pendingChanges);
|
|
567
|
+
this.#pendingFrom = null;
|
|
568
|
+
this.#pendingChanges = [];
|
|
569
|
+
this.#batchSeq += 1;
|
|
570
|
+
const seq = this.#batchSeq;
|
|
571
|
+
const frame = {
|
|
572
|
+
changes,
|
|
573
|
+
fromEpoch,
|
|
574
|
+
toEpoch
|
|
575
|
+
};
|
|
576
|
+
const effects = [{
|
|
577
|
+
frame,
|
|
578
|
+
kind: "persist-batch",
|
|
579
|
+
seq
|
|
580
|
+
}];
|
|
581
|
+
this.#lastSnapMs ??= nowMs;
|
|
582
|
+
const dueByBatches = seq - this.#snapBatch >= this.#snapshotEveryBatches;
|
|
583
|
+
const dueByTime = nowMs - this.#lastSnapMs >= this.#snapshotIntervalMs;
|
|
584
|
+
if (dueByBatches || dueByTime) {
|
|
585
|
+
const snapFrame = {
|
|
586
|
+
changes: this.#snapshotChanges(),
|
|
587
|
+
fromEpoch: 0,
|
|
588
|
+
toEpoch
|
|
589
|
+
};
|
|
590
|
+
const snapBytes = new TextEncoder().encode(JSON.stringify(snapFrame)).length;
|
|
591
|
+
if (snapBytes <= this.#maxSnapshotBytes) {
|
|
592
|
+
this.#snapBatch = seq;
|
|
593
|
+
this.#lastSnapMs = nowMs;
|
|
594
|
+
effects.push({
|
|
595
|
+
frame: snapFrame,
|
|
596
|
+
kind: "persist-snapshot",
|
|
597
|
+
seq
|
|
598
|
+
});
|
|
599
|
+
} else {
|
|
600
|
+
this.#lastSnapMs = nowMs;
|
|
601
|
+
effects.push({
|
|
602
|
+
bytes: snapBytes,
|
|
603
|
+
ceiling: this.#maxSnapshotBytes,
|
|
604
|
+
event: "snapshot-skipped-oversize",
|
|
605
|
+
kind: "telemetry"
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
effects.push({
|
|
610
|
+
frame,
|
|
611
|
+
kind: "broadcast-frame"
|
|
612
|
+
});
|
|
613
|
+
const pointer = {
|
|
614
|
+
epoch: toEpoch,
|
|
615
|
+
flushedAt: nowMs,
|
|
616
|
+
lastBatch: seq,
|
|
617
|
+
regId: this.#regId,
|
|
618
|
+
snapBatch: this.#snapBatch
|
|
619
|
+
};
|
|
620
|
+
effects.push({
|
|
621
|
+
kind: "advance-pointer",
|
|
622
|
+
pointer
|
|
623
|
+
});
|
|
624
|
+
return effects;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* A liveness heartbeat. Refreshes the `__state` pointer's freshness field
|
|
628
|
+
* (`flushedAt`) at `nowMs` while advancing NO cursor: it reads the CURRENT epoch/lastBatch/
|
|
629
|
+
* snapBatch and reuses the terminal `advance-pointer` effect, so a host blesses `__state`
|
|
630
|
+
* through the SAME executor a flush uses (one hot-key put, never a raw write). Emitted
|
|
631
|
+
* as a SINGLE pointer effect: the freshness fields move, the monotone cursors do not.
|
|
632
|
+
*
|
|
633
|
+
* STATE-NEUTRAL by construction — it mutates nothing, so the monotone cursors cannot move and
|
|
634
|
+
* replaying it is a pure no-op. That is the property that lets a host decline to journal a
|
|
635
|
+
* heartbeat for replay-exactness (replay is exact over the NON-heartbeat commands alone). The
|
|
636
|
+
* shell decides WHEN to issue it (an env-gated cadence, Ruling B); the machine only supplies the
|
|
637
|
+
* pointer bytes — `env` authors the command, it never parameterizes the machine.
|
|
638
|
+
*/
|
|
639
|
+
heartbeat(nowMs) {
|
|
640
|
+
return {
|
|
641
|
+
kind: "advance-pointer",
|
|
642
|
+
pointer: {
|
|
643
|
+
epoch: this.#epoch,
|
|
644
|
+
flushedAt: nowMs,
|
|
645
|
+
lastBatch: this.#batchSeq,
|
|
646
|
+
regId: this.#regId,
|
|
647
|
+
snapBatch: this.#snapBatch
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* The canonical snapshot change set: every tag that has ever advanced, folded
|
|
653
|
+
* over (0, epoch] by the SAME serializer the delta path uses (`canonicalBatch`) — one entry
|
|
654
|
+
* per tag, touched axes only (a 0 axis is absent, never a fabricated 0), per-axis max,
|
|
655
|
+
* lex-sorted. So a snapshot is byte-identical to the single delta batch that would carry the
|
|
656
|
+
* whole watermark map — one serializer, one applier (the unification).
|
|
657
|
+
*/
|
|
658
|
+
#snapshotChanges() {
|
|
659
|
+
const pending = [];
|
|
660
|
+
for (const [tag, w] of this.#watermarks) pending.push({
|
|
661
|
+
tag,
|
|
662
|
+
...w.hardEpoch > 0 ? { hardEpoch: w.hardEpoch } : {},
|
|
663
|
+
...w.softEpoch > 0 ? { softEpoch: w.softEpoch } : {}
|
|
664
|
+
});
|
|
665
|
+
return canonicalBatch(pending);
|
|
666
|
+
}
|
|
667
|
+
#mutate(tags, kind, nowMs) {
|
|
668
|
+
const fromEpoch = this.#epoch;
|
|
669
|
+
const toEpoch = fromEpoch + 1;
|
|
670
|
+
this.#epoch = toEpoch;
|
|
671
|
+
for (const tag of tags) {
|
|
672
|
+
const w = this.#watermarks.get(tag) ?? {
|
|
673
|
+
hardEpoch: 0,
|
|
674
|
+
softEpoch: 0
|
|
675
|
+
};
|
|
676
|
+
if (kind === "soft") {
|
|
677
|
+
w.softEpoch = Math.max(w.softEpoch, toEpoch);
|
|
678
|
+
this.#pendingChanges.push({
|
|
679
|
+
softEpoch: w.softEpoch,
|
|
680
|
+
tag
|
|
681
|
+
});
|
|
682
|
+
} else {
|
|
683
|
+
w.hardEpoch = Math.max(w.hardEpoch, toEpoch);
|
|
684
|
+
this.#pendingChanges.push({
|
|
685
|
+
hardEpoch: w.hardEpoch,
|
|
686
|
+
tag
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
this.#watermarks.set(tag, w);
|
|
690
|
+
}
|
|
691
|
+
this.#pendingFrom ??= fromEpoch;
|
|
692
|
+
return {
|
|
693
|
+
ack: { epoch: toEpoch },
|
|
694
|
+
effects: [{
|
|
695
|
+
deadlineMs: nowMs,
|
|
696
|
+
kind: "schedule-flush"
|
|
697
|
+
}]
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
//#endregion
|
|
702
|
+
//#region src/adapters/cloudflare/bus-server.ts
|
|
703
|
+
/** WS `OPEN` readyState (the numeric constant workerd and the browser share). */
|
|
704
|
+
const WS_OPEN = 1;
|
|
705
|
+
/** Close a socket, swallowing any throw (it may already be gone — the subscriber redials + resyncs). */
|
|
706
|
+
function safeClose(socket) {
|
|
707
|
+
try {
|
|
708
|
+
socket.close(CLOSE_DELIVERY_DOUBT, "astilba/cache: delivery doubt — reconnect + resync");
|
|
709
|
+
} catch {}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Send one datum to one subscriber, or CLOSE it on ANY delivery doubt: a non-OPEN
|
|
713
|
+
* socket, or a `send` that throws. Returns `true` iff the datum was handed to `send` cleanly. Never
|
|
714
|
+
* rethrows — a doubtful subscriber is closed and skipped so the fan-out continues to the rest.
|
|
715
|
+
*/
|
|
716
|
+
function sendOrClose(socket, data) {
|
|
717
|
+
if (socket.readyState !== void 0 && socket.readyState !== WS_OPEN) {
|
|
718
|
+
safeClose(socket);
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
socket.send(data);
|
|
723
|
+
return true;
|
|
724
|
+
} catch {
|
|
725
|
+
safeClose(socket);
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* The establishment handshake: emit `reset` THEN `hello{head, regId}` BEFORE any frame, so
|
|
731
|
+
* a subscriber starts with warm trust SUSPENDED and a head to reconverge on. `head` is the DO's OWN
|
|
732
|
+
* last-flushed epoch — NEVER `machine.currentEpoch` (it over-declares into the
|
|
733
|
+
* un-flushed window), NEVER a KV read-back (the mirror is stale-licensed; the DO is the truth
|
|
734
|
+
* source). The `hello` echoes `regId` so a mismatched subscriber closes loud.
|
|
735
|
+
*/
|
|
736
|
+
function establish(socket, head, regId) {
|
|
737
|
+
sendOrClose(socket, encode({ kind: "reset" }));
|
|
738
|
+
sendOrClose(socket, encode({
|
|
739
|
+
head,
|
|
740
|
+
kind: "hello",
|
|
741
|
+
regId
|
|
742
|
+
}));
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Fan ONE frame out to every current subscriber (stateless; no replay buffer). The frame
|
|
746
|
+
* is encoded ONCE, then delivered per-socket via `sendOrClose`, so a doubtful subscriber is closed
|
|
747
|
+
* and the rest still receive it. doBus emits NO `gap` here: the frame is a normal
|
|
748
|
+
* delivery, and the only failure mode is a per-socket CLOSE, whose remedy is the handshake.
|
|
749
|
+
*/
|
|
750
|
+
function fanOut(sockets, frame) {
|
|
751
|
+
const data = encode({
|
|
752
|
+
frame,
|
|
753
|
+
kind: "frame"
|
|
754
|
+
});
|
|
755
|
+
for (const socket of sockets) sendOrClose(socket, data);
|
|
756
|
+
}
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/adapters/cloudflare/kv.ts
|
|
759
|
+
/**
|
|
760
|
+
* KV's per-value ceiling (driver table): 25 MiB. An over-limit `set` is REJECTED
|
|
761
|
+
* `too_large` before any I/O — never truncated, never written.
|
|
762
|
+
*/
|
|
763
|
+
const MAX_VALUE_BYTES = 26214400;
|
|
764
|
+
/**
|
|
765
|
+
* KV's write-side minimum residency (driver table): a `put` whose `expirationTtl`
|
|
766
|
+
* is below 60s is silently raised to 60s by KV itself. The driver raises it explicitly
|
|
767
|
+
* so the honored TTL is observable and matches what KV would do. Distinct from the
|
|
768
|
+
* read-side `cacheTtl` minimum (30s) — a different knob, not conflated here.
|
|
769
|
+
*/
|
|
770
|
+
const KV_MIN_EXPIRATION_TTL = 60;
|
|
771
|
+
/**
|
|
772
|
+
* Read-cache `cacheTtl` per mirror-object kind, in SECONDS. The mutable `pointer`
|
|
773
|
+
* (`__state`) takes KV's pinned read-cache MINIMUM (30s); the immutable `delta`/`snap` cache hard
|
|
774
|
+
* (24h — cache the immutable, never the mutable). An UNHINTED read passes no `cacheTtl` and inherits
|
|
775
|
+
* KV's 60s edge-cache default (NOT minimal caching — the Appendix C trap this wiring closes).
|
|
776
|
+
*/
|
|
777
|
+
const READ_CACHE_TTL_S = {
|
|
778
|
+
delta: 86400,
|
|
779
|
+
pointer: 30,
|
|
780
|
+
snap: 86400
|
|
781
|
+
};
|
|
782
|
+
/**
|
|
783
|
+
* A `StoreWriteError`-shaped rejection raised by the driver. A CLASS (not a bare
|
|
784
|
+
* object) so oxlint's throw rules are satisfied; consumers discriminate it
|
|
785
|
+
* STRUCTURALLY via `isStoreWriteError`, never by this class's identity, so it
|
|
786
|
+
* is never exported. `cause` carries the original KV rejection where there is one.
|
|
787
|
+
*/
|
|
788
|
+
var KvStoreWriteError = class extends Error {
|
|
789
|
+
code;
|
|
790
|
+
retryable;
|
|
791
|
+
constructor(code, retryable, message, cause) {
|
|
792
|
+
super(message, cause === void 0 ? void 0 : { cause });
|
|
793
|
+
this.name = "KvStoreWriteError";
|
|
794
|
+
this.code = code;
|
|
795
|
+
this.retryable = retryable;
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
/**
|
|
799
|
+
* A `StoreReadError`-shaped rejection raised by the driver — the GET-side twin of
|
|
800
|
+
* `KvStoreWriteError`, and a CLASS for the same reason (oxlint's throw rules), recognized
|
|
801
|
+
* STRUCTURALLY by the kernel (`isStoreReadError`) and never by identity, so it is never
|
|
802
|
+
* exported. `cause` carries the original KV rejection.
|
|
803
|
+
*/
|
|
804
|
+
var KvStoreReadError = class extends Error {
|
|
805
|
+
code;
|
|
806
|
+
retryable;
|
|
807
|
+
constructor(code, retryable, message, cause) {
|
|
808
|
+
super(message, { cause });
|
|
809
|
+
this.name = "KvStoreReadError";
|
|
810
|
+
this.code = code;
|
|
811
|
+
this.retryable = retryable;
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
/**
|
|
815
|
+
* Detect a KV rate-limit (429) rejection DEFENSIVELY. KV enforces 1 write/s/key and
|
|
816
|
+
* surfaces the ceiling as a 429; workerd throws that as an ordinary `Error` with no
|
|
817
|
+
* documented, stable structured field. So probe a few shapes WITHOUT assuming any
|
|
818
|
+
* exists — a numeric `status`/`statusCode`/`code` equal to 429, or a message that names
|
|
819
|
+
* the 429 / rate-limit condition — and treat everything unrecognized as a transient
|
|
820
|
+
* `unavailable` rather than mis-tagging it `throttled`. (Miniflare emits no real 429, so
|
|
821
|
+
* this branch is proven by the deterministic conformance fixture, not the integration
|
|
822
|
+
* lane — see DIVERGENCES true production 429 is a probe concern.)
|
|
823
|
+
*/
|
|
824
|
+
function isRateLimited(error) {
|
|
825
|
+
if (typeof error === "object" && error !== null) {
|
|
826
|
+
if ("status" in error && error.status === 429) return true;
|
|
827
|
+
if ("statusCode" in error && error.statusCode === 429) return true;
|
|
828
|
+
if ("code" in error && error.code === 429) return true;
|
|
829
|
+
}
|
|
830
|
+
const message = error instanceof Error ? error.message : "";
|
|
831
|
+
return /\b429\b|too many requests|rate ?limit/iu.test(message);
|
|
832
|
+
}
|
|
833
|
+
/** Map a rejected KV write to the structural failure contract. */
|
|
834
|
+
function classifyWriteError(error) {
|
|
835
|
+
if (isRateLimited(error)) return new KvStoreWriteError("throttled", true, "cloudflareKV: write throttled (KV 1 write/s/key ceiling, 429)", error);
|
|
836
|
+
return new KvStoreWriteError("unavailable", true, "cloudflareKV: write failed (durable store unavailable)", error);
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Map a rejected KV read to the structural failure contract. TOTAL, which is the whole
|
|
840
|
+
* design: the posture is that an L2 read failure of ANY shape is a miss-class event, so there is
|
|
841
|
+
* no rejection this function declines to name. `isRateLimited` is reused verbatim — the same 429 the
|
|
842
|
+
* write half recognizes, since it is the same platform saying the same thing — and everything else
|
|
843
|
+
* lands on transient `unavailable`.
|
|
844
|
+
*
|
|
845
|
+
* The totality is not tidiness. A partial classifier would name the shapes someone already thought
|
|
846
|
+
* of and drop the rest through the kernel's rethrow to the caller — and the rest is precisely the
|
|
847
|
+
* interesting set: the production faults nobody wrote a recognizer for. Read-side, over-naming costs
|
|
848
|
+
* an origin refill; under-naming costs the request. There is no read-side `too_large`: a size
|
|
849
|
+
* ceiling is a property of what a WRITE offers.
|
|
850
|
+
*/
|
|
851
|
+
function classifyReadError(error) {
|
|
852
|
+
if (isRateLimited(error)) return new KvStoreReadError("throttled", true, "cloudflareKV: read throttled (KV rate ceiling, 429)", error);
|
|
853
|
+
return new KvStoreReadError("unavailable", true, "cloudflareKV: read failed (durable store unavailable)", error);
|
|
854
|
+
}
|
|
855
|
+
/** A `Store` backed by a single Cloudflare `KVNamespace` binding. */
|
|
856
|
+
var CloudflareKvStore = class {
|
|
857
|
+
#ns;
|
|
858
|
+
constructor(ns) {
|
|
859
|
+
this.#ns = ns;
|
|
860
|
+
}
|
|
861
|
+
async get(key, readKind) {
|
|
862
|
+
const cacheTtl = readKind === void 0 ? void 0 : READ_CACHE_TTL_S[readKind];
|
|
863
|
+
let result;
|
|
864
|
+
try {
|
|
865
|
+
result = await this.#ns.getWithMetadata(key, cacheTtl === void 0 ? void 0 : { cacheTtl });
|
|
866
|
+
} catch (error) {
|
|
867
|
+
throw classifyReadError(error);
|
|
868
|
+
}
|
|
869
|
+
if (result.value === null) return;
|
|
870
|
+
return result.metadata === null ? { value: result.value } : {
|
|
871
|
+
metadata: result.metadata,
|
|
872
|
+
value: result.value
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
async set(key, value, options) {
|
|
876
|
+
const sizeBytes = new TextEncoder().encode(value).length;
|
|
877
|
+
if (sizeBytes > MAX_VALUE_BYTES) throw new KvStoreWriteError("too_large", false, `cloudflareKV: value too large (${sizeBytes} > ${MAX_VALUE_BYTES} bytes)`);
|
|
878
|
+
const putOptions = {
|
|
879
|
+
...options?.expirationTtl === void 0 ? {} : { expirationTtl: Math.max(KV_MIN_EXPIRATION_TTL, Math.ceil(options.expirationTtl)) },
|
|
880
|
+
...options?.metadata === void 0 ? {} : { metadata: options.metadata }
|
|
881
|
+
};
|
|
882
|
+
try {
|
|
883
|
+
await this.#ns.put(key, value, putOptions);
|
|
884
|
+
} catch (error) {
|
|
885
|
+
throw classifyWriteError(error);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
async delete(key) {
|
|
889
|
+
await this.#ns.delete(key);
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
/** Build a `Store` over a Cloudflare `KVNamespace` binding. */
|
|
893
|
+
function cloudflareKV(ns) {
|
|
894
|
+
return new CloudflareKvStore(ns);
|
|
895
|
+
}
|
|
896
|
+
//#endregion
|
|
897
|
+
//#region src/adapters/cloudflare/reg-id.ts
|
|
898
|
+
/**
|
|
899
|
+
* Refused: a Coordinator addressed by a Durable Object name that violates the regId
|
|
900
|
+
* grammar (`[a-z0-9._-]`, 1–64). Typed so the boot failure is unambiguous and machine-checkable —
|
|
901
|
+
* the fix is a conforming name or an explicit `REGISTRY_ID`. Thrown at first boot only (before any
|
|
902
|
+
* data), so it strands nothing: validate-and-refuse, not journal-wins. `Error.name` stays the class
|
|
903
|
+
* name; `registryName` carries the offending Durable Object name.
|
|
904
|
+
*/
|
|
905
|
+
var InvalidRegistryNameError = class extends Error {
|
|
906
|
+
registryName;
|
|
907
|
+
constructor(registryName) {
|
|
908
|
+
super(`astilba/cache coordinator: Durable Object name ${JSON.stringify(registryName)} is not a valid registry id — it must match [a-z0-9._-], length 1-64; use a conforming name or set REGISTRY_ID (E19, E19-b)`);
|
|
909
|
+
this.name = "InvalidRegistryNameError";
|
|
910
|
+
this.registryName = registryName;
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
/**
|
|
914
|
+
* Resolve a Coordinator's regId from its platform identity. `name` is `ctx.id.name`
|
|
915
|
+
* (undefined for an un-named / `idFromString`-reached object); `envRegId` is `env.REGISTRY_ID`.
|
|
916
|
+
* Precedence: name ?? env ?? `"default"`. A NAME that violates the grammar REFUSES with a typed
|
|
917
|
+
* `InvalidRegistryNameError` (R2). An env/default winner is still asserted through the existing
|
|
918
|
+
* `assertRegId` (R3 — every winner passes the same charset gate), so a malformed `REGISTRY_ID` also
|
|
919
|
+
* fails loud. An empty-string env value is treated as unset (falls through to `"default"`).
|
|
920
|
+
*/
|
|
921
|
+
function resolveRegId(name, envRegId) {
|
|
922
|
+
if (name !== void 0) {
|
|
923
|
+
if (!isRegId(name)) throw new InvalidRegistryNameError(name);
|
|
924
|
+
return name;
|
|
925
|
+
}
|
|
926
|
+
const fallback = envRegId !== void 0 && envRegId !== "" ? envRegId : DEFAULT_REG_ID;
|
|
927
|
+
assertRegId(fallback);
|
|
928
|
+
return fallback;
|
|
929
|
+
}
|
|
930
|
+
//#endregion
|
|
931
|
+
//#region src/adapters/cloudflare/coordinator.ts
|
|
932
|
+
/** The append-only command journal — the single source of truth for machine state. */
|
|
933
|
+
const JOURNAL_KEY = "__journal";
|
|
934
|
+
/** The cursor: the epoch through which a batch has been durably accepted into KV. */
|
|
935
|
+
const CURSOR_KEY = "kvFlushedEpoch";
|
|
936
|
+
/**
|
|
937
|
+
* The seeded registry identity — the `regId` this Coordinator journaled at FIRST boot,
|
|
938
|
+
* derived from its Durable Object name. Written EXACTLY ONCE (with the first `configure`) and never
|
|
939
|
+
* rewritten: it is the durable anchor the divergence detector compares a later name-derivation
|
|
940
|
+
* against (R1 — journal-wins). Its presence marks "identity established"; its absence marks a first
|
|
941
|
+
* boot. Distinct from `machine.regId` (which a bare cadence-`configure` would default) so the
|
|
942
|
+
* seeded id is unambiguous.
|
|
943
|
+
*/
|
|
944
|
+
const IDENTITY_KEY = "regId";
|
|
945
|
+
/**
|
|
946
|
+
* KV physical residency, in SECONDS — KV `expirationTtl`. Delta batches age out at
|
|
947
|
+
* 48h; content-addressed snapshots at 7d; the `__state` pointer never expires (no TTL). TTL
|
|
948
|
+
* is host plumbing, not machine state — invisible to the content-addressed batch/snapshot
|
|
949
|
+
* bytes the transcript compares.
|
|
950
|
+
*
|
|
951
|
+
* @testExport exported solely so tests assert the single source of truth; not public API, no
|
|
952
|
+
* production importer — the integration residency scene pins the delta TTL (48h) the sink SELECTS
|
|
953
|
+
* (the decision, not KV's Miniflare-blind enforcement).
|
|
954
|
+
*/
|
|
955
|
+
const DELTA_TTL_S = 172800;
|
|
956
|
+
/**
|
|
957
|
+
* @testExport exported solely so tests assert the single source of truth; not public API, no
|
|
958
|
+
* production importer — the integration residency scene pins the snapshot TTL (7d) the sink SELECTS.
|
|
959
|
+
*/
|
|
960
|
+
const SNAP_TTL_S = 604800;
|
|
961
|
+
/**
|
|
962
|
+
* @testExport exported solely so tests assert the single source of truth; not public API, no
|
|
963
|
+
* production importer — a test pins the ≥1s floored flush window (the coalescing invariant, so a
|
|
964
|
+
* lowered tuning term can never breach the platform floor).
|
|
965
|
+
*/
|
|
966
|
+
const FLUSH_WINDOW_MS = Math.max(1100, 1e3);
|
|
967
|
+
/**
|
|
968
|
+
* The Coordinator's KV flush sink. `executeFlushEffects` owns the write ORDER and
|
|
969
|
+
* the once-only `__state` bless; this sink owns only the KV I/O — routed through the
|
|
970
|
+
* `cloudflareKV` Store driver, so a rejected write is CLASSIFIED into the structural
|
|
971
|
+
* `StoreWriteError` vocabulary the kernel already speaks — no second classification.
|
|
972
|
+
* A `__state` 429 ⇒ `throttled` PROPAGATES out of the flush: nothing was blessed, the immutable
|
|
973
|
+
* objects are content-addressed/idempotent, and the alarm retries (no recovery logic needed).
|
|
974
|
+
*
|
|
975
|
+
* @testExport exported solely so tests assert the single source of truth; not public API, no
|
|
976
|
+
* production importer — the integration residency scene constructs it directly to drive the KV
|
|
977
|
+
* write-order + `__state`-bless behavior in isolation.
|
|
978
|
+
*/
|
|
979
|
+
var CoordinatorFlushSink = class {
|
|
980
|
+
store;
|
|
981
|
+
broadcaster;
|
|
982
|
+
constructor(store, broadcaster) {
|
|
983
|
+
this.store = store;
|
|
984
|
+
this.broadcaster = broadcaster;
|
|
985
|
+
}
|
|
986
|
+
async putImmutable(key, bytes, kind) {
|
|
987
|
+
const expirationTtl = kind === "snap" ? SNAP_TTL_S : DELTA_TTL_S;
|
|
988
|
+
await this.store.set(key, bytes, { expirationTtl });
|
|
989
|
+
}
|
|
990
|
+
async putPointer(key, bytes) {
|
|
991
|
+
await this.store.set(key, bytes);
|
|
992
|
+
}
|
|
993
|
+
broadcast(frame) {
|
|
994
|
+
this.broadcaster(frame);
|
|
995
|
+
}
|
|
996
|
+
telemetry(event) {
|
|
997
|
+
console.warn("astilba/cache coordinator flush telemetry", event);
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
var Coordinator = class extends DurableObject {
|
|
1001
|
+
#machine;
|
|
1002
|
+
#journal = [];
|
|
1003
|
+
#regIdDivergence = null;
|
|
1004
|
+
/**
|
|
1005
|
+
* The name/journal regId divergence detected at this instance's boot, or null when the
|
|
1006
|
+
* name still derives the journaled identity. A read-only boot diagnostic (surfaced to the
|
|
1007
|
+
* integration divergence scene via `runInDurableObject`); never consulted on a hot path.
|
|
1008
|
+
*/
|
|
1009
|
+
get regIdDivergence() {
|
|
1010
|
+
return this.#regIdDivergence;
|
|
1011
|
+
}
|
|
1012
|
+
/** Strong read: live, un-memoized, primary-only — the DO IS the primary. */
|
|
1013
|
+
async check(tags) {
|
|
1014
|
+
return await this.ctx.blockConcurrencyWhile(async () => {
|
|
1015
|
+
return (await this.#load()).knowledgeOf(tags);
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
/** Soft purge: max-on-write bump of `softEpoch`. */
|
|
1019
|
+
async expire(tags) {
|
|
1020
|
+
return await this.ctx.blockConcurrencyWhile(async () => await this.#mutate("expire", tags));
|
|
1021
|
+
}
|
|
1022
|
+
/** Hard purge: max-on-write bump of `hardEpoch`. */
|
|
1023
|
+
async delete(tags) {
|
|
1024
|
+
return await this.ctx.blockConcurrencyWhile(async () => await this.#mutate("delete", tags));
|
|
1025
|
+
}
|
|
1026
|
+
/** Register a retention lease; the GC horizon rises to the high-water. */
|
|
1027
|
+
async registerRetention(retentionMs) {
|
|
1028
|
+
await this.ctx.blockConcurrencyWhile(async () => {
|
|
1029
|
+
(await this.#load()).registerRetention(retentionMs);
|
|
1030
|
+
this.#journal.push({
|
|
1031
|
+
op: "registerRetention",
|
|
1032
|
+
retentionMs
|
|
1033
|
+
});
|
|
1034
|
+
await this.#persist({ [JOURNAL_KEY]: this.#journal });
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* The doBus live-channel endpoint. A subscriber opens a WebSocket here; the DO
|
|
1039
|
+
* accepts it HIBERNATABLY (no per-subscriber attachment, ZERO replay state) and runs
|
|
1040
|
+
* the establishment handshake: `reset` → `hello{head, regId}`, BEFORE any frame. `head`
|
|
1041
|
+
* is the DO's OWN last-flushed epoch (`#flushedEpoch`) and the `hello` echoes `regId`,
|
|
1042
|
+
*so a mismatched subscriber closes loud. Serialized under `blockConcurrencyWhile` so the
|
|
1043
|
+
* handshake snapshots a consistent (cursor, regId) against any in-flight mutation.
|
|
1044
|
+
*/
|
|
1045
|
+
async fetch(request) {
|
|
1046
|
+
if (request.headers.get("Upgrade") !== "websocket") return new Response("astilba/cache coordinator: expected a WebSocket upgrade", { status: 426 });
|
|
1047
|
+
const { 0: client, 1: server } = new WebSocketPair();
|
|
1048
|
+
await this.ctx.blockConcurrencyWhile(async () => {
|
|
1049
|
+
this.ctx.acceptWebSocket(server);
|
|
1050
|
+
const machine = await this.#load();
|
|
1051
|
+
const head = await this.#flushedEpoch();
|
|
1052
|
+
establish(server, head, machine.regId);
|
|
1053
|
+
});
|
|
1054
|
+
return new Response(null, {
|
|
1055
|
+
status: 101,
|
|
1056
|
+
webSocket: client
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Hibernatable WebSocket message handler. The doBus client is RECEIVE-ONLY —
|
|
1061
|
+
* it dials, decodes, and dispatches; it never sends. Any inbound message is unexpected and there
|
|
1062
|
+
* is no per-subscriber state to update, so it is ignored. Defined so the hibernation runtime has a
|
|
1063
|
+
* message handler for woken sockets.
|
|
1064
|
+
*/
|
|
1065
|
+
webSocketMessage(_ws, _message) {}
|
|
1066
|
+
/**
|
|
1067
|
+
* Hibernatable WebSocket close handler. Stateless fan-out holds ZERO
|
|
1068
|
+
* per-subscriber state, so a closed socket needs no reclamation — a later `getWebSockets()` simply
|
|
1069
|
+
* will not include it. Defined so the hibernation runtime has a close handler for woken sockets.
|
|
1070
|
+
*/
|
|
1071
|
+
webSocketClose(_ws, _code, _reason, _wasClean) {}
|
|
1072
|
+
/** Hibernatable WebSocket error handler — best-effort close; no state to reclaim. */
|
|
1073
|
+
webSocketError(ws, _error) {
|
|
1074
|
+
try {
|
|
1075
|
+
ws.close(1011, "astilba/cache coordinator: socket error");
|
|
1076
|
+
} catch {}
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* The DO alarm handler, serving BOTH the flush window and the heartbeat cadence off the one
|
|
1080
|
+
* alarm slot. A PURE function of durable state, idempotent under at-least-once
|
|
1081
|
+
* delivery + redeploy: it rehydrates the machine from the journal and either FLUSHES the
|
|
1082
|
+
* coalescing window `(kvFlushedEpoch, epoch]` into one content-addressed batch (advancing the
|
|
1083
|
+
* durable cursor ONLY after KV accepts), or — on an EMPTY window — refreshes the `__state`
|
|
1084
|
+
* freshness via a state-neutral heartbeat. A redelivery before the cursor advances re-writes the
|
|
1085
|
+
* identical (content-addressed, write-once) bytes; a redelivery after finds an empty window and
|
|
1086
|
+
* heartbeats (or no-ops when the cadence is unset).
|
|
1087
|
+
*/
|
|
1088
|
+
async alarm() {
|
|
1089
|
+
await this.ctx.blockConcurrencyWhile(async () => {
|
|
1090
|
+
const machine = await this.#load();
|
|
1091
|
+
const nowMs = Date.now();
|
|
1092
|
+
const effects = machine.flush(nowMs);
|
|
1093
|
+
if (effects.length > 0) {
|
|
1094
|
+
await executeFlushEffects(effects, new CoordinatorFlushSink(this.#kvStore(), (frame) => {
|
|
1095
|
+
this.#fanOut(frame);
|
|
1096
|
+
}), machine.regId);
|
|
1097
|
+
this.#journal.push({
|
|
1098
|
+
nowMs,
|
|
1099
|
+
op: "flush"
|
|
1100
|
+
});
|
|
1101
|
+
await this.#persist({
|
|
1102
|
+
[JOURNAL_KEY]: this.#journal,
|
|
1103
|
+
[CURSOR_KEY]: machine.currentEpoch
|
|
1104
|
+
});
|
|
1105
|
+
} else await this.#heartbeat(machine, nowMs);
|
|
1106
|
+
await this.#armHeartbeatAlarm(nowMs);
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
async #mutate(op, tags) {
|
|
1110
|
+
const machine = await this.#load();
|
|
1111
|
+
const nowMs = Date.now();
|
|
1112
|
+
const { ack, effects } = op === "expire" ? machine.expire(tags, nowMs) : machine.delete(tags, nowMs);
|
|
1113
|
+
this.#journal.push({
|
|
1114
|
+
op,
|
|
1115
|
+
tags
|
|
1116
|
+
});
|
|
1117
|
+
await this.#persist({ [JOURNAL_KEY]: this.#journal });
|
|
1118
|
+
for (const schedule of effects) await this.#armFlushAlarm(schedule.deadlineMs);
|
|
1119
|
+
return ack;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* The KV mirror as a `Store` — the flush write path goes THROUGH the `cloudflareKV`
|
|
1123
|
+
* driver (not raw `put`), so a rejected write is classified into the structural
|
|
1124
|
+
* `StoreWriteError` vocabulary the kernel already speaks, and the sink stays a
|
|
1125
|
+
* dumb key/bytes writer. Built per flush off the LIVE `env.REGISTRY_KV` binding.
|
|
1126
|
+
*/
|
|
1127
|
+
#kvStore() {
|
|
1128
|
+
return cloudflareKV(this.env.REGISTRY_KV);
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* The DO's OWN last-flushed epoch — the doBus `hello` head. It is the durable
|
|
1132
|
+
* `kvFlushedEpoch` cursor the last pointer bless advanced (the value that pointer carried), read
|
|
1133
|
+
* from journaled DO state. NEVER `machine.currentEpoch` (it over-declares into the un-flushed
|
|
1134
|
+
* `(kvFlushedEpoch, epoch]` window, stranding a caught-up subscriber suspect on a purge not yet in
|
|
1135
|
+
* KV), and NEVER a KV read-back (the mirror is stale-licensed; the DO is the truth source). Absent
|
|
1136
|
+
* (a cold DO before any flush) ⇒ 0.
|
|
1137
|
+
*/
|
|
1138
|
+
async #flushedEpoch() {
|
|
1139
|
+
return await this.ctx.storage.get(CURSOR_KEY) ?? 0;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Stateless fan-out of one replication frame to every hibernatable subscriber.
|
|
1143
|
+
* Reads the CURRENT sockets via `getWebSockets()` (post-wake-safe) and delivers the frame once
|
|
1144
|
+
* each; a doubtful subscriber is CLOSED, never blocking the rest. No replay buffer, no
|
|
1145
|
+
* per-subscriber state — `reset` → `hello` + the reader's KV resync is the catch-up. doBus emits
|
|
1146
|
+
* NO server `gap`.
|
|
1147
|
+
*/
|
|
1148
|
+
#fanOut(frame) {
|
|
1149
|
+
fanOut(this.ctx.getWebSockets(), frame);
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* B5 fixed-deadline alarm: arm at the next window boundary, EARLIEST-WINS. Sets the
|
|
1153
|
+
* alarm to the NEAREST of any currently-armed deadline (possibly the heartbeat cadence) and
|
|
1154
|
+
* this flush's window boundary. Two properties, one `min`: a pending heartbeat NEVER starves the
|
|
1155
|
+
* flush (the honest ≤1.1s bound holds — the defect the old `getAlarm() !== null ⇒ return` guard
|
|
1156
|
+
* caused once a far heartbeat deadline was armed), and an already-armed EARLIER flush deadline is
|
|
1157
|
+
* never pushed forward (`setAlarm` overrides, so a naive re-arm would slide the deadline out and
|
|
1158
|
+
* starve the flush — platform note).
|
|
1159
|
+
*/
|
|
1160
|
+
async #armFlushAlarm(nowMs) {
|
|
1161
|
+
const deadline = (Math.floor(nowMs / FLUSH_WINDOW_MS) + 1) * FLUSH_WINDOW_MS;
|
|
1162
|
+
const existing = await this.ctx.storage.getAlarm();
|
|
1163
|
+
await this.ctx.storage.setAlarm(earliestAlarm(existing, deadline));
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Issue the machine's state-neutral liveness heartbeat: refresh `__state.flushedAt` through the SAME
|
|
1167
|
+
* shared executor + sink a flush uses — the executor is the ONLY `__state` writer, so there is no
|
|
1168
|
+
* raw put. A no-op when the cadence is UNSET (the default in every test lane bar the
|
|
1169
|
+
* heartbeat suite), so an idle alarm there blesses nothing. The pointer carries the current
|
|
1170
|
+
* epoch/lastBatch/snapBatch UNCHANGED — nothing durable moves, so there is no journal append.
|
|
1171
|
+
*/
|
|
1172
|
+
async #heartbeat(machine, nowMs) {
|
|
1173
|
+
if (this.#heartbeatIntervalMs() === null) return;
|
|
1174
|
+
await executeFlushEffects([machine.heartbeat(nowMs)], new CoordinatorFlushSink(this.#kvStore(), (frame) => {
|
|
1175
|
+
this.#fanOut(frame);
|
|
1176
|
+
}), machine.regId);
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Re-arm the heartbeat cadence, EARLIEST-WINS. Sets the alarm to the NEAREST of the
|
|
1180
|
+
* currently-armed deadline and `nowMs + interval`, so a pending B5 flush deadline is never pushed
|
|
1181
|
+
* out (unlike the old unconditional `setAlarm(now + interval)` last-writer-wins override). No-op
|
|
1182
|
+
* when the cadence is unset — the heartbeat path stays fully dormant there.
|
|
1183
|
+
*/
|
|
1184
|
+
async #armHeartbeatAlarm(nowMs) {
|
|
1185
|
+
const intervalMs = this.#heartbeatIntervalMs();
|
|
1186
|
+
if (intervalMs === null) return;
|
|
1187
|
+
const existing = await this.ctx.storage.getAlarm();
|
|
1188
|
+
await this.ctx.storage.setAlarm(earliestAlarm(existing, nowMs + intervalMs));
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* The heartbeat interval in ms from the config var (Ruling B): `env` decides WHEN the
|
|
1192
|
+
* shell issues a heartbeat command, never a machine parameter. `null` when unset or malformed —
|
|
1193
|
+
* the whole heartbeat path stays dormant (the default in every test lane bar the heartbeat suite).
|
|
1194
|
+
*/
|
|
1195
|
+
#heartbeatIntervalMs() {
|
|
1196
|
+
const configured = this.env.REGISTRY_HEARTBEAT_MS;
|
|
1197
|
+
if (configured === void 0) return null;
|
|
1198
|
+
const intervalMs = Number(configured);
|
|
1199
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) return null;
|
|
1200
|
+
return intervalMs;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Derive this Coordinator's regId from its platform identity: identity = address. The
|
|
1204
|
+
* DO's OWN name (`ctx.id.name`, the string clients address it by) wins; `env.REGISTRY_ID` is the
|
|
1205
|
+
* DEMOTED fallback for an un-named object, then `"default"`. A NAME violating the grammar
|
|
1206
|
+
* REFUSES loud (R2 — a typed `InvalidRegistryNameError`). Called only from `#establishConfig`.
|
|
1207
|
+
*/
|
|
1208
|
+
#deriveRegId() {
|
|
1209
|
+
return resolveRegId(this.ctx.id.name, this.env.REGISTRY_ID);
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Establish identity + cadence at boot (R1 + Ruling B). Two concerns, one seam:
|
|
1213
|
+
*
|
|
1214
|
+
* IDENTITY (R1 — journal-wins, never re-journal). regId derives from the DO name (`#deriveRegId`,
|
|
1215
|
+
* which REFUSES an invalid name — R2). At FIRST boot (no `IDENTITY_KEY`) it is journaled ONCE,
|
|
1216
|
+
* folded into the first `configure` with the deployed cadence, and recorded durably. On a LATER
|
|
1217
|
+
* boot regId is NEVER re-derived-and-rejournaled: this is a pure DIVERGENCE DETECTOR — if the name
|
|
1218
|
+
* now derives a DIFFERENT id than the journaled one, emit `regid_divergence` telemetry and KEEP
|
|
1219
|
+
* the journaled identity. Divergence cannot serve dead (both replication channels fail closed on
|
|
1220
|
+
* identity mismatch — the pointer's regId echo hello's regId echo ); it costs
|
|
1221
|
+
* misses/checks/telemetry, never a stale serve. Migration to a new identity = addressing a NEW
|
|
1222
|
+
* name (fresh journal, fresh keyspace), never a rekey of the live one.
|
|
1223
|
+
*
|
|
1224
|
+
* A name-seeded DO can NEVER diverge from itself: name-first precedence makes the journaled id
|
|
1225
|
+
* EQUAL the name at first boot, and the name is the address, so a steady-state named object always
|
|
1226
|
+
* re-derives its own journaled id. A `regid_divergence` in the field therefore means one of exactly
|
|
1227
|
+
* two things — legacy env-seeded data (journaled before), or a fallback-seeded (`newUniqueId`)
|
|
1228
|
+
* object whose env/default seed later disagrees — never a healthy name-addressed registry.
|
|
1229
|
+
*
|
|
1230
|
+
* CADENCE (Ruling B, unchanged in spirit). `env` may only AUTHOR a journaled `configure`; it never
|
|
1231
|
+
* parameterizes the machine directly. A deployed cadence differing from the journal's effective
|
|
1232
|
+
* config journals a fresh `configure` — PROSPECTIVE, at a definite journal position, replay-exact.
|
|
1233
|
+
* Crucially it carries the JOURNALED regId forward, so a cadence change NEVER rekeys the mirror
|
|
1234
|
+
* (the orphaning defect the old regId-in-the-diff path would cause — R1). Idempotent: a boot whose
|
|
1235
|
+
* cadence already matches writes nothing.
|
|
1236
|
+
*/
|
|
1237
|
+
async #establishConfig(machine) {
|
|
1238
|
+
const derived = this.#deriveRegId();
|
|
1239
|
+
const seeded = await this.ctx.storage.get(IDENTITY_KEY);
|
|
1240
|
+
const cadence = this.#cadenceOptionsFromEnv();
|
|
1241
|
+
if (seeded === void 0) {
|
|
1242
|
+
const options = {
|
|
1243
|
+
...cadence,
|
|
1244
|
+
regId: derived
|
|
1245
|
+
};
|
|
1246
|
+
machine.configure(options);
|
|
1247
|
+
this.#journal.push({
|
|
1248
|
+
op: "configure",
|
|
1249
|
+
options
|
|
1250
|
+
});
|
|
1251
|
+
await this.#persist({
|
|
1252
|
+
[IDENTITY_KEY]: derived,
|
|
1253
|
+
[JOURNAL_KEY]: this.#journal
|
|
1254
|
+
});
|
|
1255
|
+
this.#regIdDivergence = null;
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
if (derived === seeded) this.#regIdDivergence = null;
|
|
1259
|
+
else {
|
|
1260
|
+
this.#regIdDivergence = {
|
|
1261
|
+
journaled: seeded,
|
|
1262
|
+
name: derived
|
|
1263
|
+
};
|
|
1264
|
+
console.warn("astilba/cache coordinator regid divergence", {
|
|
1265
|
+
event: TELEMETRY_EVENTS.regid_divergence,
|
|
1266
|
+
journaled: seeded,
|
|
1267
|
+
name: derived
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
const desired = new RegistryMachine();
|
|
1271
|
+
desired.configure(cadence);
|
|
1272
|
+
const current = machine.effectiveConfig;
|
|
1273
|
+
const want = desired.effectiveConfig;
|
|
1274
|
+
if (current.snapshotEveryBatches === want.snapshotEveryBatches && current.snapshotIntervalMs === want.snapshotIntervalMs && current.maxSnapshotBytes === want.maxSnapshotBytes) return;
|
|
1275
|
+
const options = {
|
|
1276
|
+
...cadence,
|
|
1277
|
+
regId: seeded
|
|
1278
|
+
};
|
|
1279
|
+
machine.configure(options);
|
|
1280
|
+
this.#journal.push({
|
|
1281
|
+
op: "configure",
|
|
1282
|
+
options
|
|
1283
|
+
});
|
|
1284
|
+
await this.#persist({ [JOURNAL_KEY]: this.#journal });
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Parse the snapshot CADENCE knobs from config vars — an empty object ⇒ the machine's
|
|
1288
|
+
* 500-batch / 6h / 25 MiB defaults. Only well-formed values are forwarded
|
|
1289
|
+
* (exactOptionalPropertyTypes: no `undefined` pass-through). The scoping `regId` is NOT parsed
|
|
1290
|
+
* here (it derives from the DO name via `#deriveRegId`, journal-wins) — this returns
|
|
1291
|
+
* cadence-only options that `#establishConfig` combines with the journaled regId to author a
|
|
1292
|
+
* `configure` (Ruling B). Never fed to the machine constructor on rehydration.
|
|
1293
|
+
*/
|
|
1294
|
+
#cadenceOptionsFromEnv() {
|
|
1295
|
+
const options = {};
|
|
1296
|
+
const everyBatches = Number(this.env.REGISTRY_SNAPSHOT_EVERY_BATCHES);
|
|
1297
|
+
if (Number.isFinite(everyBatches) && everyBatches > 0) options.snapshotEveryBatches = everyBatches;
|
|
1298
|
+
const intervalMs = Number(this.env.REGISTRY_SNAPSHOT_INTERVAL_MS);
|
|
1299
|
+
if (Number.isFinite(intervalMs) && intervalMs > 0) options.snapshotIntervalMs = intervalMs;
|
|
1300
|
+
const maxSnapshotBytes = Number(this.env.REGISTRY_MAX_SNAPSHOT_BYTES);
|
|
1301
|
+
if (Number.isFinite(maxSnapshotBytes) && maxSnapshotBytes > 0) options.maxSnapshotBytes = maxSnapshotBytes;
|
|
1302
|
+
return options;
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Rebuild the hosted machine from durable state on first touch / after a redeploy. Replays the
|
|
1306
|
+
* journal through a FRESH machine (the journal is the SINGLE durable truth — cadence + identity
|
|
1307
|
+
* included, via `configure` commands), discarding the replayed effects (state reconstruction only
|
|
1308
|
+
* — no re-write to KV, no re-broadcast). Then establishes config (`#establishConfig`): seeds the
|
|
1309
|
+
* Identity once / detects its divergence, and reconciles the cadence into a fresh
|
|
1310
|
+
* `configure` if the deployed cadence changed (Ruling B). Serialized by the caller's
|
|
1311
|
+
* `blockConcurrencyWhile`, so two events never rehydrate concurrently.
|
|
1312
|
+
*/
|
|
1313
|
+
async #load() {
|
|
1314
|
+
if (this.#machine !== void 0) return this.#machine;
|
|
1315
|
+
const journal = await this.ctx.storage.get(JOURNAL_KEY) ?? [];
|
|
1316
|
+
const machine = new RegistryMachine();
|
|
1317
|
+
for (const entry of journal) switch (entry.op) {
|
|
1318
|
+
case "expire":
|
|
1319
|
+
machine.expire(entry.tags, 0);
|
|
1320
|
+
break;
|
|
1321
|
+
case "delete":
|
|
1322
|
+
machine.delete(entry.tags, 0);
|
|
1323
|
+
break;
|
|
1324
|
+
case "registerRetention":
|
|
1325
|
+
machine.registerRetention(entry.retentionMs);
|
|
1326
|
+
break;
|
|
1327
|
+
case "configure":
|
|
1328
|
+
machine.configure(entry.options);
|
|
1329
|
+
break;
|
|
1330
|
+
case "flush":
|
|
1331
|
+
machine.flush(entry.nowMs ?? 0);
|
|
1332
|
+
break;
|
|
1333
|
+
default: throw new Error(`unknown journal entry: ${String(entry)}`);
|
|
1334
|
+
}
|
|
1335
|
+
this.#journal = journal;
|
|
1336
|
+
this.#machine = machine;
|
|
1337
|
+
await this.#establishConfig(machine);
|
|
1338
|
+
return machine;
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Persist durable state, aborting the instance on any storage failure so a partial
|
|
1342
|
+
* write can never leave the in-memory machine ahead of the journal: the next event
|
|
1343
|
+
* rehydrates from the last consistent durable state (crash-consistency).
|
|
1344
|
+
*/
|
|
1345
|
+
async #persist(entries) {
|
|
1346
|
+
try {
|
|
1347
|
+
await this.ctx.storage.put(entries);
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
this.ctx.abort("coordinator: durable persist failed");
|
|
1350
|
+
throw error;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
//#endregion
|
|
1355
|
+
//#region src/adapters/cloudflare/do-registry.ts
|
|
1356
|
+
/** A `Registry` backed by a Coordinator Durable Object, reached over its RPC stub. */
|
|
1357
|
+
var DoRegistry = class {
|
|
1358
|
+
#source;
|
|
1359
|
+
/** The mirror-key scope the reader resyncs under. Defaults to `"default"`; it MUST equal the
|
|
1360
|
+
* Coordinator's regId so the client-side reader addresses the same keyspace the DO writes. Under
|
|
1361
|
+
* that regId IS the Coordinator's Durable Object NAME (identity = address) — so a caller who
|
|
1362
|
+
* addresses the DO via `idFromName(name)` passes that SAME `name` here. (C4 ledger: the
|
|
1363
|
+
* request-piggyback golden path should take ONE registry `name` and derive BOTH the DO stub —
|
|
1364
|
+
* `env.COORDINATOR.get(idFromName(name))` — AND this `regId` from it, so the address and the scope
|
|
1365
|
+
* cannot drift.) */
|
|
1366
|
+
regId;
|
|
1367
|
+
constructor(source, regId) {
|
|
1368
|
+
this.#source = source;
|
|
1369
|
+
this.regId = regId;
|
|
1370
|
+
}
|
|
1371
|
+
/** The stub for THIS call. A thunk source is invoked per use — never memoized, which is the
|
|
1372
|
+
* entire point: a cached stub is a stub minted in some earlier request, and the platform refuses
|
|
1373
|
+
* it. Minting is cheap (it is address arithmetic, not a connection), so there is nothing to save
|
|
1374
|
+
* by holding one and a working cache to lose. A bare stub source is returned as-is: the caller
|
|
1375
|
+
* chose to own the lifetime, and the signature says they may. */
|
|
1376
|
+
get #stub() {
|
|
1377
|
+
return typeof this.#source === "function" ? this.#source() : this.#source;
|
|
1378
|
+
}
|
|
1379
|
+
async check(tags) {
|
|
1380
|
+
return await this.#stub.check(tags);
|
|
1381
|
+
}
|
|
1382
|
+
async expire(tags) {
|
|
1383
|
+
return await this.#stub.expire(tags);
|
|
1384
|
+
}
|
|
1385
|
+
async delete(tags) {
|
|
1386
|
+
return await this.#stub.delete(tags);
|
|
1387
|
+
}
|
|
1388
|
+
async registerRetention(retentionMs) {
|
|
1389
|
+
await this.#stub.registerRetention(retentionMs);
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
/** Build a `Registry` from a Coordinator DO stub OR a thunk that mints one (`StubSource` —
|
|
1393
|
+
* the widening is additive, so `doRegistry(stub, regId)` remains exactly as signed). `regId`
|
|
1394
|
+
* must match the Coordinator's regId — which is its Durable Object NAME
|
|
1395
|
+
* (identity = address) — so the client-side reader resyncs the same scoped keyspace the DO writes;
|
|
1396
|
+
* defaults to `"default"` for single-registry (zero-config) deployments.
|
|
1397
|
+
*
|
|
1398
|
+
* Pass a THUNK from anything that outlives a request (`createWorkersCache` does); pass a bare stub
|
|
1399
|
+
* when the caller mints and uses within one. */
|
|
1400
|
+
function doRegistry(source, regId = DEFAULT_REG_ID) {
|
|
1401
|
+
return new DoRegistry(source, regId);
|
|
1402
|
+
}
|
|
1403
|
+
//#endregion
|
|
1404
|
+
//#region src/adapters/cloudflare/redial.ts
|
|
1405
|
+
/** First redial delay (≈ the ladder's fastest rung). */
|
|
1406
|
+
const DEFAULT_BASE_DELAY_MS = 1e3;
|
|
1407
|
+
/** Redial backoff ceiling — the same 300s cap the poll-failure backoff uses. */
|
|
1408
|
+
const MAX_REDIAL_DELAY_MS = 3e5;
|
|
1409
|
+
/** ×2 per consecutive failed establishment. */
|
|
1410
|
+
const REDIAL_BACKOFF_FACTOR = 2;
|
|
1411
|
+
/** Symmetric jitter fraction (±20%), drawn from the injected rng. */
|
|
1412
|
+
const DEFAULT_JITTER_FRACTION = .2;
|
|
1413
|
+
/**
|
|
1414
|
+
* A `Bus` that keeps ONE live doBus subscription up (driver duty), redialing
|
|
1415
|
+
* LAZILY. Wraps the mechanism-only client: every close schedules a backed-off redial that the
|
|
1416
|
+
* next `tick` performs, and the app's `onEvent` sees an uninterrupted event stream across sockets
|
|
1417
|
+
* (each new socket re-handshakes `reset` → `hello`, which is exactly the signal the kernel reader
|
|
1418
|
+
* needs to re-resync — the same events it would see on a fresh subscribe).
|
|
1419
|
+
*/
|
|
1420
|
+
var RedialingBus = class {
|
|
1421
|
+
#dial;
|
|
1422
|
+
#options;
|
|
1423
|
+
#baseDelayMs;
|
|
1424
|
+
#jitterFraction;
|
|
1425
|
+
/** The live inner subscription, or `null` while the channel is down. */
|
|
1426
|
+
#inner = null;
|
|
1427
|
+
/** The consecutive-failure count the backoff ladder rides on. */
|
|
1428
|
+
#attempt = 0;
|
|
1429
|
+
/** `true` once `close()` is called: no tick may redial afterwards. */
|
|
1430
|
+
#closed = false;
|
|
1431
|
+
/** The instant a redial becomes due, or `null` when the channel is up (nothing to redial) or has
|
|
1432
|
+
* never been dialed. Written when a close is REPORTED; consumed by `tick`. */
|
|
1433
|
+
#dueAtMs = null;
|
|
1434
|
+
/** The subscriber, held so a redial can re-attach the SAME consumer to a new socket. */
|
|
1435
|
+
#onEvent = null;
|
|
1436
|
+
/** The most recent `nowMs` a tick supplied — this driver's ONLY clock (`null` before the first
|
|
1437
|
+
* tick). See `#backoffFrom` for why a close is dated from it. */
|
|
1438
|
+
#lastTickMs = null;
|
|
1439
|
+
constructor(dial, options) {
|
|
1440
|
+
this.#dial = dial;
|
|
1441
|
+
this.#options = options;
|
|
1442
|
+
this.#baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
1443
|
+
this.#jitterFraction = options.jitterFraction ?? DEFAULT_JITTER_FRACTION;
|
|
1444
|
+
}
|
|
1445
|
+
subscribe(onEvent) {
|
|
1446
|
+
this.#onEvent = onEvent;
|
|
1447
|
+
this.#connect();
|
|
1448
|
+
return { close: () => {
|
|
1449
|
+
this.#closed = true;
|
|
1450
|
+
this.#dueAtMs = null;
|
|
1451
|
+
this.#onEvent = null;
|
|
1452
|
+
this.#inner?.close();
|
|
1453
|
+
this.#inner = null;
|
|
1454
|
+
} };
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Perform a DUE redial — called from a request context, which is what makes the dial legal.
|
|
1458
|
+
* A no-op when the channel is up, when nothing is due yet, or after `close()`. Cheap and
|
|
1459
|
+
* idempotent, so a carrier may call it on every tick without gating.
|
|
1460
|
+
*/
|
|
1461
|
+
tick(nowMs) {
|
|
1462
|
+
this.#lastTickMs = nowMs;
|
|
1463
|
+
if (this.#closed || this.#dueAtMs === null || nowMs < this.#dueAtMs) return;
|
|
1464
|
+
this.#dueAtMs = null;
|
|
1465
|
+
this.#connect();
|
|
1466
|
+
}
|
|
1467
|
+
/** Open one socket and wire it. The close handler SCHEDULES the next attempt; it never dials. */
|
|
1468
|
+
#connect() {
|
|
1469
|
+
if (this.#closed || this.#onEvent === null) return;
|
|
1470
|
+
const onEvent = this.#onEvent;
|
|
1471
|
+
this.#inner = doBus(this.#dial, {
|
|
1472
|
+
onClose: (info) => {
|
|
1473
|
+
this.#onSocketClosed(info);
|
|
1474
|
+
},
|
|
1475
|
+
regId: this.#options.regId
|
|
1476
|
+
}).subscribe((event) => {
|
|
1477
|
+
if (event.kind === "hello") this.#attempt = 0;
|
|
1478
|
+
onEvent(event);
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
/** A socket closed: count it, schedule the next attempt, and pass the report through. */
|
|
1482
|
+
#onSocketClosed(info) {
|
|
1483
|
+
this.#inner = null;
|
|
1484
|
+
if (!this.#closed) {
|
|
1485
|
+
this.#attempt += 1;
|
|
1486
|
+
this.#dueAtMs = this.#backoffFrom() + this.#jitter(this.#delayFor(this.#attempt));
|
|
1487
|
+
}
|
|
1488
|
+
this.#options.onClose?.(info);
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* The instant a backoff is measured from. This driver is handed time ONLY by `tick`, and a socket
|
|
1492
|
+
* dies whenever it dies — between ticks, with no `nowMs` in scope. So a close is dated from the
|
|
1493
|
+
* last tick observed.
|
|
1494
|
+
*
|
|
1495
|
+
* THE RESIDUE, PRICED: the last tick is at most one tick-cadence old, so a rung can be served up
|
|
1496
|
+
* to that much early. It is bounded (never "arbitrarily stale" — every tick refreshes it) and it
|
|
1497
|
+
* errs toward redialing SOONER, which is the direction that costs a dial rather than a stale
|
|
1498
|
+
* serve. Against the rungs that matter — the cap is 300s, the cadence is ~1s — it is noise.
|
|
1499
|
+
*
|
|
1500
|
+
* Before the first tick there is no observation at all, so `0` stands in: the due-time becomes
|
|
1501
|
+
* "the delay, counted from the epoch", which every real `nowMs` already exceeds, so the first tick
|
|
1502
|
+
* after an early close redials at once. That is the right answer for the case it covers — a dial
|
|
1503
|
+
* that failed during the very first request, where a prompt retry is exactly what is wanted.
|
|
1504
|
+
*/
|
|
1505
|
+
#backoffFrom() {
|
|
1506
|
+
return this.#lastTickMs ?? 0;
|
|
1507
|
+
}
|
|
1508
|
+
/** The un-jittered delay for the nth consecutive failed establishment: base × 2^(n−1), cap 300s. */
|
|
1509
|
+
#delayFor(attempt) {
|
|
1510
|
+
const grown = this.#baseDelayMs * REDIAL_BACKOFF_FACTOR ** Math.max(0, attempt - 1);
|
|
1511
|
+
return Math.min(grown, MAX_REDIAL_DELAY_MS);
|
|
1512
|
+
}
|
|
1513
|
+
/** Jitter a delay symmetrically by ±`jitterFraction` off the injected rng — a fleet whose
|
|
1514
|
+
* Coordinator just restarted must not redial in lockstep. rng.next() ∈ [0,1) ⇒ factor ∈ [1−f, 1+f). */
|
|
1515
|
+
#jitter(baseMs) {
|
|
1516
|
+
const f = this.#jitterFraction;
|
|
1517
|
+
return baseMs * (1 - f + 2 * f * this.#options.rng.next());
|
|
1518
|
+
}
|
|
1519
|
+
};
|
|
1520
|
+
/**
|
|
1521
|
+
* Build a self-redialing doBus `Bus`. This is the bus `createWorkersCache` wires: the
|
|
1522
|
+
* transport reports its close, and this driver owns the reconnect + the ×2/300s jittered
|
|
1523
|
+
* backoff — LAZILY, performed by `tick` inside a request context rather than by a timer that would
|
|
1524
|
+
* be refused at global scope and would race a dead isolate everywhere else. Use the bare `doBus`
|
|
1525
|
+
* instead when the caller drives redials itself (the integration lane does — tests own time).
|
|
1526
|
+
*/
|
|
1527
|
+
function redialingDoBus(dial, options) {
|
|
1528
|
+
return new RedialingBus(dial, options);
|
|
1529
|
+
}
|
|
1530
|
+
//#endregion
|
|
1531
|
+
//#region src/adapters/cloudflare/workers-cache.ts
|
|
1532
|
+
/**
|
|
1533
|
+
* The safe posture `createWorkersCache` stamps unless a caller overrides it: reads are
|
|
1534
|
+
* `eventual`, stale-if-error resilience is on (the default HTTP retriable classifier), an
|
|
1535
|
+
* `unknown` validity fails CLOSED to a live registry check (`registry-check`, never `miss`), and the
|
|
1536
|
+
* liveness heartbeat cadence the reader's silence monitor mirrors is the one number above (30s) — the
|
|
1537
|
+
* READER end of it; the Coordinator end is a wrangler var no factory can reach (R6).
|
|
1538
|
+
* Set EXPLICITLY (not left to `createCache`'s implicit defaults) so the posture is a single, pinned
|
|
1539
|
+
* object the factory spreads — a caller's `config.defaults` still wins per-field. Private-by-default
|
|
1540
|
+
* (a principal-derived request is `usr:`-scoped, L1-only) is `createCache`'s own scope resolution,
|
|
1541
|
+
*inherited unchanged. Internal (a caller never needs it — the factory already merges it
|
|
1542
|
+
* with `config.defaults`); the safe-posture tooth proves it BEHAVIORALLY, not by importing it.
|
|
1543
|
+
*/
|
|
1544
|
+
const SAFE_DEFAULTS = {
|
|
1545
|
+
consistency: "eventual",
|
|
1546
|
+
heartbeatInterval: "30s",
|
|
1547
|
+
staleIfError: isRetriableHttp,
|
|
1548
|
+
unknownPolicy: "registry-check"
|
|
1549
|
+
};
|
|
1550
|
+
/** The Coordinator `/bus` WebSocket endpoint the dial upgrades against (mirrors the DO's router). */
|
|
1551
|
+
const BUS_URL = "https://coordinator/bus";
|
|
1552
|
+
/**
|
|
1553
|
+
* Minimum ms between two carrier ticks on ONE instance. The ladder's fastest rung (~1s): ticking
|
|
1554
|
+
* faster cannot make the poller act sooner (every path it drives is gated by its own due-time), so
|
|
1555
|
+
* anything below this is pure waste under load. A burst of 1000 reads/second costs one tick.
|
|
1556
|
+
*/
|
|
1557
|
+
const CARRIER_TICK_MIN_INTERVAL_MS = 1e3;
|
|
1558
|
+
/**
|
|
1559
|
+
* THE CARRIER (the Ticks clause). Anything this library ships as a ready-made composition has to
|
|
1560
|
+
* bring a freshness driver with it; this is the one `createWorkersCache` brings.
|
|
1561
|
+
*
|
|
1562
|
+
* WHY IT HAS TO EXIST. The poller and the redial ladder are both tick-driven and neither runs a
|
|
1563
|
+
* timer — a Workers isolate has no free-running timers, and the reachability rule forbids a timer performing I/O
|
|
1564
|
+
* anyway. Until now the ONLY thing that ever called a tick was the React Router middleware, so a
|
|
1565
|
+
* plain Worker built on this factory had no driver at all: its `__state` poll never ran, its
|
|
1566
|
+
* silence monitor was never fed, and its redial ladder never advanced. Nothing then held its
|
|
1567
|
+
* eventual reads inside the staleness bound INV-25 fixes. Whoever ships the drivers owes them
|
|
1568
|
+
* something to drive them.
|
|
1569
|
+
*
|
|
1570
|
+
* WHY THE READ PATH. It is the only thing a plain Worker is guaranteed to do. Riding the read means
|
|
1571
|
+
* freshness observation is request-coupled — detection latency is max(threshold, inter-request gap)
|
|
1572
|
+
* — which is the same honest residue the middleware's request-start tick has, and for the same
|
|
1573
|
+
* reason it is acceptable: the only reader a late observation could hurt is one that is not reading.
|
|
1574
|
+
*
|
|
1575
|
+
* ADOPT-DON'T-AWAIT. The tick is fired and NOT awaited: a freshness probe must never add latency to
|
|
1576
|
+
* the read that happened to trigger it, and must never fail it. `#run` catches everything, so the
|
|
1577
|
+
* returned promise cannot reject and nothing floats as an unhandled rejection.
|
|
1578
|
+
*
|
|
1579
|
+
* CANCELLATION-TOLERANT, AND HERE IS THE ARGUMENT. An unawaited promise in a Workers request is
|
|
1580
|
+
* cancelled when the response is returned — the tick's in-flight I/O simply rejects. That is safe,
|
|
1581
|
+
* by three independent properties, none of which is an accident of timing:
|
|
1582
|
+
* (i) the tick's writes are per-frame atomic and MONOTONE (max-on-write watermarks, a contiguous
|
|
1583
|
+
* applied epoch), so an interrupted pass leaves the reader BEHIND where it would have been —
|
|
1584
|
+
* never ahead. Behind is the fail-closed direction: it costs a live check, never a stale
|
|
1585
|
+
* serve.
|
|
1586
|
+
* (ii) suspicion is cleared only AFTER a replay completes, so a pass cut short mid-replay cannot
|
|
1587
|
+
* leave the reader falsely converged. The half-applied state is exactly the state it would
|
|
1588
|
+
* have had if the pass had never started, plus some correctly-applied frames.
|
|
1589
|
+
* (iii) the poller's in-flight guard is released in a `finally`, and the rejection a teardown
|
|
1590
|
+
* produces travels through the same `await` any other failure does — so it cannot wedge.
|
|
1591
|
+
* The cursor below is written BEFORE the tick is fired for the same family of reasons: a cancelled
|
|
1592
|
+
* tick must not leave the gate open for the next read to retry into a hot loop.
|
|
1593
|
+
*/
|
|
1594
|
+
function withCarrier(cache, bus, clock, sink, onSinkError) {
|
|
1595
|
+
const poller = replicationPollerOf(cache);
|
|
1596
|
+
let lastTickMs = null;
|
|
1597
|
+
const run = async (nowMs) => {
|
|
1598
|
+
try {
|
|
1599
|
+
bus.tick(nowMs);
|
|
1600
|
+
await poller?.onPollTick(nowMs);
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
safeEmit(sink, {
|
|
1603
|
+
error: error instanceof Error ? error.name : "unknown",
|
|
1604
|
+
type: TELEMETRY_EVENTS.poll_tick_failed
|
|
1605
|
+
}, onSinkError);
|
|
1606
|
+
}
|
|
1607
|
+
};
|
|
1608
|
+
const carry = () => {
|
|
1609
|
+
const nowMs = clock.now();
|
|
1610
|
+
if (lastTickMs !== null && nowMs - lastTickMs < CARRIER_TICK_MIN_INTERVAL_MS) return;
|
|
1611
|
+
lastTickMs = nowMs;
|
|
1612
|
+
run(nowMs);
|
|
1613
|
+
};
|
|
1614
|
+
const carried = {
|
|
1615
|
+
...cache,
|
|
1616
|
+
async getOrSet(options) {
|
|
1617
|
+
carry();
|
|
1618
|
+
return await cache.getOrSet(options);
|
|
1619
|
+
},
|
|
1620
|
+
async getOrSetEntry(options) {
|
|
1621
|
+
carry();
|
|
1622
|
+
return await cache.getOrSetEntry(options);
|
|
1623
|
+
}
|
|
1624
|
+
};
|
|
1625
|
+
aliasCacheIdentity(carried, cache);
|
|
1626
|
+
return carried;
|
|
1627
|
+
}
|
|
1628
|
+
/** L1 bounds for the Workers profile (example): 512 entries, 5 MB. */
|
|
1629
|
+
const L1_MAX_ENTRIES = 512;
|
|
1630
|
+
const L1_MAX_BYTES = 5e6;
|
|
1631
|
+
/**
|
|
1632
|
+
* Build a Workers-native `Cache` from one registry `name` + a KV namespace + a Coordinator DO
|
|
1633
|
+
* namespace. Sugar over `createCache`: the wall clock and RNG enter here at the platform
|
|
1634
|
+
* boundary and reach the kernel only as the injected `clock`/`rng`; the drivers are composed against
|
|
1635
|
+
* the single `name` (DO stub, registry id, key namespace). A caller's `defaults` override the safe
|
|
1636
|
+
* posture per-field; everything else is fixed.
|
|
1637
|
+
*
|
|
1638
|
+
* WHY THE L1 IS NOT OPTIONAL HERE, AND WHAT IT COSTS TO SKIP IT. This factory always composes an L1,
|
|
1639
|
+
* and under an L2 outage that L1 is the absorber. A failing L2 read is suppressed to a plain miss,
|
|
1640
|
+
*so the reader refills from origin — and with an L1 the refill lands there, is checked first
|
|
1641
|
+
* on every subsequent read, and the failing tier is never consulted again: one origin call,
|
|
1642
|
+
* however long the outage runs. Take the L1 away and nothing catches the refill. The read suppresses
|
|
1643
|
+
* to a miss, the write-back's read-compare cannot read, so nothing lands durably (`durable: false`),
|
|
1644
|
+
* and the next call repeats the whole trip — every call, forever, for as long as L2 is down. So a
|
|
1645
|
+
* raw `createCache` composed WITHOUT an `l1` converts an L2 outage into an unbounded origin
|
|
1646
|
+
* stampede, exactly when the origin is least able to absorb one. That is a priced consequence of
|
|
1647
|
+
* the miss-class posture, not a defect — a suppressed read must refill, and a refill it cannot
|
|
1648
|
+
* cache must happen again. Compose an L1, or accept the bill.
|
|
1649
|
+
*
|
|
1650
|
+
* ONE DEPLOYMENT STEP THIS FACTORY CANNOT DO FOR YOU (R6). A Worker cannot write a Durable Object's
|
|
1651
|
+
* `env`, so the Coordinator's idle liveness heartbeat is seeded ONLY by its own wrangler config, and it
|
|
1652
|
+
* ships DORMANT. Recommended production posture — set it to the same 30s this reader defaults to
|
|
1653
|
+
* (see `DEFAULT_HEARTBEAT_INTERVAL` for the full price of leaving it unset, and for why a mismatch
|
|
1654
|
+
* is safe-direction rather than a bug):
|
|
1655
|
+
*
|
|
1656
|
+
* // wrangler.jsonc (the Worker that hosts the Coordinator Durable Object)
|
|
1657
|
+
* "vars": { "REGISTRY_HEARTBEAT_MS": "30000" }
|
|
1658
|
+
*/
|
|
1659
|
+
function createWorkersCache(config) {
|
|
1660
|
+
const { name, kv, coordinator } = config;
|
|
1661
|
+
const clock = { now: () => Date.now() };
|
|
1662
|
+
const rng = { next: () => Math.random() };
|
|
1663
|
+
const stubFor = () => coordinator.get(coordinator.idFromName(name));
|
|
1664
|
+
const dial = async () => {
|
|
1665
|
+
const response = await stubFor().fetch(BUS_URL, { headers: { Upgrade: "websocket" } });
|
|
1666
|
+
const socket = response.webSocket;
|
|
1667
|
+
if (!socket) throw new Error(`astilba/cache: expected a 101 webSocket from the Coordinator /bus endpoint, got status ${response.status}`);
|
|
1668
|
+
socket.accept();
|
|
1669
|
+
return socket;
|
|
1670
|
+
};
|
|
1671
|
+
const { telemetry } = config;
|
|
1672
|
+
const sink = typeof telemetry === "function" ? telemetry : telemetry?.sink;
|
|
1673
|
+
const onSinkError = typeof telemetry === "function" ? void 0 : telemetry?.onSinkError;
|
|
1674
|
+
const bus = redialingDoBus(dial, {
|
|
1675
|
+
regId: name,
|
|
1676
|
+
rng
|
|
1677
|
+
});
|
|
1678
|
+
return withCarrier(createCache({
|
|
1679
|
+
namespace: name,
|
|
1680
|
+
clock,
|
|
1681
|
+
rng,
|
|
1682
|
+
l1: memory({
|
|
1683
|
+
clock,
|
|
1684
|
+
maxEntries: L1_MAX_ENTRIES,
|
|
1685
|
+
maxBytes: L1_MAX_BYTES
|
|
1686
|
+
}),
|
|
1687
|
+
l2: cloudflareKV(kv),
|
|
1688
|
+
registry: doRegistry(stubFor, name),
|
|
1689
|
+
bus,
|
|
1690
|
+
defaults: {
|
|
1691
|
+
...SAFE_DEFAULTS,
|
|
1692
|
+
...config.defaults
|
|
1693
|
+
},
|
|
1694
|
+
...telemetry === void 0 ? {} : { telemetry },
|
|
1695
|
+
...config.takedownSensitive === void 0 ? {} : { takedownSensitive: config.takedownSensitive }
|
|
1696
|
+
}), bus, clock, sink, onSinkError);
|
|
1697
|
+
}
|
|
1698
|
+
//#endregion
|
|
1699
|
+
export { Coordinator, InvalidRegistryNameError, cloudflareKV, createWorkersCache, doBus, doRegistry, redialingDoBus };
|