@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.
Files changed (66) hide show
  1. package/README.md +26 -11
  2. package/dist/bindings.d.ts +1 -0
  3. package/dist/bindings.d.ts.map +1 -1
  4. package/dist/bindings.js +2 -0
  5. package/dist/budgets.d.ts +50 -29
  6. package/dist/budgets.d.ts.map +1 -1
  7. package/dist/budgets.js +88 -40
  8. package/dist/connections.d.ts +1 -1
  9. package/dist/connections.js +1 -1
  10. package/dist/do-calls.d.ts +91 -9
  11. package/dist/do-calls.d.ts.map +1 -1
  12. package/dist/do-calls.js +161 -22
  13. package/dist/facet-pool.d.ts +3 -0
  14. package/dist/facet-pool.d.ts.map +1 -1
  15. package/dist/facet-pool.js +3 -0
  16. package/dist/fanout.d.ts +40 -32
  17. package/dist/fanout.d.ts.map +1 -1
  18. package/dist/fanout.js +50 -53
  19. package/dist/fenced-work.d.ts +3 -3
  20. package/dist/host-wasm.d.ts +29 -0
  21. package/dist/host-wasm.d.ts.map +1 -0
  22. package/dist/host-wasm.js +31 -0
  23. package/dist/image-store.d.ts +1 -1
  24. package/dist/image-store.d.ts.map +1 -1
  25. package/dist/image-store.js +34 -2
  26. package/dist/inner-do-registry.d.ts +9 -0
  27. package/dist/inner-do-registry.d.ts.map +1 -1
  28. package/dist/inner-do-registry.js +35 -0
  29. package/dist/isolate-pool.d.ts +32 -24
  30. package/dist/isolate-pool.d.ts.map +1 -1
  31. package/dist/isolate-pool.js +78 -54
  32. package/dist/process-fabric.d.ts +42 -23
  33. package/dist/process-fabric.d.ts.map +1 -1
  34. package/dist/process-fabric.js +62 -6
  35. package/dist/process-host.d.ts +3 -1
  36. package/dist/process-host.d.ts.map +1 -1
  37. package/dist/process-host.js +13 -14
  38. package/dist/supervisor-props.d.ts +47 -0
  39. package/dist/supervisor-props.d.ts.map +1 -0
  40. package/dist/supervisor-props.js +37 -0
  41. package/dist/timers.d.ts +12 -0
  42. package/dist/timers.d.ts.map +1 -1
  43. package/dist/timers.js +44 -9
  44. package/dist/vendor/types.d.ts +11 -5
  45. package/dist/vendor/types.d.ts.map +1 -1
  46. package/dist/workerd-facet-host.d.ts +4 -17
  47. package/dist/workerd-facet-host.d.ts.map +1 -1
  48. package/dist/workerd-facet-host.js +120 -54
  49. package/package.json +6 -6
  50. package/src/bindings.ts +2 -0
  51. package/src/budgets.ts +96 -49
  52. package/src/connections.ts +1 -1
  53. package/src/do-calls.ts +216 -25
  54. package/src/facet-pool.ts +5 -0
  55. package/src/fanout.ts +64 -56
  56. package/src/fenced-work.ts +3 -3
  57. package/src/host-wasm.ts +41 -0
  58. package/src/image-store.ts +30 -3
  59. package/src/inner-do-registry.ts +31 -0
  60. package/src/isolate-pool.ts +108 -74
  61. package/src/process-fabric.ts +88 -30
  62. package/src/process-host.ts +16 -14
  63. package/src/supervisor-props.ts +56 -0
  64. package/src/timers.ts +45 -9
  65. package/src/vendor/types.ts +11 -5
  66. package/src/workerd-facet-host.ts +123 -58
