@nimbus-sh/fabric 0.8.0 → 0.10.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 (51) hide show
  1. package/README.md +98 -11
  2. package/dist/bindings.d.ts +31 -33
  3. package/dist/bindings.d.ts.map +1 -1
  4. package/dist/bindings.js +108 -97
  5. package/dist/budgets.d.ts +102 -29
  6. package/dist/budgets.d.ts.map +1 -1
  7. package/dist/budgets.js +266 -44
  8. package/dist/do-calls.d.ts +20 -0
  9. package/dist/do-calls.d.ts.map +1 -1
  10. package/dist/do-calls.js +24 -11
  11. package/dist/fanout.d.ts +40 -32
  12. package/dist/fanout.d.ts.map +1 -1
  13. package/dist/fanout.js +48 -51
  14. package/dist/fenced-work.d.ts +3 -3
  15. package/dist/fenced-work.js +3 -3
  16. package/dist/host-wasm.d.ts +29 -0
  17. package/dist/host-wasm.d.ts.map +1 -0
  18. package/dist/host-wasm.js +31 -0
  19. package/dist/image-store.d.ts +1 -1
  20. package/dist/image-store.d.ts.map +1 -1
  21. package/dist/image-store.js +33 -1
  22. package/dist/inner-do-env.d.ts +83 -0
  23. package/dist/inner-do-env.d.ts.map +1 -0
  24. package/dist/inner-do-env.js +181 -0
  25. package/dist/isolate-pool.d.ts +40 -23
  26. package/dist/isolate-pool.d.ts.map +1 -1
  27. package/dist/isolate-pool.js +105 -55
  28. package/dist/process-fabric.d.ts +26 -11
  29. package/dist/process-fabric.d.ts.map +1 -1
  30. package/dist/process-fabric.js +44 -0
  31. package/dist/timers.d.ts +12 -0
  32. package/dist/timers.d.ts.map +1 -1
  33. package/dist/timers.js +44 -9
  34. package/dist/vendor/types.d.ts +11 -5
  35. package/dist/vendor/types.d.ts.map +1 -1
  36. package/dist/workerd-facet-host.d.ts.map +1 -1
  37. package/dist/workerd-facet-host.js +35 -30
  38. package/package.json +4 -4
  39. package/src/bindings.ts +121 -98
  40. package/src/budgets.ts +311 -53
  41. package/src/do-calls.ts +45 -11
  42. package/src/fanout.ts +62 -53
  43. package/src/fenced-work.ts +3 -3
  44. package/src/host-wasm.ts +41 -0
  45. package/src/image-store.ts +29 -2
  46. package/src/inner-do-env.ts +213 -0
  47. package/src/isolate-pool.ts +145 -75
  48. package/src/process-fabric.ts +55 -13
  49. package/src/timers.ts +45 -9
  50. package/src/vendor/types.ts +11 -5
  51. package/src/workerd-facet-host.ts +36 -31
package/src/budgets.ts CHANGED
@@ -1,60 +1,215 @@
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. Work that would rather wait
19
+ * than be refused waits on the ledger ({@link beginLoaderFetchWhenFree}) and
20
+ * is let in, in the order it asked, by whichever release makes room.
21
21
  *
22
22
  * 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
23
+ * limit is per Durable Object, and dynamic workers die with the isolate that
24
24
  * loaded them, so a ledger that goes away with its host describes nothing
25
25
  * that still exists.
26
26
  */
27
27
 
28
28
  import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
29
+ import { hostWasmIdentity } from './host-wasm.js';
30
+
31
+ /**
32
+ * Distinct Dynamic Workers one Durable Object may have with in-flight
33
+ * requests at once, shared across all concurrent requests to that object;
34
+ * multiple in-flight requests to one Dynamic Worker count once.
35
+ * https://developers.cloudflare.com/changelog/post/2026-08-28-durable-objects-dynamic-workers-limit/
36
+ */
37
+ export const DO_DYNAMIC_WORKER_LIMIT = 10;
38
+
39
+ /**
40
+ * Ends one hold, idempotently. Pass the error the call failed with, if it
41
+ * did: a "Dynamic worker concurrency limit exceeded" refusal pauses the
42
+ * ledger's admissions (see {@link beginLoaderFetchWhenFree}); anything else,
43
+ * or nothing, just ends the hold.
44
+ */
45
+ export type EndLoaderFetch = (failure?: unknown) => void;
46
+
47
+ /**
48
+ * A width one fan-out reserved with {@link claimDynamicWorkers}. Holds taken
49
+ * under it (`beginLoaderFetch(ctx, key, claim)`) count inside that width, not
50
+ * on top of it, until `release` (idempotent).
51
+ */
52
+ export interface DynamicWorkerClaim {
53
+ release(): void;
54
+ }
55
+
56
+ interface ClaimEntry {
57
+ width: number;
58
+ /** Loader id → open holds taken under this claim. */
59
+ keys: Map<string, number>;
60
+ }
61
+
62
+ interface Waiter {
63
+ key: string;
64
+ claim: ClaimEntry | undefined;
65
+ admit(end: EndLoaderFetch): void;
66
+ }
29
67
 
