@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 +26 -11
- package/dist/budgets.d.ts +50 -29
- package/dist/budgets.d.ts.map +1 -1
- package/dist/budgets.js +88 -40
- package/dist/do-calls.d.ts +20 -0
- package/dist/do-calls.d.ts.map +1 -1
- package/dist/do-calls.js +24 -11
- package/dist/fanout.d.ts +40 -32
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +46 -50
- 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 +33 -1
- package/dist/isolate-pool.d.ts +31 -23
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +68 -46
- package/dist/process-fabric.d.ts +26 -11
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +44 -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.map +1 -1
- package/dist/workerd-facet-host.js +30 -30
- package/package.json +4 -4
- package/src/budgets.ts +96 -49
- package/src/do-calls.ts +45 -11
- package/src/fanout.ts +60 -53
- package/src/host-wasm.ts +41 -0
- package/src/image-store.ts +29 -2
- package/src/isolate-pool.ts +98 -66
- package/src/process-fabric.ts +55 -13
- package/src/timers.ts +45 -9
- package/src/vendor/types.ts +11 -5
- package/src/workerd-facet-host.ts +32 -31
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
16
16
|
import { StorageLedger, forgetFacetStorage } from '@nimbus-sh/core/runtime/storage-ledger.js';
|
|
17
17
|
import { getCtxExports, stagedBootAssembler, supervisorEntrypoint, supervisorEntrypointName, } from './composition.js';
|
|
18
|
-
import { assertModuleMapWithinCodeLimit, beginLoaderFetch, facetNameCount, facetNameCountDurable, recordFacetNameMinted,
|
|
18
|
+
import { assertModuleMapWithinCodeLimit, beginLoaderFetch, facetNameCount, facetNameCountDurable, recordFacetNameMinted, withDynamicWorkerCapNamed, withFacetBudgetNamed, } from './budgets.js';
|
|
19
19
|
import { RESIDENT_PROCESS_CLASS, residentLoaderConfig, } from './process-fabric.js';
|
|
20
20
|
import { supervisorLoaderKey } from './supervisor-props.js';
|
|
21
21
|
export function getNimbusCtxExports() {
|
|
@@ -87,7 +87,7 @@ export async function cloneStorage(ctx, clone) {
|
|
|
87
87
|
const facets = facetContainer(ctx);
|
|
88
88
|
if (typeof facets.clone !== 'function') {
|
|
89
89
|
throw new Error('Nimbus: ctx.facets.clone is unavailable in this runtime; the reflink image '
|
|
90
|
-
+ 'path needs
|
|
90
|
+
+ 'path needs workerd 1.20260926.1 or later, or deployed Cloudflare workerd');
|
|
91
91
|
}
|
|
92
92
|
const { src, dst } = clone;
|
|
93
93
|
if (!(await clone.populated(src))) {
|
|
@@ -292,10 +292,13 @@ function spawnResident(ctx, env, disk, supervisor, params) {
|
|
|
292
292
|
+ 'it is not restarted');
|
|
293
293
|
}
|
|
294
294
|
evaluated = true;
|
|
295
|
-
return { class: residentProcessClass(
|
|
295
|
+
return { class: residentProcessClass(env, disk, supervisor, params, loaderKey) };
|
|
296
296
|
};
|
|
297
297
|
const book = slotBook(ctx);
|
|
298
298
|
const ledger = sessionLedger(ctx);
|
|
299
|
+
// A warm worker keeps the SUPERVISOR binding it was built with, and the
|
|
300
|
+
// loader outlives this instance.
|
|
301
|
+
const loaderKey = supervisorLoaderKey(params.workerKey, supervisor);
|
|
299
302
|
let facet;
|
|
300
303
|
try {
|
|
301
304
|
// N18: the fill is admitted, and recorded under the facet's name, before
|
|
@@ -315,6 +318,12 @@ function spawnResident(ctx, env, disk, supervisor, params) {
|
|
|
315
318
|
}
|
|
316
319
|
if (explicit)
|
|
317
320
|
book.live.add(name);
|
|
321
|
+
// The facet's worker is one Dynamic Worker in flight for as long as the
|
|
322
|
+
// process is resident, not only while a call is open: its WebSockets and
|
|
323
|
+
// streamed responses outlive the calls the ledger could bracket, and a
|
|
324
|
+
// request can reach it at any moment. Held from here to `release`, so no
|
|
325
|
+
// fan-out spends the slot a running process needs.
|
|
326
|
+
const endResidency = beginLoaderFetch(ctx, loaderKey);
|
|
318
327
|
facetOfPid(ctx).set(params.pid, name);
|
|
319
328
|
let disposed = false;
|
|
320
329
|
const release = async () => {
|
|
@@ -323,6 +332,7 @@ function spawnResident(ctx, env, disk, supervisor, params) {
|
|
|
323
332
|
disposed = true;
|
|
324
333
|
released = true;
|
|
325
334
|
facetOfPid(ctx).delete(params.pid);
|
|
335
|
+
endResidency();
|
|
326
336
|
try {
|
|
327
337
|
facets.abort(name, new Error('Nimbus: resident process released'));
|
|
328
338
|
}
|
|
@@ -396,25 +406,15 @@ function spawnResident(ctx, env, disk, supervisor, params) {
|
|
|
396
406
|
* assembles its module map at most once and the bytes never stay resident in
|
|
397
407
|
* the hosting DO's heap.
|
|
398
408
|
*/
|
|
399
|
-
function residentProcessClass(
|
|
409
|
+
function residentProcessClass(env, disk, supervisor, params, loaderKey) {
|
|
400
410
|
const loader = env.LOADER;
|
|
401
411
|
if (!loader || typeof loader.get !== 'function') {
|
|
402
412
|
throw new Error('Nimbus: env.LOADER binding missing or invalid. Resident processes require '
|
|
403
413
|
+ 'the Worker Loader binding; add it via worker_loaders in wrangler.jsonc.');
|
|
404
414
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
try {
|
|
409
|
-
const worker = loader
|
|
410
|
-
.get(loaderKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
|
|
411
|
-
.getDurableObjectClass(RESIDENT_PROCESS_CLASS);
|
|
412
|
-
recordLoaderId(ctx, loaderKey);
|
|
413
|
-
return worker;
|
|
414
|
-
}
|
|
415
|
-
catch (error) {
|
|
416
|
-
throw withDynamicWorkerCapNamed(ctx, error);
|
|
417
|
-
}
|
|
415
|
+
return loader
|
|
416
|
+
.get(loaderKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
|
|
417
|
+
.getDurableObjectClass(RESIDENT_PROCESS_CLASS);
|
|
418
418
|
}
|
|
419
419
|
async function runOneShot(ctx, env, supervisor, params, consume) {
|
|
420
420
|
const loader = env.LOADER;
|
|
@@ -455,24 +455,24 @@ async function runOneShot(ctx, env, supervisor, params, consume) {
|
|
|
455
455
|
throw new Error('Nimbus: one-shot runtime entrypoint has no fetch method');
|
|
456
456
|
}
|
|
457
457
|
params.onLoaded?.();
|
|
458
|
-
// The unkeyed worker is
|
|
459
|
-
//
|
|
460
|
-
// never wrapped: see beginLoaderFetch for the
|
|
461
|
-
// hazard, and the pipelined-`fetch.call` note above
|
|
462
|
-
|
|
463
|
-
|
|
458
|
+
// The unkeyed worker is one distinct dynamic worker in flight until its
|
|
459
|
+
// response is consumed (the body streams from it), keyed by this run's
|
|
460
|
+
// writer id — bracketed, never wrapped: see beginLoaderFetch for the
|
|
461
|
+
// measured DO-poisoning hazard, and the pipelined-`fetch.call` note above
|
|
462
|
+
// for its sibling.
|
|
463
|
+
const endFetch = beginLoaderFetch(ctx, `one-shot:${params.writerId}`);
|
|
464
464
|
try {
|
|
465
|
-
response = await ep.fetch(params.request);
|
|
465
|
+
const response = await ep.fetch(params.request);
|
|
466
|
+
try {
|
|
467
|
+
return await consume(response);
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
disposeRpcResource(response);
|
|
471
|
+
}
|
|
466
472
|
}
|
|
467
473
|
finally {
|
|
468
474
|
endFetch();
|
|
469
475
|
}
|
|
470
|
-
try {
|
|
471
|
-
return await consume(response);
|
|
472
|
-
}
|
|
473
|
-
finally {
|
|
474
|
-
disposeRpcResource(response);
|
|
475
|
-
}
|
|
476
476
|
}
|
|
477
477
|
catch (error) {
|
|
478
478
|
throw withDynamicWorkerCapNamed(ctx, error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nimbus-sh/fabric",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "The Cloudflare half of Nimbus \u2014 Durable Object facet hosting, dynamic-worker loader pools, the resident-process fabric, and DO alarm/hibernation machinery.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -50,12 +50,12 @@
|
|
|
50
50
|
"typecheck": "tsc --noEmit"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@nimbus-sh/core": "^0.
|
|
54
|
-
"@nimbus-sh/platform": "^0.
|
|
53
|
+
"@nimbus-sh/core": "^0.14.0",
|
|
54
|
+
"@nimbus-sh/platform": "^0.7.0",
|
|
55
55
|
"zod": "^4.4.3"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@cloudflare/workers-types": "^
|
|
58
|
+
"@cloudflare/workers-types": "^5.20260928.1",
|
|
59
59
|
"typescript": "^5.7.0"
|
|
60
60
|
},
|
|
61
61
|
"publishConfig": {
|
package/src/budgets.ts
CHANGED
|
@@ -1,39 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* budgets.ts — per-DO accounting for the platform budgets the fabric spends:
|
|
3
|
-
* 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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
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
|
|
|
28
26
|
import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
|
|
27
|
+
import { hostWasmIdentity } from './host-wasm.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Distinct Dynamic Workers one Durable Object may have with in-flight
|
|
31
|
+
* requests at once, shared across all concurrent requests to that object;
|
|
32
|
+
* multiple in-flight requests to one Dynamic Worker count once.
|
|
33
|
+
* https://developers.cloudflare.com/changelog/post/2026-08-28-durable-objects-dynamic-workers-limit/
|
|
34
|
+
*/
|
|
35
|
+
export const DO_DYNAMIC_WORKER_LIMIT = 10;
|
|
29
36
|
|
|
30
37
|
interface LoaderLedger {
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
/** The most
|
|
36
|
-
|
|
38
|
+
/** Loader id → open holds on it. A key is present only while held. */
|
|
39
|
+
inFlight: Map<string, number>;
|
|
40
|
+
/** Width claimed by fan-outs that have not released it. */
|
|
41
|
+
claimed: number;
|
|
42
|
+
/** The most distinct workers (holds plus claims) ever counted at once. */
|
|
43
|
+
peak: number;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
const ledgers = new WeakMap<object, LoaderLedger>();
|
|
@@ -41,20 +48,21 @@ const ledgers = new WeakMap<object, LoaderLedger>();
|
|
|
41
48
|
function ledger(ctx: object): LoaderLedger {
|
|
42
49
|
let entry = ledgers.get(ctx);
|
|
43
50
|
if (!entry) {
|
|
44
|
-
entry = {
|
|
51
|
+
entry = { inFlight: new Map(), claimed: 0, peak: 0 };
|
|
45
52
|
ledgers.set(ctx, entry);
|
|
46
53
|
}
|
|
47
54
|
return entry;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ledger(ctx).ids.add(id);
|
|
57
|
+
function inUse(entry: LoaderLedger): number {
|
|
58
|
+
return entry.inFlight.size + entry.claimed;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
/**
|
|
56
|
-
*
|
|
57
|
-
* function ends
|
|
62
|
+
* Hold the Dynamic Worker `workerKey` in flight on this actor's ledger; the
|
|
63
|
+
* returned function ends the hold (idempotently), from the caller's own
|
|
64
|
+
* `finally`. Holds on one key nest: the worker counts once until the last
|
|
65
|
+
* one ends, as the platform counts it.
|
|
58
66
|
*
|
|
59
67
|
* A begin/end pair rather than a wrapper on purpose, and the shape is
|
|
60
68
|
* load-bearing: wrapping the stub call in a ledger-owned async frame
|
|
@@ -67,48 +75,82 @@ export function recordLoaderId(ctx: object, id: string): void {
|
|
|
67
75
|
* workers: an RPC stub call must stay a direct property call awaited by the
|
|
68
76
|
* frame that made it, so the ledger only brackets it.
|
|
69
77
|
*/
|
|
70
|
-
export function beginLoaderFetch(ctx: object): () => void {
|
|
78
|
+
export function beginLoaderFetch(ctx: object, workerKey: string): () => void {
|
|
71
79
|
const entry = ledger(ctx);
|
|
72
|
-
entry.
|
|
73
|
-
entry.
|
|
80
|
+
entry.inFlight.set(workerKey, (entry.inFlight.get(workerKey) ?? 0) + 1);
|
|
81
|
+
entry.peak = Math.max(entry.peak, inUse(entry));
|
|
74
82
|
let ended = false;
|
|
75
83
|
return () => {
|
|
76
84
|
if (ended) return;
|
|
77
85
|
ended = true;
|
|
78
|
-
entry.
|
|
86
|
+
const open = (entry.inFlight.get(workerKey) ?? 1) - 1;
|
|
87
|
+
if (open > 0) entry.inFlight.set(workerKey, open);
|
|
88
|
+
else entry.inFlight.delete(workerKey);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Distinct Dynamic Workers this actor may still put in flight: the limit
|
|
94
|
+
* less what is held and claimed right now. Never negative.
|
|
95
|
+
*/
|
|
96
|
+
export function dynamicWorkerHeadroom(ctx: object): number {
|
|
97
|
+
return Math.max(0, DO_DYNAMIC_WORKER_LIMIT - inUse(ledger(ctx)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Claim `width` distinct Dynamic Workers for one fan-out, or null when the
|
|
102
|
+
* headroom cannot hold it. The claim counts until `release` (idempotent), so
|
|
103
|
+
* a second fan-out sizing itself meanwhile sees it; the claimant's own
|
|
104
|
+
* dispatches are held as well while they run, which only ever over-counts
|
|
105
|
+
* toward sending that second fan-out elsewhere.
|
|
106
|
+
*/
|
|
107
|
+
export function claimDynamicWorkers(ctx: object, width: number): { release(): void } | null {
|
|
108
|
+
const entry = ledger(ctx);
|
|
109
|
+
if (width < 1 || width > DO_DYNAMIC_WORKER_LIMIT - inUse(entry)) return null;
|
|
110
|
+
entry.claimed += width;
|
|
111
|
+
entry.peak = Math.max(entry.peak, inUse(entry));
|
|
112
|
+
let released = false;
|
|
113
|
+
return {
|
|
114
|
+
release() {
|
|
115
|
+
if (released) return;
|
|
116
|
+
released = true;
|
|
117
|
+
entry.claimed -= width;
|
|
118
|
+
},
|
|
79
119
|
};
|
|
80
120
|
}
|
|
81
121
|
|
|
82
122
|
/** Snapshot for the diag surface. Pure read; no I/O. */
|
|
83
123
|
export function loaderLedgerStats(ctx: object): {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
124
|
+
limit: number;
|
|
125
|
+
inFlightWorkers: string[];
|
|
126
|
+
claimed: number;
|
|
127
|
+
headroom: number;
|
|
128
|
+
peak: number;
|
|
87
129
|
} {
|
|
88
130
|
const entry = ledger(ctx);
|
|
89
131
|
return {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
132
|
+
limit: DO_DYNAMIC_WORKER_LIMIT,
|
|
133
|
+
inFlightWorkers: [...entry.inFlight.keys()],
|
|
134
|
+
claimed: entry.claimed,
|
|
135
|
+
headroom: dynamicWorkerHeadroom(ctx),
|
|
136
|
+
peak: entry.peak,
|
|
93
137
|
};
|
|
94
138
|
}
|
|
95
139
|
|
|
96
140
|
/**
|
|
97
|
-
* Name the per-DO accounting on a "
|
|
141
|
+
* Name the per-DO accounting on a "Dynamic worker concurrency limit exceeded"
|
|
98
142
|
* failure; hand every other error back untouched. The platform's message
|
|
99
|
-
* says only that the
|
|
100
|
-
*
|
|
101
|
-
* shrink anything.
|
|
143
|
+
* says only that the limit was hit — which workers were in flight, and what
|
|
144
|
+
* fan-outs had claimed, is what the operator needs to know to shrink anything.
|
|
102
145
|
*/
|
|
103
146
|
export function withDynamicWorkerCapNamed<E>(ctx: object, error: E): E | Error {
|
|
104
147
|
if (classifyError(error) !== 'dynamic_worker_cap') return error;
|
|
105
148
|
const entry = ledger(ctx);
|
|
106
149
|
const platform = error instanceof Error ? error.message : String(error);
|
|
107
150
|
return new Error(
|
|
108
|
-
`${platform} — this Durable Object
|
|
109
|
-
+ `
|
|
110
|
-
+
|
|
111
|
-
+ `peak ${entry.peakLiveFetches}`,
|
|
151
|
+
`${platform} — this Durable Object had ${entry.inFlight.size} distinct dynamic worker(s) in flight `
|
|
152
|
+
+ `(${[...entry.inFlight.keys()].join(', ') || 'none recorded'}) and ${entry.claimed} claimed by fan-outs, `
|
|
153
|
+
+ `against a limit of ${DO_DYNAMIC_WORKER_LIMIT}; peak ${entry.peak}`,
|
|
112
154
|
{ cause: error },
|
|
113
155
|
);
|
|
114
156
|
}
|
|
@@ -163,18 +205,23 @@ export function assertModuleMapWithinCodeLimit(modules: Record<string, unknown>)
|
|
|
163
205
|
|
|
164
206
|
/**
|
|
165
207
|
* Bytes one module-map member carries, across the loader's content kinds
|
|
166
|
-
* (plain string, `{ js | cjs | py | text }`, `{ wasm | data }
|
|
167
|
-
* encoder, text is measured exactly; without
|
|
208
|
+
* (plain string, `{ js | cjs | py | text }`, `{ wasm | data }`, a bare
|
|
209
|
+
* WebAssembly.Module). With an encoder, text is measured exactly; without
|
|
210
|
+
* one, by code-unit length. A compiled module counts the wire size its host
|
|
211
|
+
* described (host-wasm.ts); one nobody described counts nothing here and is
|
|
212
|
+
* left to the platform's own refusal, as the text undercount is.
|
|
168
213
|
*/
|
|
169
214
|
function memberBytes(content: unknown, encoder: TextEncoder | null): number {
|
|
170
215
|
const textBytes = (text: string): number =>
|
|
171
216
|
encoder ? encoder.encode(text).byteLength : text.length;
|
|
172
217
|
if (typeof content === 'string') return textBytes(content);
|
|
218
|
+
if (content instanceof WebAssembly.Module) return hostWasmIdentity(content)?.bytes ?? 0;
|
|
173
219
|
if (content !== null && typeof content === 'object') {
|
|
174
220
|
for (const value of Object.values(content)) {
|
|
175
221
|
if (typeof value === 'string') return textBytes(value);
|
|
176
222
|
if (value instanceof ArrayBuffer) return value.byteLength;
|
|
177
223
|
if (ArrayBuffer.isView(value)) return value.byteLength;
|
|
224
|
+
if (value instanceof WebAssembly.Module) return hostWasmIdentity(value)?.bytes ?? 0;
|
|
178
225
|
}
|
|
179
226
|
}
|
|
180
227
|
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()))
|
|
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();
|