@nimbus-sh/fabric 0.8.0 → 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 CHANGED
@@ -22,9 +22,19 @@ npm install @nimbus-sh/fabric
22
22
 
23
23
  ## Requirements
24
24
 
25
- Set `compatibility_flags: ["nodejs_compat"]` in your Worker. The timer
26
- dispatcher needs `AsyncLocalStorage`, which workerd ships only under that
27
- flag. Without it the module fails to load at deploy time.
25
+ Use a compatibility date of 2026-08-04 or later, or list `nodejs_compat` in
26
+ `compatibility_flags`. The timer dispatcher needs `AsyncLocalStorage`, which
27
+ workerd ships only under `nodejs_compat`, on by date from 2026-08-04. Without
28
+ it the module fails to load at deploy time.
29
+
30
+ `composeFabric` also needs `enhanced_error_serialization`: a compatibility
31
+ date of 2026-04-21 or later, or the flag in `compatibility_flags` on an older
32
+ date. A program's filesystem errors reach it across workerd RPC, and only
33
+ that flag carries their `code`. On workerd without it `composeFabric` throws,
34
+ naming both fixes, rather than every program seeing `EIO`. It throws where
35
+ it is called. At module scope the Worker fails at startup. A library host
36
+ that composes through `NimbusWorkspace.create({ fabric })` deploys and
37
+ starts, and its first create throws.
28
38
 
29
39
  Import the root inside a Worker. Outside workerd, import subpaths such as
30
40
  `@nimbus-sh/fabric/timers.js`, which are typed against plain objects and run
@@ -224,13 +234,18 @@ Warm isolates are scoped to one session. A pool may opt into
224
234
  `cacheScope: 'global'` only if it takes no supervisor binding and keeps no
225
235
  user state.
226
236
 
227
- `Fanout` handles wider batches. One DO method can drive at most 4 concurrent
228
- loader fetches, so batches under 5 run in the coordinator and larger ones
229
- shard across up to 32 sibling objects, 4 at a time.
237
+ `Fanout` handles wider batches. A Durable Object may have 10 distinct Dynamic
238
+ Workers with in-flight requests at once (`DO_DYNAMIC_WORKER_LIMIT`), shared
239
+ across every concurrent request to it; repeated requests to one Dynamic
240
+ Worker count once. A batch that fits the coordinator's remaining headroom
241
+ runs there, one Dynamic Worker per task; a wider one shards across up to 32
242
+ sibling objects, 4 at a time, each spending its own headroom.
230
243
 
231
- Each keyed `loader.get(id)` permanently holds one of roughly 5–6
232
- dynamic-worker slots. `loaderLedgerStats(ctx)` reports what you have
233
- consumed, and a cap refusal names the IDs holding slots.
244
+ The loader ledger counts what is in flight per DO: pool and one-shot calls,
245
+ esbuild facet calls, git network ops, and every resident process for as long
246
+ as it lives. `dynamicWorkerHeadroom(ctx)` is what is left,
247
+ `claimDynamicWorkers(ctx, n)` reserves a width, `loaderLedgerStats(ctx)`
248
+ reports it all, and a limit refusal names the workers in flight.
234
249
 
235
250
  ## Process fabric
236
251
 
@@ -370,8 +385,8 @@ or left to you.
370
385
  | Request-time `WebAssembly.compile`/`instantiate` CSP-blocked; wasm rides the loader modules map as `{ wasm: ArrayBuffer }`, compiled at module load | RPC of a compiled `Module` refused by structured clone; inlined bytes OOMed the supervisor |
371
386
  | Module scope bans I/O; `new Function` succeeds at module scope and throws at request time | code reaches a facet through the module map or not at all |
372
387
  | The facet start callback fires at most once | re-running it would re-execute the user's program |
