@nimbus-sh/fabric 0.2.0 → 0.3.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/README.md +169 -283
- package/dist/composition.d.ts +2 -86
- package/dist/composition.d.ts.map +1 -1
- package/dist/composition.js +2 -76
- package/dist/fenced-work.d.ts +33 -4
- package/dist/fenced-work.d.ts.map +1 -1
- package/dist/fenced-work.js +81 -28
- package/dist/process-fabric.d.ts +31 -1
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +19 -0
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +11 -4
- package/dist/turn-budget.d.ts +5 -0
- package/dist/turn-budget.d.ts.map +1 -1
- package/dist/turn-budget.js +2 -1
- package/dist/workerd-facet-host.d.ts +44 -8
- package/dist/workerd-facet-host.d.ts.map +1 -1
- package/dist/workerd-facet-host.js +80 -10
- package/package.json +3 -3
- package/src/composition.ts +16 -127
- package/src/fenced-work.ts +81 -30
- package/src/process-fabric.ts +37 -1
- package/src/process-host.ts +13 -6
- package/src/turn-budget.ts +4 -3
- package/src/workerd-facet-host.ts +93 -16
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* that is not a Durable Object implements `ProcessHost` against the same
|
|
13
13
|
* `HostedProcess` and never imports this file.
|
|
14
14
|
*/
|
|
15
|
-
import { type HostedProcess, type OneShotParams, type ProcessHostParams, type ResidentDiskReader, type ResidentSupervisorProps } from './process-fabric.js';
|
|
15
|
+
import { type HostedProcess, type OneShotParams, type ProcessHostParams, type ResidentBootSpec, type ResidentDiskReader, type ResidentSupervisorProps } from './process-fabric.js';
|
|
16
16
|
/** Structural surface of a NimbusLoadedEntrypoint RPC stub. */
|
|
17
17
|
export interface LoadedWorkerEntrypointStub {
|
|
18
18
|
handleHttpRequest?: (request: Request) => Promise<Response>;
|
|
@@ -57,9 +57,14 @@ interface LoadedWorkerStub {
|
|
|
57
57
|
* Durable Object class, so a resident process can be re-entered; `load` is
|
|
58
58
|
* unkeyed and yields a stateless entrypoint, which is all a program that ends
|
|
59
59
|
* with its call can ever need.
|
|
60
|
+
*
|
|
61
|
+
* `get` stays wide on purpose: the platform passes a null id for the unkeyed
|
|
62
|
+
* call and answers the callback with the code object or a promise of it, so a
|
|
63
|
+
* narrower declaration would refuse the real binding. The fabric itself always
|
|
64
|
+
* passes a string id and a promise callback.
|
|
60
65
|
*/
|
|
61
66
|
interface WorkerLoaderBinding {
|
|
62
|
-
get(id: string, code: () =>
|
|
67
|
+
get(id: string | null, code: () => unknown): {
|
|
63
68
|
getDurableObjectClass(name: string): unknown;
|
|
64
69
|
};
|
|
65
70
|
load(code: unknown): LoadedWorkerStub;
|
|
@@ -106,7 +111,7 @@ export declare function cloneStorage(ctx: DurableObjectState, clone: {
|
|
|
106
111
|
populated(name: string): boolean | Promise<boolean>;
|
|
107
112
|
}): Promise<void>;
|
|
108
113
|
/**
|
|
109
|
-
* The facet name for
|
|
114
|
+
* The facet name for an ephemeral slot. Reused, and that is the entire point.
|
|
110
115
|
*
|
|
111
116
|
* A Durable Object admits 65,536 facets over its LIFETIME: the IDs are
|
|
112
117
|
* append-only and are never reclaimed, so the bound is on facets ever CREATED,
|
|
@@ -118,18 +123,36 @@ export declare function cloneStorage(ctx: DurableObjectState, clone: {
|
|
|
118
123
|
* Reusing a NAME costs no new ID. So the name comes from a free list and the
|
|
119
124
|
* pid stays what it always was: the process identity in the ProcessTable. The
|
|
120
125
|
* two were only ever conflated because one of them happened to be handy.
|
|
126
|
+
*
|
|
127
|
+
* The book shares the facet-ID space with one other namespace: durable
|
|
128
|
+
* applications, which mint `app-slot-<n>` names of their own (one ID per app,
|
|
129
|
+
* ever). The prefixes are disjoint BY CONSTRUCTION, and that disjointness is
|
|
130
|
+
* load-bearing — a proc-slot name reissued onto a durable app's retained
|
|
131
|
+
* storage would boot the wrong process into someone else's disk.
|
|
121
132
|
*/
|
|
122
133
|
export declare function residentFacetName(slot: number): string;
|
|
134
|
+
/** The prefix every durable application's facet name carries. */
|
|
135
|
+
export declare const DURABLE_FACET_NAME_PREFIX = "app-slot-";
|
|
136
|
+
/**
|
|
137
|
+
* Drop one facet's SQLite by name — the ONLY call site that may delete facet
|
|
138
|
+
* storage. `spawnResident` releases ephemeral processes with abort+delete
|
|
139
|
+
* (storage is slot-reuse hygiene) and durable ones with abort alone (the
|
|
140
|
+
* storage IS the durable application's state); explicit removal arrives here
|
|
141
|
+
* through the coordinator's durable-slot book, owner-checked.
|
|
142
|
+
*/
|
|
143
|
+
export declare function deleteFacetStorage(ctx: DurableObjectState, name: string): void;
|
|
123
144
|
/**
|
|
124
145
|
* What `processes(ctx, env).spawn` hands back: a running process, minus its placement.
|
|
125
146
|
*
|
|
126
|
-
* `
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
147
|
+
* `name` is the facet's real name and `slot` its ephemeral book entry (absent
|
|
148
|
+
* for a durable spawn, whose name its coordinator allocated out of storage).
|
|
149
|
+
* Both ride along because the caller's `describe` needs them and neither is
|
|
150
|
+
* derivable from the pid — reading a slot back out of the book would race the
|
|
151
|
+
* release that empties it.
|
|
130
152
|
*/
|
|
131
153
|
export type ResidentFacet = Omit<HostedProcess, 'describe'> & {
|
|
132
|
-
|
|
154
|
+
name: string;
|
|
155
|
+
slot?: number;
|
|
133
156
|
};
|
|
134
157
|
/**
|
|
135
158
|
* The process surface of one hosting actor: how a resident process comes
|
|
@@ -164,5 +187,18 @@ export declare class Processes {
|
|
|
164
187
|
*/
|
|
165
188
|
run<T>(supervisor: ResidentSupervisorProps, params: OneShotParams, consume: (response: Response) => Promise<T>): Promise<T>;
|
|
166
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* The WorkerCode the loader callback returns for one resident boot: the
|
|
192
|
+
* module map from {@link residentLoaderConfig} (or the staged assembler),
|
|
193
|
+
* plus the isolate's env and network posture.
|
|
194
|
+
*
|
|
195
|
+
* A `code` boot with an explicit `env` — defined, even as `{}` — is the
|
|
196
|
+
* embedder's whole statement about the isolate: the env rides through
|
|
197
|
+
* exactly as minted (loopback stubs by reference), and the composed supervisor
|
|
198
|
+
* entrypoint is not consulted at all, so no SUPERVISOR binding appears.
|
|
199
|
+
* Without one, the default holds: inherited network plus a SUPERVISOR
|
|
200
|
+
* minted from the composed entrypoint for the coordinator's identity.
|
|
201
|
+
*/
|
|
202
|
+
export declare function residentWorkerConfig(env: ResidentFacetEnv, disk: () => ResidentDiskReader, supervisor: ResidentSupervisorProps, boot: ResidentBootSpec): Promise<Record<string, unknown>>;
|
|
167
203
|
export {};
|
|
168
204
|
//# sourceMappingURL=workerd-facet-host.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workerd-facet-host.d.ts","sourceRoot":"","sources":["../src/workerd-facet-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAmBH,OAAO,EAGL,KAAK,aAAa,EAElB,KAAK,aAAa,EAClB,KAAK,iBAAiB,
|
|
1
|
+
{"version":3,"file":"workerd-facet-host.d.ts","sourceRoot":"","sources":["../src/workerd-facet-host.ts"],"names":[],"mappings":"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"}
|
|
@@ -98,7 +98,7 @@ export async function cloneStorage(ctx, clone) {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
/**
|
|
101
|
-
* The facet name for
|
|
101
|
+
* The facet name for an ephemeral slot. Reused, and that is the entire point.
|
|
102
102
|
*
|
|
103
103
|
* A Durable Object admits 65,536 facets over its LIFETIME: the IDs are
|
|
104
104
|
* append-only and are never reclaimed, so the bound is on facets ever CREATED,
|
|
@@ -110,10 +110,18 @@ export async function cloneStorage(ctx, clone) {
|
|
|
110
110
|
* Reusing a NAME costs no new ID. So the name comes from a free list and the
|
|
111
111
|
* pid stays what it always was: the process identity in the ProcessTable. The
|
|
112
112
|
* two were only ever conflated because one of them happened to be handy.
|
|
113
|
+
*
|
|
114
|
+
* The book shares the facet-ID space with one other namespace: durable
|
|
115
|
+
* applications, which mint `app-slot-<n>` names of their own (one ID per app,
|
|
116
|
+
* ever). The prefixes are disjoint BY CONSTRUCTION, and that disjointness is
|
|
117
|
+
* load-bearing — a proc-slot name reissued onto a durable app's retained
|
|
118
|
+
* storage would boot the wrong process into someone else's disk.
|
|
113
119
|
*/
|
|
114
120
|
export function residentFacetName(slot) {
|
|
115
121
|
return `proc-slot-${slot}`;
|
|
116
122
|
}
|
|
123
|
+
/** The prefix every durable application's facet name carries. */
|
|
124
|
+
export const DURABLE_FACET_NAME_PREFIX = 'app-slot-';
|
|
117
125
|
/**
|
|
118
126
|
* Slot books, per hosting actor, because the facet index is per Durable
|
|
119
127
|
* Object.
|
|
@@ -128,6 +136,11 @@ export function residentFacetName(slot) {
|
|
|
128
136
|
* VFS epoch, in which case `invalidatedSince` can only answer poison and the
|
|
129
137
|
* whole store is dropped. A process therefore cannot boot onto a previous
|
|
130
138
|
* tenant's filesystem even when release never ran.
|
|
139
|
+
*
|
|
140
|
+
* The book names only the `proc-slot-` space. Durable `app-slot-` names are
|
|
141
|
+
* allocated against DO storage instead (their owner survives a reset), so a
|
|
142
|
+
* fresh incarnation's `next` starting at 0 can never collide with them even
|
|
143
|
+
* before the durable ledger is adopted.
|
|
131
144
|
*/
|
|
132
145
|
const slotBooks = new WeakMap();
|
|
133
146
|
function slotBook(ctx) {
|
|
@@ -163,6 +176,16 @@ function releaseSlot(ctx, pid) {
|
|
|
163
176
|
book.free.push(slot);
|
|
164
177
|
book.free.sort((a, b) => a - b);
|
|
165
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Drop one facet's SQLite by name — the ONLY call site that may delete facet
|
|
181
|
+
* storage. `spawnResident` releases ephemeral processes with abort+delete
|
|
182
|
+
* (storage is slot-reuse hygiene) and durable ones with abort alone (the
|
|
183
|
+
* storage IS the durable application's state); explicit removal arrives here
|
|
184
|
+
* through the coordinator's durable-slot book, owner-checked.
|
|
185
|
+
*/
|
|
186
|
+
export function deleteFacetStorage(ctx, name) {
|
|
187
|
+
facetContainer(ctx).delete(name);
|
|
188
|
+
}
|
|
166
189
|
/**
|
|
167
190
|
* The process surface of one hosting actor: how a resident process comes
|
|
168
191
|
* into existence on workerd, and how a one-shot program runs to completion.
|
|
@@ -207,8 +230,19 @@ export class Processes {
|
|
|
207
230
|
}
|
|
208
231
|
function spawnResident(ctx, env, disk, supervisor, params) {
|
|
209
232
|
const facets = facetContainer(ctx);
|
|
210
|
-
|
|
211
|
-
|
|
233
|
+
// An explicit name is the durable path: the caller allocated an
|
|
234
|
+
// `app-slot-<n>` identity out of DO storage and this facet keeps its SQLite
|
|
235
|
+
// across aborts. Anything else takes the in-memory book — and that book's
|
|
236
|
+
// `proc-slot-` names must never be minted for it, or an ephemeral release's
|
|
237
|
+
// delete would wipe the app's storage and a reused slot would land a new
|
|
238
|
+
// process on someone else's disk.
|
|
239
|
+
const explicit = params.facet;
|
|
240
|
+
if (explicit && !explicit.name.startsWith(DURABLE_FACET_NAME_PREFIX)) {
|
|
241
|
+
throw new Error(`Nimbus: an explicit facet name must carry the '${DURABLE_FACET_NAME_PREFIX}' `
|
|
242
|
+
+ `prefix, got '${explicit.name}'`);
|
|
243
|
+
}
|
|
244
|
+
const slot = explicit ? undefined : acquireSlot(ctx, params.pid);
|
|
245
|
+
const name = explicit ? explicit.name : residentFacetName(slot);
|
|
212
246
|
// The start callback is the ONLY way this facet is ever created, and it
|
|
213
247
|
// fires AT MOST ONCE. Every later use goes through the stub below, so the
|
|
214
248
|
// callback running a second time means the facet was released or died —
|
|
@@ -233,7 +267,8 @@ function spawnResident(ctx, env, disk, supervisor, params) {
|
|
|
233
267
|
facet = facets.get(name, start);
|
|
234
268
|
}
|
|
235
269
|
catch (error) {
|
|
236
|
-
|
|
270
|
+
if (slot !== undefined)
|
|
271
|
+
releaseSlot(ctx, params.pid);
|
|
237
272
|
throw withFacetBudgetNamed(facetNameCount(ctx), error);
|
|
238
273
|
}
|
|
239
274
|
let disposed = false;
|
|
@@ -246,13 +281,21 @@ function spawnResident(ctx, env, disk, supervisor, params) {
|
|
|
246
281
|
facets.abort(name, new Error('Nimbus: resident process released'));
|
|
247
282
|
}
|
|
248
283
|
catch { /* already gone */ }
|
|
249
|
-
|
|
250
|
-
|
|
284
|
+
// The two release classes: an ephemeral facet's SQLite is slot-reuse
|
|
285
|
+
// hygiene — the name is handed out again, so the store must not be — and
|
|
286
|
+
// a durable one's is the application itself: abort ends the process, the
|
|
287
|
+
// data stays for the next boot, and only removeDurableApp's explicit
|
|
288
|
+
// deleteFacetStorage call ever drops it.
|
|
289
|
+
if (!explicit?.durable) {
|
|
290
|
+
try {
|
|
291
|
+
facets.delete(name);
|
|
292
|
+
}
|
|
293
|
+
catch { /* already gone */ }
|
|
251
294
|
}
|
|
252
|
-
catch { /* already gone */ }
|
|
253
295
|
// Only after the facet is gone. A slot handed out while its previous
|
|
254
296
|
// tenant were still being torn down would have two processes on one name.
|
|
255
|
-
|
|
297
|
+
if (slot !== undefined)
|
|
298
|
+
releaseSlot(ctx, params.pid);
|
|
256
299
|
};
|
|
257
300
|
let started;
|
|
258
301
|
try {
|
|
@@ -280,6 +323,7 @@ function spawnResident(ctx, env, disk, supervisor, params) {
|
|
|
280
323
|
handleHttpRequest: (request) => facet.handleHttpRequest(request),
|
|
281
324
|
handleWebSocketRequest: (request) => facet.fetch(request),
|
|
282
325
|
release,
|
|
326
|
+
name,
|
|
283
327
|
slot,
|
|
284
328
|
};
|
|
285
329
|
}
|
|
@@ -373,14 +417,40 @@ async function runOneShot(ctx, env, supervisor, params, consume) {
|
|
|
373
417
|
disposeRpcResource(supervisorBinding);
|
|
374
418
|
}
|
|
375
419
|
}
|
|
376
|
-
|
|
420
|
+
/**
|
|
421
|
+
* The WorkerCode the loader callback returns for one resident boot: the
|
|
422
|
+
* module map from {@link residentLoaderConfig} (or the staged assembler),
|
|
423
|
+
* plus the isolate's env and network posture.
|
|
424
|
+
*
|
|
425
|
+
* A `code` boot with an explicit `env` — defined, even as `{}` — is the
|
|
426
|
+
* embedder's whole statement about the isolate: the env rides through
|
|
427
|
+
* exactly as minted (loopback stubs by reference), and the composed supervisor
|
|
428
|
+
* entrypoint is not consulted at all, so no SUPERVISOR binding appears.
|
|
429
|
+
* Without one, the default holds: inherited network plus a SUPERVISOR
|
|
430
|
+
* minted from the composed entrypoint for the coordinator's identity.
|
|
431
|
+
*/
|
|
432
|
+
export async function residentWorkerConfig(env, disk, supervisor, boot) {
|
|
433
|
+
if (boot.kind === 'code' && boot.code.env !== undefined) {
|
|
434
|
+
const isolated = await residentLoaderConfig(boot.code, disk());
|
|
435
|
+
assertModuleMapWithinCodeLimit(configModules(isolated));
|
|
436
|
+
return isolated;
|
|
437
|
+
}
|
|
377
438
|
const config = boot.kind === 'staged'
|
|
378
439
|
? await stagedBootAssembler()(env, boot.stage)
|
|
379
440
|
: await residentLoaderConfig(boot.code, disk());
|
|
380
|
-
assertModuleMapWithinCodeLimit(config
|
|
441
|
+
assertModuleMapWithinCodeLimit(configModules(config));
|
|
381
442
|
const supervisorRpc = supervisorEntrypoint();
|
|
382
443
|
if (!supervisorRpc) {
|
|
383
444
|
throw new Error(`Nimbus: ctx.exports.${supervisorEntrypointName() ?? '<supervisor entrypoint>'} unavailable`);
|
|
384
445
|
}
|
|
385
446
|
return { ...config, env: { SUPERVISOR: supervisorRpc({ props: supervisor }) } };
|
|
386
447
|
}
|
|
448
|
+
/** The module map a loader config assembled, or empty when it named none. */
|
|
449
|
+
function configModules(config) {
|
|
450
|
+
const modules = 'modules' in config ? config.modules : undefined;
|
|
451
|
+
if (typeof modules !== 'object' || modules === null)
|
|
452
|
+
return {};
|
|
453
|
+
// Loader configs only ever carry a module map under this key.
|
|
454
|
+
const map = modules;
|
|
455
|
+
return map;
|
|
456
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nimbus-sh/fabric",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The Cloudflare half of Nimbus — Durable Object facet hosting, dynamic-worker loader pools, the resident-process fabric, and DO alarm/hibernation machinery.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"typecheck": "tsc --noEmit"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@nimbus-sh/core": "^0.
|
|
54
|
-
"@nimbus-sh/platform": "^0.
|
|
53
|
+
"@nimbus-sh/core": "^0.7.0",
|
|
54
|
+
"@nimbus-sh/platform": "^0.2.0",
|
|
55
55
|
"zod": "^4.4.3"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
package/src/composition.ts
CHANGED
|
@@ -1,127 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
* `ctx.exports` is runtime state, not composition: workerd mints it per
|
|
18
|
-
* instance, so the embedder captures it where the platform hands it over —
|
|
19
|
-
* the first fetch, or the DO constructor — with {@link adoptCtxExports}.
|
|
20
|
-
*
|
|
21
|
-
* This module stays a leaf (no fabric imports) so helpers (notably
|
|
22
|
-
* isolate-pool.ts) can read `ctx.exports` without transitively importing the
|
|
23
|
-
* Durable Object classes, which is what lets the pool be unit-tested in a
|
|
24
|
-
* plain Node/Bun process.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* One entry of `ctx.exports`: a top-level entrypoint's loopback factory, which
|
|
29
|
-
* mints a Service Binding stub for that entrypoint when called with props.
|
|
30
|
-
*
|
|
31
|
-
* The stub's RPC surface belongs to the entrypoint CLASS, which this leaf
|
|
32
|
-
* cannot see — `Cloudflare.Exports` is derived from the embedder's own main
|
|
33
|
-
* module, so for a library it evaluates to `{}`. A caller that knows the class
|
|
34
|
-
* names the surface it expects (`factory<MySupervisorRpc>({ props })`); one
|
|
35
|
-
* that does not gets `unknown` and has to narrow, same as
|
|
36
|
-
* `DurableObjectNamespace<T>` and `RpcStub<T>` in @cloudflare/workers-types.
|
|
37
|
-
*/
|
|
38
|
-
export type EntrypointLoopbackFactory = <Stub = unknown>(options: { props: object }) => Stub;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* `ctx.exports` itself — one factory per top-level entrypoint export, keyed by
|
|
42
|
-
* export name. Absent names read as undefined, which is how a caller finds out
|
|
43
|
-
* the embedder's entry module does not re-export the class it needs.
|
|
44
|
-
*/
|
|
45
|
-
export type CtxExports = Record<string, EntrypointLoopbackFactory | undefined>;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Assemble a complete Worker Loader config from a staged-artifact spec. The
|
|
49
|
-
* embedder supplies this: a stage names artifact sources only the embedder
|
|
50
|
-
* knows how to fetch (Nimbus's largest staged artifact is a ~23 MB module map
|
|
51
|
-
* from ASSETS), and the assembler runs inside the loader's cache-miss callback
|
|
52
|
-
* so those sources are materialized only while the facet actually loads.
|
|
53
|
-
* `env` is whichever hosting actor's env the facet is opened with.
|
|
54
|
-
*/
|
|
55
|
-
export type StagedBootAssembler = (
|
|
56
|
-
env: unknown,
|
|
57
|
-
stage: unknown,
|
|
58
|
-
) => Promise<object>;
|
|
59
|
-
|
|
60
|
-
/** What an embedder states about itself, once, in its composition root. */
|
|
61
|
-
export interface FabricComposition {
|
|
62
|
-
/**
|
|
63
|
-
* The ctx.exports name of the embedder's supervisor WorkerEntrypoint. The
|
|
64
|
-
* fabric mints one supervisor binding per hosted program from it
|
|
65
|
-
* (`env.SUPERVISOR` inside the facet).
|
|
66
|
-
*/
|
|
67
|
-
supervisorEntrypoint: string;
|
|
68
|
-
/** Only embedders that use 'staged' boot specs supply one. */
|
|
69
|
-
stagedBootAssembler?: StagedBootAssembler;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
let _composition: FabricComposition | null = null;
|
|
73
|
-
|
|
74
|
-
/** Register the embedder's composition. First-write-wins. */
|
|
75
|
-
export function composeFabric(composition: FabricComposition): void {
|
|
76
|
-
if (_composition) return;
|
|
77
|
-
_composition = composition;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
let _ctxExports: CtxExports | null = null;
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Capture `ctx.exports` for the helpers that mint loopback bindings. The
|
|
84
|
-
* embedder calls this where the platform hands the bag over — the first
|
|
85
|
-
* fetch, or the DO constructor. First-write-wins.
|
|
86
|
-
*/
|
|
87
|
-
export function adoptCtxExports(value: CtxExports): void {
|
|
88
|
-
if (_ctxExports) return;
|
|
89
|
-
_ctxExports = value;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function getCtxExports(): CtxExports | null {
|
|
93
|
-
return _ctxExports;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Resolve the composed supervisor entrypoint on an exports object —
|
|
98
|
-
* `exportsObj` when given (a WorkerEntrypoint reads its own ctx.exports),
|
|
99
|
-
* the adopted ctx.exports otherwise. Calling the result with props mints one
|
|
100
|
-
* supervisor binding (`env.SUPERVISOR`) for one hosted program. Null when
|
|
101
|
-
* either half is missing; the caller decides whether that degrades or throws.
|
|
102
|
-
*/
|
|
103
|
-
export function supervisorEntrypoint(exportsObj?: unknown): EntrypointLoopbackFactory | null {
|
|
104
|
-
const exports = exportsObj ?? _ctxExports;
|
|
105
|
-
const name = _composition?.supervisorEntrypoint;
|
|
106
|
-
if (!name) return null;
|
|
107
|
-
if ((typeof exports !== 'object' && typeof exports !== 'function') || exports === null) return null;
|
|
108
|
-
const factory = (exports as Record<string, unknown>)[name];
|
|
109
|
-
return typeof factory === 'function' ? (factory as EntrypointLoopbackFactory) : null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** The composed name, for error messages that point at the missing export. */
|
|
113
|
-
export function supervisorEntrypointName(): string | null {
|
|
114
|
-
return _composition?.supervisorEntrypoint ?? null;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** The composed assembler; a 'staged' boot spec cannot assemble without one. */
|
|
118
|
-
export function stagedBootAssembler(): StagedBootAssembler {
|
|
119
|
-
const assembler = _composition?.stagedBootAssembler;
|
|
120
|
-
if (!assembler) {
|
|
121
|
-
throw new Error(
|
|
122
|
-
'fabric: no staged-boot assembler composed; a \'staged\' boot spec '
|
|
123
|
-
+ 'cannot be assembled without one (composeFabric)',
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
return assembler;
|
|
127
|
-
}
|
|
1
|
+
// Core and fabric share one holder without introducing a dependency cycle.
|
|
2
|
+
export {
|
|
3
|
+
adoptCtxExports,
|
|
4
|
+
composeFabric,
|
|
5
|
+
getCtxExports,
|
|
6
|
+
stagedBootAssembler,
|
|
7
|
+
supervisorEntrypoint,
|
|
8
|
+
supervisorEntrypointName,
|
|
9
|
+
} from '@nimbus-sh/platform/composition.js';
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
CtxExports,
|
|
13
|
+
EntrypointLoopbackFactory,
|
|
14
|
+
FabricComposition,
|
|
15
|
+
StagedBootAssembler,
|
|
16
|
+
} from '@nimbus-sh/platform/composition.js';
|
package/src/fenced-work.ts
CHANGED
|
@@ -133,6 +133,9 @@ export class FencedWork<R extends FencedWorkRecord> {
|
|
|
133
133
|
* paying a storage delete for pids that never had a row.
|
|
134
134
|
*/
|
|
135
135
|
private journalledPids = new Set<number>();
|
|
136
|
+
/** Re-drives in flight, by journal key — recovery's un-awaited ones and a
|
|
137
|
+
* caller-driven one share the same drive for the same row. */
|
|
138
|
+
private drives = new Map<string, Promise<boolean>>();
|
|
136
139
|
/** Whether this instance has already read the journal a reset leaves behind. */
|
|
137
140
|
private recovered = false;
|
|
138
141
|
|
|
@@ -143,8 +146,8 @@ export class FencedWork<R extends FencedWorkRecord> {
|
|
|
143
146
|
|
|
144
147
|
/**
|
|
145
148
|
* Record a launch as in flight, so an instance that replaces this one knows
|
|
146
|
-
* it never finished.
|
|
147
|
-
*
|
|
149
|
+
* it never finished. A launch that cannot be journalled does not start: the
|
|
150
|
+
* rejection reaches the caller, which reports it like any launch failure.
|
|
148
151
|
*
|
|
149
152
|
* Synced, not merely put: `await put()` resolves before durability, and the
|
|
150
153
|
* reset this journal exists for destroys every write its turn still had
|
|
@@ -157,12 +160,12 @@ export class FencedWork<R extends FencedWorkRecord> {
|
|
|
157
160
|
* losing its row costs a retype, not a recovery.
|
|
158
161
|
*/
|
|
159
162
|
async journal(record: R): Promise<void> {
|
|
163
|
+
this.journalledPids.add(record.pid);
|
|
160
164
|
try {
|
|
161
|
-
this.journalledPids.add(record.pid);
|
|
162
165
|
await this.storage.put(`${FENCED_WORK_KEY_PREFIX}${record.pid}`, record);
|
|
163
166
|
await this.storage.sync();
|
|
164
|
-
} catch (
|
|
165
|
-
|
|
167
|
+
} catch (cause: unknown) {
|
|
168
|
+
throw new Error('resident launch journal write failed', { cause });
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
171
|
|
|
@@ -178,12 +181,68 @@ export class FencedWork<R extends FencedWorkRecord> {
|
|
|
178
181
|
*/
|
|
179
182
|
async release(pid: number): Promise<void> {
|
|
180
183
|
if (!this.journalledPids.delete(pid)) return;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
await this.storage.delete(`${FENCED_WORK_KEY_PREFIX}${pid}`);
|
|
185
|
+
await this.storage.sync();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Drop every row `predicate` claims, live-pid bookkeeping included, synced
|
|
190
|
+
* like {@link release}. The one bulk delete the journal admits: an owner
|
|
191
|
+
* that removes its durable application is owed no recovery, however many
|
|
192
|
+
* generations back its rows were written.
|
|
193
|
+
*/
|
|
194
|
+
async purgeWhere(predicate: (record: R) => boolean): Promise<number> {
|
|
195
|
+
const rows = await this.storage.list<R>({ prefix: FENCED_WORK_KEY_PREFIX });
|
|
196
|
+
let purged = 0;
|
|
197
|
+
for (const [key, record] of rows) {
|
|
198
|
+
if (!predicate(record)) continue;
|
|
199
|
+
this.journalledPids.delete(record.pid);
|
|
200
|
+
await this.storage.delete(key);
|
|
201
|
+
purged += 1;
|
|
202
|
+
}
|
|
203
|
+
if (purged > 0) await this.storage.sync();
|
|
204
|
+
return purged;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Every journal row, storage-true — the rows this instance wrote and the
|
|
209
|
+
* ones a previous instance left behind. The one read surface a request-
|
|
210
|
+
* driven recovery needs to find the durable launch a port belongs to.
|
|
211
|
+
*/
|
|
212
|
+
async rows(): Promise<Map<string, R>> {
|
|
213
|
+
return this.storage.list<R>({ prefix: FENCED_WORK_KEY_PREFIX });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Re-drive one journal row — the awaited sibling of recovery's un-awaited
|
|
218
|
+
* re-drives, for a caller that must know whether the launch actually came
|
|
219
|
+
* back. Single-flight per row: a request-driven drive and recovery's own
|
|
220
|
+
* never boot the same launch twice. Resolves true only when the re-drive
|
|
221
|
+
* itself FAILED and the failure was reported; a settled drive supersedes
|
|
222
|
+
* the row the same way recovery's does.
|
|
223
|
+
*/
|
|
224
|
+
drive(key: string, record: R): Promise<boolean> {
|
|
225
|
+
let inflight = this.drives.get(key);
|
|
226
|
+
if (inflight === undefined) {
|
|
227
|
+
inflight = (async (): Promise<boolean> => {
|
|
228
|
+
let failed = false;
|
|
229
|
+
try {
|
|
230
|
+
await this.host.redrive(record, record.attempt + 1);
|
|
231
|
+
} catch (e: unknown) {
|
|
232
|
+
this.host.onRedriveFailed?.(record, e);
|
|
233
|
+
failed = true;
|
|
234
|
+
}
|
|
235
|
+
// A reported failure is settled business — supersede either way, so
|
|
236
|
+
// the row never re-surfaces on the next recovery.
|
|
237
|
+
await this.supersede(key);
|
|
238
|
+
return failed;
|
|
239
|
+
})();
|
|
240
|
+
this.drives.set(key, inflight);
|
|
241
|
+
inflight.finally(() => {
|
|
242
|
+
if (this.drives.get(key) === inflight) this.drives.delete(key);
|
|
243
|
+
});
|
|
186
244
|
}
|
|
245
|
+
return inflight;
|
|
187
246
|
}
|
|
188
247
|
|
|
189
248
|
/**
|
|
@@ -195,8 +254,12 @@ export class FencedWork<R extends FencedWorkRecord> {
|
|
|
195
254
|
* that replaces this one. So the first turn after a reset is already this
|
|
196
255
|
* one.
|
|
197
256
|
*
|
|
198
|
-
* Runs once per instance
|
|
199
|
-
*
|
|
257
|
+
* Runs once per instance — re-calls in the same instance are no-ops — and
|
|
258
|
+
* re-drives every row whose pid is `> 0` and at or below `generationBase()`
|
|
259
|
+
* with `attempt < FENCED_WORK_MAX_ATTEMPT`; the rest are abandoned. What the
|
|
260
|
+
* re-drive resolver receives is the journalled recipe and nothing else:
|
|
261
|
+
* env and credentials are never written to storage, so the resolver's
|
|
262
|
+
* embedder re-resolves them rather than reading them back.
|
|
200
263
|
*/
|
|
201
264
|
async recoverInterrupted(): Promise<void> {
|
|
202
265
|
if (this.recovered) return;
|
|
@@ -233,14 +296,10 @@ export class FencedWork<R extends FencedWorkRecord> {
|
|
|
233
296
|
// Not awaited: this call is running inside the alarm that granted the
|
|
234
297
|
// turn, and the launch it starts asks for turns of its own through that
|
|
235
298
|
// same alarm — awaiting it here would be waiting on an alarm that cannot
|
|
236
|
-
// be scheduled until this one returns.
|
|
237
|
-
this
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
this.host.onRedriveFailed?.(record, e);
|
|
241
|
-
})
|
|
242
|
-
.then(() => this.supersede(key)),
|
|
243
|
-
);
|
|
299
|
+
// be scheduled until this one returns. `drive` single-flights it: a
|
|
300
|
+
// request that arrives mid-launch waits on this same drive rather than
|
|
301
|
+
// booting a second process.
|
|
302
|
+
this.host.waitUntil(this.drive(key, record));
|
|
244
303
|
}
|
|
245
304
|
}
|
|
246
305
|
|
|
@@ -251,15 +310,7 @@ export class FencedWork<R extends FencedWorkRecord> {
|
|
|
251
310
|
* a previous generation the terminal hook will never fire for.
|
|
252
311
|
*/
|
|
253
312
|
private async supersede(key: string): Promise<void> {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
await this.storage.sync();
|
|
257
|
-
} catch (e: unknown) {
|
|
258
|
-
console.warn('[nimbus] resident launch journal supersede failed:', errorMessage(e));
|
|
259
|
-
}
|
|
313
|
+
await this.storage.delete(key);
|
|
314
|
+
await this.storage.sync();
|
|
260
315
|
}
|
|
261
316
|
}
|
|
262
|
-
|
|
263
|
-
function errorMessage(error: unknown): string {
|
|
264
|
-
return error instanceof Error ? error.message : String(error);
|
|
265
|
-
}
|