@nimbus-sh/fabric 0.9.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.
- package/README.md +72 -0
- package/dist/bindings.d.ts +31 -33
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +108 -97
- package/dist/budgets.d.ts +61 -9
- package/dist/budgets.d.ts.map +1 -1
- package/dist/budgets.js +205 -31
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +4 -3
- package/dist/fenced-work.d.ts +3 -3
- package/dist/fenced-work.js +3 -3
- package/dist/inner-do-env.d.ts +83 -0
- package/dist/inner-do-env.d.ts.map +1 -0
- package/dist/inner-do-env.js +181 -0
- package/dist/isolate-pool.d.ts +9 -0
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +55 -27
- package/dist/workerd-facet-host.d.ts.map +1 -1
- package/dist/workerd-facet-host.js +5 -0
- package/package.json +2 -2
- package/src/bindings.ts +121 -98
- package/src/budgets.ts +244 -33
- package/src/fanout.ts +5 -3
- package/src/fenced-work.ts +3 -3
- package/src/inner-do-env.ts +213 -0
- package/src/isolate-pool.ts +65 -27
- package/src/workerd-facet-host.ts +4 -0
package/src/isolate-pool.ts
CHANGED
|
@@ -29,7 +29,13 @@ import { supervisorEntrypoint, type HostRoute } from './composition.js';
|
|
|
29
29
|
import { supervisorBindingProps, supervisorLoaderKey } from './supervisor-props.js';
|
|
30
30
|
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
31
31
|
import { serializeFunction, hashSource } from './vendor/serialize.js';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
beginLoaderFetch,
|
|
34
|
+
beginLoaderFetchWhenFree,
|
|
35
|
+
withDynamicWorkerCapNamed,
|
|
36
|
+
type DynamicWorkerClaim,
|
|
37
|
+
type EndLoaderFetch,
|
|
38
|
+
} from './budgets.js';
|
|
33
39
|
import { assertModuleMapWithinCodeLimit } from './budgets.js';
|
|
34
40
|
import { recordFailure, setLastFacetId, getLastRpcFrame } from '@nimbus-sh/platform/oom-discriminator.js';
|
|
35
41
|
import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
|
|
@@ -69,6 +75,12 @@ export interface IsolatePoolOptions {
|
|
|
69
75
|
* caller that wants more sizes it against that budget (Fanout does).
|
|
70
76
|
*/
|
|
71
77
|
concurrency?: number;
|
|
78
|
+
/**
|
|
79
|
+
* The width the caller claimed for this pool's dispatches
|
|
80
|
+
* (`claimDynamicWorkers`): they are held inside it rather than on top of
|
|
81
|
+
* it, and a refused one waits for a slot of the claim.
|
|
82
|
+
*/
|
|
83
|
+
claim?: DynamicWorkerClaim;
|
|
72
84
|
/** Per-task timeout in ms. Default 60_000. */
|
|
73
85
|
timeoutMs?: number;
|
|
74
86
|
/**
|
|
@@ -250,10 +262,11 @@ interface ResolvedResilience {
|
|
|
250
262
|
}
|
|
251
263
|
|
|
252
264
|
/**
|
|
253
|
-
* How long one call waits, in all,
|
|
254
|
-
* "Dynamic worker concurrency limit exceeded"
|
|
255
|
-
*
|
|
256
|
-
*
|
|
265
|
+
* How long one call waits, in all, on the Dynamic Worker ledger after
|
|
266
|
+
* "Dynamic worker concurrency limit exceeded" before the refusal surfaces
|
|
267
|
+
* (beginLoaderFetchWhenFree: let in when a hold ends or the refusal's pause
|
|
268
|
+
* passes). A deployed Durable Object admitted the refused batch after a 6 s
|
|
269
|
+
* pause; 15 s bounds a call that would never be admitted.
|
|
257
270
|
*/
|
|
258
271
|
const CAP_REFUSAL_WAIT_MS = 15_000;
|
|
259
272
|
|
|
@@ -396,6 +409,8 @@ export class IsolatePool {
|
|
|
396
409
|
private readonly loader: WorkerLoader;
|
|
397
410
|
/** The hosting actor, as the loader budget ledger's per-DO key. */
|
|
398
411
|
private readonly ctx: DurableObjectState;
|
|
412
|
+
/** The width this pool's dispatches are held inside (IsolatePoolOptions.claim). */
|
|
413
|
+
private readonly claim: DynamicWorkerClaim | undefined;
|
|
399
414
|
private readonly concurrency: number;
|
|
400
415
|
private readonly defaultTimeoutMs: number;
|
|
401
416
|
private readonly defaultRetries: number;
|
|
@@ -474,6 +489,7 @@ export class IsolatePool {
|
|
|
474
489
|
}
|
|
475
490
|
this.loader = loader;
|
|
476
491
|
this.ctx = ctx;
|
|
492
|
+
this.claim = opts?.claim;
|
|
477
493
|
this.concurrency = Math.max(1, opts?.concurrency ?? 1);
|
|
478
494
|
this.defaultTimeoutMs = opts?.timeoutMs ?? 60_000;
|
|
479
495
|
this.defaultRetries = Math.max(0, opts?.retries ?? 0);
|
|
@@ -778,7 +794,7 @@ export class IsolatePool {
|
|
|
778
794
|
invoke: (entrypoint: { execute(...args: unknown[]): Promise<unknown>; fetch(input: RequestInfo, init?: RequestInit): Promise<Response> }, attempt: number) => Promise<T>,
|
|
779
795
|
resilience: ResolvedResilience,
|
|
780
796
|
perCallWasm?: Record<string, ArrayBuffer>,
|
|
781
|
-
|
|
797
|
+
signal?: AbortSignal,
|
|
782
798
|
): Promise<T> {
|
|
783
799
|
// A warm slot executes one dispatch at a time: queue behind the
|
|
784
800
|
// previous owner, then record this dispatch as the new tail. The
|
|
@@ -796,7 +812,7 @@ export class IsolatePool {
|
|
|
796
812
|
}
|
|
797
813
|
const inFlight: Promise<unknown>[] = [];
|
|
798
814
|
try {
|
|
799
|
-
return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight,
|
|
815
|
+
return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight, signal);
|
|
800
816
|
} finally {
|
|
801
817
|
// Do not delay the caller's own outcome — the tail releases when
|
|
802
818
|
// the RPCs the body launched have actually settled.
|
|
@@ -813,7 +829,7 @@ export class IsolatePool {
|
|
|
813
829
|
resilience: ResolvedResilience,
|
|
814
830
|
perCallWasm: Record<string, ArrayBuffer> | undefined,
|
|
815
831
|
inFlight: Promise<unknown>[],
|
|
816
|
-
|
|
832
|
+
signal?: AbortSignal,
|
|
817
833
|
): Promise<T> {
|
|
818
834
|
// Per-call wasm fingerprint. Mixed into the cache key so two calls
|
|
819
835
|
// with different bytes hit different slots (no cache poisoning).
|
|
@@ -837,6 +853,9 @@ export class IsolatePool {
|
|
|
837
853
|
// slot updated on every dispatch.
|
|
838
854
|
try { setLastFacetId(id, slotIndex); } catch { /* best-effort */ }
|
|
839
855
|
|
|
856
|
+
// A hold the ledger already took for the next attempt, when a refused
|
|
857
|
+
// call waited on it for room; otherwise the attempt begins its own.
|
|
858
|
+
let admitted: EndLoaderFetch | undefined;
|
|
840
859
|
const runOnce = async (): Promise<T> => {
|
|
841
860
|
// loader.get() is synchronous from the caller's POV; the callback
|
|
842
861
|
// is only invoked on cache miss. We wrap the callback tightly so a
|
|
@@ -862,14 +881,20 @@ export class IsolatePool {
|
|
|
862
881
|
// fine and stays — it only tears down the long-lived SUPERVISOR
|
|
863
882
|
// binding stub once the whole pool is done, which does NOT
|
|
864
883
|
// invalidate any in-flight slot's entrypoint reference.
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
//
|
|
868
|
-
|
|
869
|
-
|
|
884
|
+
//
|
|
885
|
+
// The hold comes first, so it ends whatever setup throws: a retry's
|
|
886
|
+
// was taken when the ledger let it in.
|
|
887
|
+
const endFetch = admitted ?? beginLoaderFetch(this.ctx, id, this.claim);
|
|
888
|
+
admitted = undefined;
|
|
870
889
|
try {
|
|
890
|
+
const stub = this.loader.get(id, async () => code);
|
|
891
|
+
const entrypoint = stub.getEntrypoint();
|
|
892
|
+
// Direct property call, awaited by this frame — bracketed, never
|
|
893
|
+
// wrapped. See beginLoaderFetch for the measured DO-poisoning hazard.
|
|
871
894
|
return await invoke(entrypoint, attempt);
|
|
872
895
|
} catch (err) {
|
|
896
|
+
// The ledger learns a limit refusal from the hold it ends.
|
|
897
|
+
endFetch(err);
|
|
873
898
|
if (err instanceof Error) {
|
|
874
899
|
throw new ExecutionError(err.message, err.stack);
|
|
875
900
|
}
|
|
@@ -882,8 +907,8 @@ export class IsolatePool {
|
|
|
882
907
|
const maxAttempts = 1 + resilience.retries;
|
|
883
908
|
let lastError: Error | undefined;
|
|
884
909
|
let retriedCloneRefusal = false;
|
|
885
|
-
|
|
886
|
-
let
|
|
910
|
+
// When this call's waits for room after a limit refusal run out.
|
|
911
|
+
let capDeadline: number | undefined;
|
|
887
912
|
let attempt = 0;
|
|
888
913
|
while (attempt < maxAttempts) {
|
|
889
914
|
try {
|
|
@@ -942,7 +967,7 @@ export class IsolatePool {
|
|
|
942
967
|
message: lastError.message,
|
|
943
968
|
});
|
|
944
969
|
} catch { /* fail-soft */ }
|
|
945
|
-
if (
|
|
970
|
+
if (signal?.aborted) {
|
|
946
971
|
// The caller aborted this dispatch's Request. workerd cancelled
|
|
947
972
|
// the isolate's execution context wherever it was suspended —
|
|
948
973
|
// mid-syscall, mid-stream — so the interpreter's heap may hold
|
|
@@ -965,16 +990,29 @@ export class IsolatePool {
|
|
|
965
990
|
// reverse direction. This refresh targets the stale-loader case.
|
|
966
991
|
continue;
|
|
967
992
|
}
|
|
968
|
-
if (cause === 'dynamic_worker_cap'
|
|
969
|
-
// The platform refused to start this call
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
993
|
+
if (cause === 'dynamic_worker_cap') {
|
|
994
|
+
// The platform refused to start this call. Nothing ran, so the
|
|
995
|
+
// call waits, as the platform asks, and is sent again without
|
|
996
|
+
// spending an attempt: on the ledger, which lets it in when a
|
|
997
|
+
// hold ends, or when the pause this refusal started has passed
|
|
998
|
+
// (the platform still counts a worker the ledger has given back:
|
|
999
|
+
// a fan-out's workers stay counted for a moment after their
|
|
1000
|
+
// calls return).
|
|
1001
|
+
capDeadline ??= Date.now() + CAP_REFUSAL_WAIT_MS;
|
|
1002
|
+
const remainingMs = capDeadline - Date.now();
|
|
1003
|
+
if (remainingMs > 0) {
|
|
1004
|
+
// The deadline's timer is cleared once the wait settles: a
|
|
1005
|
+
// pending timer keeps the hosting object from hibernating.
|
|
1006
|
+
const deadline = new AbortController();
|
|
1007
|
+
const timer = setTimeout(() => deadline.abort(), remainingMs);
|
|
1008
|
+
const waitFor = signal ? AbortSignal.any([deadline.signal, signal]) : deadline.signal;
|
|
1009
|
+
admitted = await beginLoaderFetchWhenFree(this.ctx, id, { claim: this.claim, signal: waitFor })
|
|
1010
|
+
.catch(() => undefined)
|
|
1011
|
+
.finally(() => clearTimeout(timer));
|
|
1012
|
+
if (admitted) continue;
|
|
1013
|
+
// The caller aborted while waiting: surface its abort as above.
|
|
1014
|
+
if (signal?.aborted) throw lastError;
|
|
1015
|
+
}
|
|
978
1016
|
}
|
|
979
1017
|
if (attempt < maxAttempts - 1) {
|
|
980
1018
|
// 100 * 2^attempt, capped at 2s so retries don't compound waiting.
|
|
@@ -1053,7 +1091,7 @@ export class IsolatePool {
|
|
|
1053
1091
|
(entrypoint) => entrypoint.fetch(request.clone()),
|
|
1054
1092
|
resilience,
|
|
1055
1093
|
opts?.wasmModules,
|
|
1056
|
-
|
|
1094
|
+
request.signal,
|
|
1057
1095
|
);
|
|
1058
1096
|
}
|
|
1059
1097
|
|
|
@@ -652,6 +652,10 @@ async function runOneShot<T>(
|
|
|
652
652
|
} finally {
|
|
653
653
|
disposeRpcResource(response);
|
|
654
654
|
}
|
|
655
|
+
} catch (error) {
|
|
656
|
+
// A limit refusal pauses the ledger's admissions (beginLoaderFetchWhenFree).
|
|
657
|
+
endFetch(error);
|
|
658
|
+
throw error;
|
|
655
659
|
} finally {
|
|
656
660
|
endFetch();
|
|
657
661
|
}
|