30
68
  interface LoaderLedger {
31
- /** Distinct loader ids ever gotten — each one a permanently consumed slot. */
32
- ids: Set<string>;
33
- /** In-flight calls into dynamic workers, right now. */
34
- liveFetches: number;
35
- /** The most that were ever in flight at once. */
36
- peakLiveFetches: number;
69
+ /** Loader id → open holds on it, under a claim or not. A key is present only while held. */
70
+ inFlight: Map<string, number>;
71
+ /** Claims not yet released. */
72
+ claims: Set<ClaimEntry>;
73
+ /** The most distinct workers (holds plus claims) ever counted at once. */
74
+ peak: number;
75
+ /** Waits not yet admitted, in the order they asked. */
76
+ waiters: Waiter[];
77
+ /** Length of the pause a refusal started, while it lasts; 0 when admitting. */
78
+ pauseMs: number;
79
+ pauseTimer: ReturnType<typeof setTimeout> | undefined;
80
+ /** Advanced when a pause starts and when it ends; a hold keeps the one it began in. */
81
+ epoch: number;
82
+ /** Pauses in a row with no worker admitted between them. */
83
+ refusals: number;
37
84
  }
38
85
 
86
+ /**
87
+ * The first pause after a limit refusal, doubling while refusals continue,
88
+ * up to {@link REFUSAL_PAUSE_MAX_MS}. A deployed Durable Object admitted a
89
+ * batch it had refused after a 6 s pause.
90
+ */
91
+ const REFUSAL_PAUSE_MS = 50;
92
+ const REFUSAL_PAUSE_MAX_MS = 2_000;
93
+
39
94
  const ledgers = new WeakMap<object, LoaderLedger>();
95
+ const claimEntries = new WeakMap<DynamicWorkerClaim, { ledger: LoaderLedger; entry: ClaimEntry }>();
40
96
 
41
97
  function ledger(ctx: object): LoaderLedger {
42
98
  let entry = ledgers.get(ctx);
43
99
  if (!entry) {
44
- entry = { ids: new Set(), liveFetches: 0, peakLiveFetches: 0 };
100
+ entry = {
101
+ inFlight: new Map(), claims: new Set(), peak: 0,
102
+ waiters: [], pauseMs: 0, pauseTimer: undefined, epoch: 0, refusals: 0,
103
+ };
45
104
  ledgers.set(ctx, entry);
46
105
  }
47
106
  return entry;
48
107
  }
49
108
 
