@nimbus-sh/fabric 0.1.0 → 0.3.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 +208 -293
- package/dist/bindings.js +5 -5
- package/dist/budgets.d.ts +132 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +248 -0
- package/dist/composition.d.ts +3 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +2 -0
- package/dist/connections.d.ts +81 -0
- package/dist/connections.d.ts.map +1 -0
- package/dist/connections.js +114 -0
- package/dist/derived.d.ts +65 -0
- package/dist/derived.d.ts.map +1 -0
- package/dist/derived.js +95 -0
- package/dist/do-calls.d.ts +94 -0
- package/dist/do-calls.d.ts.map +1 -0
- package/dist/do-calls.js +111 -0
- package/dist/facet-pool.d.ts +90 -0
- package/dist/facet-pool.d.ts.map +1 -0
- package/dist/facet-pool.js +113 -0
- package/dist/{fanout-pool.d.ts → fanout.d.ts} +20 -20
- package/dist/fanout.d.ts.map +1 -0
- package/dist/{fanout-pool.js → fanout.js} +20 -20
- package/dist/{launch-journal.d.ts → fenced-work.d.ts} +58 -17
- package/dist/fenced-work.d.ts.map +1 -0
- package/dist/fenced-work.js +241 -0
- package/dist/generation.d.ts +69 -0
- package/dist/generation.d.ts.map +1 -0
- package/dist/generation.js +118 -0
- package/dist/{facet-image-store.d.ts → image-store.d.ts} +8 -8
- package/dist/image-store.d.ts.map +1 -0
- package/dist/{facet-image-store.js → image-store.js} +4 -4
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -8
- package/dist/{loader-pool.d.ts → isolate-pool.d.ts} +19 -19
- package/dist/isolate-pool.d.ts.map +1 -0
- package/dist/{loader-pool.js → isolate-pool.js} +20 -20
- package/dist/journal.d.ts +111 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +177 -0
- package/dist/outbox.d.ts +249 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +355 -0
- package/dist/process-fabric.d.ts +33 -15
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +25 -15
- package/dist/process-host.d.ts +1 -1
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +19 -11
- package/dist/sealed.d.ts +78 -0
- package/dist/sealed.d.ts.map +1 -0
- package/dist/sealed.js +145 -0
- package/dist/timers.d.ts +138 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +231 -0
- package/dist/{launch-pacer.d.ts → turn-budget.d.ts} +24 -21
- package/dist/turn-budget.d.ts.map +1 -0
- package/dist/{launch-pacer.js → turn-budget.js} +24 -12
- package/dist/workerd-facet-host.d.ts +67 -70
- package/dist/workerd-facet-host.d.ts.map +1 -1
- package/dist/workerd-facet-host.js +129 -181
- package/examples/agent-core-adapter.ts +191 -0
- package/package.json +4 -2
- package/src/bindings.ts +6 -6
- package/src/budgets.ts +308 -0
- package/src/composition.ts +16 -0
- package/src/connections.ts +140 -0
- package/src/derived.ts +135 -0
- package/src/do-calls.ts +156 -0
- package/src/facet-pool.ts +157 -0
- package/src/{fanout-pool.ts → fanout.ts} +35 -35
- package/src/{launch-journal.ts → fenced-work.ts} +129 -42
- package/src/generation.ts +144 -0
- package/src/{facet-image-store.ts → image-store.ts} +9 -9
- package/src/index.ts +16 -8
- package/src/{loader-pool.ts → isolate-pool.ts} +34 -34
- package/src/journal.ts +242 -0
- package/src/node-async-hooks.d.ts +14 -0
- package/src/outbox.ts +520 -0
- package/src/process-fabric.ts +43 -34
- package/src/process-host.ts +22 -20
- package/src/sealed.ts +150 -0
- package/src/timers.ts +294 -0
- package/src/{launch-pacer.ts → turn-budget.ts} +34 -27
- package/src/workerd-facet-host.ts +159 -208
- package/dist/alarms.d.ts +0 -134
- package/dist/alarms.d.ts.map +0 -1
- package/dist/alarms.js +0 -214
- package/dist/ctx-exports.d.ts +0 -47
- package/dist/ctx-exports.d.ts.map +0 -1
- package/dist/ctx-exports.js +0 -54
- package/dist/facet-image-store.d.ts.map +0 -1
- package/dist/fanout-pool.d.ts.map +0 -1
- package/dist/launch-journal.d.ts.map +0 -1
- package/dist/launch-journal.js +0 -154
- package/dist/launch-pacer.d.ts.map +0 -1
- package/dist/loader-ledger.d.ts +0 -57
- package/dist/loader-ledger.d.ts.map +0 -1
- package/dist/loader-ledger.js +0 -91
- package/dist/loader-pool.d.ts.map +0 -1
- package/src/alarms.ts +0 -275
- package/src/ctx-exports.ts +0 -77
- package/src/loader-ledger.ts +0 -112
package/src/do-calls.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* do-calls.ts — the two verbs for calling another Durable Object, split by
|
|
3
|
+
* the one property that decides whether a retry is safe.
|
|
4
|
+
*
|
|
5
|
+
* Both consumers asked for this. Proteus hand-wrote the retry
|
|
6
|
+
* (`cf-backend/src/lib/do-rpc.ts`) with the rule its header states:
|
|
7
|
+
* "An operation that appends, sends, charges or mints is never wrapped: a
|
|
8
|
+
* dropped call there may already have run, so a retry is a correctness bug
|
|
9
|
+
* wearing resilience as a costume." agent-core has no retry machinery at all
|
|
10
|
+
* and its backlog calls the gap "the most production-proven gap in the
|
|
11
|
+
* corpus". Here the rule is a type: `idempotent` retries, `mutating` cannot.
|
|
12
|
+
*
|
|
13
|
+
* What the platform contract requires, and this keeps:
|
|
14
|
+
* - a FRESH stub per attempt. Cloudflare documents that many exceptions
|
|
15
|
+
* leave a stub permanently broken, so both verbs take a stub RESOLVER,
|
|
16
|
+
* not a stub — which is also what lets placement pins and auth wrappers
|
|
17
|
+
* compose (agent-core's PlacementResolver pins an Actor to one
|
|
18
|
+
* jurisdiction for life; the resolver seam is where that lives).
|
|
19
|
+
* - `overloaded` is never retried, by either verb: retrying an overloaded
|
|
20
|
+
* object is what overloaded it.
|
|
21
|
+
* - attempts and backoff are the consumer-proven bounds: 3 attempts total,
|
|
22
|
+
* full-jitter delays in [0, 2**attempt * 60ms).
|
|
23
|
+
*
|
|
24
|
+
* The resolver MINTS a stub per call and the verb disposes each one it
|
|
25
|
+
* minted — that ownership is what makes the fresh-stub retry real.
|
|
26
|
+
*
|
|
27
|
+
* The stub method call itself happens inside the CALLER's closure
|
|
28
|
+
* (`(stub) => stub.method(args)`): nothing here proxies property resolution
|
|
29
|
+
* or dispatches by method name. Do NOT use these verbs around a dynamically
|
|
30
|
+
* loaded worker's entrypoint stub — those calls must stay direct property
|
|
31
|
+
* calls bracketed by `beginLoaderFetch` (budgets.ts records the 7/7 staging
|
|
32
|
+
* poisoning that rule comes from). These verbs are for Durable Object
|
|
33
|
+
* namespace stubs, where the thunk shape is production-proven in Proteus.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { classifyDoCall, isRetryableDoCall, type DoCallClass } from '@nimbus-sh/platform/oom-classify.js';
|
|
37
|
+
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
38
|
+
|
|
39
|
+
/** Total attempts. Two retries is what a dropped connection or a deploy
|
|
40
|
+
* bounce needs; beyond that the object is not coming back inside this
|
|
41
|
+
* request (the consumer's measured bound). */
|
|
42
|
+
const MAX_ATTEMPTS = 3;
|
|
43
|
+
/** Full-jitter base, in the shape the Agents SDK itself uses. */
|
|
44
|
+
const BASE_DELAY_MS = 60;
|
|
45
|
+
|
|
46
|
+
export interface DoCallRetryPolicy {
|
|
47
|
+
maxAttempts?: number;
|
|
48
|
+
baseDelayMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Called once per retry, before its backoff delay, with the failure the
|
|
51
|
+
* retry is answering. The consumer's logging seam: Proteus's hand-rolled
|
|
52
|
+
* predecessor logged every retry so a flaky object is visible in Workers
|
|
53
|
+
* Logs rather than silently absorbed, and `operation` names it there.
|
|
54
|
+
*/
|
|
55
|
+
onRetry?(info: DoCallRetryInfo): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What one retry is answering: which call, which platform class, which
|
|
59
|
+
* attempt just failed out of how many. */
|
|
60
|
+
export interface DoCallRetryInfo {
|
|
61
|
+
operation: string;
|
|
62
|
+
classification: DoCallClass;
|
|
63
|
+
/** The 1-based attempt that failed; the retry about to run is attempt+1. */
|
|
64
|
+
attempt: number;
|
|
65
|
+
maxAttempts: number;
|
|
66
|
+
error: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mints one stub per call. `idempotent` calls it once per attempt.
|
|
71
|
+
*
|
|
72
|
+
* MINT means mint. Both verbs dispose the stub they were handed when the
|
|
73
|
+
* call settles — on success as much as on failure (`disposeRpcResource`).
|
|
74
|
+
* A resolver that returns a shared, long-lived stub hands its other users
|
|
75
|
+
* a disposed stub, and only in production: a test double is a plain object
|
|
76
|
+
* with nothing to dispose, so the test stays green while the deployed
|
|
77
|
+
* Worker breaks on the second call.
|
|
78
|
+
*/
|
|
79
|
+
export type DoStubResolver<S> = () => S | Promise<S>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A failed `mutating` call, typed so the caller can act on WHAT failed:
|
|
83
|
+
* `classification` names the platform condition, and a transient class on a
|
|
84
|
+
* mutating call means the call may already have run — the indeterminacy the
|
|
85
|
+
* consumer's rule exists to surface rather than paper over.
|
|
86
|
+
*/
|
|
87
|
+
export class DoCallError extends Error {
|
|
88
|
+
constructor(
|
|
89
|
+
readonly operation: string,
|
|
90
|
+
readonly verb: 'idempotent' | 'mutating',
|
|
91
|
+
readonly classification: DoCallClass,
|
|
92
|
+
cause: unknown,
|
|
93
|
+
) {
|
|
94
|
+
const text = cause instanceof Error ? cause.message : String(cause);
|
|
95
|
+
const indeterminate = isRetryableDoCall(classification)
|
|
96
|
+
? ' — a dropped mutating call may already have run, so it is not retried'
|
|
97
|
+
: '';
|
|
98
|
+
super(`${verb} call '${operation}' failed [${classification}]: ${text}${indeterminate}`, { cause });
|
|
99
|
+
this.name = 'DoCallError';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Call another Durable Object with an operation that is safe to repeat: a
|
|
105
|
+
* read, or a converge-to-a-value write. Transient failures retry on a fresh
|
|
106
|
+
* stub with full-jitter backoff; overloaded and permanent failures surface
|
|
107
|
+
* unchanged, as does the last error at exhaustion.
|
|
108
|
+
*/
|
|
109
|
+
export async function idempotent<S, T>(
|
|
110
|
+
operation: string,
|
|
111
|
+
stub: DoStubResolver<S>,
|
|
112
|
+
call: (stub: S) => Promise<T>,
|
|
113
|
+
policy: DoCallRetryPolicy = {},
|
|
114
|
+
): Promise<T> {
|
|
115
|
+
const maxAttempts = policy.maxAttempts ?? MAX_ATTEMPTS;
|
|
116
|
+
const baseDelayMs = policy.baseDelayMs ?? BASE_DELAY_MS;
|
|
117
|
+
for (let attempt = 1; ; attempt++) {
|
|
118
|
+
const minted = await stub();
|
|
119
|
+
try {
|
|
120
|
+
const result = await call(minted);
|
|
121
|
+
disposeRpcResource(minted);
|
|
122
|
+
return result;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
// A stub that threw may be permanently broken; it is never reused.
|
|
125
|
+
disposeRpcResource(minted);
|
|
126
|
+
const classification = classifyDoCall(error);
|
|
127
|
+
if (!isRetryableDoCall(classification) || attempt >= maxAttempts) throw error;
|
|
128
|
+
policy.onRetry?.({ operation, classification, attempt, maxAttempts, error });
|
|
129
|
+
await new Promise<void>((resolve) => {
|
|
130
|
+
setTimeout(resolve, Math.floor(Math.random() * 2 ** attempt * baseDelayMs));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Call another Durable Object with an operation that appends, sends, charges
|
|
138
|
+
* or mints. NEVER retried — a dropped call may already have run. Failure
|
|
139
|
+
* surfaces as a {@link DoCallError} carrying the classification, so the
|
|
140
|
+
* caller can tell a refusal from an indeterminate drop.
|
|
141
|
+
*/
|
|
142
|
+
export async function mutating<S, T>(
|
|
143
|
+
operation: string,
|
|
144
|
+
stub: DoStubResolver<S>,
|
|
145
|
+
call: (stub: S) => Promise<T>,
|
|
146
|
+
): Promise<T> {
|
|
147
|
+
const minted = await stub();
|
|
148
|
+
try {
|
|
149
|
+
const result = await call(minted);
|
|
150
|
+
disposeRpcResource(minted);
|
|
151
|
+
return result;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
disposeRpcResource(minted);
|
|
154
|
+
throw new DoCallError(operation, 'mutating', classifyDoCall(error), error);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* facet-pool.ts — leased facets, so reclaiming storage is the default and
|
|
3
|
+
* leaking it takes intent.
|
|
4
|
+
*
|
|
5
|
+
* Proteus's facet-spawn.ts (313 lines) exists because the platform's two
|
|
6
|
+
* teardown verbs are indistinguishable to a caller and only one gives
|
|
7
|
+
* storage back: `abort` is mid-flight eviction with storage KEPT, `delete`
|
|
8
|
+
* is terminal with storage WIPED. Its docstring records the cost of
|
|
9
|
+
* confusing them: "the leak this module previously had, in which every head
|
|
10
|
+
* and every MCTS branch abandoned a permanent database inside the
|
|
11
|
+
* orchestrator DO". The lease makes that leak unreachable: disposal retires
|
|
12
|
+
* the facet (evict, then wipe), and keeping storage is the explicit opt-in
|
|
13
|
+
* (`detach()`, today's abort).
|
|
14
|
+
*
|
|
15
|
+
* The constraints a caller must not be surprised by, from Proteus's platform
|
|
16
|
+
* catalog (all proven by probe or by source):
|
|
17
|
+
* - a facet cannot set alarms (`do.facet.no_alarms`) — a head cannot
|
|
18
|
+
* schedule its own resumption; everything time-driven routes through the
|
|
19
|
+
* root's single alarm (`timers`).
|
|
20
|
+
* - a facet stub is coordinator-local (`do.facet.stub_local`) — it cannot
|
|
21
|
+
* be transferred, stored, or re-invoked indirectly.
|
|
22
|
+
* - facet storage is charged to the ROOT's shared budget, and a clone that
|
|
23
|
+
* crosses it is an uncatchable reset, not an error (`do.storage.bytes`).
|
|
24
|
+
* - a parent and its facets are evicted JOINTLY after minutes idle, so
|
|
25
|
+
* in-memory facet state is never safe to assume between two RPCs.
|
|
26
|
+
* - 65,536 facet ids per DO lifetime (`do.facet.count`), append-only and
|
|
27
|
+
* never reclaimed — the binding constraint for the leak, reached an
|
|
28
|
+
* order of magnitude before the byte quota. The pool counts first-use
|
|
29
|
+
* names in the durable ledger (budgets.ts) and refuses a NEW name at the
|
|
30
|
+
* wall by name, instead of letting the platform fail opaquely. Refusal
|
|
31
|
+
* at the wall is exact, not a threshold: the ledger never overcounts.
|
|
32
|
+
*
|
|
33
|
+
* A failed reclaim stays loud (facet-spawn's `runOnceAndReclaim`): storage
|
|
34
|
+
* that was not given back is a permanent charge against the root's quota,
|
|
35
|
+
* and swallowing that is how the original leak stayed invisible.
|
|
36
|
+
*
|
|
37
|
+
* The pool drives the RAW `ctx.facets` container and assumes it is the only
|
|
38
|
+
* thing naming facets on this actor. The Agents SDK's sub-agent layer makes
|
|
39
|
+
* the same assumption from the other side — it owns facet naming and runs
|
|
40
|
+
* its own cleanup — so the two are mutually exclusive on one actor:
|
|
41
|
+
* whichever acts second aborts or retires facets the other still tracks,
|
|
42
|
+
* and the facet-id ledger here counts only the names this pool minted.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
FACET_ID_LIFETIME_BUDGET,
|
|
47
|
+
facetNameCount,
|
|
48
|
+
recordFacetNameMinted,
|
|
49
|
+
withFacetBudgetNamed,
|
|
50
|
+
} from './budgets.js';
|
|
51
|
+
|
|
52
|
+
/** `ctx.facets`, as the pool drives it — same surface the facet host uses. */
|
|
53
|
+
export interface FacetPoolContainer {
|
|
54
|
+
get(name: string, start: () => Promise<{ class: unknown }>): unknown;
|
|
55
|
+
abort(name: string, reason?: unknown): void;
|
|
56
|
+
delete(name: string): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The hosting actor's context: its facet container, and the storage the
|
|
60
|
+
* facet-id ledger persists through. */
|
|
61
|
+
export interface FacetPoolContext {
|
|
62
|
+
facets?: FacetPoolContainer;
|
|
63
|
+
storage: {
|
|
64
|
+
get(key: string): Promise<unknown> | unknown;
|
|
65
|
+
put(key: string, value: unknown): Promise<void>;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One leased facet. Dispose (or `retire()`) evicts the instance and WIPES
|
|
71
|
+
* its storage; `detach()` first to keep the storage — after it, disposal
|
|
72
|
+
* only evicts. The stub is coordinator-local: do not store it past the turn
|
|
73
|
+
* or hand it to anything else.
|
|
74
|
+
*/
|
|
75
|
+
export interface FacetLease<S> {
|
|
76
|
+
readonly name: string;
|
|
77
|
+
readonly stub: S;
|
|
78
|
+
/** Keep the facet's storage: disposal becomes eviction only. */
|
|
79
|
+
detach(): void;
|
|
80
|
+
/** Idempotent. Throws, loudly, when the platform refuses the wipe. */
|
|
81
|
+
retire(): Promise<void>;
|
|
82
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Names this ctx's pool has already charged to the lifetime ledger. */
|
|
86
|
+
const chargedNames = new WeakMap<object, Set<string>>();
|
|
87
|
+
|
|
88
|
+
/** The facet pool of one hosting actor. Cheap accessor, like `timers()`. */
|
|
89
|
+
export function facetPool(ctx: FacetPoolContext): FacetPool {
|
|
90
|
+
return new FacetPool(ctx);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class FacetPool {
|
|
94
|
+
constructor(private readonly ctx: FacetPoolContext) {}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Open (or re-enter) the named facet under a lease. A first-use name
|
|
98
|
+
* consumes one of the object's 65,536 lifetime facet ids and is refused at
|
|
99
|
+
* the wall; a reused name costs nothing, in this incarnation or any other.
|
|
100
|
+
*/
|
|
101
|
+
async acquire<S = unknown>(
|
|
102
|
+
name: string,
|
|
103
|
+
start: () => Promise<{ class: unknown }>,
|
|
104
|
+
): Promise<FacetLease<S>> {
|
|
105
|
+
const facets = this.ctx.facets;
|
|
106
|
+
if (!facets || typeof facets.get !== 'function') {
|
|
107
|
+
throw new Error('fabric: ctx.facets is unavailable in this Durable Object; facets cannot be leased');
|
|
108
|
+
}
|
|
109
|
+
let charged = chargedNames.get(this.ctx);
|
|
110
|
+
if (!charged) {
|
|
111
|
+
charged = new Set();
|
|
112
|
+
chargedNames.set(this.ctx, charged);
|
|
113
|
+
}
|
|
114
|
+
if (!charged.has(name)) {
|
|
115
|
+
const consumed = facetNameCount(this.ctx);
|
|
116
|
+
if (consumed >= FACET_ID_LIFETIME_BUDGET) {
|
|
117
|
+
throw withFacetBudgetNamed(
|
|
118
|
+
consumed,
|
|
119
|
+
new Error(`facet '${name}' refused before creation: no lifetime ids remain`),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
recordFacetNameMinted(this.ctx, consumed + 1);
|
|
123
|
+
charged.add(name);
|
|
124
|
+
}
|
|
125
|
+
const stub = facets.get(name, start) as S;
|
|
126
|
+
let settled = false;
|
|
127
|
+
let keepStorage = false;
|
|
128
|
+
const retire = async (): Promise<void> => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
// Evict first so the wipe never lands under a live writer; a facet
|
|
132
|
+
// already gone makes the abort a no-op.
|
|
133
|
+
try { facets.abort(name, new Error('fabric: facet lease retired')); } catch { /* already gone */ }
|
|
134
|
+
if (keepStorage) return;
|
|
135
|
+
try {
|
|
136
|
+
facets.delete(name);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`fabric: facet '${name}' was evicted but its storage was not reclaimed — `
|
|
140
|
+
+ `it is leaked into the root Durable Object's shared quota: ${errorText(e)}`,
|
|
141
|
+
{ cause: e },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
name,
|
|
147
|
+
stub,
|
|
148
|
+
detach(): void { keepStorage = true; },
|
|
149
|
+
retire,
|
|
150
|
+
[Symbol.asyncDispose]: retire,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function errorText(error: unknown): string {
|
|
156
|
+
return error instanceof Error ? error.message : String(error);
|
|
157
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* A single Durable Object method can drive at most four concurrent
|
|
6
6
|
* Worker Loader fetches before extra dispatches serialize or fail. Small
|
|
7
|
-
* batches therefore run in the coordinator DO through
|
|
7
|
+
* batches therefore run in the coordinator DO through IsolatePool.
|
|
8
8
|
* Wider batches are sharded across sibling NimbusSession DOs, each of
|
|
9
9
|
* which owns its own four-loader budget.
|
|
10
10
|
*
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
import { serializeFunction } from './vendor/serialize.js';
|
|
18
18
|
import { BindingError } from './vendor/errors.js';
|
|
19
|
-
import {
|
|
20
|
-
import { disposeRpcResource } from '@nimbus-sh/
|
|
21
|
-
import { describeError, isDoOverloaded, isTransientDoReset } from '@nimbus-sh/
|
|
19
|
+
import { IsolatePool, type FacetTaskFn } from './isolate-pool.js';
|
|
20
|
+
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
21
|
+
import { describeError, isDoOverloaded, isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
|
|
22
22
|
import type { WorkerLoader } from './vendor/types.js';
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -31,7 +31,7 @@ interface PeerSessionNamespace {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** The bindings a fan-out needs off the coordinator DO's env. */
|
|
34
|
-
export interface
|
|
34
|
+
export interface FanoutEnv {
|
|
35
35
|
LOADER?: WorkerLoader;
|
|
36
36
|
NIMBUS_SESSION?: PeerSessionNamespace;
|
|
37
37
|
}
|
|
@@ -104,45 +104,45 @@ export interface FanoutTask<A> {
|
|
|
104
104
|
args: A;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/** Options handed to
|
|
108
|
-
export interface
|
|
107
|
+
/** Options handed to Fanout's constructor. */
|
|
108
|
+
export interface FanoutOptions {
|
|
109
109
|
/**
|
|
110
110
|
* Tag prepended to peer-DO ids and in-DO loader ids for debugging
|
|
111
111
|
* (e.g. "npm-install-batch"). Affects neither isolate identity (in-DO
|
|
112
|
-
* path uses the existing
|
|
112
|
+
* path uses the existing IsolatePool's tag-fold) nor peer-DO
|
|
113
113
|
* deterministic placement (peer ids fold tag + key).
|
|
114
114
|
*/
|
|
115
115
|
tag: string;
|
|
116
116
|
/**
|
|
117
117
|
* Per-task timeout in ms. Default 60_000. Forwarded to the in-DO
|
|
118
|
-
*
|
|
119
|
-
*
|
|
118
|
+
* IsolatePool's submit calls and to the peer-DO RPC's own
|
|
119
|
+
* IsolatePool.
|
|
120
120
|
*/
|
|
121
121
|
timeoutMs?: number;
|
|
122
122
|
/**
|
|
123
123
|
* Preamble bundled into every facet (in-DO and inside each peer
|
|
124
|
-
* DO). Same semantics as
|
|
124
|
+
* DO). Same semantics as IsolatePool's preamble option.
|
|
125
125
|
*/
|
|
126
126
|
preamble?: string;
|
|
127
127
|
/**
|
|
128
128
|
* Wasm modules forwarded to every facet. Same semantics as
|
|
129
|
-
*
|
|
129
|
+
* IsolatePool's wasmModules option.
|
|
130
130
|
*/
|
|
131
131
|
wasmModules?: Record<string, ArrayBuffer>;
|
|
132
132
|
/**
|
|
133
133
|
* Extra bindings forwarded to every facet. Same semantics as
|
|
134
|
-
*
|
|
134
|
+
* IsolatePool's extraBindings option.
|
|
135
135
|
*/
|
|
136
136
|
extraBindings?: Record<string, unknown>;
|
|
137
137
|
/**
|
|
138
138
|
* If set, skip the supervisor-RPC binding injection (mirrors
|
|
139
|
-
*
|
|
139
|
+
* IsolatePool's omitSupervisor flag).
|
|
140
140
|
*/
|
|
141
141
|
omitSupervisor?: boolean;
|
|
142
142
|
/**
|
|
143
143
|
* Invoking process pid, baked into each facet's SUPERVISOR binding so
|
|
144
144
|
* filesystem RPCs (writeBatchStream) are authorized under the caller's
|
|
145
|
-
* credential (mirrors
|
|
145
|
+
* credential (mirrors IsolatePool's supervisorPid). Threaded to both
|
|
146
146
|
* the in-DO loader pool and, via `_rpcFanoutExecute`, the peer-DO pools.
|
|
147
147
|
* npm install passes the shell command's `ctx.pid`; resolve leaves it 0.
|
|
148
148
|
*/
|
|
@@ -186,10 +186,10 @@ function isFanoutPeerStub(value: unknown): value is FanoutPeerStub {
|
|
|
186
186
|
|
|
187
187
|
function fanoutPeerStub(value: unknown): FanoutPeerStub {
|
|
188
188
|
if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
|
|
189
|
-
throw new BindingError('
|
|
189
|
+
throw new BindingError('Fanout: NIMBUS_SESSION.get() did not return a peer stub.');
|
|
190
190
|
}
|
|
191
191
|
if (!isFanoutPeerStub(value)) {
|
|
192
|
-
throw new BindingError('
|
|
192
|
+
throw new BindingError('Fanout: peer stub does not expose _rpcFanoutExecute().');
|
|
193
193
|
}
|
|
194
194
|
return value;
|
|
195
195
|
}
|
|
@@ -203,23 +203,23 @@ function fanoutPeerStub(value: unknown): FanoutPeerStub {
|
|
|
203
203
|
* exists primarily as a clean API surface; per-call dispatch state
|
|
204
204
|
* lives only inside submitMany's promise.
|
|
205
205
|
*/
|
|
206
|
-
export class
|
|
207
|
-
private readonly env:
|
|
206
|
+
export class Fanout {
|
|
207
|
+
private readonly env: FanoutEnv;
|
|
208
208
|
private readonly ctx: DurableObjectState;
|
|
209
|
-
private readonly opts:
|
|
209
|
+
private readonly opts: FanoutOptions;
|
|
210
210
|
private readonly coordDoId: string;
|
|
211
211
|
private readonly coordDoIdShort: string;
|
|
212
212
|
|
|
213
|
-
constructor(rawEnv: unknown, ctx: DurableObjectState, opts:
|
|
213
|
+
constructor(rawEnv: unknown, ctx: DurableObjectState, opts: FanoutOptions) {
|
|
214
214
|
// A host hands its whole env over; the bindings are claimed here and the
|
|
215
215
|
// LOADER claim is checked immediately. Hard-fail on a missing LOADER —
|
|
216
|
-
//
|
|
217
|
-
// diagnostic at the fanout
|
|
218
|
-
// deferred
|
|
219
|
-
const env = (rawEnv as
|
|
216
|
+
// IsolatePool also enforces this, but checking up front points the
|
|
217
|
+
// diagnostic at the fanout construction site rather than the
|
|
218
|
+
// deferred isolate-pool one.
|
|
219
|
+
const env = (rawEnv as FanoutEnv | null | undefined) ?? {};
|
|
220
220
|
if (!env.LOADER || typeof env.LOADER.get !== 'function') {
|
|
221
221
|
throw new BindingError(
|
|
222
|
-
'
|
|
222
|
+
'Fanout: env.LOADER binding missing or invalid. ' +
|
|
223
223
|
'Add a [[worker_loaders]] entry to wrangler.jsonc.',
|
|
224
224
|
);
|
|
225
225
|
}
|
|
@@ -235,21 +235,21 @@ export class FanoutPool {
|
|
|
235
235
|
* results in input order.
|
|
236
236
|
*
|
|
237
237
|
* Routing:
|
|
238
|
-
* tasks.length < 5 -> coordinator-local
|
|
238
|
+
* tasks.length < 5 -> coordinator-local IsolatePool
|
|
239
239
|
* tasks.length >= 5 -> sibling NimbusSession DOs
|
|
240
240
|
*
|
|
241
241
|
* Backpressure: if `tasks.length > MAX_PEER_FANOUT (32)`, tasks
|
|
242
242
|
* are sharded modulo `MAX_PEER_FANOUT` and each shard's bucket
|
|
243
243
|
* runs serially inside its assigned peer DO via the in-peer
|
|
244
|
-
*
|
|
244
|
+
* IsolatePool's concurrency (capped at 4 there too). A
|
|
245
245
|
* single submitMany call returns when ALL tasks complete (or any
|
|
246
246
|
* throws).
|
|
247
247
|
*
|
|
248
248
|
* `fn` is the user function executed per task. It runs INSIDE a
|
|
249
249
|
* Worker Loader isolate (in the in-DO path) or inside a peer DO's
|
|
250
250
|
* Worker Loader isolate (in the peer-DO path); same trust posture
|
|
251
|
-
* as
|
|
252
|
-
* the vendored serializeFunction (same as
|
|
251
|
+
* as IsolatePool.submit. The function is serialized via
|
|
252
|
+
* the vendored serializeFunction (same as IsolatePool#prepare).
|
|
253
253
|
*/
|
|
254
254
|
async submitMany<A, R>(
|
|
255
255
|
tasks: FanoutTask<A>[],
|
|
@@ -287,12 +287,12 @@ export class FanoutPool {
|
|
|
287
287
|
tasks: FanoutTask<A>[],
|
|
288
288
|
fn: FacetTaskFn<A, R>,
|
|
289
289
|
): Promise<R[]> {
|
|
290
|
-
// Use the existing
|
|
290
|
+
// Use the existing IsolatePool. Concurrency = task count
|
|
291
291
|
// (capped at 4 by constructor — tasks.length is already < 5
|
|
292
292
|
// here, so the cap won't bite). Each task = one pool.submit;
|
|
293
293
|
// pool.map runs them with stable-slot reuse.
|
|
294
294
|
const concurrency = Math.min(tasks.length, IN_DO_THRESHOLD - 1);
|
|
295
|
-
const pool = new
|
|
295
|
+
const pool = new IsolatePool(this.env, this.ctx, {
|
|
296
296
|
concurrency,
|
|
297
297
|
timeoutMs: this.opts.timeoutMs,
|
|
298
298
|
tag: this.opts.tag,
|
|
@@ -326,7 +326,7 @@ export class FanoutPool {
|
|
|
326
326
|
const ns = this.env?.NIMBUS_SESSION;
|
|
327
327
|
if (!ns || typeof ns.idFromName !== 'function' || typeof ns.get !== 'function') {
|
|
328
328
|
throw new BindingError(
|
|
329
|
-
'
|
|
329
|
+
'Fanout: env.NIMBUS_SESSION binding missing or invalid. ' +
|
|
330
330
|
'The peer-DO topology requires it. ' +
|
|
331
331
|
'Add the binding via durable_objects.bindings in wrangler.jsonc.',
|
|
332
332
|
);
|
|
@@ -340,7 +340,7 @@ export class FanoutPool {
|
|
|
340
340
|
|
|
341
341
|
// Cap peer count at MAX_PEER_FANOUT. Tasks beyond N=32 are
|
|
342
342
|
// bucketed into existing shards — each shard's peer DO then
|
|
343
|
-
// runs its bucket through its in-DO
|
|
343
|
+
// runs its bucket through its in-DO IsolatePool.map
|
|
344
344
|
// (concurrency capped at 4 there).
|
|
345
345
|
const peerCount = Math.min(tasks.length, this.opts.maxPeers ?? MAX_PEER_FANOUT);
|
|
346
346
|
// Group tasks by deterministic shard. Same key → same shard, so
|
|
@@ -397,7 +397,7 @@ export class FanoutPool {
|
|
|
397
397
|
extraBindings: this.opts.extraBindings,
|
|
398
398
|
omitSupervisor: this.opts.omitSupervisor,
|
|
399
399
|
// INSTALL-HONESTY: forward the COORDINATOR's full doId so
|
|
400
|
-
// the peer's
|
|
400
|
+
// the peer's IsolatePool can mint a SUPERVISOR
|
|
401
401
|
// binding that routes back HERE (the user's session DO),
|
|
402
402
|
// not to the peer DO itself. Without this, peer DOs'
|
|
403
403
|
// env.SUPERVISOR.writeBatch / writeBatchStream / stdout /
|