package/src/fanout.ts CHANGED
@@ -1,25 +1,30 @@
1
1
  /**
2
2
  * Two-tier fan-out primitive for work that must execute in Worker Loader
3
- * facets without tripping workerd's per-DO dynamic-worker ceiling.
3
+ * isolates within the Durable Object's Dynamic Worker budget.
4
4
  *
5
- * A single Durable Object method can drive at most four concurrent
6
- * Worker Loader fetches before extra dispatches serialize or fail. Small
7
- * batches therefore run in the coordinator DO through IsolatePool.
8
- * Wider batches are sharded across sibling NimbusSession DOs, each of
9
- * which owns its own four-loader budget.
5
+ * A Durable Object may have `DO_DYNAMIC_WORKER_LIMIT` distinct Dynamic
6
+ * Workers with in-flight requests, shared across every concurrent request to
7
+ * it (budgets.ts). A batch the coordinator's remaining headroom can hold —
8
+ * the limit less the workers it already has in flight (resident processes,
9
+ * the esbuild facet, a git network op) and other fan-outs' claims — runs in
10
+ * the coordinator through IsolatePool, one Dynamic Worker per task. Only a
11
+ * batch wider than that headroom is sharded across sibling NimbusSession
12
+ * DOs, each of which spends its own budget.
10
13
  *
11
- * Routing is deterministic: each task has a stable key, and the key maps
12
- * to a sibling DO shard. There is no silent fallback to width-1 execution;
13
- * missing LOADER or NIMBUS_SESSION bindings fail loudly so install and
14
- * runtime operations do not appear successful after partial dispatch.
14
+ * Routing is deterministic for a given headroom: each task has a stable key,
15
+ * and the key maps to a sibling DO shard. There is no silent fallback to
16
+ * width-1 execution; missing LOADER or NIMBUS_SESSION bindings fail loudly so
17
+ * install and runtime operations do not appear successful after partial
18
+ * dispatch.
15
19
  */
16
20
 
17
21
  import { serializeFunction } from './vendor/serialize.js';
18
22
  import { BindingError } from './vendor/errors.js';
19
23
  import { IsolatePool, type FacetTaskFn } from './isolate-pool.js';
24
+ import { claimDynamicWorkers, dynamicWorkerHeadroom } from './budgets.js';
20
25
  import { hostRoute } from './composition.js';