50
- /** Record a keyed `loader.get(id)` — a permanent slot if the id is new. */
51
- export function recordLoaderId(ctx: object, id: string): void {
52
- ledger(ctx).ids.add(id);
109
+ /** Distinct workers counted: each claim's width (or more, if its holds exceed it), plus held keys no claim covers. */
110
+ function inUse(entry: LoaderLedger): number {
111
+ let count = 0;
112
+ const covered = new Set<string>();
113
+ for (const claim of entry.claims) {
114
+ count += Math.max(claim.width, claim.keys.size);
115
+ for (const key of claim.keys.keys()) covered.add(key);
116
+ }
117
+ for (const key of entry.inFlight.keys()) if (!covered.has(key)) count++;
118
+ return count;
119
+ }
120
+
121
+ function headroom(entry: LoaderLedger): number {
122
+ return entry.pauseMs > 0 ? 0 : Math.max(0, DO_DYNAMIC_WORKER_LIMIT - inUse(entry));
123
+ }
124
+
125
+ function claimOf(ctx: object, claim: DynamicWorkerClaim | undefined): ClaimEntry | undefined {
126
+ if (claim === undefined) return undefined;
127
+ const owned = claimEntries.get(claim);
128
+ if (owned === undefined || owned.ledger !== ledger(ctx)) {
129
+ throw new Error('Nimbus: a Dynamic Worker claim is used only on the ledger of the actor that claimed it');
130
+ }
131
+ return owned.ledger.claims.has(owned.entry) ? owned.entry : undefined;
132
+ }
133
+
134
+ function count(map: Map<string, number>, key: string, by: 1 | -1): void {
135
+ const open = (map.get(key) ?? 0) + by;
136
+ if (open > 0) map.set(key, open);
137
+ else map.delete(key);
138
+ }
139
+
140
+ /** Take one hold; the caller admits waiters after. */
141
+ function hold(entry: LoaderLedger, workerKey: string, claim: ClaimEntry | undefined): EndLoaderFetch {
142
+ count(entry.inFlight, workerKey, 1);
143
+ if (claim) count(claim.keys, workerKey, 1);
144
+ entry.peak = Math.max(entry.peak, inUse(entry));
145
+ const epoch = entry.epoch;
146
+ let ended = false;
147
+ return (failure) => {
148
+ if (ended) return;
149
+ ended = true;
150
+ count(entry.inFlight, workerKey, -1);
151
+ if (claim) count(claim.keys, workerKey, -1);
152
+ if (classifyError(failure) === 'dynamic_worker_cap') refused(entry, epoch);
153
+ else if (epoch === entry.epoch && entry.pauseMs === 0) entry.refusals = 0;
154
+ admitWaiters(entry);
155
+ };
53
156
  }
54
157
 
55
158
  /**
56
- * Count one call into a dynamic worker as a live Loader fetch; the returned
57
- * function ends it (idempotently), from the caller's own `finally`.
159
+ * The platform refused a worker this ledger counted room for: it still
160
+ * counts workers the ledger has released, which no release here can show.
161
+ * So nothing new is admitted until a pause has passed. A refusal of a call
162
+ * that began before the latest pause started or ended is the same lag and
163
+ * changes nothing; one of a call let in after it doubles the next pause.
164
+ */
165
+ function refused(entry: LoaderLedger, epoch: number): void {
166
+ if (epoch !== entry.epoch) return;
167
+ if (entry.pauseTimer !== undefined) clearTimeout(entry.pauseTimer);
168
+ entry.pauseMs = Math.min(REFUSAL_PAUSE_MAX_MS, REFUSAL_PAUSE_MS * 2 ** entry.refusals);
169
+ entry.refusals++;
170
+ entry.epoch++;
171
+ entry.pauseTimer = setTimeout(() => {
172
+ entry.pauseMs = 0;
173
+ entry.pauseTimer = undefined;
174
+ entry.epoch++;
175
+ admitWaiters(entry);
176
+ }, entry.pauseMs);
177
+ }
178
+
179
+ function admissible(entry: LoaderLedger, waiter: Waiter): boolean {
180
+ // Requests to a worker already in flight count once, even while paused.
181
+ if (entry.inFlight.has(waiter.key)) return true;
182
+ if (entry.pauseMs > 0) return false;
183
+ if (waiter.claim && entry.claims.has(waiter.claim) && waiter.claim.keys.size < waiter.claim.width) return true;
184
+ return inUse(entry) < DO_DYNAMIC_WORKER_LIMIT;
185
+ }
186
+
187
+ /**
188
+ * Let in every waiter that fits, in the order they asked: each takes its
189
+ * hold here, so a freed slot goes to exactly one waiter and is never left
190
+ * between a wake and a begin. Run after every change that can make room.
191
+ * A waiter let in on a new key lets in the later ones on that key, and the
192
+ * earlier ones too: the scan starts over.
193
+ */
194
+ function admitWaiters(entry: LoaderLedger): void {
195
+ for (let i = 0; i < entry.waiters.length;) {
196
+ const waiter = entry.waiters[i];
197
+ if (!admissible(entry, waiter)) { i++; continue; }
198
+ const joins = entry.inFlight.has(waiter.key);
199
+ entry.waiters.splice(i, 1);
200
+ waiter.admit(hold(entry, waiter.key, waiter.claim));
201
+ if (!joins) i = 0;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Hold the Dynamic Worker `workerKey` in flight on this actor's ledger; the
207
+ * returned function ends the hold (idempotently), from the caller's own
208
+ * `finally`. Holds on one key nest: the worker counts once until the last
209
+ * one ends, as the platform counts it. Under a `claim`, the hold counts
210
+ * inside the claim's width. This never waits: it is for work the actor
211
+ * starts regardless (a resident process); {@link beginLoaderFetchWhenFree}
212
+ * waits for room.
58
213
  *
59
214
  * A begin/end pair rather than a wrapper on purpose, and the shape is
60
215
  * load-bearing: wrapping the stub call in a ledger-owned async frame
@@ -67,48 +222,146 @@ export function recordLoaderId(ctx: object, id: string): void {
67
222
  * workers: an RPC stub call must stay a direct property call awaited by the
68
223
  * frame that made it, so the ledger only brackets it.
69
224
  */
70
- export function beginLoaderFetch(ctx: object): () => void {
225
+ export function beginLoaderFetch(ctx: object, workerKey: string, claim?: DynamicWorkerClaim): EndLoaderFetch {
71
226
  const entry = ledger(ctx);
72
- entry.liveFetches++;
73
- entry.peakLiveFetches = Math.max(entry.peakLiveFetches, entry.liveFetches);
74
- let ended = false;
75
- return () => {
76
- if (ended) return;
77
- ended = true;
78
- entry.liveFetches--;
227
+ const end = hold(entry, workerKey, claimOf(ctx, claim));
228
+ admitWaiters(entry);
229
+ return end;
230
+ }
231
+
232
+ /**
233
+ * {@link beginLoaderFetch} once the ledger has room: resolves, holding
234
+ * `workerKey`, as soon as that worker is already in flight (holds on it
235
+ * count once) or a distinct worker more fits — within the `claim`'s width,
236
+ * or the actor's headroom. Waits are let in in the order they asked, by
237
+ * whoever's release makes the room: a hold's end, a claim's release, a
238
+ * pause's end. The hold is taken as the wait is let in, so a freed slot
239
+ * wakes one waiter and no other caller can take it first; once resolved, it
240
+ * is the caller's to end.
241
+ *
242
+ * A call refused with "Dynamic worker concurrency limit exceeded" ends its
243
+ * hold with the refusal (`end(error)`) and waits again: the refusal pauses
244
+ * admission (50 ms, doubling to 2 s while refusals continue), because the
245
+ * platform counts a worker for a moment after its call returns and no
246
+ * release can show that.
247
+ *
248
+ * `signal` abandons the wait: it rejects with the signal's reason and holds
249
+ * nothing. A wait outlives nothing on its own: bound it with a signal when
250
+ * room may never come (a resident process holds its worker for as long as
251
+ * it runs).
252
+ *
253
+ * const end = await beginLoaderFetchWhenFree(ctx, key, { signal });
254
+ * try { return await worker.getEntrypoint().run(); }
255
+ * catch (error) { end(error); throw error; }
256
+ * finally { end(); }
257
+ */
258
+ export function beginLoaderFetchWhenFree(
259
+ ctx: object,
260
+ workerKey: string,
261
+ options: { signal?: AbortSignal; claim?: DynamicWorkerClaim } = {},
262
+ ): Promise<EndLoaderFetch> {
263
+ const { signal } = options;
264
+ return new Promise<EndLoaderFetch>((resolve, reject) => {
265
+ if (signal?.aborted) {
266
+ reject(signal.reason);
267
+ return;
268
+ }
269
+ const entry = ledger(ctx);
270
+ const abandon = () => {
271
+ const at = entry.waiters.indexOf(waiter);
272
+ if (at < 0) return;
273
+ entry.waiters.splice(at, 1);
274
+ reject(signal?.reason);
275
+ };
276
+ const waiter: Waiter = {
277
+ key: workerKey,
278
+ claim: claimOf(ctx, options.claim),
279
+ admit(end) {
280
+ signal?.removeEventListener('abort', abandon);
281
+ resolve(end);
282
+ },
283
+ };
284
+ entry.waiters.push(waiter);
285
+ signal?.addEventListener('abort', abandon, { once: true });
286
+ admitWaiters(entry);
287
+ });
288
+ }
289
+
290
+ /**
291
+ * Distinct Dynamic Workers this actor may still put in flight: the limit
292
+ * less what is held and claimed right now, and none while a limit refusal's
293
+ * pause lasts. Never negative.
294
+ */
295
+ export function dynamicWorkerHeadroom(ctx: object): number {
296
+ return headroom(ledger(ctx));
297
+ }
298
+
299
+ /**
300
+ * Claim `width` distinct Dynamic Workers for one fan-out, or null when the
301
+ * headroom cannot hold it. The claim counts until `release` (idempotent), so
302
+ * a second fan-out sizing itself meanwhile sees it; the claimant's own
303
+ * dispatches, held under the claim, count inside it.
304
+ */
305
+ export function claimDynamicWorkers(ctx: object, width: number): DynamicWorkerClaim | null {
306
+ const entry = ledger(ctx);
307
+ if (width < 1 || width > headroom(entry)) return null;
308
+ const claim: ClaimEntry = { width, keys: new Map() };
309
+ entry.claims.add(claim);
310
+ entry.peak = Math.max(entry.peak, inUse(entry));
311
+ const handle: DynamicWorkerClaim = {
312
+ release() {
313
+ if (!entry.claims.delete(claim)) return;
314
+ admitWaiters(entry);
315
+ },
79
316
  };
317
+ claimEntries.set(handle, { ledger: entry, entry: claim });
318
+ return handle;
80
319
  }
81
320
 
82
321
  /** Snapshot for the diag surface. Pure read; no I/O. */
83
322
  export function loaderLedgerStats(ctx: object): {
84
- idsEverGotten: string[];
85
- liveFetches: number;
86
- peakLiveFetches: number;
323
+ limit: number;
324
+ inFlightWorkers: string[];
325
+ claimed: number;
326
+ headroom: number;
327
+ peak: number;
328
+ /** Waits not yet admitted. */
329
+ waiting: number;
330
+ /** Length of the pause a limit refusal started, while it lasts; 0 when admitting. */
331
+ pauseMs: number;
87
332
  } {
88
333
  const entry = ledger(ctx);
89
334
  return {
90
- idsEverGotten: [...entry.ids],
91
- liveFetches: entry.liveFetches,
92
- peakLiveFetches: entry.peakLiveFetches,
335
+ limit: DO_DYNAMIC_WORKER_LIMIT,
336
+ inFlightWorkers: [...entry.inFlight.keys()],
337
+ claimed: claimedWidth(entry),
338
+ headroom: headroom(entry),
339
+ peak: entry.peak,
340
+ waiting: entry.waiters.length,
341
+ pauseMs: entry.pauseMs,
93
342
  };
94
343
  }
95
344
 
345
+ function claimedWidth(entry: LoaderLedger): number {
346
+ let width = 0;
347
+ for (const claim of entry.claims) width += claim.width;
348
+ return width;
349
+ }
350
+
96
351
  /**
97
- * Name the per-DO accounting on a "Too many concurrent dynamic workers"
352
+ * Name the per-DO accounting on a "Dynamic worker concurrency limit exceeded"
98
353
  * failure; hand every other error back untouched. The platform's message
99
- * says only that the cap was hit — which ids hold the slots, and that a
100
- * keyed id can never give one back, is what the operator needs to know to
101
- * shrink anything.
354
+ * says only that the limit was hit — which workers were in flight, and what
355
+ * fan-outs had claimed, is what the operator needs to know to shrink anything.
102
356
  */
103
357
  export function withDynamicWorkerCapNamed<E>(ctx: object, error: E): E | Error {
104
358
  if (classifyError(error) !== 'dynamic_worker_cap') return error;
105
359
  const entry = ledger(ctx);
106
360
  const platform = error instanceof Error ? error.message : String(error);
107
361
  return new Error(
108
- `${platform} — this Durable Object has ${entry.ids.size} loader id(s) permanently `
109
- + `holding dynamic-worker slots (a loader.get id is never released): `
110
- + `${[...entry.ids].join(', ') || '(none recorded)'}; live Loader fetches ${entry.liveFetches}, `
111
- + `peak ${entry.peakLiveFetches}`,
362
+ `${platform} — this Durable Object had ${entry.inFlight.size} distinct dynamic worker(s) in flight `
363
+ + `(${[...entry.inFlight.keys()].join(', ') || 'none recorded'}) and ${claimedWidth(entry)} claimed by fan-outs, `
364
+ + `against a limit of ${DO_DYNAMIC_WORKER_LIMIT}; peak ${entry.peak}`,
112
365
  { cause: error },
113
366
  );
114
367
  }
@@ -163,18 +416,23 @@ export function assertModuleMapWithinCodeLimit(modules: Record<string, unknown>)
163
416
 
164
417
  /**
165
418
  * Bytes one module-map member carries, across the loader's content kinds
166
- * (plain string, `{ js | cjs | py | text }`, `{ wasm | data }`). With an
167
- * encoder, text is measured exactly; without one, by code-unit length.
419
+ * (plain string, `{ js | cjs | py | text }`, `{ wasm | data }`, a bare
420
+ * WebAssembly.Module). With an encoder, text is measured exactly; without
421
+ * one, by code-unit length. A compiled module counts the wire size its host
422
+ * described (host-wasm.ts); one nobody described counts nothing here and is
423
+ * left to the platform's own refusal, as the text undercount is.
168
424
  */
169
425
  function memberBytes(content: unknown, encoder: TextEncoder | null): number {
170
426
  const textBytes = (text: string): number =>
171
427
  encoder ? encoder.encode(text).byteLength : text.length;
172
428
  if (typeof content === 'string') return textBytes(content);
429
+ if (content instanceof WebAssembly.Module) return hostWasmIdentity(content)?.bytes ?? 0;
173
430
  if (content !== null && typeof content === 'object') {
174
431
  for (const value of Object.values(content)) {
175
432
  if (typeof value === 'string') return textBytes(value);
176
433
  if (value instanceof ArrayBuffer) return value.byteLength;
177
434
  if (ArrayBuffer.isView(value)) return value.byteLength;
435
+ if (value instanceof WebAssembly.Module) return hostWasmIdentity(value)?.bytes ?? 0;
178
436
  }
179
437
  }
180
438
  return 0;
package/src/do-calls.ts CHANGED
@@ -42,6 +42,7 @@
42
42
 
43
43
  import { classifyDoCall, isRetryableDoCall, type DoCallClass } from '@nimbus-sh/platform/oom-classify.js';
44
44
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
45
+ import { untraced, type SpanRecorder } from '@nimbus-sh/platform/tracing.js';
45
46
 
46
47
  /** Total attempts. Two retries is what a dropped connection or a deploy
47
48
  * bounce needs; beyond that the object is not coming back inside this
@@ -88,8 +89,28 @@ export interface DoCallRetryPolicy {
88
89
  * its error.
89
90
  */
90
91
  onRetry?(info: DoCallRetryInfo): void;
92
+ /**
93
+ * Where the call's telemetry goes: its span's recorder. Each attempt lost
94
+ * to a transient or overloaded failure is recorded as an exception whose
95
+ * `code` is the failure's class, and once the call has settled it gets
96
+ * `do_call.attempts` (started), `do_call.hedges` (started by a hedge),
97
+ * `do_call.answered_by` (the attempt whose answer the call took, absent
98
+ * when none answered) and `do_call.outcome` ({@link DoCallOutcome}).
99
+ * Nothing recorded can change the call's answer. Records nothing when
100
+ * absent.
101
+ */
102
+ span?: SpanRecorder;
91
103
  }
92
104
 
105
+ /**
106
+ * How an `idempotent` call ended: `answered` (an attempt succeeded),
107
+ * `callee_error` (the callee's own failure, which is an answer),
108
+ * `exhausted` (the last transient failure, no repeat left), `overloaded`
109
+ * (shed, nothing repeated after it), or `caller_error` (the resolver or
110
+ * `onRetry` threw).
111
+ */
112
+ export type DoCallOutcome = 'answered' | 'callee_error' | 'exhausted' | 'overloaded' | 'caller_error';
113
+
93
114
  /** What one retry is answering: which call, which platform class, which
94
115
  * attempt just failed out of how many. */
95
116
  export interface DoCallRetryInfo {
@@ -161,7 +182,7 @@ export function idempotent<S, T>(
161
182
  ): Promise<T> {
162
183
  const maxAttempts = policy.maxAttempts ?? MAX_ATTEMPTS;
163
184
  const baseDelayMs = policy.baseDelayMs ?? BASE_DELAY_MS;
164
- const { hedgeAfterMs, retryWindowMs } = policy;
185
+ const { hedgeAfterMs, retryWindowMs, span = untraced } = policy;
165
186
  const startedAt = Date.now();
166
187
  // The executor form: fabric's library target predates Promise.withResolvers.
167
188
  return new Promise<T>((resolve, reject) => {
@@ -173,13 +194,21 @@ export function idempotent<S, T>(
173
194
  // An attempt was shed as overloaded: nothing is repeated after it.
174
195
  let refused = false;
175
196
  let settled = false;
197
+ // Attempts a hedge started rather than a retry or the first send.
198
+ let hedged = 0;
176
199
 
177
- const settle = (answer: () => void): void => {
200
+ const settle = (outcome: DoCallOutcome, answeredBy: number | undefined, answer: () => void): void => {
178
201
  if (settled) return;
179
202
  settled = true;
180
203
  for (const timer of hedges) clearTimeout(timer);
181
204
  hedges.clear();
182
205
  answer();
206
+ span.set({
207
+ 'do_call.attempts': started,
208
+ 'do_call.hedges': hedged,
209
+ 'do_call.answered_by': answeredBy,
210
+ 'do_call.outcome': outcome,
211
+ });
183
212
  };
184
213
  /** May another attempt start at `at`? */
185
214
  const canRepeat = (at: number): boolean =>
@@ -187,13 +216,20 @@ export function idempotent<S, T>(
187
216
  && (retryWindowMs === undefined || at - startedAt <= retryWindowMs);
188
217
  /** `error` ended an attempt that will not be repeated: the call's answer, once nothing else is live. */
189
218
  const exhausted = <E>(error: E): void => {
190
- if (live === 0) settle(() => reject(error));
219
+ if (live === 0) settle(refused ? 'overloaded' : 'exhausted', undefined, () => reject(error));
191
220
  };
192
221
 
193
222
  /** A failed attempt, numbered: repeat it after its backoff, or let it stand. */
194
223
  const failed = async <E>(number: number, error: E): Promise<void> => {
195
224
  if (settled) return;
196
225
  const classification = classifyDoCall(error);
226
+ if (!isRetryableDoCall(classification) && classification !== 'overloaded') {
227
+ // The call ran and its answer is this error — ENOENT is a read's answer as much as bytes are.
228
+ settle('callee_error', number, () => reject(error));
229
+ return;
230
+ }
231
+ // A lost attempt: the call's span says which, and why.
232
+ span.exception(error, classification, `attempt ${number} of ${maxAttempts}: `);
197
233
  if (classification === 'overloaded') {
198
234
  // A shed call is no answer: nothing more is sent, and an attempt
199
235
  // still in flight may yet answer.
@@ -201,11 +237,6 @@ export function idempotent<S, T>(
201
237
  exhausted(error);
202
238
  return;
203
239
  }
204
- if (!isRetryableDoCall(classification)) {
205
- // The call ran and its answer is this error — ENOENT is a read's answer as much as bytes are.
206
- settle(() => reject(error));
207
- return;
208
- }
209
240
  const delayMs = Math.floor(Math.random() * 2 ** number * baseDelayMs);
210
241
  if (!canRepeat(Date.now() + delayMs)) {
211
242
  exhausted(error);
@@ -236,7 +267,10 @@ export function idempotent<S, T>(
236
267
  }
237
268
  const hedge = hedgeAfterMs === undefined ? undefined : setTimeout(() => {
238
269
  if (hedge !== undefined) hedges.delete(hedge);
239
- if (canRepeat(Date.now())) attempt();
270
+ if (canRepeat(Date.now())) {
271
+ hedged++;
272
+ attempt();
273
+ }
240
274
  }, hedgeAfterMs);
241
275
  if (hedge !== undefined) hedges.add(hedge);
242
276
  /** This attempt has its answer: it hedges no more, and its stub goes. */
@@ -263,12 +297,12 @@ export function idempotent<S, T>(
263
297
  disposeRpcResource(result);
264
298
  return;
265
299
  }
266
- settle(() => resolve(result));
300
+ settle('answered', number, () => resolve(result));
267
301
  };
268
302
 
269
303
  /** Start an attempt. Whatever it throws outside the call itself fails the call. */
270
304
  const attempt = (): void => {
271
- run().catch((error) => settle(() => reject(error)));
305
+ run().catch((error) => settle('caller_error', undefined, () => reject(error)));
272
306
  };
273
307
 
274
308
  attempt();