@nimbus-sh/fabric 0.7.1 → 0.9.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 +26 -11
- package/dist/bindings.d.ts +1 -0
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +2 -0
- package/dist/budgets.d.ts +50 -29
- package/dist/budgets.d.ts.map +1 -1
- package/dist/budgets.js +88 -40
- package/dist/connections.d.ts +1 -1
- package/dist/connections.js +1 -1
- package/dist/do-calls.d.ts +91 -9
- package/dist/do-calls.d.ts.map +1 -1
- package/dist/do-calls.js +161 -22
- package/dist/facet-pool.d.ts +3 -0
- package/dist/facet-pool.d.ts.map +1 -1
- package/dist/facet-pool.js +3 -0
- package/dist/fanout.d.ts +40 -32
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +50 -53
- package/dist/fenced-work.d.ts +3 -3
- package/dist/host-wasm.d.ts +29 -0
- package/dist/host-wasm.d.ts.map +1 -0
- package/dist/host-wasm.js +31 -0
- package/dist/image-store.d.ts +1 -1
- package/dist/image-store.d.ts.map +1 -1
- package/dist/image-store.js +34 -2
- package/dist/inner-do-registry.d.ts +9 -0
- package/dist/inner-do-registry.d.ts.map +1 -1
- package/dist/inner-do-registry.js +35 -0
- package/dist/isolate-pool.d.ts +32 -24
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +78 -54
- package/dist/process-fabric.d.ts +42 -23
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +62 -6
- package/dist/process-host.d.ts +3 -1
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +13 -14
- package/dist/supervisor-props.d.ts +47 -0
- package/dist/supervisor-props.d.ts.map +1 -0
- package/dist/supervisor-props.js +37 -0
- package/dist/timers.d.ts +12 -0
- package/dist/timers.d.ts.map +1 -1
- package/dist/timers.js +44 -9
- package/dist/vendor/types.d.ts +11 -5
- package/dist/vendor/types.d.ts.map +1 -1
- package/dist/workerd-facet-host.d.ts +4 -17
- package/dist/workerd-facet-host.d.ts.map +1 -1
- package/dist/workerd-facet-host.js +120 -54
- package/package.json +6 -6
- package/src/bindings.ts +2 -0
- package/src/budgets.ts +96 -49
- package/src/connections.ts +1 -1
- package/src/do-calls.ts +216 -25
- package/src/facet-pool.ts +5 -0
- package/src/fanout.ts +64 -56
- package/src/fenced-work.ts +3 -3
- package/src/host-wasm.ts +41 -0
- package/src/image-store.ts +30 -3
- package/src/inner-do-registry.ts +31 -0
- package/src/isolate-pool.ts +108 -74
- package/src/process-fabric.ts +88 -30
- package/src/process-host.ts +16 -14
- package/src/supervisor-props.ts +56 -0
- package/src/timers.ts +45 -9
- package/src/vendor/types.ts +11 -5
- package/src/workerd-facet-host.ts +123 -58
package/src/process-host.ts
CHANGED
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
*/
|
|
66
66
|
|
|
67
67
|
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
68
|
-
import {
|
|
68
|
+
import { classifyDoCall, isRetryableDoCall } from '@nimbus-sh/platform/oom-classify.js';
|
|
69
69
|
import { PEER_RETRY_BACKOFF_MS, PEER_TRANSIENT_RESET_RETRIES } from './fanout.js';
|
|
70
70
|
import { hostNamespaceBinding, hostOpDispatch, type HostNamespaceBinding } from './host-dispatch.js';
|
|
71
71
|
import { z } from 'zod/v4';
|
|
@@ -85,7 +85,8 @@ import {
|
|
|
85
85
|
processes,
|
|
86
86
|
type ResidentFacetEnv,
|
|
87
87
|
} from './workerd-facet-host.js';
|
|
88
|
-
import {
|
|
88
|
+
import type { HostRoute } from './composition.js';
|
|
89
|
+
import { supervisorBindingProps } from './supervisor-props.js';
|
|
89
90
|
|
|
90
91
|
/** The substrates this deployment can be configured for. */
|
|
91
92
|
export type ProcessHostMode = 'facet' | 'peer';
|
|
@@ -138,19 +139,14 @@ class FacetProcessHost implements ProcessHost {
|
|
|
138
139
|
|
|
139
140
|
runOnce<T>(params: OneShotParams, consume: (response: Response) => Promise<T>): Promise<T> {
|
|
140
141
|
return processes(this.ctx, this.env).run(
|
|
141
|
-
{
|
|
142
|
+
{ ...supervisorBindingProps(this.ctx, params.pid), writerId: params.writerId },
|
|
142
143
|
params,
|
|
143
144
|
consume,
|
|
144
145
|
);
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
async open(params: ProcessHostParams): Promise<HostedProcess> {
|
|
148
|
-
const supervisor: ResidentSupervisorProps = {
|
|
149
|
-
doId: this.coordDoId,
|
|
150
|
-
pid: params.pid,
|
|
151
|
-
writerId: params.writerId,
|
|
152
|
-
route: hostRoute() ?? undefined,
|
|
153
|
-
};
|
|
149
|
+
const supervisor: ResidentSupervisorProps = { ...supervisorBindingProps(this.ctx, params.pid), writerId: params.writerId };
|
|
154
150
|
const { name, ...facet } = processes(this.ctx, this.env).spawn(this.disk, supervisor, params);
|
|
155
151
|
return {
|
|
156
152
|
...facet,
|
|
@@ -198,6 +194,8 @@ export interface HostProcessOpts {
|
|
|
198
194
|
route?: HostRoute;
|
|
199
195
|
pid: number;
|
|
200
196
|
writerId: string;
|
|
197
|
+
/** The coordinator instance's delivery incarnation, minted into the SUPERVISOR binding (ResidentSupervisorProps). */
|
|
198
|
+
hostIncarnation?: string;
|
|
201
199
|
workerKey: string;
|
|
202
200
|
/** Unforgeable capability for the fetch-semantic WebSocket hop. */
|
|
203
201
|
webSocketCapability: string;
|
|
@@ -358,7 +356,7 @@ class PeerProcessHost implements ProcessHost {
|
|
|
358
356
|
*/
|
|
359
357
|
runOnce<T>(params: OneShotParams, consume: (response: Response) => Promise<T>): Promise<T> {
|
|
360
358
|
return processes(this.ctx, this.env).run(
|
|
361
|
-
{
|
|
359
|
+
{ ...supervisorBindingProps(this.ctx, params.pid), writerId: params.writerId },
|
|
362
360
|
params,
|
|
363
361
|
consume,
|
|
364
362
|
);
|
|
@@ -383,11 +381,15 @@ class PeerProcessHost implements ProcessHost {
|
|
|
383
381
|
// also what keeps the hosting DO resident. It settles when a `lifetime`
|
|
384
382
|
// runner exits or when a `boot` runner's host is cancelled, and rejects if
|
|
385
383
|
// the peer dies under either.
|
|
384
|
+
// The peer mints the process's binding from these, for THIS object: the
|
|
385
|
+
// coordinator's doId, route and delivery instance.
|
|
386
|
+
const supervisor = supervisorBindingProps(this.ctx, params.pid);
|
|
386
387
|
const hostLeg = placement.stub._rpcHostProcess(params.boot, {
|
|
387
|
-
coordinatorDoId:
|
|
388
|
-
route:
|
|
389
|
-
pid:
|
|
388
|
+
coordinatorDoId: supervisor.doId,
|
|
389
|
+
route: supervisor.route,
|
|
390
|
+
pid: supervisor.pid,
|
|
390
391
|
writerId: params.writerId,
|
|
392
|
+
hostIncarnation: supervisor.hostIncarnation,
|
|
391
393
|
workerKey: params.workerKey,
|
|
392
394
|
webSocketCapability,
|
|
393
395
|
startArgs: params.startArgs,
|
|
@@ -496,7 +498,7 @@ class PeerProcessHost implements ProcessHost {
|
|
|
496
498
|
}
|
|
497
499
|
return probe;
|
|
498
500
|
} catch (err) {
|
|
499
|
-
if (attempt < PEER_TRANSIENT_RESET_RETRIES &&
|
|
501
|
+
if (attempt < PEER_TRANSIENT_RESET_RETRIES && isRetryableDoCall(classifyDoCall(err))) {
|
|
500
502
|
await new Promise((r) => setTimeout(
|
|
501
503
|
r, PEER_RETRY_BACKOFF_MS[Math.min(attempt, PEER_RETRY_BACKOFF_MS.length - 1)],
|
|
502
504
|
));
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* supervisor-props.ts — what every SUPERVISOR binding a Durable Object mints
|
|
3
|
+
* for a process carries, and the one rule for naming the instance in it.
|
|
4
|
+
*
|
|
5
|
+
* A binding names its host INSTANCE (`hostIncarnation`) exactly when a
|
|
6
|
+
* mutation sent through it can be delivered once
|
|
7
|
+
* (@nimbus-sh/core/workspace/supervisor-delivery.js): the host opened a
|
|
8
|
+
* delivery store, the binding acts as a real process (SupervisorRPC refuses
|
|
9
|
+
* every filesystem mutation of pid 0), and it routes back to this very
|
|
10
|
+
* object rather than another (a fanout pool writes to its coordinator).
|
|
11
|
+
* Minting every binding here is what keeps a new mint site from forgetting
|
|
12
|
+
* that, which would fail silently: its mutations would simply never be
|
|
13
|
+
* re-sent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { supervisorDeliveryProps } from '@nimbus-sh/core/workspace/supervisor-delivery.js';
|
|
17
|
+
import { hostRoute, type HostRoute } from './composition.js';
|
|
18
|
+
|
|
19
|
+
/** The props every SUPERVISOR binding for a process carries. */
|
|
20
|
+
export interface SupervisorBindingProps {
|
|
21
|
+
/** The Durable Object the binding's calls reach. */
|
|
22
|
+
doId: string;
|
|
23
|
+
/** The process the calls act as; 0 is none, and can mutate nothing. */
|
|
24
|
+
pid: number;
|
|
25
|
+
/** The way back, minted with the binding in the host's isolate. */
|
|
26
|
+
route?: HostRoute;
|
|
27
|
+
/** The host instance that applies this binding's mutations once, when there is one. */
|
|
28
|
+
hostIncarnation?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The props of a SUPERVISOR binding minted in the Durable Object whose state
|
|
33
|
+
* is `ctx`, for process `pid`, reaching `options.doId` (this object by
|
|
34
|
+
* default) by `options.route` (this isolate's composition by default).
|
|
35
|
+
*/
|
|
36
|
+
export function supervisorBindingProps(
|
|
37
|
+
ctx: { readonly id: { toString(): string } },
|
|
38
|
+
pid: number,
|
|
39
|
+
options: { doId?: string; route?: HostRoute } = {},
|
|
40
|
+
): SupervisorBindingProps {
|
|
41
|
+
const own = ctx.id.toString();
|
|
42
|
+
const doId = options.doId ?? own;
|
|
43
|
+
const route = options.route ?? hostRoute() ?? undefined;
|
|
44
|
+
const delivery = pid > 0 && doId === own ? supervisorDeliveryProps(ctx) : {};
|
|
45
|
+
return { doId, pid, route, ...delivery };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `key`, for a loader cache entry whose worker holds a binding with `props`:
|
|
50
|
+
* made specific to the host instance the binding names, since the loader
|
|
51
|
+
* outlives that instance and the next one refuses every mutation the binding
|
|
52
|
+
* would deliver. A binding that names none keeps `key`, and its warm worker.
|
|
53
|
+
*/
|
|
54
|
+
export function supervisorLoaderKey(key: string, props: Pick<SupervisorBindingProps, 'hostIncarnation'>): string {
|
|
55
|
+
return props.hostIncarnation === undefined ? key : `${key}:${props.hostIncarnation}`;
|
|
56
|
+
}
|
package/src/timers.ts
CHANGED
|
@@ -58,9 +58,12 @@ export const TIMER_REASONS_KEY = 'w1_next_alarm_reasons';
|
|
|
58
58
|
* The host instance carrying the per-instance timer chain. The field lives on
|
|
59
59
|
* the embedder's DO instance so one chain serializes every timer-map
|
|
60
60
|
* read-modify-write for that instance (see {@link Timers.schedule}).
|
|
61
|
+
* `_timerEpoch` counts {@link Timers.reset}s: a schedule or dispatch writes
|
|
62
|
+
* only while the epoch it was requested in is still current.
|
|
61
63
|
*/
|
|
62
64
|
export interface TimerHost {
|
|
63
65
|
_timerChain?: Promise<unknown>;
|
|
66
|
+
_timerEpoch?: number;
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
/**
|
|
@@ -159,6 +162,7 @@ export class Timers {
|
|
|
159
162
|
arms.push({ reason, whenMs });
|
|
160
163
|
return Promise.resolve(true);
|
|
161
164
|
}
|
|
165
|
+
const epoch = host._timerEpoch ?? 0;
|
|
162
166
|
// Serialize every read-modify-write of the reasons map through one
|
|
163
167
|
// per-instance chain: two schedulers firing back-to-back from one activity
|
|
164
168
|
// hook would otherwise interleave their get→put cycles and silently drop
|
|
@@ -170,15 +174,20 @@ export class Timers {
|
|
|
170
174
|
const existing = (await ctx.storage.get(TIMER_REASONS_KEY)) as
|
|
171
175
|
| Record<string, number>
|
|
172
176
|
| undefined;
|
|
177
|
+
// Requested before a reset: it belongs to the timers the reset voided.
|
|
178
|
+
if ((host._timerEpoch ?? 0) !== epoch) return false;
|
|
173
179
|
const map: Record<string, number> = { ...(existing || {}) };
|
|
174
180
|
// Earliest-deadline-first: only update if new request is sooner or
|
|
175
181
|
// this reason has no pending entry.
|
|
182
|
+
let written: Promise<unknown> | undefined;
|
|
176
183
|
if (!(reason in map) || whenMs < map[reason]) {
|
|
177
184
|
map[reason] = whenMs;
|
|
178
|
-
|
|
185
|
+
written = ctx.storage.put(TIMER_REASONS_KEY, map);
|
|
179
186
|
}
|
|
180
|
-
|
|
181
|
-
|
|
187
|
+
// Issued in the turn the epoch was checked in: a reset after this
|
|
188
|
+
// point wipes and disarms after these, never before.
|
|
189
|
+
setAlarmFn.call(ctx.storage, Math.min(...Object.values(map)));
|
|
190
|
+
await written;
|
|
182
191
|
return true;
|
|
183
192
|
} catch (e) {
|
|
184
193
|
console.warn('[nimbus/W1] timers.schedule threw:', errorText(e));
|
|
@@ -190,6 +199,18 @@ export class Timers {
|
|
|
190
199
|
return chained;
|
|
191
200
|
}
|
|
192
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Void every timer of this instance: a schedule or dispatch already
|
|
204
|
+
* requested — still queued on the chain, or a dispatch whose handlers are
|
|
205
|
+
* running — writes no reason and arms no alarm from here on. For a
|
|
206
|
+
* deliberate end of the actor's state (a session destroy): call it before
|
|
207
|
+
* wiping storage and deleting the alarm, so nothing in flight writes the
|
|
208
|
+
* map back or re-arms after the wipe. Requests made after it proceed.
|
|
209
|
+
*/
|
|
210
|
+
reset(): void {
|
|
211
|
+
this.host._timerEpoch = (this.host._timerEpoch ?? 0) + 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
193
214
|
/**
|
|
194
215
|
* Multi-reason timer dispatcher. Called from the DO's `alarm()` handler
|
|
195
216
|
* with the embedder's handler map.
|
|
@@ -216,19 +237,28 @@ export class Timers {
|
|
|
216
237
|
alarmInfo?: TimerAlarmInfo,
|
|
217
238
|
): Promise<void> {
|
|
218
239
|
const { host, ctx } = this;
|
|
240
|
+
const epoch = host._timerEpoch ?? 0;
|
|
241
|
+
const current = (): boolean => (host._timerEpoch ?? 0) === epoch;
|
|
219
242
|
// Same serialization as schedule: the dispatcher's read→handlers→write
|
|
220
243
|
// cycle must not interleave with an activity-hook schedule.
|
|
221
244
|
const chained = (host._timerChain ?? Promise.resolve()).then(
|
|
222
|
-
() => dispatchBody(ctx, handlers, onLegacyAlarm, alarmInfo),
|
|
223
|
-
() => dispatchBody(ctx, handlers, onLegacyAlarm, alarmInfo),
|
|
245
|
+
() => dispatchBody(ctx, current, handlers, onLegacyAlarm, alarmInfo),
|
|
246
|
+
() => dispatchBody(ctx, current, handlers, onLegacyAlarm, alarmInfo),
|
|
224
247
|
);
|
|
225
248
|
host._timerChain = chained;
|
|
226
249
|
return chained;
|
|
227
250
|
}
|
|
228
251
|
}
|
|
229
252
|
|
|
253
|
+
/**
|
|
254
|
+
* One dispatch. `current` answers whether the epoch it was requested in still
|
|
255
|
+
* stands: once a reset voids it, no further handler runs and nothing is
|
|
256
|
+
* written or armed — the reset's caller is wiping this state, and a write
|
|
257
|
+
* after the wipe would outlive it.
|
|
258
|
+
*/
|
|
230
259
|
async function dispatchBody(
|
|
231
260
|
ctx: TimerContext,
|
|
261
|
+
current: () => boolean,
|
|
232
262
|
handlers: TimerHandlers,
|
|
233
263
|
onLegacyAlarm?: () => void,
|
|
234
264
|
alarmInfo?: TimerAlarmInfo,
|
|
@@ -242,6 +272,7 @@ async function dispatchBody(
|
|
|
242
272
|
const existing = (await ctx?.storage?.get?.(TIMER_REASONS_KEY)) as
|
|
243
273
|
| Record<string, number>
|
|
244
274
|
| undefined;
|
|
275
|
+
if (!current()) return;
|
|
245
276
|
const map: Record<string, number> = { ...(existing || {}) };
|
|
246
277
|
const hadMap = Object.keys(map).length > 0;
|
|
247
278
|
if (!hadMap) {
|
|
@@ -255,6 +286,7 @@ async function dispatchBody(
|
|
|
255
286
|
if (when <= now) fired.push(reason);
|
|
256
287
|
}
|
|
257
288
|
for (const reason of fired) {
|
|
289
|
+
if (!current()) return;
|
|
258
290
|
delete map[reason];
|
|
259
291
|
const handler = handlers[reason];
|
|
260
292
|
// Unknown reasons silently dropped (forward-compat).
|
|
@@ -269,20 +301,24 @@ async function dispatchBody(
|
|
|
269
301
|
}
|
|
270
302
|
}
|
|
271
303
|
}
|
|
304
|
+
// A reset while the handlers ran: the map read above is gone, and
|
|
305
|
+
// writing it back — or arming for it — would revive what was ended.
|
|
306
|
+
if (!current()) return;
|
|
272
307
|
// Fold the in-dispatch arms, earliest-deadline-first per reason.
|
|
273
308
|
for (const arm of arms) {
|
|
274
309
|
if (!(arm.reason in map) || arm.whenMs < map[arm.reason]) {
|
|
275
310
|
map[arm.reason] = arm.whenMs;
|
|
276
311
|
}
|
|
277
312
|
}
|
|
278
|
-
// Re-arm or clear
|
|
313
|
+
// Re-arm or clear, issued in the turn the epoch was checked in: a reset
|
|
314
|
+
// after this point wipes and disarms after these, never before.
|
|
279
315
|
const setAlarmFn = ctx?.storage?.setAlarm;
|
|
280
316
|
if (Object.keys(map).length > 0) {
|
|
281
|
-
|
|
282
|
-
const earliest = Math.min(...Object.values(map));
|
|
317
|
+
const written = ctx.storage.put(TIMER_REASONS_KEY, map);
|
|
283
318
|
if (typeof setAlarmFn === 'function') {
|
|
284
|
-
setAlarmFn.call(ctx.storage,
|
|
319
|
+
setAlarmFn.call(ctx.storage, Math.min(...Object.values(map)));
|
|
285
320
|
}
|
|
321
|
+
await written;
|
|
286
322
|
} else if (hadMap) {
|
|
287
323
|
try { await ctx.storage.delete(TIMER_REASONS_KEY); } catch {}
|
|
288
324
|
// No remaining reasons → no setAlarm call → DO becomes
|
package/src/vendor/types.ts
CHANGED
|
@@ -9,12 +9,18 @@ export interface TextModule { text: string }
|
|
|
9
9
|
export interface DataModule { data: ArrayBuffer }
|
|
10
10
|
export interface JsonModule { json: unknown }
|
|
11
11
|
/**
|
|
12
|
-
* A
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* A WebAssembly module, importable from a facet by module name. Nimbus ships
|
|
13
|
+
* these on every path — sql.js for node:sqlite, the interpreter images for
|
|
14
|
+
* python and ruby — so the omission here was the type lagging the API, not a
|
|
15
|
+
* kind the loader lacks. The loader takes either the bytes, which it
|
|
16
|
+
* compiles, or a module the caller holds compiled already, whose compiled
|
|
17
|
+
* code the dynamic worker then shares (workerd src/workerd/api/
|
|
18
|
+
* worker-loader.c++, extractWasmModuleContent). A module handed over this
|
|
19
|
+
* way should be described with describeHostWasm (host-wasm.ts): the
|
|
20
|
+
* code-size budget and the loader cache key cannot read a Module's size or
|
|
21
|
+
* identity from JS.
|
|
16
22
|
*/
|
|
17
|
-
export interface WasmModule { wasm: ArrayBuffer }
|
|
23
|
+
export interface WasmModule { wasm: ArrayBuffer | WebAssembly.Module }
|
|
18
24
|
|
|
19
25
|
/** Plain string = type inferred from file extension (.js or .py). */
|
|
20
26
|
export type ModuleContent =
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
17
|
+
import { StorageLedger, forgetFacetStorage } from '@nimbus-sh/core/runtime/storage-ledger.js';
|
|
18
|
+
import type { SqlDatabase } from '@nimbus-sh/core/runtime/os-contracts.js';
|
|
17
19
|
import {
|
|
18
20
|
getCtxExports,
|
|
19
21
|
stagedBootAssembler,
|
|
@@ -26,7 +28,6 @@ import {
|
|
|
26
28
|
facetNameCount,
|
|
27
29
|
facetNameCountDurable,
|
|
28
30
|
recordFacetNameMinted,
|
|
29
|
-
recordLoaderId,
|
|
30
31
|
withDynamicWorkerCapNamed,
|
|
31
32
|
withFacetBudgetNamed,
|
|
32
33
|
} from './budgets.js';
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
type ResidentDiskReader,
|
|
42
43
|
type ResidentSupervisorProps,
|
|
43
44
|
} from './process-fabric.js';
|
|
45
|
+
import { supervisorLoaderKey } from './supervisor-props.js';
|
|
44
46
|
|
|
45
47
|
// ── Loaded-worker entrypoint plumbing ───────────────────────────────────────
|
|
46
48
|
|
|
@@ -56,7 +58,7 @@ export interface NimbusCtxExports {
|
|
|
56
58
|
key: string;
|
|
57
59
|
name: string | null;
|
|
58
60
|
depth: number;
|
|
59
|
-
supervisor:
|
|
61
|
+
supervisor: ResidentSupervisorProps;
|
|
60
62
|
stage?: unknown;
|
|
61
63
|
};
|
|
62
64
|
}) => LoadedWorkerEntrypointStub;
|
|
@@ -78,7 +80,7 @@ export function getNimbusCtxExports(): NimbusCtxExports {
|
|
|
78
80
|
*/
|
|
79
81
|
export async function createLoadedWorkerEntrypoint(
|
|
80
82
|
ctxExports: NimbusCtxExports,
|
|
81
|
-
supervisor:
|
|
83
|
+
supervisor: ResidentSupervisorProps,
|
|
82
84
|
stage: unknown,
|
|
83
85
|
name: string | null = null,
|
|
84
86
|
): Promise<LoadedWorkerEntrypointStub> {
|
|
@@ -87,7 +89,9 @@ export async function createLoadedWorkerEntrypoint(
|
|
|
87
89
|
}
|
|
88
90
|
return await ctxExports.NimbusLoadedEntrypoint({
|
|
89
91
|
props: {
|
|
90
|
-
|
|
92
|
+
// The entrypoint's loader outlives this instance, and a warm worker keeps
|
|
93
|
+
// the SUPERVISOR binding it was built with.
|
|
94
|
+
key: supervisorLoaderKey(`nimbus-process:${supervisor.doId}:${supervisor.pid}`, supervisor),
|
|
91
95
|
name,
|
|
92
96
|
depth: 0,
|
|
93
97
|
supervisor,
|
|
@@ -116,9 +120,10 @@ interface FacetContainer {
|
|
|
116
120
|
abort(name: string, reason?: unknown): void;
|
|
117
121
|
delete(name: string): void;
|
|
118
122
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* {@link cloneStorage}, the one way the
|
|
123
|
+
* Declared by `@cloudflare/workers-types` 5 and present in workerd
|
|
124
|
+
* ≥ 1.20260926.1 and in production; an embedder's older local workerd
|
|
125
|
+
* (≤ 1.20260603.1) lacks it — see {@link cloneStorage}, the one way the
|
|
126
|
+
* fabric calls it.
|
|
122
127
|
*/
|
|
123
128
|
clone?(src: string, dst: string): void;
|
|
124
129
|
}
|
|
@@ -207,7 +212,7 @@ export async function cloneStorage(
|
|
|
207
212
|
if (typeof facets.clone !== 'function') {
|
|
208
213
|
throw new Error(
|
|
209
214
|
'Nimbus: ctx.facets.clone is unavailable in this runtime; the reflink image '
|
|
210
|
-
+ 'path needs
|
|
215
|
+
+ 'path needs workerd 1.20260926.1 or later, or deployed Cloudflare workerd',
|
|
211
216
|
);
|
|
212
217
|
}
|
|
213
218
|
const { src, dst } = clone;
|
|
@@ -262,26 +267,25 @@ interface SlotBook {
|
|
|
262
267
|
next: number;
|
|
263
268
|
/** Slot held by each live pid, so release can find it. */
|
|
264
269
|
held: Map<number, number>;
|
|
270
|
+
/** Explicit (`app-slot-`) names this incarnation started a process under and has not released. */
|
|
271
|
+
live: Set<string>;
|
|
265
272
|
}
|
|
266
273
|
|
|
267
274
|
/**
|
|
268
275
|
* Slot books, per hosting actor, because the facet index is per Durable
|
|
269
276
|
* Object.
|
|
270
277
|
*
|
|
271
|
-
* Keyed weakly off `ctx`,
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* VFS epoch, in which case `invalidatedSince` can only answer poison and the
|
|
279
|
-
* whole store is dropped. A process therefore cannot boot onto a previous
|
|
280
|
-
* tenant's filesystem even when release never ran.
|
|
278
|
+
* Keyed weakly off `ctx`, so a book describes one incarnation. A facet that
|
|
279
|
+
* is still running when that incarnation ends outlives it (measured: a timer
|
|
280
|
+
* or an outgoing call keeps it going), and a `get` of its name with a new
|
|
281
|
+
* class then resets the whole object. So a fresh incarnation's first get of
|
|
282
|
+
* each name ends whatever runs there first: a minted `proc-slot-` name is
|
|
283
|
+
* deleted, which also wipes the storage a previous incarnation left, and an
|
|
284
|
+
* explicit name is aborted, which keeps it.
|
|
281
285
|
*
|
|
282
|
-
* The book
|
|
283
|
-
* allocated against DO storage instead (their owner survives a reset), so
|
|
284
|
-
* fresh incarnation's `next` starting at 0 can never collide with them even
|
|
286
|
+
* The book allocates only the `proc-slot-` space. Durable `app-slot-` names
|
|
287
|
+
* are allocated against DO storage instead (their owner survives a reset), so
|
|
288
|
+
* a fresh incarnation's `next` starting at 0 can never collide with them even
|
|
285
289
|
* before the durable ledger is adopted.
|
|
286
290
|
*/
|
|
287
291
|
const slotBooks = new WeakMap<DurableObjectState, SlotBook>();
|
|
@@ -289,24 +293,28 @@ const slotBooks = new WeakMap<DurableObjectState, SlotBook>();
|
|
|
289
293
|
function slotBook(ctx: DurableObjectState): SlotBook {
|
|
290
294
|
let book = slotBooks.get(ctx);
|
|
291
295
|
if (!book) {
|
|
292
|
-
book = { free: [], next: 0, held: new Map() };
|
|
296
|
+
book = { free: [], next: 0, held: new Map(), live: new Set() };
|
|
293
297
|
slotBooks.set(ctx, book);
|
|
294
298
|
}
|
|
295
299
|
return book;
|
|
296
300
|
}
|
|
297
301
|
|
|
298
|
-
/**
|
|
299
|
-
|
|
302
|
+
/**
|
|
303
|
+
* Take a slot for `pid`, reusing a returned one before minting a new name.
|
|
304
|
+
* `minted` names may still hold storage a previous incarnation of this actor
|
|
305
|
+
* left there, so the caller deletes it before the first get.
|
|
306
|
+
*/
|
|
307
|
+
function acquireSlot(ctx: DurableObjectState, pid: number): { slot: number; minted: boolean } {
|
|
300
308
|
const book = slotBook(ctx);
|
|
301
309
|
const existing = book.held.get(pid);
|
|
302
|
-
if (existing !== undefined) return existing;
|
|
310
|
+
if (existing !== undefined) return { slot: existing, minted: false };
|
|
303
311
|
const reused = book.free.length > 0;
|
|
304
312
|
const slot = reused ? book.free.shift()! : book.next++;
|
|
305
313
|
book.held.set(pid, slot);
|
|
306
314
|
// A fresh name is a permanently consumed facet ID; the durable count lives
|
|
307
315
|
// in the budgets ledger (see budgets.ts).
|
|
308
316
|
if (!reused) recordFacetNameMinted(ctx, book.next);
|
|
309
|
-
return slot;
|
|
317
|
+
return { slot, minted: !reused };
|
|
310
318
|
}
|
|
311
319
|
|
|
312
320
|
/** Return `pid`'s slot to the free list. */
|
|
@@ -320,14 +328,36 @@ function releaseSlot(ctx: DurableObjectState, pid: number): void {
|
|
|
320
328
|
}
|
|
321
329
|
|
|
322
330
|
/**
|
|
323
|
-
* Drop one facet's SQLite by name
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
331
|
+
* Drop one facet's SQLite by name, and its row in the session's storage
|
|
332
|
+
* ledger (N18) in the same step: the only way a facet database is deleted.
|
|
333
|
+
* `spawnResident` releases ephemeral processes with abort+delete (storage is
|
|
334
|
+
* slot-reuse hygiene) and durable ones with abort alone (the storage IS the
|
|
335
|
+
* durable application's state); explicit removal arrives here through the
|
|
336
|
+
* coordinator's durable-slot book, owner-checked.
|
|
328
337
|
*/
|
|
338
|
+
/** The session's storage ledger (N18), over this actor's SQL; null where it has none. */
|
|
339
|
+
function sessionLedger(ctx: DurableObjectState): StorageLedger | null {
|
|
340
|
+
const sql = (ctx as { storage?: { sql?: SqlDatabase } }).storage?.sql;
|
|
341
|
+
return sql ? new StorageLedger(sql) : null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const facetNames = new WeakMap<object, Map<number, string>>();
|
|
345
|
+
|
|
346
|
+
function facetOfPid(ctx: DurableObjectState): Map<number, string> {
|
|
347
|
+
let names = facetNames.get(ctx);
|
|
348
|
+
if (!names) facetNames.set(ctx, names = new Map());
|
|
349
|
+
return names;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The facet a running resident process `pid` lives in on this actor, for its storage ledger row. */
|
|
353
|
+
export function residentFacetOf(ctx: DurableObjectState, pid: number): string | undefined {
|
|
354
|
+
return facetNames.get(ctx)?.get(pid);
|
|
355
|
+
}
|
|
356
|
+
|
|
329
357
|
export function deleteFacetStorage(ctx: DurableObjectState, name: string): void {
|
|
330
358
|
facetContainer(ctx).delete(name);
|
|
359
|
+
const sql = (ctx as { storage?: { sql?: SqlDatabase } }).storage?.sql;
|
|
360
|
+
if (sql) forgetFacetStorage(sql, name);
|
|
331
361
|
}
|
|
332
362
|
|
|
333
363
|
|
|
@@ -416,8 +446,12 @@ function spawnResident(
|
|
|
416
446
|
+ `prefix, got '${explicit.name}'`,
|
|
417
447
|
);
|
|
418
448
|
}
|
|
419
|
-
const
|
|
449
|
+
const grant = explicit ? undefined : acquireSlot(ctx, params.pid);
|
|
450
|
+
const slot = grant?.slot;
|
|
420
451
|
const name = explicit ? explicit.name : residentFacetName(slot!);
|
|
452
|
+
if (grant?.minted) {
|
|
453
|
+
try { deleteFacetStorage(ctx, name); } catch { /* nothing stored under this name */ }
|
|
454
|
+
}
|
|
421
455
|
// The start callback is the ONLY way this facet is ever created, and it
|
|
422
456
|
// fires AT MOST ONCE. Every later use goes through the stub below, so the
|
|
423
457
|
// callback running a second time means the facet was released or died —
|
|
@@ -437,29 +471,52 @@ function spawnResident(
|
|
|
437
471
|
);
|
|
438
472
|
}
|
|
439
473
|
evaluated = true;
|
|
440
|
-
return { class: residentProcessClass(
|
|
474
|
+
return { class: residentProcessClass(env, disk, supervisor, params, loaderKey) };
|
|
441
475
|
};
|
|
476
|
+
const book = slotBook(ctx);
|
|
477
|
+
const ledger = sessionLedger(ctx);
|
|
478
|
+
// A warm worker keeps the SUPERVISOR binding it was built with, and the
|
|
479
|
+
// loader outlives this instance.
|
|
480
|
+
const loaderKey = supervisorLoaderKey(params.workerKey, supervisor);
|
|
442
481
|
let facet: ResidentFacetStub;
|
|
443
482
|
try {
|
|
483
|
+
// N18: the fill is admitted, and recorded under the facet's name, before
|
|
484
|
+
// the facet exists; a refusal leaves no facet.
|
|
485
|
+
if (ledger !== null && params.storageBytes !== undefined) ledger.fill(name, params.storageBytes);
|
|
486
|
+
// get() with a new class on a facet an earlier incarnation left running resets this object.
|
|
487
|
+
if (explicit && !book.live.has(name)) {
|
|
488
|
+
facets.abort(name, new Error('Nimbus: a new incarnation takes this facet name'));
|
|
489
|
+
}
|
|
444
490
|
facet = facets.get(name, start);
|
|
445
491
|
} catch (error) {
|
|
446
492
|
if (slot !== undefined) releaseSlot(ctx, params.pid);
|
|
447
493
|
throw withFacetBudgetNamed(facetNameCount(ctx), error);
|
|
448
494
|
}
|
|
495
|
+
if (explicit) book.live.add(name);
|
|
496
|
+
// The facet's worker is one Dynamic Worker in flight for as long as the
|
|
497
|
+
// process is resident, not only while a call is open: its WebSockets and
|
|
498
|
+
// streamed responses outlive the calls the ledger could bracket, and a
|
|
499
|
+
// request can reach it at any moment. Held from here to `release`, so no
|
|
500
|
+
// fan-out spends the slot a running process needs.
|
|
501
|
+
const endResidency = beginLoaderFetch(ctx, loaderKey);
|
|
502
|
+
facetOfPid(ctx).set(params.pid, name);
|
|
449
503
|
|
|
450
504
|
let disposed = false;
|
|
451
505
|
const release = async () => {
|
|
452
506
|
if (disposed) return;
|
|
453
507
|
disposed = true;
|
|
454
508
|
released = true;
|
|
509
|
+
facetOfPid(ctx).delete(params.pid);
|
|
510
|
+
endResidency();
|
|
455
511
|
try { facets.abort(name, new Error('Nimbus: resident process released')); } catch { /* already gone */ }
|
|
512
|
+
if (explicit) book.live.delete(name);
|
|
456
513
|
// The two release classes: an ephemeral facet's SQLite is slot-reuse
|
|
457
514
|
// hygiene — the name is handed out again, so the store must not be — and
|
|
458
515
|
// a durable one's is the application itself: abort ends the process, the
|
|
459
516
|
// data stays for the next boot, and only removeDurableApp's explicit
|
|
460
517
|
// deleteFacetStorage call ever drops it.
|
|
461
518
|
if (!explicit?.durable) {
|
|
462
|
-
try {
|
|
519
|
+
try { deleteFacetStorage(ctx, name); } catch { /* already gone */ }
|
|
463
520
|
}
|
|
464
521
|
// Only after the facet is gone. A slot handed out while its previous
|
|
465
522
|
// tenant were still being torn down would have two processes on one name.
|
|
@@ -468,7 +525,11 @@ function spawnResident(
|
|
|
468
525
|
|
|
469
526
|
let started: Promise<unknown>;
|
|
470
527
|
try {
|
|
471
|
-
|
|
528
|
+
// The allowance the ledger admitted, for the facet's store to keep under.
|
|
529
|
+
const startArgs = ledger !== null && params.storageBytes !== undefined && params.startArgs !== null && typeof params.startArgs === 'object'
|
|
530
|
+
? { ...(params.startArgs as Record<string, unknown>), storage: { facet: name, grant: params.storageBytes } }
|
|
531
|
+
: params.startArgs;
|
|
532
|
+
started = facet.startProcess(startArgs);
|
|
472
533
|
} catch (error) {
|
|
473
534
|
void release();
|
|
474
535
|
throw withFacetBudgetNamed(facetNameCount(ctx), error);
|
|
@@ -477,7 +538,17 @@ function spawnResident(
|
|
|
477
538
|
// this one, and it is annotated AFTER awaiting the ledger — the first
|
|
478
539
|
// failure of a fresh incarnation must compare against the persisted count,
|
|
479
540
|
// not the zero its adoption read has not yet replaced.
|
|
480
|
-
started = started.
|
|
541
|
+
started = started.then((payload) => {
|
|
542
|
+
// Once the facet is up (N18) its row is the cap its store keeps under
|
|
543
|
+
// (what it measures plus what it may still grow into), or what it
|
|
544
|
+
// measures if that is more (overshoot).
|
|
545
|
+
const { databaseSize: size, storageCap: cap } = (payload ?? {}) as { databaseSize?: unknown; storageCap?: unknown };
|
|
546
|
+
const measured = typeof size === 'number' && Number.isFinite(size) ? size : null;
|
|
547
|
+
const capped = typeof cap === 'number' && Number.isFinite(cap) ? cap : null;
|
|
548
|
+
const row = capped !== null ? Math.max(capped, measured ?? 0) : measured;
|
|
549
|
+
if (ledger !== null && row !== null) ledger.reportSize(name, row);
|
|
550
|
+
return payload;
|
|
551
|
+
}, async (error) => {
|
|
481
552
|
throw withFacetBudgetNamed(await facetNameCountDurable(ctx), error);
|
|
482
553
|
});
|
|
483
554
|
// A caller reads whichever of `started` and the lifecycle it needs, so keep
|
|
@@ -503,11 +574,11 @@ function spawnResident(
|
|
|
503
574
|
* the hosting DO's heap.
|
|
504
575
|
*/
|
|
505
576
|
function residentProcessClass(
|
|
506
|
-
ctx: DurableObjectState,
|
|
507
577
|
env: ResidentFacetEnv,
|
|
508
578
|
disk: () => ResidentDiskReader,
|
|
509
579
|
supervisor: ResidentSupervisorProps,
|
|
510
580
|
params: ProcessHostParams,
|
|
581
|
+
loaderKey: string,
|
|
511
582
|
): unknown {
|
|
512
583
|
const loader = env.LOADER;
|
|
513
584
|
if (!loader || typeof loader.get !== 'function') {
|
|
@@ -516,15 +587,9 @@ function residentProcessClass(
|
|
|
516
587
|
+ 'the Worker Loader binding; add it via worker_loaders in wrangler.jsonc.',
|
|
517
588
|
);
|
|
518
589
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
.getDurableObjectClass(RESIDENT_PROCESS_CLASS);
|
|
523
|
-
recordLoaderId(ctx, params.workerKey);
|
|
524
|
-
return worker;
|
|
525
|
-
} catch (error) {
|
|
526
|
-
throw withDynamicWorkerCapNamed(ctx, error);
|
|
527
|
-
}
|
|
590
|
+
return loader
|
|
591
|
+
.get(loaderKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
|
|
592
|
+
.getDurableObjectClass(RESIDENT_PROCESS_CLASS);
|
|
528
593
|
}
|
|
529
594
|
|
|
530
595
|
async function runOneShot<T>(
|
|
@@ -574,22 +639,22 @@ async function runOneShot<T>(
|
|
|
574
639
|
throw new Error('Nimbus: one-shot runtime entrypoint has no fetch method');
|
|
575
640
|
}
|
|
576
641
|
params.onLoaded?.();
|
|
577
|
-
// The unkeyed worker is
|
|
578
|
-
//
|
|
579
|
-
// never wrapped: see beginLoaderFetch for the
|
|
580
|
-
// hazard, and the pipelined-`fetch.call` note above
|
|
581
|
-
|
|
582
|
-
|
|
642
|
+
// The unkeyed worker is one distinct dynamic worker in flight until its
|
|
643
|
+
// response is consumed (the body streams from it), keyed by this run's
|
|
644
|
+
// writer id — bracketed, never wrapped: see beginLoaderFetch for the
|
|
645
|
+
// measured DO-poisoning hazard, and the pipelined-`fetch.call` note above
|
|
646
|
+
// for its sibling.
|
|
647
|
+
const endFetch = beginLoaderFetch(ctx, `one-shot:${params.writerId}`);
|
|
583
648
|
try {
|
|
584
|
-
response = await ep.fetch(params.request);
|
|
649
|
+
const response = await ep.fetch(params.request);
|
|
650
|
+
try {
|
|
651
|
+
return await consume(response);
|
|
652
|
+
} finally {
|
|
653
|
+
disposeRpcResource(response);
|
|
654
|
+
}
|
|
585
655
|
} finally {
|
|
586
656
|
endFetch();
|
|
587
657
|
}
|
|
588
|
-
try {
|
|
589
|
-
return await consume(response);
|
|
590
|
-
} finally {
|
|
591
|
-
disposeRpcResource(response);
|
|
592
|
-
}
|
|
593
658
|
} catch (error) {
|
|
594
659
|
throw withDynamicWorkerCapNamed(ctx, error);
|
|
595
660
|
} finally {
|