373
- | ~5–6 concurrent dynamic workers per DO; at most 4 concurrent Loader fetches per DO method; loader-cache entries are never released | `IN_DO_THRESHOLD` = 5 sits under the fetch cap; every `loader.get(id)` permanently consumes a slot — counted per DO by the loader ledger, and a cap refusal names the ids holding them |
374
- | `ctx.facets.clone` is same-object only, absent from `@cloudflare/workers-types` and the pinned workerd, present in production | 18–31 ms / 45.7 MB, 34–54 ms / 1 GB; an unresolvable `src` silently EMPTIES the destination and reports success — `cloneStorage` enforces the both-ends validation |
388
+ | 10 distinct Dynamic Workers with in-flight requests per DO, shared across its concurrent requests; repeated requests to one Dynamic Worker count once ([changelog, 2026-08-28](https://developers.cloudflare.com/changelog/post/2026-08-28-durable-objects-dynamic-workers-limit/)) | `DO_DYNAMIC_WORKER_LIMIT`; `Fanout` sizes in-DO batches to the live headroom, and a refusal names the workers in flight |
389
+ | `ctx.facets.clone` is same-object only; declared by `@cloudflare/workers-types` 5 and present in workerd ≥ 1.20260926.1 and in production | 18–31 ms / 45.7 MB, 34–54 ms / 1 GB; an unresolvable `src` silently EMPTIES the destination and reports success — `cloneStorage` enforces the both-ends validation |
375
390
  | A DO dies at ~200 MiB of live wasm linear memory; reserved and written pages die at the same ceiling | lazy growth buys nothing; bound guest memory by rewriting the memory section |
376
391
  | A wasm stack suspended (JSPI) in one request cannot resume in another | 3 in-context resumes took 6 ms; the first cross-context one hit a 30 s timeout |
377
392
 
package/dist/budgets.d.ts CHANGED
@@ -1,34 +1,39 @@
1
1
  /**
2
2
  * budgets.ts — per-DO accounting for the platform budgets the fabric spends:
3
- * the Worker Loader's two caps, the facet-ID lifetime budget, and the
4
- * dynamic-worker module-map ceiling.
3
+ * the Durable Object's Dynamic Worker concurrency limit, the facet-ID
4
+ * lifetime budget, and the dynamic-worker module-map ceiling.
5
5
  *
6
- * Measured on production workerd: a Durable Object admits ~5–6 concurrent
7
- * dynamic workers before the platform refuses with "Too many concurrent
8
- * dynamic workers", one DO method can drive at most 4 concurrent Loader
9
- * fetches, and loader-cache entries are never released — every DISTINCT
10
- * `loader.get(id)` permanently consumes one of the dynamic-worker slots for
11
- * the object's lifetime. Nimbus stays under the caps by construction
12
- * (`IN_DO_THRESHOLD` = 5 in the fanout pool), which until now meant the slots
13
- * were counted in prose. This ledger counts them at the fabric's loader call
14
- * sites instead — the loader pool's slots, a resident process's keyed worker,
15
- * a one-shot's load — so proximity is measurable and a cap failure can name
16
- * the ids actually holding slots.
6
+ * The Dynamic Worker model is Cloudflare's documented one
7
+ * ({@link DO_DYNAMIC_WORKER_LIMIT}): a Durable Object may have a fixed number
8
+ * of DISTINCT Dynamic Workers with in-flight requests at once, shared across
9
+ * every concurrent request to that object (one I/O context), and any number
10
+ * of in-flight requests to the same Dynamic Worker count as one. Only
11
+ * in-flight requests count: a loader id with nothing in flight holds nothing.
17
12
  *
18
- * Measurement only: no admission control. The caps are the platform's, they
19
- * are approximate ("~5–6"), and a gate on an approximate number would refuse
20
- * work the platform would have run.
13
+ * The ledger counts, per hosting actor, the distinct workers that are in
14
+ * flight right now, keyed by loader id (a fresh key per unkeyed `load`), plus
15
+ * the width fan-outs have claimed and not yet released. A fan-out spends only
16
+ * the {@link dynamicWorkerHeadroom} that leaves, so work a Durable Object
17
+ * already has in flight — a resident process, the esbuild facet, a git
18
+ * network op, another fan-out — keeps its slots.
21
19
  *
22
20
  * Keyed weakly off the hosting actor's `ctx`, like the facet slot books: the
23
- * caps are per Durable Object, and dynamic workers die with the isolate that
21
+ * limit is per Durable Object, and dynamic workers die with the isolate that
24
22
  * loaded them, so a ledger that goes away with its host describes nothing
25
23
  * that still exists.
26
24
  */
27
- /** Record a keyed `loader.get(id)` — a permanent slot if the id is new. */
28
- export declare function recordLoaderId(ctx: object, id: string): void;
29
25
  /**
30
- * Count one call into a dynamic worker as a live Loader fetch; the returned
31
- * function ends it (idempotently), from the caller's own `finally`.
26
+ * Distinct Dynamic Workers one Durable Object may have with in-flight
27
+ * requests at once, shared across all concurrent requests to that object;
28
+ * multiple in-flight requests to one Dynamic Worker count once.
29
+ * https://developers.cloudflare.com/changelog/post/2026-08-28-durable-objects-dynamic-workers-limit/
30
+ */
31
+ export declare const DO_DYNAMIC_WORKER_LIMIT = 10;
32
+ /**
33
+ * Hold the Dynamic Worker `workerKey` in flight on this actor's ledger; the
34
+ * returned function ends the hold (idempotently), from the caller's own
35
+ * `finally`. Holds on one key nest: the worker counts once until the last
36
+ * one ends, as the platform counts it.
32
37
  *
33
38
  * A begin/end pair rather than a wrapper on purpose, and the shape is
34
39
  * load-bearing: wrapping the stub call in a ledger-owned async frame
@@ -41,19 +46,35 @@ export declare function recordLoaderId(ctx: object, id: string): void;
41
46
  * workers: an RPC stub call must stay a direct property call awaited by the
42
47
  * frame that made it, so the ledger only brackets it.
43
48
  */
44
- export declare function beginLoaderFetch(ctx: object): () => void;
49
+ export declare function beginLoaderFetch(ctx: object, workerKey: string): () => void;
50
+ /**
51
+ * Distinct Dynamic Workers this actor may still put in flight: the limit
52
+ * less what is held and claimed right now. Never negative.
53
+ */
54
+ export declare function dynamicWorkerHeadroom(ctx: object): number;
55
+ /**
56
+ * Claim `width` distinct Dynamic Workers for one fan-out, or null when the
57
+ * headroom cannot hold it. The claim counts until `release` (idempotent), so
58
+ * a second fan-out sizing itself meanwhile sees it; the claimant's own
59
+ * dispatches are held as well while they run, which only ever over-counts
60
+ * toward sending that second fan-out elsewhere.
61
+ */
62
+ export declare function claimDynamicWorkers(ctx: object, width: number): {
63
+ release(): void;
64
+ } | null;
45
65
  /** Snapshot for the diag surface. Pure read; no I/O. */
46
66
  export declare function loaderLedgerStats(ctx: object): {
47
- idsEverGotten: string[];
48
- liveFetches: number;
49
- peakLiveFetches: number;
67
+ limit: number;
68
+ inFlightWorkers: string[];
69
+ claimed: number;
70
+ headroom: number;
71
+ peak: number;
50
72
  };
51
73
  /**
52
- * Name the per-DO accounting on a "Too many concurrent dynamic workers"
74
+ * Name the per-DO accounting on a "Dynamic worker concurrency limit exceeded"
53
75
  * failure; hand every other error back untouched. The platform's message
54
- * says only that the cap was hit — which ids hold the slots, and that a
55
- * keyed id can never give one back, is what the operator needs to know to
56
- * shrink anything.
76
+ * says only that the limit was hit — which workers were in flight, and what
77
+ * fan-outs had claimed, is what the operator needs to know to shrink anything.
57
78
  */
58
79
  export declare function withDynamicWorkerCapNamed<E>(ctx: object, error: E): E | Error;
59
80
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"budgets.d.ts","sourceRoot":"","sources":["../src/budgets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAwBH,2EAA2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI,CAE5D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,IAAI,CAUxD;AAED,wDAAwD;AACxD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG;IAC9C,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;CACzB,CAOA;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,KAAK,CAW7E;AAID;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,WAAa,CAAC;AAE1D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAoBrF;AAuBD;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAE/C,sEAAsE;AACtE,eAAO,MAAM,yBAAyB,iCAAiC,CAAC;AAExE,mEAAmE;AACnE,UAAU,sBAAsB;IAC9B,OAAO,EAAE;QACP,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;QAC7C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KACjD,CAAC;CACH;AAqCD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,sBAAsB,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAatF;AAED,4EAA4E;AAC5E,wBAAgB,cAAc,CAAC,GAAG,EAAE,sBAAsB,GAAG,MAAM,CAGlE;AAED,4EAA4E;AAC5E,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,sBAAsB,GAAG,OAAO,CAAC,MAAM,CAAC,CAIxF;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,sBAAsB,GAC1B,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAK/C;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAU9E"}
1
+ {"version":3,"file":"budgets.d.ts","sourceRoot":"","sources":["../src/budgets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAKH;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,KAAK,CAAC;AA0B1C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,IAAI,CAY3E;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG;IAAE,OAAO,IAAI,IAAI,CAAA;CAAE,GAAG,IAAI,CAa1F;AAED,wDAAwD;AACxD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG;IAC9C,KAAK,EAAE,MAAM,CAAC;IACd,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd,CASA;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,KAAK,CAU7E;AAID;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,WAAa,CAAC;AAE1D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAoBrF;AA4BD;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAE/C,sEAAsE;AACtE,eAAO,MAAM,yBAAyB,iCAAiC,CAAC;AAExE,mEAAmE;AACnE,UAAU,sBAAsB;IAC9B,OAAO,EAAE;QACP,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;QAC7C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KACjD,CAAC;CACH;AAqCD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,sBAAsB,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAatF;AAED,4EAA4E;AAC5E,wBAAgB,cAAc,CAAC,GAAG,EAAE,sBAAsB,GAAG,MAAM,CAGlE;AAED,4EAA4E;AAC5E,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,sBAAsB,GAAG,OAAO,CAAC,MAAM,CAAC,CAIxF;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,sBAAsB,GAC1B,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAK/C;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAU9E"}
package/dist/budgets.js CHANGED
@@ -1,46 +1,53 @@
1
1
  /**
2
2
  * budgets.ts — per-DO accounting for the platform budgets the fabric spends:
3
- * the Worker Loader's two caps, the facet-ID lifetime budget, and the
4
- * dynamic-worker module-map ceiling.
3
+ * the Durable Object's Dynamic Worker concurrency limit, the facet-ID
4
+ * lifetime budget, and the dynamic-worker module-map ceiling.
5
5
  *
6
- * Measured on production workerd: a Durable Object admits ~5–6 concurrent
7
- * dynamic workers before the platform refuses with "Too many concurrent
8
- * dynamic workers", one DO method can drive at most 4 concurrent Loader
9
- * fetches, and loader-cache entries are never released — every DISTINCT
10
- * `loader.get(id)` permanently consumes one of the dynamic-worker slots for
11
- * the object's lifetime. Nimbus stays under the caps by construction
12
- * (`IN_DO_THRESHOLD` = 5 in the fanout pool), which until now meant the slots
13
- * were counted in prose. This ledger counts them at the fabric's loader call
14
- * sites instead — the loader pool's slots, a resident process's keyed worker,
15
- * a one-shot's load — so proximity is measurable and a cap failure can name
16
- * the ids actually holding slots.
6
+ * The Dynamic Worker model is Cloudflare's documented one
7
+ * ({@link DO_DYNAMIC_WORKER_LIMIT}): a Durable Object may have a fixed number
8
+ * of DISTINCT Dynamic Workers with in-flight requests at once, shared across
9
+ * every concurrent request to that object (one I/O context), and any number
10
+ * of in-flight requests to the same Dynamic Worker count as one. Only
11
+ * in-flight requests count: a loader id with nothing in flight holds nothing.
17
12
  *
18
- * Measurement only: no admission control. The caps are the platform's, they
19
- * are approximate ("~5–6"), and a gate on an approximate number would refuse
20
- * work the platform would have run.
13
+ * The ledger counts, per hosting actor, the distinct workers that are in
14
+ * flight right now, keyed by loader id (a fresh key per unkeyed `load`), plus
15
+ * the width fan-outs have claimed and not yet released. A fan-out spends only
16
+ * the {@link dynamicWorkerHeadroom} that leaves, so work a Durable Object
17
+ * already has in flight — a resident process, the esbuild facet, a git
18
+ * network op, another fan-out — keeps its slots.
21
19
  *
22
20
  * Keyed weakly off the hosting actor's `ctx`, like the facet slot books: the
23
- * caps are per Durable Object, and dynamic workers die with the isolate that
21
+ * limit is per Durable Object, and dynamic workers die with the isolate that
24
22
  * loaded them, so a ledger that goes away with its host describes nothing
25
23
  * that still exists.
26
24
  */
27
25
  import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
26
+ import { hostWasmIdentity } from './host-wasm.js';
27
+ /**
28
+ * Distinct Dynamic Workers one Durable Object may have with in-flight
29
+ * requests at once, shared across all concurrent requests to that object;
30
+ * multiple in-flight requests to one Dynamic Worker count once.
31
+ * https://developers.cloudflare.com/changelog/post/2026-08-28-durable-objects-dynamic-workers-limit/
32
+ */
33
+ export const DO_DYNAMIC_WORKER_LIMIT = 10;
28
34
  const ledgers = new WeakMap();
29
35
  function ledger(ctx) {
30
36
  let entry = ledgers.get(ctx);
31
37
  if (!entry) {
32
- entry = { ids: new Set(), liveFetches: 0, peakLiveFetches: 0 };
38
+ entry = { inFlight: new Map(), claimed: 0, peak: 0 };
33
39
  ledgers.set(ctx, entry);
34
40
  }
35
41
  return entry;
36
42
  }
37
- /** Record a keyed `loader.get(id)` — a permanent slot if the id is new. */
38
- export function recordLoaderId(ctx, id) {
39
- ledger(ctx).ids.add(id);
43
+ function inUse(entry) {
44
+ return entry.inFlight.size + entry.claimed;
40
45
  }
41
46
  /**
42
- * Count one call into a dynamic worker as a live Loader fetch; the returned
43
- * function ends it (idempotently), from the caller's own `finally`.
47
+ * Hold the Dynamic Worker `workerKey` in flight on this actor's ledger; the
48
+ * returned function ends the hold (idempotently), from the caller's own
49
+ * `finally`. Holds on one key nest: the worker counts once until the last
50
+ * one ends, as the platform counts it.
44
51
  *
45
52
  * A begin/end pair rather than a wrapper on purpose, and the shape is
46
53
  * load-bearing: wrapping the stub call in a ledger-owned async frame
@@ -53,43 +60,77 @@ export function recordLoaderId(ctx, id) {
53
60
  * workers: an RPC stub call must stay a direct property call awaited by the
54
61
  * frame that made it, so the ledger only brackets it.
55
62
  */
56
- export function beginLoaderFetch(ctx) {
63
+ export function beginLoaderFetch(ctx, workerKey) {
57
64
  const entry = ledger(ctx);
58
- entry.liveFetches++;
59
- entry.peakLiveFetches = Math.max(entry.peakLiveFetches, entry.liveFetches);
65
+ entry.inFlight.set(workerKey, (entry.inFlight.get(workerKey) ?? 0) + 1);
66
+ entry.peak = Math.max(entry.peak, inUse(entry));
60
67
  let ended = false;
61
68
  return () => {
62
69
  if (ended)
63
70
  return;
64
71
  ended = true;
65
- entry.liveFetches--;
72
+ const open = (entry.inFlight.get(workerKey) ?? 1) - 1;
73
+ if (open > 0)
74
+ entry.inFlight.set(workerKey, open);
75
+ else
76
+ entry.inFlight.delete(workerKey);
77
+ };
78
+ }
79
+ /**
80
+ * Distinct Dynamic Workers this actor may still put in flight: the limit
81
+ * less what is held and claimed right now. Never negative.
82
+ */
83
+ export function dynamicWorkerHeadroom(ctx) {
84
+ return Math.max(0, DO_DYNAMIC_WORKER_LIMIT - inUse(ledger(ctx)));
85
+ }
86
+ /**
87
+ * Claim `width` distinct Dynamic Workers for one fan-out, or null when the
88
+ * headroom cannot hold it. The claim counts until `release` (idempotent), so
89
+ * a second fan-out sizing itself meanwhile sees it; the claimant's own
90
+ * dispatches are held as well while they run, which only ever over-counts
91
+ * toward sending that second fan-out elsewhere.
92
+ */
93
+ export function claimDynamicWorkers(ctx, width) {
94
+ const entry = ledger(ctx);
95
+ if (width < 1 || width > DO_DYNAMIC_WORKER_LIMIT - inUse(entry))
96
+ return null;
97
+ entry.claimed += width;
98
+ entry.peak = Math.max(entry.peak, inUse(entry));
99
+ let released = false;
100
+ return {
101
+ release() {
102
+ if (released)
103
+ return;
104
+ released = true;
105
+ entry.claimed -= width;
106
+ },
66
107
  };
67
108
  }
68
109
  /** Snapshot for the diag surface. Pure read; no I/O. */
69
110
  export function loaderLedgerStats(ctx) {
70
111
  const entry = ledger(ctx);
71
112
  return {
72
- idsEverGotten: [...entry.ids],
73
- liveFetches: entry.liveFetches,
74
- peakLiveFetches: entry.peakLiveFetches,
113
+ limit: DO_DYNAMIC_WORKER_LIMIT,
114
+ inFlightWorkers: [...entry.inFlight.keys()],
115
+ claimed: entry.claimed,
116
+ headroom: dynamicWorkerHeadroom(ctx),
117
+ peak: entry.peak,
75
118
  };
76
119
  }
77
120
  /**
78
- * Name the per-DO accounting on a "Too many concurrent dynamic workers"
121
+ * Name the per-DO accounting on a "Dynamic worker concurrency limit exceeded"
79
122
  * failure; hand every other error back untouched. The platform's message
80
- * says only that the cap was hit — which ids hold the slots, and that a
81
- * keyed id can never give one back, is what the operator needs to know to
82
- * shrink anything.
123
+ * says only that the limit was hit — which workers were in flight, and what
124
+ * fan-outs had claimed, is what the operator needs to know to shrink anything.
83
125
  */
84
126
  export function withDynamicWorkerCapNamed(ctx, error) {
85
127
  if (classifyError(error) !== 'dynamic_worker_cap')
86
128
  return error;
87
129
  const entry = ledger(ctx);
88
130
  const platform = error instanceof Error ? error.message : String(error);
89
- return new Error(`${platform} — this Durable Object has ${entry.ids.size} loader id(s) permanently `
90
- + `holding dynamic-worker slots (a loader.get id is never released): `
91
- + `${[...entry.ids].join(', ') || '(none recorded)'}; live Loader fetches ${entry.liveFetches}, `
92
- + `peak ${entry.peakLiveFetches}`, { cause: error });
131
+ return new Error(`${platform} — this Durable Object had ${entry.inFlight.size} distinct dynamic worker(s) in flight `
132
+ + `(${[...entry.inFlight.keys()].join(', ') || 'none recorded'}) and ${entry.claimed} claimed by fan-outs, `
133
+ + `against a limit of ${DO_DYNAMIC_WORKER_LIMIT}; peak ${entry.peak}`, { cause: error });
93
134
  }
94
135
  // ── Dynamic-worker module-map ceiling ───────────────────────────────────────
95
136
  /**
@@ -136,13 +177,18 @@ export function assertModuleMapWithinCodeLimit(modules) {
136
177
  }
137
178
  /**
138
179
  * Bytes one module-map member carries, across the loader's content kinds
139
- * (plain string, `{ js | cjs | py | text }`, `{ wasm | data }`). With an
140
- * encoder, text is measured exactly; without one, by code-unit length.
180
+ * (plain string, `{ js | cjs | py | text }`, `{ wasm | data }`, a bare
181
+ * WebAssembly.Module). With an encoder, text is measured exactly; without
182
+ * one, by code-unit length. A compiled module counts the wire size its host
183
+ * described (host-wasm.ts); one nobody described counts nothing here and is
184
+ * left to the platform's own refusal, as the text undercount is.
141
185
  */
142
186
  function memberBytes(content, encoder) {
143
187
  const textBytes = (text) => encoder ? encoder.encode(text).byteLength : text.length;
144
188
  if (typeof content === 'string')
145
189
  return textBytes(content);
190
+ if (content instanceof WebAssembly.Module)
191
+ return hostWasmIdentity(content)?.bytes ?? 0;
146
192
  if (content !== null && typeof content === 'object') {
147
193
  for (const value of Object.values(content)) {
148
194
  if (typeof value === 'string')
@@ -151,6 +197,8 @@ function memberBytes(content, encoder) {
151
197
  return value.byteLength;
152
198
  if (ArrayBuffer.isView(value))
153
199
  return value.byteLength;
200
+ if (value instanceof WebAssembly.Module)
201
+ return hostWasmIdentity(value)?.bytes ?? 0;
154
202
  }
155
203
  }
156
204
  return 0;
@@ -40,6 +40,7 @@
40
40
  * namespace stubs, where the thunk shape is production-proven in Proteus.
41
41
  */
42
42
  import { type DoCallClass } from '@nimbus-sh/platform/oom-classify.js';
43
+ import { type SpanRecorder } from '@nimbus-sh/platform/tracing.js';
43
44
  export interface DoCallRetryPolicy {
44
45
  maxAttempts?: number;
45
46
  baseDelayMs?: number;
@@ -78,7 +79,26 @@ export interface DoCallRetryPolicy {
78
79
  * its error.
79
80
  */
80
81
  onRetry?(info: DoCallRetryInfo): void;
82
+ /**
83
+ * Where the call's telemetry goes: its span's recorder. Each attempt lost
84
+ * to a transient or overloaded failure is recorded as an exception whose
85
+ * `code` is the failure's class, and once the call has settled it gets
86
+ * `do_call.attempts` (started), `do_call.hedges` (started by a hedge),
87
+ * `do_call.answered_by` (the attempt whose answer the call took, absent
88
+ * when none answered) and `do_call.outcome` ({@link DoCallOutcome}).
89
+ * Nothing recorded can change the call's answer. Records nothing when
90
+ * absent.
91
+ */
92
+ span?: SpanRecorder;
81
93
  }
94
+ /**
95
+ * How an `idempotent` call ended: `answered` (an attempt succeeded),
96
+ * `callee_error` (the callee's own failure, which is an answer),
97
+ * `exhausted` (the last transient failure, no repeat left), `overloaded`
98
+ * (shed, nothing repeated after it), or `caller_error` (the resolver or
99
+ * `onRetry` threw).
100
+ */
101
+ export type DoCallOutcome = 'answered' | 'callee_error' | 'exhausted' | 'overloaded' | 'caller_error';
82
102
  /** What one retry is answering: which call, which platform class, which
83
103
  * attempt just failed out of how many. */
84
104
  export interface DoCallRetryInfo {
@@ -1 +1 @@
1
- {"version":3,"file":"do-calls.d.ts","sourceRoot":"","sources":["../src/do-calls.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,qCAAqC,CAAC;AAU1G,MAAM,WAAW,iBAAiB;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;;;;;;;;OAeG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,OAAO,CAAC,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI,CAAC;CACvC;AAED;2CAC2C;AAC3C,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,WAAW,CAAC;IAC5B;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,IAAI,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAErD;;;;;GAKG;AACH,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,SAAS,EAAE,MAAM;IAC1B,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,UAAU;IACxC,QAAQ,CAAC,cAAc,EAAE,WAAW;gBAF3B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,YAAY,GAAG,UAAU,EAC/B,cAAc,EAAE,WAAW,EACpC,KAAK,EAAE,OAAO;CASjB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,CAAC,EAC7B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,cAAc,CAAC,CAAC,CAAC,EACvB,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,EAC7B,MAAM,GAAE,iBAAsB,GAC7B,OAAO,CAAC,CAAC,CAAC,CAmHZ;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAAE,CAAC,EACjC,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,cAAc,CAAC,CAAC,CAAC,EACvB,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAC5B,OAAO,CAAC,CAAC,CAAC,CAUZ"}
1
+ {"version":3,"file":"do-calls.d.ts","sourceRoot":"","sources":["../src/do-calls.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,qCAAqC,CAAC;AAE1G,OAAO,EAAY,KAAK,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAS7E,MAAM,WAAW,iBAAiB;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;;;;;;;;OAeG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,OAAO,CAAC,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI,CAAC;IACtC;;;;;;;;;OASG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;CACrB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,cAAc,GAAG,WAAW,GAAG,YAAY,GAAG,cAAc,CAAC;AAEtG;2CAC2C;AAC3C,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,WAAW,CAAC;IAC5B;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,IAAI,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAErD;;;;;GAKG;AACH,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,SAAS,EAAE,MAAM;IAC1B,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,UAAU;IACxC,QAAQ,CAAC,cAAc,EAAE,WAAW;gBAF3B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,YAAY,GAAG,UAAU,EAC/B,cAAc,EAAE,WAAW,EACpC,KAAK,EAAE,OAAO;CASjB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,CAAC,EAC7B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,cAAc,CAAC,CAAC,CAAC,EACvB,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,EAC7B,MAAM,GAAE,iBAAsB,GAC7B,OAAO,CAAC,CAAC,CAAC,CAgIZ;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAAE,CAAC,EACjC,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,cAAc,CAAC,CAAC,CAAC,EACvB,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAC5B,OAAO,CAAC,CAAC,CAAC,CAUZ"}
package/dist/do-calls.js CHANGED
@@ -41,6 +41,7 @@
41
41
  */
42
42
  import { classifyDoCall, isRetryableDoCall } from '@nimbus-sh/platform/oom-classify.js';
43
43
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
44
+ import { untraced } from '@nimbus-sh/platform/tracing.js';
44
45
  /** Total attempts. Two retries is what a dropped connection or a deploy
45
46
  * bounce needs; beyond that the object is not coming back inside this
46
47
  * request (the consumer's measured bound). */
@@ -87,7 +88,7 @@ export class DoCallError extends Error {
87
88
  export function idempotent(operation, stub, call, policy = {}) {
88
89
  const maxAttempts = policy.maxAttempts ?? MAX_ATTEMPTS;
89
90
  const baseDelayMs = policy.baseDelayMs ?? BASE_DELAY_MS;
90
- const { hedgeAfterMs, retryWindowMs } = policy;
91
+ const { hedgeAfterMs, retryWindowMs, span = untraced } = policy;
91
92
  const startedAt = Date.now();
92
93
  // The executor form: fabric's library target predates Promise.withResolvers.
93
94
  return new Promise((resolve, reject) => {
@@ -99,7 +100,9 @@ export function idempotent(operation, stub, call, policy = {}) {
99
100
  // An attempt was shed as overloaded: nothing is repeated after it.
100
101
  let refused = false;
101
102
  let settled = false;
102
- const settle = (answer) => {
103
+ // Attempts a hedge started rather than a retry or the first send.
104
+ let hedged = 0;
105
+ const settle = (outcome, answeredBy, answer) => {
103
106
  if (settled)
104
107
  return;
105
108
  settled = true;
@@ -107,6 +110,12 @@ export function idempotent(operation, stub, call, policy = {}) {
107
110
  clearTimeout(timer);
108
111
  hedges.clear();
109
112
  answer();
113
+ span.set({
114
+ 'do_call.attempts': started,
115
+ 'do_call.hedges': hedged,
116
+ 'do_call.answered_by': answeredBy,
117
+ 'do_call.outcome': outcome,
118
+ });
110
119
  };
111
120
  /** May another attempt start at `at`? */
112
121
  const canRepeat = (at) => !settled && !refused && started < maxAttempts
@@ -114,13 +123,20 @@ export function idempotent(operation, stub, call, policy = {}) {
114
123
  /** `error` ended an attempt that will not be repeated: the call's answer, once nothing else is live. */
115
124
  const exhausted = (error) => {
116
125
  if (live === 0)
117
- settle(() => reject(error));
126
+ settle(refused ? 'overloaded' : 'exhausted', undefined, () => reject(error));
118
127
  };
119
128
  /** A failed attempt, numbered: repeat it after its backoff, or let it stand. */
120
129
  const failed = async (number, error) => {
121
130
  if (settled)
122
131
  return;
123
132
  const classification = classifyDoCall(error);
133
+ if (!isRetryableDoCall(classification) && classification !== 'overloaded') {
134
+ // The call ran and its answer is this error — ENOENT is a read's answer as much as bytes are.
135
+ settle('callee_error', number, () => reject(error));
136
+ return;
137
+ }
138
+ // A lost attempt: the call's span says which, and why.
139
+ span.exception(error, classification, `attempt ${number} of ${maxAttempts}: `);
124
140
  if (classification === 'overloaded') {
125
141
  // A shed call is no answer: nothing more is sent, and an attempt
126
142
  // still in flight may yet answer.
@@ -128,11 +144,6 @@ export function idempotent(operation, stub, call, policy = {}) {
128
144
  exhausted(error);
129
145
  return;
130
146
  }
131
- if (!isRetryableDoCall(classification)) {
132
- // The call ran and its answer is this error — ENOENT is a read's answer as much as bytes are.
133
- settle(() => reject(error));
134
- return;
135
- }
136
147
  const delayMs = Math.floor(Math.random() * 2 ** number * baseDelayMs);
137
148
  if (!canRepeat(Date.now() + delayMs)) {
138
149
  exhausted(error);
@@ -163,8 +174,10 @@ export function idempotent(operation, stub, call, policy = {}) {
163
174
  const hedge = hedgeAfterMs === undefined ? undefined : setTimeout(() => {
164
175
  if (hedge !== undefined)
165
176
  hedges.delete(hedge);
166
- if (canRepeat(Date.now()))
177
+ if (canRepeat(Date.now())) {
178
+ hedged++;
167
179
  attempt();
180
+ }
168
181
  }, hedgeAfterMs);
169
182
  if (hedge !== undefined)
170
183
  hedges.add(hedge);
@@ -193,11 +206,11 @@ export function idempotent(operation, stub, call, policy = {}) {
193
206
  disposeRpcResource(result);
194
207
  return;
195
208
  }
196
- settle(() => resolve(result));
209
+ settle('answered', number, () => resolve(result));
197
210
  };
198
211
  /** Start an attempt. Whatever it throws outside the call itself fails the call. */
199
212
  const attempt = () => {
200
- run().catch((error) => settle(() => reject(error)));
213
+ run().catch((error) => settle('caller_error', undefined, () => reject(error)));
201
214
  };
202
215
  attempt();
203
216
  });