@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/process-host.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* peer — the process is a named child actor of a SIBLING session DO, and
|
|
10
10
|
* the coordinator reaches it over one held-open RPC.
|
|
11
11
|
*
|
|
12
|
-
* Both call the same `
|
|
12
|
+
* Both call the same `processes(ctx, env).spawn`. The peer leg is not a second process
|
|
13
13
|
* implementation; it is the same call made on a different actor, which is why
|
|
14
14
|
* the runner, the boot spec, the class name, the writer handshake, the start
|
|
15
15
|
* contract and the lifecycle are shared code rather than parallel paths.
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
* that pair unforgeable by anything that did not open the process.
|
|
65
65
|
*/
|
|
66
66
|
|
|
67
|
-
import { disposeRpcResource } from '@nimbus-sh/
|
|
68
|
-
import { isTransientDoReset } from '@nimbus-sh/
|
|
69
|
-
import { PEER_RETRY_BACKOFF_MS, PEER_TRANSIENT_RESET_RETRIES } from './fanout
|
|
67
|
+
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
68
|
+
import { isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
|
|
69
|
+
import { PEER_RETRY_BACKOFF_MS, PEER_TRANSIENT_RESET_RETRIES } from './fanout.js';
|
|
70
70
|
import {
|
|
71
71
|
type HostedProcess,
|
|
72
72
|
type OneShotParams,
|
|
@@ -77,14 +77,12 @@ import {
|
|
|
77
77
|
type ResidentDiskReader,
|
|
78
78
|
type ResidentSupervisorProps,
|
|
79
79
|
} from './process-fabric.js';
|
|
80
|
+
import { DYNAMIC_WORKER_CODE_LIMIT_BYTES } from './budgets.js';
|
|
81
|
+
import { BindingError } from './vendor/errors.js';
|
|
80
82
|
import {
|
|
81
|
-
|
|
82
|
-
openResidentFacet,
|
|
83
|
-
residentFacetName,
|
|
84
|
-
runOneShotWorker,
|
|
83
|
+
processes,
|
|
85
84
|
type ResidentFacetEnv,
|
|
86
85
|
} from './workerd-facet-host.js';
|
|
87
|
-
import { BindingError } from './vendor/errors.js';
|
|
88
86
|
|
|
89
87
|
/** The substrates this deployment can be configured for. */
|
|
90
88
|
export type ProcessHostMode = 'facet' | 'peer';
|
|
@@ -136,9 +134,7 @@ class FacetProcessHost implements ProcessHost {
|
|
|
136
134
|
}
|
|
137
135
|
|
|
138
136
|
runOnce<T>(params: OneShotParams, consume: (response: Response) => Promise<T>): Promise<T> {
|
|
139
|
-
return
|
|
140
|
-
this.ctx,
|
|
141
|
-
this.env,
|
|
137
|
+
return processes(this.ctx, this.env).run(
|
|
142
138
|
{ doId: this.coordDoId, pid: params.pid, writerId: params.writerId },
|
|
143
139
|
params,
|
|
144
140
|
consume,
|
|
@@ -151,11 +147,11 @@ class FacetProcessHost implements ProcessHost {
|
|
|
151
147
|
pid: params.pid,
|
|
152
148
|
writerId: params.writerId,
|
|
153
149
|
};
|
|
154
|
-
const {
|
|
150
|
+
const { name, ...facet } = processes(this.ctx, this.env).spawn(this.disk, supervisor, params);
|
|
155
151
|
return {
|
|
156
152
|
...facet,
|
|
157
153
|
describe: () =>
|
|
158
|
-
`facet '${
|
|
154
|
+
`facet '${name}' (pid ${params.pid})`
|
|
159
155
|
+ ` of session ${this.coordDoId.slice(-12)}`
|
|
160
156
|
+ `; ${describeImageDelivery(this.imageDelivery)}`,
|
|
161
157
|
};
|
|
@@ -337,18 +333,24 @@ class PeerProcessHost implements ProcessHost {
|
|
|
337
333
|
* worker of the coordinator here exactly as it does on `facet`.
|
|
338
334
|
*/
|
|
339
335
|
runOnce<T>(params: OneShotParams, consume: (response: Response) => Promise<T>): Promise<T> {
|
|
340
|
-
return
|
|
341
|
-
this.ctx,
|
|
342
|
-
this.env,
|
|
336
|
+
return processes(this.ctx, this.env).run(
|
|
343
337
|
{ doId: this.coordDoId, pid: params.pid, writerId: params.writerId },
|
|
344
338
|
params,
|
|
345
339
|
consume,
|
|
346
340
|
);
|
|
347
341
|
}
|
|
348
|
-
|
|
349
342
|
async open(params: ProcessHostParams): Promise<HostedProcess> {
|
|
343
|
+
if (params.facet) {
|
|
344
|
+
// A durable application's facet must be a child of the COORDINATOR's
|
|
345
|
+
// Durable Object — its `app-slot-<n>` row and retained SQLite live in
|
|
346
|
+
// that DO's storage. A sibling host would own storage the coordinator's
|
|
347
|
+
// durable-slot book and removeDurableApp cannot reach.
|
|
348
|
+
throw new Error(
|
|
349
|
+
'Nimbus: a durable spawn must be facet-hosted on its own coordinator; '
|
|
350
|
+
+ 'the peer substrate cannot serve one',
|
|
351
|
+
);
|
|
352
|
+
}
|
|
350
353
|
const placement = await this._place(params.pid);
|
|
351
|
-
this.tokensInUse.set(params.pid, placement.isolateToken);
|
|
352
354
|
// Minted per open, held only by this coordinator and the peer that hosts
|
|
353
355
|
// the process. The workerKey is derivable from a pid; this is not.
|
|
354
356
|
const webSocketCapability = crypto.randomUUID();
|
|
@@ -386,7 +388,7 @@ class PeerProcessHost implements ProcessHost {
|
|
|
386
388
|
} catch (error) {
|
|
387
389
|
this.tokensInUse.delete(params.pid);
|
|
388
390
|
// Awaited, not fired off: a spawn that rejects must mean nothing was
|
|
389
|
-
// left running, which is what a throw from `
|
|
391
|
+
// left running, which is what a throw from `processes().spawn` means on
|
|
390
392
|
// the other substrate.
|
|
391
393
|
try { await this._cancel(params.workerKey, placement.peerName); }
|
|
392
394
|
finally { disposeRpcResource(placement.stub); }
|
package/src/sealed.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sealed.ts — prototype-chain RPC sealing, and the versioned surface
|
|
3
|
+
* constants that make it safe to run over the Agents SDK.
|
|
4
|
+
*
|
|
5
|
+
* Cloudflare resolves `stub.foo(...)` on the receiver's PROTOTYPE CHAIN.
|
|
6
|
+
* Proteus verified the three consequences against real workerd
|
|
7
|
+
* (`cf-backend/src/rpc-surface.ts:4-22`, catalog `rpc.prototype_chain`,
|
|
8
|
+
* proven-by-probe): TypeScript `private` is erased, so private methods ARE
|
|
9
|
+
* callable over RPC; superclass methods are reachable too — inherited
|
|
10
|
+
* `Agent.sql` hands any stub-holder arbitrary SQL against the receiver's
|
|
11
|
+
* storage, and `Agent` + `Server` alone contribute hundreds of reachable
|
|
12
|
+
* names; and OWN instance properties are NOT reachable — workerd rejects
|
|
13
|
+
* them exactly as it rejects a missing name, including when the own
|
|
14
|
+
* property shadows a prototype method.
|
|
15
|
+
*
|
|
16
|
+
* That third consequence is the primitive: {@link sealRpcSurface} copies
|
|
17
|
+
* every reachable member that is NOT on the declared surface down onto the
|
|
18
|
+
* instance as a non-enumerable own property. In-process behaviour is
|
|
19
|
+
* unchanged — `this.x(...)` finds the same function object, `super.x()`
|
|
20
|
+
* still reaches the prototype, accessors stay accessors. From outside, the
|
|
21
|
+
* name has ceased to exist. Call it as the LAST statement of the
|
|
22
|
+
* constructor, after every base class installed its wrappers.
|
|
23
|
+
*
|
|
24
|
+
* The surface constants are the part that breaks whenever the SDK moves,
|
|
25
|
+
* which is why fabric owns them: Proteus reverse-engineered its facet
|
|
26
|
+
* surface from `agents/dist` by hand, and a fabric test diffs these
|
|
27
|
+
* constants against the INSTALLED packages so drift is caught by CI, not by
|
|
28
|
+
* a leak. Verified against agents@0.20.1 and partyserver@0.5.10
|
|
29
|
+
* (2026-08-19); an SDK upgrade that changes the cross-stub set fails that
|
|
30
|
+
* test and demands re-derivation, which is the intended failure.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Every name RPC can resolve on `target`: own property names of every
|
|
35
|
+
* prototype up to (and excluding) Object.prototype, minus `constructor` and
|
|
36
|
+
* minus anything `target` already carries as an own property — own
|
|
37
|
+
* properties are not RPC-reachable, so they need no seal. The single
|
|
38
|
+
* definition {@link sealRpcSurface} and its tests both work from.
|
|
39
|
+
*/
|
|
40
|
+
export function rpcReachableNames(target: object): string[] {
|
|
41
|
+
const names = new Set<string>();
|
|
42
|
+
const own = new Set(Object.getOwnPropertyNames(target));
|
|
43
|
+
let proto = Object.getPrototypeOf(target) as object | null;
|
|
44
|
+
while (proto !== null && proto !== Object.prototype) {
|
|
45
|
+
for (const name of Object.getOwnPropertyNames(proto)) {
|
|
46
|
+
if (name === 'constructor' || own.has(name)) continue;
|
|
47
|
+
names.add(name);
|
|
48
|
+
}
|
|
49
|
+
proto = Object.getPrototypeOf(proto) as object | null;
|
|
50
|
+
}
|
|
51
|
+
return [...names].sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function inheritedDescriptor(target: object, name: string): PropertyDescriptor | undefined {
|
|
55
|
+
let proto = Object.getPrototypeOf(target) as object | null;
|
|
56
|
+
while (proto !== null && proto !== Object.prototype) {
|
|
57
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, name);
|
|
58
|
+
if (descriptor) return descriptor;
|
|
59
|
+
proto = Object.getPrototypeOf(proto) as object | null;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Shadow every RPC-reachable member not on `surface` with a non-enumerable
|
|
66
|
+
* own property carrying the SAME descriptor — the same function object, the
|
|
67
|
+
* same accessor pair — so in-process behaviour cannot change while the name
|
|
68
|
+
* stops resolving over RPC. Surface names the class lacks are ignored: a
|
|
69
|
+
* surface is a ceiling, and the runtime already denies what does not exist.
|
|
70
|
+
*/
|
|
71
|
+
export function sealRpcSurface<Instance extends object>(
|
|
72
|
+
instance: Instance,
|
|
73
|
+
surface: readonly string[],
|
|
74
|
+
): void {
|
|
75
|
+
const allowed = new Set(surface);
|
|
76
|
+
for (const name of rpcReachableNames(instance)) {
|
|
77
|
+
if (allowed.has(name)) continue;
|
|
78
|
+
const descriptor = inheritedDescriptor(instance, name);
|
|
79
|
+
if (descriptor) Object.defineProperty(instance, name, { ...descriptor, enumerable: false });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The platform half of any surface built on partyserver's `Server` (and
|
|
85
|
+
* therefore the Agents SDK's `Agent`): what the RUNTIME and the SDK's own
|
|
86
|
+
* routing must still reach after sealing. From the consumer's verified list
|
|
87
|
+
* (`rpc-surface.ts:63-86`): `setName` is called by `getServerByName` before
|
|
88
|
+
* the stub is returned; `_initAndFetch` is `setName` plus `fetch`, so it
|
|
89
|
+
* exposes nothing new; the WebSocket handlers' arguments cannot cross an
|
|
90
|
+
* RPC boundary anyway.
|
|
91
|
+
*/
|
|
92
|
+
export const PLATFORM_RPC_SURFACE = [
|
|
93
|
+
'fetch',
|
|
94
|
+
'setName',
|
|
95
|
+
'_initAndFetch',
|
|
96
|
+
'alarm',
|
|
97
|
+
'webSocketMessage',
|
|
98
|
+
'webSocketClose',
|
|
99
|
+
'webSocketError',
|
|
100
|
+
] as const;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The Agents SDK's own cross-stub facet protocol at agents@0.20.1: every
|
|
104
|
+
* `_cf_` method the SDK calls on a receiver that is not `this`, derived
|
|
105
|
+
* from `agents/dist` (the SDK's ACTUAL cross-stub surface, not a prefix
|
|
106
|
+
* rule — Agent's prototype defines 58 `_cf_` methods and only these are
|
|
107
|
+
* cross-called). An Agent that hosts SDK facets must keep these; one that
|
|
108
|
+
* does not (Proteus's UserDO) should not carry them at all.
|
|
109
|
+
*/
|
|
110
|
+
export const AGENTS_FACET_RPC_SURFACE = [
|
|
111
|
+
'_cf_acquireFacetKeepAlive',
|
|
112
|
+
'_cf_broadcastToSubAgent',
|
|
113
|
+
'_cf_cancelScheduleForFacet',
|
|
114
|
+
'_cf_checkRunFibersForFacet',
|
|
115
|
+
'_cf_cleanupFacetPrefix',
|
|
116
|
+
'_cf_closeSubAgentConnection',
|
|
117
|
+
'_cf_destroyDescendantFacet',
|
|
118
|
+
'_cf_dispatchScheduledCallback',
|
|
119
|
+
'_cf_getScheduleForFacet',
|
|
120
|
+
'_cf_handleSubAgentWebSocketClose',
|
|
121
|
+
'_cf_handleSubAgentWebSocketConnect',
|
|
122
|
+
'_cf_handleSubAgentWebSocketMessage',
|
|
123
|
+
'_cf_initAsFacet',
|
|
124
|
+
'_cf_listSchedulesForFacet',
|
|
125
|
+
'_cf_registerFacetRun',
|
|
126
|
+
'_cf_releaseFacetKeepAlive',
|
|
127
|
+
'_cf_scheduleEveryForFacet',
|
|
128
|
+
'_cf_scheduleForFacet',
|
|
129
|
+
'_cf_sendToSubAgentConnection',
|
|
130
|
+
'_cf_setSubAgentConnectionState',
|
|
131
|
+
'_cf_subAgentConnectionMetas',
|
|
132
|
+
'_cf_unregisterFacetRun',
|
|
133
|
+
] as const;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Cross-stub `_cf_` members deliberately absent from EVERY surface: each
|
|
137
|
+
* takes a method NAME and calls it on the receiver, which would re-open
|
|
138
|
+
* everything sealing closes. Sealing them fail-closes the SDK features
|
|
139
|
+
* built on them (`parentAgent()` proxies, workflow-to-agent bridges) — the
|
|
140
|
+
* consumer accepts exactly that cost, and fail-closed is the right default
|
|
141
|
+
* for a bridge that dispatches arbitrary names. (`_cf_invokeStubMethod` is
|
|
142
|
+
* only ever self-called in the pinned dist, but it is prototype-defined, so
|
|
143
|
+
* it is named here and sealed.)
|
|
144
|
+
*/
|
|
145
|
+
export const AGENTS_INVOKE_BRIDGES = [
|
|
146
|
+
'_cf_invokeAgentPath',
|
|
147
|
+
'_cf_invokeStubMethod',
|
|
148
|
+
'_cf_invokeSubAgent',
|
|
149
|
+
'_cf_invokeSubAgentPath',
|
|
150
|
+
] as const;
|
package/src/timers.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timers.ts — Durable Object alarm multiplexing, persisted across
|
|
3
|
+
* hibernation.
|
|
4
|
+
*
|
|
5
|
+
* A Durable Object has ONE alarm, and a second `setAlarm()` silently
|
|
6
|
+
* overwrites the first — so every alarm-driven subsystem coordinates through
|
|
7
|
+
* a single reason→deadline map and one dispatcher. Reasons are plain strings
|
|
8
|
+
* registered by the embedder: `timers(host, ctx).schedule` arms one, and
|
|
9
|
+
* `timers(host, ctx).dispatch` runs the embedder-supplied handler for every
|
|
10
|
+
* reason whose deadline has passed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
|
+
import { errorText } from '@nimbus-sh/core/_shared/error-text.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The storage the timer map lives in. `setAlarm` is optional because
|
|
18
|
+
* `wrangler dev` serves a storage without it, which is the whole reason
|
|
19
|
+
* scheduling degrades to a no-op instead of throwing.
|
|
20
|
+
*/
|
|
21
|
+
export interface TimerStorage {
|
|
22
|
+
get(key: string): Promise<unknown>;
|
|
23
|
+
put(key: string, value: unknown): Promise<void>;
|
|
24
|
+
delete(key: string): Promise<boolean>;
|
|
25
|
+
setAlarm?(scheduledTime: number): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The hosting actor's context, as the timer coordination reads it. */
|
|
29
|
+
export interface TimerContext {
|
|
30
|
+
storage: TimerStorage;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Multi-reason timer coordination map.
|
|
35
|
+
*
|
|
36
|
+
* JSON-serialised `Record<reason, deadlineMsEpoch>` where keys are the
|
|
37
|
+
* embedder's canonical reason strings (e.g. 'w9-flush', 'log-janitor'). The
|
|
38
|
+
* alarm() dispatcher reads this on fire, dispatches every reason whose
|
|
39
|
+
* deadline has passed, and re-arms `ctx.storage.setAlarm` at the earliest
|
|
40
|
+
* remaining deadline.
|
|
41
|
+
*
|
|
42
|
+
* Why a map (not a single nextAlarmAt + reason): two subsystems can have
|
|
43
|
+
* distinct deadlines. Without the map, the later setAlarm() call would
|
|
44
|
+
* overwrite the earlier reason silently, breaking whichever subsystem
|
|
45
|
+
* expected its deadline.
|
|
46
|
+
*
|
|
47
|
+
* Forward-compat: the dispatcher silently drops unknown reasons so a
|
|
48
|
+
* rollback from a future deploy that added new reasons doesn't leave the
|
|
49
|
+
* alarm stuck.
|
|
50
|
+
*
|
|
51
|
+
* The VALUE is live production DO storage ('w1_next_alarm_reasons', from the
|
|
52
|
+
* workstream that introduced it) and must never change — renaming a storage
|
|
53
|
+
* key is a migration, and orphaned rows are the least of what it breaks.
|
|
54
|
+
*/
|
|
55
|
+
export const TIMER_REASONS_KEY = 'w1_next_alarm_reasons';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The host instance carrying the per-instance timer chain. The field lives on
|
|
59
|
+
* the embedder's DO instance so one chain serializes every timer-map
|
|
60
|
+
* read-modify-write for that instance (see {@link Timers.schedule}).
|
|
61
|
+
*/
|
|
62
|
+
export interface TimerHost {
|
|
63
|
+
_timerChain?: Promise<unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The async context of a running dispatch's handlers. A schedule request
|
|
68
|
+
* made inside it lands in the dispatch's arm collection instead of the
|
|
69
|
+
* chain — a handler that AWAITED a chained schedule would be waiting on an
|
|
70
|
+
* entry queued behind the dispatch it is running inside, which is a
|
|
71
|
+
* deadlock. `Outbox.queue` awaits `timers.schedule`, so any handler that
|
|
72
|
+
* queues into an outbox reaches this. The dispatcher folds the collected
|
|
73
|
+
* arms into the reason map before its own re-arm.
|
|
74
|
+
*
|
|
75
|
+
* AsyncLocalStorage, not a host field, so the redirect is scoped to the
|
|
76
|
+
* dispatch's OWN async context: a concurrent turn that schedules while a
|
|
77
|
+
* handler awaits external IO takes the normal chain path and keeps its own
|
|
78
|
+
* turn-gated persistence. Requires the `nodejs_compat` (or `nodejs_als`)
|
|
79
|
+
* compatibility flag on workerd.
|
|
80
|
+
*/
|
|
81
|
+
const dispatchArms = new AsyncLocalStorage<Array<{ reason: string; whenMs: number }>>();
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What one timer handler may return: nothing, or a deadline this reason
|
|
85
|
+
* re-arms itself at. Re-arming through the return value keeps the map's
|
|
86
|
+
* read-modify-write inside the dispatcher, where it is serialized.
|
|
87
|
+
*/
|
|
88
|
+
export type TimerHandlerResult = void | { rearmAt: number };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The platform's alarm-invocation report, forwarded to every handler: the
|
|
92
|
+
* platform retries a failed alarm() with backoff and abandons it after its
|
|
93
|
+
* retry budget, and `isRetry`/`retryCount` are the only way a handler can
|
|
94
|
+
* tell how close it is to that abandonment. Structurally identical to
|
|
95
|
+
* workers-types' AlarmInvocationInfo; declared here so the module stays
|
|
96
|
+
* usable without the ambient types.
|
|
97
|
+
*/
|
|
98
|
+
export interface TimerAlarmInfo {
|
|
99
|
+
readonly isRetry: boolean;
|
|
100
|
+
readonly retryCount: number;
|
|
101
|
+
readonly scheduledTime: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The embedder's reasons, each with the handler that answers it. */
|
|
105
|
+
export type TimerHandlers = Record<
|
|
106
|
+
string,
|
|
107
|
+
(now: number, info?: TimerAlarmInfo) => TimerHandlerResult | Promise<TimerHandlerResult>
|
|
108
|
+
>;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One actor's timers: the reason map over its ONE platform alarm.
|
|
112
|
+
*
|
|
113
|
+
* A cheap accessor over `(host, ctx)` — the chain that serializes the map's
|
|
114
|
+
* read-modify-write lives on the host instance, so every `timers()` call for
|
|
115
|
+
* one instance coordinates through the same chain.
|
|
116
|
+
*/
|
|
117
|
+
export function timers(host: TimerHost, ctx: TimerContext): Timers {
|
|
118
|
+
return new Timers(host, ctx);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class Timers {
|
|
122
|
+
constructor(
|
|
123
|
+
private readonly host: TimerHost,
|
|
124
|
+
private readonly ctx: TimerContext,
|
|
125
|
+
) {}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Schedule (or re-schedule) a timer reason. Coordinated via a single map in
|
|
129
|
+
* DO storage so multiple subsystems don't clobber each other's `setAlarm()`
|
|
130
|
+
* calls.
|
|
131
|
+
*
|
|
132
|
+
* Semantics:
|
|
133
|
+
* - Reads the existing reasons map.
|
|
134
|
+
* - Sets `map[reason] = whenMs` IF `whenMs` is sooner than the
|
|
135
|
+
* currently-pending deadline for that reason (or no entry exists).
|
|
136
|
+
* Later-than-pending requests are silently ignored — the existing
|
|
137
|
+
* alarm will fire and re-arm anyway.
|
|
138
|
+
* - Writes the map back and calls `ctx.storage.setAlarm(min(deadlines))`.
|
|
139
|
+
*
|
|
140
|
+
* Cost: 1 storage read + 1 storage write + 1 setAlarm per call. setAlarm
|
|
141
|
+
* itself is billed as 1 row written per DO pricing. At a 60s janitor
|
|
142
|
+
* cadence, this is ~$0.05/mo/session at scale — dwarfed by the
|
|
143
|
+
* hibernation duration savings.
|
|
144
|
+
*
|
|
145
|
+
* Fail-soft: any throw is swallowed with a warn. On older runtimes /
|
|
146
|
+
* wrangler-dev where setAlarm is unavailable, this is a no-op (the
|
|
147
|
+
* subsystem's in-isolate setTimeout fallback continues to work).
|
|
148
|
+
*/
|
|
149
|
+
schedule(reason: string, whenMs: number): Promise<boolean> {
|
|
150
|
+
const { host, ctx } = this;
|
|
151
|
+
// From inside a dispatch handler, hand the arm to the dispatcher
|
|
152
|
+
// instead of the chain (see dispatchArms): the fold keeps EDF
|
|
153
|
+
// semantics, and the dispatch's own write and re-arm carry it. The
|
|
154
|
+
// setAlarm gate matches the chain path's, so both paths refuse alike
|
|
155
|
+
// on a runtime without alarms.
|
|
156
|
+
const arms = dispatchArms.getStore();
|
|
157
|
+
if (arms) {
|
|
158
|
+
if (typeof ctx?.storage?.setAlarm !== 'function') return Promise.resolve(false);
|
|
159
|
+
arms.push({ reason, whenMs });
|
|
160
|
+
return Promise.resolve(true);
|
|
161
|
+
}
|
|
162
|
+
// Serialize every read-modify-write of the reasons map through one
|
|
163
|
+
// per-instance chain: two schedulers firing back-to-back from one activity
|
|
164
|
+
// hook would otherwise interleave their get→put cycles and silently drop
|
|
165
|
+
// whichever reason wrote first.
|
|
166
|
+
const run = async (): Promise<boolean> => {
|
|
167
|
+
try {
|
|
168
|
+
const setAlarmFn = ctx?.storage?.setAlarm;
|
|
169
|
+
if (typeof setAlarmFn !== 'function') return false;
|
|
170
|
+
const existing = (await ctx.storage.get(TIMER_REASONS_KEY)) as
|
|
171
|
+
| Record<string, number>
|
|
172
|
+
| undefined;
|
|
173
|
+
const map: Record<string, number> = { ...(existing || {}) };
|
|
174
|
+
// Earliest-deadline-first: only update if new request is sooner or
|
|
175
|
+
// this reason has no pending entry.
|
|
176
|
+
if (!(reason in map) || whenMs < map[reason]) {
|
|
177
|
+
map[reason] = whenMs;
|
|
178
|
+
await ctx.storage.put(TIMER_REASONS_KEY, map);
|
|
179
|
+
}
|
|
180
|
+
const earliest = Math.min(...Object.values(map));
|
|
181
|
+
setAlarmFn.call(ctx.storage, earliest);
|
|
182
|
+
return true;
|
|
183
|
+
} catch (e) {
|
|
184
|
+
console.warn('[nimbus/W1] timers.schedule threw:', errorText(e));
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const chained = (host._timerChain ?? Promise.resolve()).then(run, run);
|
|
189
|
+
host._timerChain = chained;
|
|
190
|
+
return chained;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Multi-reason timer dispatcher. Called from the DO's `alarm()` handler
|
|
195
|
+
* with the embedder's handler map.
|
|
196
|
+
*
|
|
197
|
+
* For each pending reason whose deadline has passed, run its handler.
|
|
198
|
+
* Handlers are awaited in place: the alarm invocation is the fresh turn a
|
|
199
|
+
* re-entering subsystem asked for, and it has to stay the one paying for the
|
|
200
|
+
* work it just released.
|
|
201
|
+
*
|
|
202
|
+
* After running fireable reasons, re-arms `ctx.storage.setAlarm` at the
|
|
203
|
+
* earliest remaining deadline. If no reasons remain, deletes the map key and
|
|
204
|
+
* does NOT call setAlarm — the DO becomes hibernation-eligible after the 10s
|
|
205
|
+
* idle window.
|
|
206
|
+
*
|
|
207
|
+
* Forward/back-compat: unknown reasons silently dropped. `onLegacyAlarm`
|
|
208
|
+
* covers an alarm that fires with no map at all — a deploy from before the
|
|
209
|
+
* map existed left a bare `setAlarm` behind, and the embedder decides what
|
|
210
|
+
* that one-time fire means (one dispatch later the map is populated by the
|
|
211
|
+
* next schedule call).
|
|
212
|
+
*/
|
|
213
|
+
dispatch(
|
|
214
|
+
handlers: TimerHandlers,
|
|
215
|
+
onLegacyAlarm?: () => void,
|
|
216
|
+
alarmInfo?: TimerAlarmInfo,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
const { host, ctx } = this;
|
|
219
|
+
// Same serialization as schedule: the dispatcher's read→handlers→write
|
|
220
|
+
// cycle must not interleave with an activity-hook schedule.
|
|
221
|
+
const chained = (host._timerChain ?? Promise.resolve()).then(
|
|
222
|
+
() => dispatchBody(ctx, handlers, onLegacyAlarm, alarmInfo),
|
|
223
|
+
() => dispatchBody(ctx, handlers, onLegacyAlarm, alarmInfo),
|
|
224
|
+
);
|
|
225
|
+
host._timerChain = chained;
|
|
226
|
+
return chained;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function dispatchBody(
|
|
231
|
+
ctx: TimerContext,
|
|
232
|
+
handlers: TimerHandlers,
|
|
233
|
+
onLegacyAlarm?: () => void,
|
|
234
|
+
alarmInfo?: TimerAlarmInfo,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
// Collect schedule requests made inside handler context (see
|
|
237
|
+
// dispatchArms) and fold them into the map below, so an in-dispatch arm
|
|
238
|
+
// neither deadlocks on the chain nor races the write.
|
|
239
|
+
const arms: Array<{ reason: string; whenMs: number }> = [];
|
|
240
|
+
try {
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
const existing = (await ctx?.storage?.get?.(TIMER_REASONS_KEY)) as
|
|
243
|
+
| Record<string, number>
|
|
244
|
+
| undefined;
|
|
245
|
+
const map: Record<string, number> = { ...(existing || {}) };
|
|
246
|
+
const hadMap = Object.keys(map).length > 0;
|
|
247
|
+
if (!hadMap) {
|
|
248
|
+
dispatchArms.run(arms, () => onLegacyAlarm?.());
|
|
249
|
+
} else {
|
|
250
|
+
// Snapshot fireable reasons BEFORE running any of them, so a
|
|
251
|
+
// handler that schedules itself for the next cycle doesn't get
|
|
252
|
+
// immediately re-fired in the same dispatch.
|
|
253
|
+
const fired: string[] = [];
|
|
254
|
+
for (const [reason, when] of Object.entries(map)) {
|
|
255
|
+
if (when <= now) fired.push(reason);
|
|
256
|
+
}
|
|
257
|
+
for (const reason of fired) {
|
|
258
|
+
delete map[reason];
|
|
259
|
+
const handler = handlers[reason];
|
|
260
|
+
// Unknown reasons silently dropped (forward-compat).
|
|
261
|
+
if (!handler) continue;
|
|
262
|
+
try {
|
|
263
|
+
const result = await dispatchArms.run(arms, () => handler(now, alarmInfo));
|
|
264
|
+
if (result && typeof result.rearmAt === 'number') {
|
|
265
|
+
map[reason] = result.rearmAt;
|
|
266
|
+
}
|
|
267
|
+
} catch (e) {
|
|
268
|
+
console.warn(`[nimbus/W1] dispatch ${reason} threw:`, errorText(e));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Fold the in-dispatch arms, earliest-deadline-first per reason.
|
|
273
|
+
for (const arm of arms) {
|
|
274
|
+
if (!(arm.reason in map) || arm.whenMs < map[arm.reason]) {
|
|
275
|
+
map[arm.reason] = arm.whenMs;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Re-arm or clear.
|
|
279
|
+
const setAlarmFn = ctx?.storage?.setAlarm;
|
|
280
|
+
if (Object.keys(map).length > 0) {
|
|
281
|
+
await ctx.storage.put(TIMER_REASONS_KEY, map);
|
|
282
|
+
const earliest = Math.min(...Object.values(map));
|
|
283
|
+
if (typeof setAlarmFn === 'function') {
|
|
284
|
+
setAlarmFn.call(ctx.storage, earliest);
|
|
285
|
+
}
|
|
286
|
+
} else if (hadMap) {
|
|
287
|
+
try { await ctx.storage.delete(TIMER_REASONS_KEY); } catch {}
|
|
288
|
+
// No remaining reasons → no setAlarm call → DO becomes
|
|
289
|
+
// hibernation-eligible after the 10s idle window.
|
|
290
|
+
}
|
|
291
|
+
} catch (e) {
|
|
292
|
+
console.warn('[nimbus/W1] timers.dispatch threw:', errorText(e));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* turn-budget.ts — spreading a resident launch across Durable Object turns.
|
|
3
3
|
*
|
|
4
4
|
* Building a resident process is the largest single span of computation this
|
|
5
5
|
* session performs: for pi it walks a 17 MB source tree through eight
|
|
@@ -33,8 +33,10 @@
|
|
|
33
33
|
* bytes and treats its wall guard as coarse.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
import { runColdStart } from './generation.js';
|
|
37
|
+
|
|
36
38
|
/** How a paced launch gets back onto a fresh Durable Object turn. */
|
|
37
|
-
export interface
|
|
39
|
+
export interface TurnScheduler {
|
|
38
40
|
/**
|
|
39
41
|
* Suspend until a fresh turn is running this launch again.
|
|
40
42
|
*
|
|
@@ -54,7 +56,7 @@ export interface LaunchTurnScheduler {
|
|
|
54
56
|
* each an alarm round trip — small enough not to dominate a launch. pi's
|
|
55
57
|
* 22.9 MB map crosses this about a dozen times per phase that handles it.
|
|
56
58
|
*/
|
|
57
|
-
export const
|
|
59
|
+
export const TURN_CHUNK_MAX_BYTES = 2_000_000;
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
62
|
* Accounts launch progress and ends the turn when a chunk's worth has been
|
|
@@ -66,7 +68,7 @@ export const LAUNCH_CHUNK_MAX_BYTES = 2_000_000;
|
|
|
66
68
|
* behaviour and cost. Nothing here decides WHAT the launch does, only where it
|
|
67
69
|
* is allowed to stop.
|
|
68
70
|
*/
|
|
69
|
-
export class
|
|
71
|
+
export class TurnBudget {
|
|
70
72
|
/** Turn handoffs this launch has taken. Reported with the launch. */
|
|
71
73
|
chunks = 0;
|
|
72
74
|
/** Total work accounted, for the same report. */
|
|
@@ -84,8 +86,8 @@ export class LaunchPacer {
|
|
|
84
86
|
* remembered to ask.
|
|
85
87
|
*/
|
|
86
88
|
constructor(
|
|
87
|
-
private readonly scheduler:
|
|
88
|
-
private readonly maxChunkBytes: number =
|
|
89
|
+
private readonly scheduler: TurnScheduler,
|
|
90
|
+
private readonly maxChunkBytes: number = TURN_CHUNK_MAX_BYTES,
|
|
89
91
|
private readonly stillWanted?: () => void,
|
|
90
92
|
) {}
|
|
91
93
|
|
|
@@ -121,39 +123,33 @@ export class LaunchPacer {
|
|
|
121
123
|
}
|
|
122
124
|
}
|
|
123
125
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
/** `Promise.withResolvers` for the runtime the project targets. */
|
|
127
|
+
export function withResolvers<T = void>(): { promise: Promise<T>; resolve: (value: T | PromiseLike<T>) => void } {
|
|
128
|
+
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
129
|
+
const promise = new Promise<T>((r) => { resolve = r; });
|
|
127
130
|
return { promise, resolve };
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
/** What {@link
|
|
131
|
-
export interface
|
|
133
|
+
/** What {@link PacedWork} needs from the Durable Object hosting it. */
|
|
134
|
+
export interface PacedWorkHost {
|
|
132
135
|
/**
|
|
133
|
-
* Arrange for {@link
|
|
136
|
+
* Arrange for {@link PacedWork.pump} to run on a fresh Durable Object
|
|
134
137
|
* turn.
|
|
135
138
|
*
|
|
136
139
|
* The embedder satisfies this with an alarm, which is the only primitive
|
|
137
140
|
* that genuinely re-enters the object: a fresh turn is both a released
|
|
138
141
|
* thread and a fresh CPU budget, and a launch needs each for a different
|
|
139
142
|
* reason. Without it the pump degrades to a same-context timer — see
|
|
140
|
-
* {@link
|
|
143
|
+
* {@link PacedWork.nextTurn}.
|
|
141
144
|
*/
|
|
142
145
|
requestTurn?: () => void;
|
|
143
|
-
/**
|
|
144
|
-
* Awaited first on every pump, before any waiter resumes. Where the
|
|
145
|
-
* resident-launch journal's recovery sits: the pump is what an alarm calls,
|
|
146
|
-
* and the first turn after a reset is the re-delivered alarm of a launch
|
|
147
|
-
* the reset interrupted.
|
|
148
|
-
*/
|
|
149
|
-
recover?: () => Promise<void>;
|
|
150
146
|
}
|
|
151
147
|
|
|
152
148
|
/**
|
|
153
|
-
* The granting side of {@link
|
|
149
|
+
* The granting side of {@link TurnScheduler}: parks suspended launches
|
|
154
150
|
* and resumes every one of them when the host grants a fresh turn.
|
|
155
151
|
*/
|
|
156
|
-
export class
|
|
152
|
+
export class PacedWork implements TurnScheduler {
|
|
157
153
|
/**
|
|
158
154
|
* Launches suspended between chunks, waiting for a turn of their own.
|
|
159
155
|
*
|
|
@@ -166,7 +162,14 @@ export class LaunchTurnPump implements LaunchTurnScheduler {
|
|
|
166
162
|
*/
|
|
167
163
|
private waiters: Array<{ resume: () => void; chunkEnded: Promise<void> }> = [];
|
|
168
164
|
|
|
169
|
-
|
|
165
|
+
/**
|
|
166
|
+
* `ctx` keys the cold-start queue the pump drains first on every turn it
|
|
167
|
+
* grants — see {@link pump}.
|
|
168
|
+
*/
|
|
169
|
+
constructor(
|
|
170
|
+
private readonly ctx: object,
|
|
171
|
+
private readonly host: PacedWorkHost,
|
|
172
|
+
) {}
|
|
170
173
|
|
|
171
174
|
/**
|
|
172
175
|
* How a paced launch asks for a fresh turn.
|
|
@@ -198,7 +201,11 @@ export class LaunchTurnPump implements LaunchTurnScheduler {
|
|
|
198
201
|
* the runtime may tear the context down mid-chunk.
|
|
199
202
|
*/
|
|
200
203
|
async pump(): Promise<void> {
|
|
201
|
-
|
|
204
|
+
// Deferred reconciliation runs first, before any waiter resumes. Where
|
|
205
|
+
// the resident-launch journal's recovery sits (`onColdStart`): the pump
|
|
206
|
+
// is what an alarm calls, and the first turn after a reset is the
|
|
207
|
+
// re-delivered alarm of a launch the reset interrupted.
|
|
208
|
+
await runColdStart(this.ctx);
|
|
202
209
|
const waiting = this.waiters;
|
|
203
210
|
if (waiting.length === 0) return;
|
|
204
211
|
this.waiters = [];
|
|
@@ -222,10 +229,10 @@ export class LaunchTurnPump implements LaunchTurnScheduler {
|
|
|
222
229
|
* `git/commands.ts` carries `NIMBUS_GIT_CHECKOUT_CHUNK_ENTRIES`. Unset in
|
|
223
230
|
* production, where the default applies.
|
|
224
231
|
*/
|
|
225
|
-
export function
|
|
232
|
+
export function turnChunkMaxBytes(env: unknown): number {
|
|
226
233
|
const raw = (env as { NIMBUS_LAUNCH_CHUNK_BYTES?: string } | null | undefined)
|
|
227
234
|
?.NIMBUS_LAUNCH_CHUNK_BYTES;
|
|
228
|
-
if (!raw) return
|
|
235
|
+
if (!raw) return TURN_CHUNK_MAX_BYTES;
|
|
229
236
|
const parsed = Number(raw);
|
|
230
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed :
|
|
237
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : TURN_CHUNK_MAX_BYTES;
|
|
231
238
|
}
|