21
26
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
22
- import { describeError, isDoOverloaded, isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
27
+ import { classifyDoCall, describeError, isRetryableDoCall } from '@nimbus-sh/platform/oom-classify.js';
23
28
  import type { WorkerLoader } from './vendor/types.js';
24
29
  import {
25
30
  hostNamespaceBinding,
@@ -35,14 +40,17 @@ export interface FanoutEnv {
35
40
  NIMBUS_SESSION?: unknown;
36
41
  }
37
42
 
38
- /**
39
- * Threshold at which routing switches from coordinator-local loaders to
40
- * sibling Durable Objects.
41
- *
42
- * Set to **5** so the in-DO path stays below the V8 4-loaders-per-method
43
- * cap by construction. width < 5 stays local; width >= 5 uses sibling DOs.
44
- */
45
- export const IN_DO_THRESHOLD = 5;
43
+ /** Where one `submitMany` ran. */
44
+ export type FanoutTopology = 'in-do' | 'peer-do';
45
+
46
+ /** The routing decision for one `submitMany`, as its caller may log it. */
47
+ export interface FanoutRoute {
48
+ topology: FanoutTopology;
49
+ /** Tasks in the batch — the Dynamic Workers an in-DO run spends. */
50
+ tasks: number;
51
+ /** The coordinator's Dynamic Worker headroom when the batch was routed. */
52
+ headroom: number;
53
+ }
46
54
 
47
55
  /**
48
56
  * Hard cap on concurrent peer DOs per single submitMany call. Throughput stays
@@ -153,12 +161,14 @@ export interface FanoutOptions {
153
161
  * Not called on the in-DO path, which has no phases.
154
162
  */
155
163
  onDispatchPhase?: (width: number, elapsedMs: number) => void;
164
+ /** Called once per non-empty submitMany with the route it took. */
165
+ onRoute?: (route: FanoutRoute) => void;
156
166
  /**
157
167
  * Cap on peer DOs this pool will spread one submitMany across. Defaults to
158
168
  * MAX_PEER_FANOUT. Tasks beyond the cap bucket into the peers that exist and
159
169
  * run through their in-peer pool, so lowering it trades peers for barriers
160
- * without lowering total concurrency: each peer runs its bucket at
161
- * concurrency 4, so N peers still resolve 4N tasks at once.
170
+ * without lowering total concurrency: each peer runs its bucket as wide as
171
+ * its own Dynamic Worker headroom allows.
162
172
  *
163
173
  * A caller sets this when its per-task work is small enough that a peer per
164
174
  * task buys nothing but round-trips — one task per peer costs ⌈tasks/
@@ -178,7 +188,7 @@ function isPeerResult<R>(value: Awaited<ReturnType<HostOpDispatch>>): value is F
178
188
 
179
189
  /**
180
190
  * Two-tier fan-out pool. Constructed by the supervisor DO; routes
181
- * each `submitMany` call automatically based on width.
191
+ * each `submitMany` call on the coordinator's live Dynamic Worker headroom.
182
192
  *
183
193
  * Lifetime: cheap to construct (no async init). Multiple submitMany
184
194
  * calls share NO state — each is dispatched fresh. The class
@@ -216,16 +226,16 @@ export class Fanout {
216
226
  * Dispatch `tasks` across the appropriate topology and return
217
227
  * results in input order.
218
228
  *
219
- * Routing:
220
- * tasks.length < 5 -> coordinator-local IsolatePool
221
- * tasks.length >= 5 -> sibling NimbusSession DOs
229
+ * Routing, against the coordinator's Dynamic Worker headroom at call time:
230
+ * tasks.length <= headroom -> coordinator-local IsolatePool, one Dynamic
231
+ * Worker per task, the width claimed on the
232
+ * ledger until the batch settles
233
+ * tasks.length > headroom -> sibling NimbusSession DOs
222
234
  *
223
- * Backpressure: if `tasks.length > MAX_PEER_FANOUT (32)`, tasks
224
- * are sharded modulo `MAX_PEER_FANOUT` and each shard's bucket
225
- * runs serially inside its assigned peer DO via the in-peer
226
- * IsolatePool's concurrency (capped at 4 there too). A
227
- * single submitMany call returns when ALL tasks complete (or any
228
- * throws).
235
+ * Peer shards: tasks hash onto min(tasks, maxPeers ?? MAX_PEER_FANOUT)
236
+ * peers and each peer runs its bucket through its own IsolatePool, as
237
+ * wide as that peer's headroom allows. A single submitMany call returns
238
+ * when ALL tasks complete (or any throws).
229
239
  *
230
240
  * `fn` is the user function executed per task. It runs INSIDE a
231
241
  * Worker Loader isolate (in the in-DO path) or inside a peer DO's
@@ -239,16 +249,21 @@ export class Fanout {
239
249
  ): Promise<R[]> {
240
250
  if (tasks.length === 0) return [];
241
251
 
242
- if (tasks.length < IN_DO_THRESHOLD) {
243
- return this._dispatchInDo<A, R>(tasks, fn);
252
+ const headroom = dynamicWorkerHeadroom(this.ctx);
253
+ const claim = claimDynamicWorkers(this.ctx, tasks.length);
254
+ this.opts.onRoute?.({ topology: claim ? 'in-do' : 'peer-do', tasks: tasks.length, headroom });
255
+ if (!claim) return this._dispatchPeerDo<A, R>(tasks, fn);
256
+ try {
257
+ return await this._dispatchInDo<A, R>(tasks, fn);
258
+ } finally {
259
+ claim.release();
244
260
  }
245
- return this._dispatchPeerDo<A, R>(tasks, fn);
246
261
  }
247
262
 
248
- /** Report which topology a task count uses without dispatching. */
249
- topologyFor(taskCount: number): 'in-do' | 'peer-do' | 'empty' {
263
+ /** The topology a task count would take against the headroom right now. */
264
+ topologyFor(taskCount: number): FanoutTopology | 'empty' {
250
265
  if (taskCount === 0) return 'empty';
251
- return taskCount < IN_DO_THRESHOLD ? 'in-do' : 'peer-do';
266
+ return taskCount <= dynamicWorkerHeadroom(this.ctx) ? 'in-do' : 'peer-do';
252
267
  }
253
268
 
254
269
  /**
@@ -269,13 +284,10 @@ export class Fanout {
269
284
  tasks: FanoutTask<A>[],
270
285
  fn: FacetTaskFn<A, R>,
271
286
  ): Promise<R[]> {
272
- // Use the existing IsolatePool. Concurrency = task count
273
- // (capped at 4 by constructor — tasks.length is already < 5
274
- // here, so the cap won't bite). Each task = one pool.submit;
275
- // pool.map runs them with stable-slot reuse.
276
- const concurrency = Math.min(tasks.length, IN_DO_THRESHOLD - 1);
287
+ // One slot — one Dynamic Worker — per task; submitMany has claimed
288
+ // that width on the ledger.
277
289
  const pool = new IsolatePool(this.env, this.ctx, {
278
- concurrency,
290
+ concurrency: tasks.length,
279
291
  timeoutMs: this.opts.timeoutMs,
280
292
  tag: this.opts.tag,
281
293
  preamble: this.opts.preamble,
@@ -285,9 +297,6 @@ export class Fanout {
285
297
  supervisorPid: this.opts.supervisorPid,
286
298
  });
287
299
  try {
288
- // pool.map runs the function over `items` with concurrency-bounded
289
- // slot reuse. Each slot is one warm loader isolate; we get exactly
290
- // `concurrency` loader isolates total — well under the 4-cap.
291
300
  const items = tasks.map((t) => t.args);
292
301
  const results = await pool.map<A, R>(fn, items);
293
302
  // pool.map returns Array<R | null> (null on per-item failure with
@@ -316,10 +325,9 @@ export class Fanout {
316
325
  // identical fns.
317
326
  const fnSource = serializeFunction(fn);
318
327
 
319
- // Cap peer count at MAX_PEER_FANOUT. Tasks beyond N=32 are
320
- // bucketed into existing shards — each shard's peer DO then
321
- // runs its bucket through its in-DO IsolatePool.map
322
- // (concurrency capped at 4 there).
328
+ // Cap peer count at maxPeers (default MAX_PEER_FANOUT). Tasks beyond
329
+ // it bucket into existing shards; each peer runs its bucket through
330
+ // its own IsolatePool, as wide as its own headroom allows.
323
331
  const peerCount = Math.min(tasks.length, this.opts.maxPeers ?? MAX_PEER_FANOUT);
324
332
  // Group tasks by deterministic shard. Same key → same shard, so
325
333
  // tests can predict which peer handles which task.
@@ -360,10 +368,9 @@ export class Fanout {
360
368
  const peerStub = ns.get(id);
361
369
  try {
362
370
  const dispatch = hostOpDispatch(peerStub, `Fanout peer ${siblingName}`);
363
- // Each peer DO RPC call uses ONE LOADER worker on its side.
364
- // Supervisor → peer DO is a stub.fetch / RPC method call,
365
- // NOT an env.LOADER.get(); that's the cap-sidestep that
366
- // makes peer-DO fanout work.
371
+ // A peer DO call is a Durable Object RPC, not a Dynamic Worker:
372
+ // it spends none of this coordinator's budget, and the peer
373
+ // spends its own.
367
374
  const rpcResp = await dispatch({
368
375
  op: 'fanoutExecute',
369
376
  args: [
@@ -414,8 +421,9 @@ export class Fanout {
414
421
  }
415
422
  return;
416
423
  } catch (err) {
417
- const schedule = isTransientDoReset(err) ? PEER_RETRY_BACKOFF_MS
418
- : isDoOverloaded(err) ? PEER_OVERLOAD_BACKOFF_MS
424
+ const cls = classifyDoCall(err);
425
+ const schedule = cls === 'overloaded' ? PEER_OVERLOAD_BACKOFF_MS
426
+ : isRetryableDoCall(cls) ? PEER_RETRY_BACKOFF_MS
419
427
  : null;
420
428
  if (schedule && attempt < PEER_TRANSIENT_RESET_RETRIES) {
421
429
  const backoff = schedule[Math.min(attempt, schedule.length - 1)];
@@ -56,9 +56,9 @@ export const FENCED_WORK_MAX_ATTEMPT = 1;
56
56
  * (staging, 2026-08-13): every observed reset struck seconds AFTER the launch
57
57
  * settled — the platform kills the object while the resident runs, which is
58
58
  * when a launch-scoped row had already been deleted and recovery had nothing
59
- * to find. A resident's facet cannot outlive its session instance (the
60
- * process host's held-open leg dies with it), so a row from a previous
61
- * generation always names a process that is genuinely gone.
59
+ * to find. A resident's facet can outlive its session instance (a pending
60
+ * timer keeps it running), but nothing routes to it from the next one, and a
61
+ * spawn that takes its facet name ends it first.
62
62
  */
63
63
  export interface FencedWorkRecord {
64
64
  pid: number;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * host-wasm.ts — what the fabric knows about a WebAssembly.Module a host
3
+ * hands a dynamic worker already compiled.
4
+ *
5
+ * workerd accepts a compiled module in a Worker Loader module map and lets
6
+ * the dynamic worker share its compiled code (src/workerd/api/
7
+ * worker-loader.c++, extractWasmModuleContent), so a host that bundles a
8
+ * fixed wasm (esbuild's, say) hands that module over instead of fetching a
9
+ * second copy of the bytes and having the guest compile them again. Two
10
+ * things the fabric needs about a member are unreadable from JS for a
11
+ * Module:
12
+ * - its size: the module's wire bytes still count toward the 64 MiB
13
+ * dynamic-worker code limit (worker-loader.c++ sums every member), and
14
+ * assertModuleMapWithinCodeLimit must count them;
15
+ * - its identity: a pool folds its wasm into the loader cache key, and a
16
+ * Module has no bytes to fingerprint.
17
+ * The host that owns the module states both once, here.
18
+ */
19
+
20
+ export interface HostWasmIdentity {
21
+ /** A stable name for the module's content, e.g. `esbuild@0.24.2`. */
22
+ readonly id: string;
23
+ /** The module's wire bytes: what it costs the dynamic-worker code budget. */
24
+ readonly bytes: number;
25
+ }
26
+
27
+ const described = new WeakMap<WebAssembly.Module, HostWasmIdentity>();
28
+
29
+ /** Record what `module` is, and return it. */
30
+ export function describeHostWasm(module: WebAssembly.Module, identity: HostWasmIdentity): WebAssembly.Module {
31
+ if (!Number.isSafeInteger(identity.bytes) || identity.bytes <= 0) {
32
+ throw new RangeError(`describeHostWasm: '${identity.id}' needs its wire size in bytes, got ${identity.bytes}`);
33
+ }
34
+ described.set(module, identity);
35
+ return module;
36
+ }
37
+
38
+ /** What a host said `module` is, or undefined for a module nobody described. */
39
+ export function hostWasmIdentity(module: WebAssembly.Module): HostWasmIdentity | undefined {
40
+ return described.get(module);
41
+ }
@@ -122,7 +122,7 @@ export class ImageStore {
122
122
  */
123
123
  async materialize(
124
124
  pid: number,
125
- images: AsyncIterable<readonly [string, string]> | Iterable<readonly [string, string]>,
125
+ images: AsyncIterable<readonly [string, string | readonly string[]]> | Iterable<readonly [string, string | readonly string[]]>,
126
126
  pacer: TurnBudget,
127
127
  ): Promise<Record<string, string>> {
128
128
  const fs = this.blobs();
@@ -138,12 +138,12 @@ export class ImageStore {
138
138
  fs.mkdirp(FACET_IMAGE_DIR);
139
139
  let count = 0;
140
140
  for await (const [moduleName, source] of images) {
141
- const path = facetImagePath(await facetImageDigest(source));
141
+ const bytes = typeof source === 'string' ? new TextEncoder().encode(source) : encodeParts(source);
142
+ const path = facetImagePath(await facetImageDigest(bytes));
142
143
  paths[moduleName] = path;
143
144
  rooted.push(path);
144
145
  count++;
145
146
  const stored = path.replace(/^\/+/, '');
146
- const bytes = new TextEncoder().encode(source);
147
147
  console.log(
148
148
  '[image-store] pid=' + pid + ' image ' + count + ' ' + moduleName + ' → '
149
149
  + path.slice(-12) + ' ' + bytes.byteLength + ' bytes, slice=' + FACET_IMAGE_WRITE_SLICE_BYTES
@@ -207,3 +207,30 @@ export class ImageStore {
207
207
  }
208
208
  }
209
209
  }
210
+
211
+ /**
212
+ * UTF-8 bytes of an image given as ordered parts, encoded into one buffer a
213
+ * part at a time: joining the parts first would hold the whole image twice as
214
+ * text.
215
+ */
216
+ function encodeParts(parts: readonly string[]): Uint8Array {
217
+ let length = 0;
218
+ for (const part of parts) {
219
+ for (let i = 0; i < part.length; i++) {
220
+ const code = part.charCodeAt(i);
221
+ if (code < 0x80) length += 1;
222
+ else if (code < 0x800) length += 2;
223
+ else if (code >= 0xd800 && code <= 0xdbff && (part.charCodeAt(i + 1) & 0xfc00) === 0xdc00) { length += 4; i++; }
224
+ else length += 3;
225
+ }
226
+ }
227
+ const bytes = new Uint8Array(length);
228
+ const encoder = new TextEncoder();
229
+ let offset = 0;
230
+ for (const part of parts) {
231
+ const encoded = encoder.encode(part);
232
+ bytes.set(encoded, offset);
233
+ offset += encoded.byteLength;
234
+ }
235
+ return bytes;
236
+ }
@@ -56,3 +56,34 @@ export function clearInnerDoClasses(supervisorDoId: string): void {
56
56
  if (k.startsWith(prefix)) _NIMBUS_INNER_DO_CLASSES.delete(k);
57
57
  }
58
58
  }
59
+
60
+ /** Inner-DO facet names each incarnation has opened, by binding; keyed off ctx, so a new incarnation starts empty. */
61
+ const openedInnerDoFacets = new WeakMap<DurableObjectState, Map<string, Set<string>>>();
62
+
63
+ /**
64
+ * Record that this incarnation opens `facetName` for `bindingName`. True on the
65
+ * name's first open since the incarnation began or the binding's facets were
66
+ * last aborted: whatever still runs under it has a class this build no longer
67
+ * uses, and a get() with the new class on it resets the whole object.
68
+ */
69
+ export function noteInnerDoFacetOpened(ctx: DurableObjectState, bindingName: string, facetName: string): boolean {
70
+ let byBinding = openedInnerDoFacets.get(ctx);
71
+ if (!byBinding) openedInnerDoFacets.set(ctx, byBinding = new Map());
72
+ let names = byBinding.get(bindingName);
73
+ if (!names) byBinding.set(bindingName, names = new Set());
74
+ if (names.has(facetName)) return false;
75
+ names.add(facetName);
76
+ return true;
77
+ }
78
+
79
+ /** Abort the facets this incarnation opened for `bindingNames`, keeping their storage, so each next request starts the class registered then. */
80
+ export function abortInnerDoFacets(ctx: DurableObjectState, bindingNames: Iterable<string>, reason: Error): void {
81
+ const byBinding = openedInnerDoFacets.get(ctx);
82
+ if (!byBinding) return;
83
+ for (const bindingName of bindingNames) {
84
+ for (const facetName of byBinding.get(bindingName) ?? []) {
85
+ try { ctx.facets.abort(facetName, reason); } catch { /* already gone */ }
86
+ }
87
+ byBinding.delete(bindingName);
88
+ }
89
+ }