@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* fenced-work.ts — durable record of the resident launches a Durable Object
|
|
3
3
|
* owes, and their recovery after an instance reset.
|
|
4
4
|
*
|
|
5
5
|
* The platform resets a session Durable Object over what one turn has
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* What a launch IS stays the embedder's: the journal stores the record it is
|
|
16
16
|
* given and hands it back on recovery. The mechanism reads only the fields in
|
|
17
|
-
* {@link
|
|
17
|
+
* {@link FencedWorkRecord}; everything else in the record rides through
|
|
18
18
|
* opaquely.
|
|
19
19
|
*/
|
|
20
20
|
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
* storage key is a migration, and orphaned rows are the least of what it
|
|
38
38
|
* breaks.
|
|
39
39
|
*/
|
|
40
|
-
export const
|
|
40
|
+
export const FENCED_WORK_KEY_PREFIX = 'resident-launch:';
|
|
41
41
|
|
|
42
42
|
/** A launch is re-driven once. A reset that recurs is not the transient one. */
|
|
43
|
-
export const
|
|
43
|
+
export const FENCED_WORK_MAX_ATTEMPT = 1;
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* A resident process this session owes the user, as a later instance would
|
|
@@ -60,7 +60,7 @@ export const RESIDENT_LAUNCH_MAX_ATTEMPT = 1;
|
|
|
60
60
|
* process host's held-open leg dies with it), so a row from a previous
|
|
61
61
|
* generation always names a process that is genuinely gone.
|
|
62
62
|
*/
|
|
63
|
-
export interface
|
|
63
|
+
export interface FencedWorkRecord {
|
|
64
64
|
pid: number;
|
|
65
65
|
command: string;
|
|
66
66
|
/** 0 for a launch the user asked for; 1 for the one re-drive it may get. */
|
|
@@ -74,9 +74,9 @@ export interface ResidentLaunchRecord {
|
|
|
74
74
|
/**
|
|
75
75
|
* The slice of Durable Object storage the journal writes through. Exactly a
|
|
76
76
|
* `DurableObjectStorage`, narrowed to what the mechanism performs — `sync()`
|
|
77
|
-
* is load-bearing, see {@link
|
|
77
|
+
* is load-bearing, see {@link FencedWork.journal}.
|
|
78
78
|
*/
|
|
79
|
-
export interface
|
|
79
|
+
export interface FencedWorkStorage {
|
|
80
80
|
put(key: string, value: unknown): Promise<void>;
|
|
81
81
|
delete(key: string): Promise<boolean>;
|
|
82
82
|
list<T = unknown>(options: { prefix: string }): Promise<Map<string, T>>;
|
|
@@ -84,7 +84,7 @@ export interface LaunchJournalStorage {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/** What the journal's recovery needs from its embedder. */
|
|
87
|
-
export interface
|
|
87
|
+
export interface FencedWorkHost<R extends FencedWorkRecord> {
|
|
88
88
|
/**
|
|
89
89
|
* The current instance generation's pid floor. A pid at or below it was
|
|
90
90
|
* allocated by a PREVIOUS instance (core's process-table, PID_GEN_STRIDE),
|
|
@@ -94,8 +94,13 @@ export interface LaunchJournalHost<R extends ResidentLaunchRecord> {
|
|
|
94
94
|
*/
|
|
95
95
|
generationBase(): number;
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
97
|
+
* Where an un-awaited re-drive goes so it is not a floating rejection —
|
|
98
|
+
* `ctx.waitUntil` in practice. Hygiene, not retention: a Durable Object
|
|
99
|
+
* cancels an in-flight promise on reset with no signal, and workerd's
|
|
100
|
+
* `waitUntil` is a no-op there (PLATFORM.md, probe 2026-08-17 — awaiting
|
|
101
|
+
* inside the invocation is the only retention an actor has). The recovery
|
|
102
|
+
* guarantee comes from the journal row and the platform's alarm
|
|
103
|
+
* re-delivery, never from this hook.
|
|
99
104
|
*/
|
|
100
105
|
waitUntil(promise: Promise<unknown>): void;
|
|
101
106
|
/**
|
|
@@ -121,25 +126,28 @@ export interface LaunchJournalHost<R extends ResidentLaunchRecord> {
|
|
|
121
126
|
* read the journal a reset leaves behind. Rows from a previous instance are
|
|
122
127
|
* recovery's to consume, never the release path's.
|
|
123
128
|
*/
|
|
124
|
-
export class
|
|
129
|
+
export class FencedWork<R extends FencedWorkRecord> {
|
|
125
130
|
/**
|
|
126
131
|
* Pids THIS instance holds journal rows for. What keeps the terminal hook —
|
|
127
132
|
* which fires for every process, shells and one-shots included — from
|
|
128
133
|
* paying a storage delete for pids that never had a row.
|
|
129
134
|
*/
|
|
130
135
|
private journalledPids = new Set<number>();
|
|
136
|
+
/** Re-drives in flight, by journal key — recovery's un-awaited ones and a
|
|
137
|
+
* caller-driven one share the same drive for the same row. */
|
|
138
|
+
private drives = new Map<string, Promise<boolean>>();
|
|
131
139
|
/** Whether this instance has already read the journal a reset leaves behind. */
|
|
132
140
|
private recovered = false;
|
|
133
141
|
|
|
134
142
|
constructor(
|
|
135
|
-
private readonly storage:
|
|
136
|
-
private readonly host:
|
|
143
|
+
private readonly storage: FencedWorkStorage,
|
|
144
|
+
private readonly host: FencedWorkHost<R>,
|
|
137
145
|
) {}
|
|
138
146
|
|
|
139
147
|
/**
|
|
140
148
|
* Record a launch as in flight, so an instance that replaces this one knows
|
|
141
|
-
* it never finished.
|
|
142
|
-
*
|
|
149
|
+
* it never finished. A launch that cannot be journalled does not start: the
|
|
150
|
+
* rejection reaches the caller, which reports it like any launch failure.
|
|
143
151
|
*
|
|
144
152
|
* Synced, not merely put: `await put()` resolves before durability, and the
|
|
145
153
|
* reset this journal exists for destroys every write its turn still had
|
|
@@ -152,12 +160,12 @@ export class ResidentLaunchJournal<R extends ResidentLaunchRecord> {
|
|
|
152
160
|
* losing its row costs a retype, not a recovery.
|
|
153
161
|
*/
|
|
154
162
|
async journal(record: R): Promise<void> {
|
|
163
|
+
this.journalledPids.add(record.pid);
|
|
155
164
|
try {
|
|
156
|
-
this.
|
|
157
|
-
await this.storage.put(`${RESIDENT_LAUNCH_KEY_PREFIX}${record.pid}`, record);
|
|
165
|
+
await this.storage.put(`${FENCED_WORK_KEY_PREFIX}${record.pid}`, record);
|
|
158
166
|
await this.storage.sync();
|
|
159
|
-
} catch (
|
|
160
|
-
|
|
167
|
+
} catch (cause: unknown) {
|
|
168
|
+
throw new Error('resident launch journal write failed', { cause });
|
|
161
169
|
}
|
|
162
170
|
}
|
|
163
171
|
|
|
@@ -173,12 +181,68 @@ export class ResidentLaunchJournal<R extends ResidentLaunchRecord> {
|
|
|
173
181
|
*/
|
|
174
182
|
async release(pid: number): Promise<void> {
|
|
175
183
|
if (!this.journalledPids.delete(pid)) return;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
184
|
+
await this.storage.delete(`${FENCED_WORK_KEY_PREFIX}${pid}`);
|
|
185
|
+
await this.storage.sync();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Drop every row `predicate` claims, live-pid bookkeeping included, synced
|
|
190
|
+
* like {@link release}. The one bulk delete the journal admits: an owner
|
|
191
|
+
* that removes its durable application is owed no recovery, however many
|
|
192
|
+
* generations back its rows were written.
|
|
193
|
+
*/
|
|
194
|
+
async purgeWhere(predicate: (record: R) => boolean): Promise<number> {
|
|
195
|
+
const rows = await this.storage.list<R>({ prefix: FENCED_WORK_KEY_PREFIX });
|
|
196
|
+
let purged = 0;
|
|
197
|
+
for (const [key, record] of rows) {
|
|
198
|
+
if (!predicate(record)) continue;
|
|
199
|
+
this.journalledPids.delete(record.pid);
|
|
200
|
+
await this.storage.delete(key);
|
|
201
|
+
purged += 1;
|
|
181
202
|
}
|
|
203
|
+
if (purged > 0) await this.storage.sync();
|
|
204
|
+
return purged;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Every journal row, storage-true — the rows this instance wrote and the
|
|
209
|
+
* ones a previous instance left behind. The one read surface a request-
|
|
210
|
+
* driven recovery needs to find the durable launch a port belongs to.
|
|
211
|
+
*/
|
|
212
|
+
async rows(): Promise<Map<string, R>> {
|
|
213
|
+
return this.storage.list<R>({ prefix: FENCED_WORK_KEY_PREFIX });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Re-drive one journal row — the awaited sibling of recovery's un-awaited
|
|
218
|
+
* re-drives, for a caller that must know whether the launch actually came
|
|
219
|
+
* back. Single-flight per row: a request-driven drive and recovery's own
|
|
220
|
+
* never boot the same launch twice. Resolves true only when the re-drive
|
|
221
|
+
* itself FAILED and the failure was reported; a settled drive supersedes
|
|
222
|
+
* the row the same way recovery's does.
|
|
223
|
+
*/
|
|
224
|
+
drive(key: string, record: R): Promise<boolean> {
|
|
225
|
+
let inflight = this.drives.get(key);
|
|
226
|
+
if (inflight === undefined) {
|
|
227
|
+
inflight = (async (): Promise<boolean> => {
|
|
228
|
+
let failed = false;
|
|
229
|
+
try {
|
|
230
|
+
await this.host.redrive(record, record.attempt + 1);
|
|
231
|
+
} catch (e: unknown) {
|
|
232
|
+
this.host.onRedriveFailed?.(record, e);
|
|
233
|
+
failed = true;
|
|
234
|
+
}
|
|
235
|
+
// A reported failure is settled business — supersede either way, so
|
|
236
|
+
// the row never re-surfaces on the next recovery.
|
|
237
|
+
await this.supersede(key);
|
|
238
|
+
return failed;
|
|
239
|
+
})();
|
|
240
|
+
this.drives.set(key, inflight);
|
|
241
|
+
inflight.finally(() => {
|
|
242
|
+
if (this.drives.get(key) === inflight) this.drives.delete(key);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return inflight;
|
|
182
246
|
}
|
|
183
247
|
|
|
184
248
|
/**
|
|
@@ -190,40 +254,63 @@ export class ResidentLaunchJournal<R extends ResidentLaunchRecord> {
|
|
|
190
254
|
* that replaces this one. So the first turn after a reset is already this
|
|
191
255
|
* one.
|
|
192
256
|
*
|
|
193
|
-
* Runs once per instance
|
|
194
|
-
*
|
|
257
|
+
* Runs once per instance — re-calls in the same instance are no-ops — and
|
|
258
|
+
* re-drives every row whose pid is `> 0` and at or below `generationBase()`
|
|
259
|
+
* with `attempt < FENCED_WORK_MAX_ATTEMPT`; the rest are abandoned. What the
|
|
260
|
+
* re-drive resolver receives is the journalled recipe and nothing else:
|
|
261
|
+
* env and credentials are never written to storage, so the resolver's
|
|
262
|
+
* embedder re-resolves them rather than reading them back.
|
|
195
263
|
*/
|
|
196
264
|
async recoverInterrupted(): Promise<void> {
|
|
197
265
|
if (this.recovered) return;
|
|
198
266
|
this.recovered = true;
|
|
199
|
-
const journal = await this.storage.list<R>({ prefix:
|
|
267
|
+
const journal = await this.storage.list<R>({ prefix: FENCED_WORK_KEY_PREFIX });
|
|
200
268
|
const base = this.host.generationBase();
|
|
269
|
+
const abandoned: Array<[string, R]> = [];
|
|
270
|
+
const redriven: Array<[string, R]> = [];
|
|
201
271
|
for (const [key, record] of journal) {
|
|
202
272
|
// A pid at or below this instance's base was allocated by a PREVIOUS one
|
|
203
273
|
// (process-table.ts, PID_GEN_STRIDE), so its launch never finished; above
|
|
204
274
|
// the base is this instance's own, still running. Same predicate as
|
|
205
275
|
// `session/rpc.ts` uses to attribute a prior generation's pid.
|
|
206
276
|
if (!(record.pid > 0 && record.pid <= base)) continue;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
277
|
+
(record.attempt >= FENCED_WORK_MAX_ATTEMPT ? abandoned : redriven).push([key, record]);
|
|
278
|
+
}
|
|
279
|
+
// The attempt is SPENT in storage, synced, before any re-drive starts.
|
|
280
|
+
// Deleting the row here instead would open a loss window: writes flush in
|
|
281
|
+
// order, so a reset between the delete's flush and the re-driven launch's
|
|
282
|
+
// own journal write leaves a durable state with no row for an owed
|
|
283
|
+
// launch — and the un-awaited re-drive dies with the instance (waitUntil
|
|
284
|
+
// retains nothing). With the rewrite, every durable cut is either the
|
|
285
|
+
// untouched row (recovery re-runs) or a spent attempt (the recurrence
|
|
286
|
+
// abandons loudly). The superseded row is deleted only when its re-drive
|
|
287
|
+
// settles, after the launch's own row exists.
|
|
288
|
+
for (const [key] of abandoned) await this.storage.delete(key);
|
|
289
|
+
for (const [key, record] of redriven) {
|
|
290
|
+
await this.storage.put(key, { ...record, attempt: record.attempt + 1 });
|
|
291
|
+
}
|
|
292
|
+
if (abandoned.length > 0 || redriven.length > 0) await this.storage.sync();
|
|
293
|
+
for (const [, record] of abandoned) this.host.onAbandoned?.(record);
|
|
294
|
+
for (const [key, record] of redriven) {
|
|
212
295
|
this.host.onRedrive?.(record);
|
|
213
296
|
// Not awaited: this call is running inside the alarm that granted the
|
|
214
297
|
// turn, and the launch it starts asks for turns of its own through that
|
|
215
298
|
// same alarm — awaiting it here would be waiting on an alarm that cannot
|
|
216
|
-
// be scheduled until this one returns.
|
|
217
|
-
this
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
this.host.onRedriveFailed?.(record, e);
|
|
221
|
-
}),
|
|
222
|
-
);
|
|
299
|
+
// be scheduled until this one returns. `drive` single-flights it: a
|
|
300
|
+
// request that arrives mid-launch waits on this same drive rather than
|
|
301
|
+
// booting a second process.
|
|
302
|
+
this.host.waitUntil(this.drive(key, record));
|
|
223
303
|
}
|
|
224
304
|
}
|
|
225
|
-
}
|
|
226
305
|
|
|
227
|
-
|
|
228
|
-
|
|
306
|
+
/**
|
|
307
|
+
* Delete a row whose re-drive has settled — succeeded, failed and been
|
|
308
|
+
* reported, or handed off to its own journal row. Not `release()`: that
|
|
309
|
+
* path is for pids THIS instance journalled, and this row's pid belongs to
|
|
310
|
+
* a previous generation the terminal hook will never fire for.
|
|
311
|
+
*/
|
|
312
|
+
private async supersede(key: string): Promise<void> {
|
|
313
|
+
await this.storage.delete(key);
|
|
314
|
+
await this.storage.sync();
|
|
315
|
+
}
|
|
229
316
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generation.ts — the isolate-generation clock, and the deferred
|
|
3
|
+
* reconciliation that runs once per fresh incarnation.
|
|
4
|
+
*
|
|
5
|
+
* Workerd hibernates Durable Objects between requests to free memory. On
|
|
6
|
+
* wake, the new isolate must rebuild its in-memory state from SQL — but it
|
|
7
|
+
* also needs to know "is this the same lifecycle as before, or did workerd
|
|
8
|
+
* recycle me?" That distinction matters for recovery (warmJoin vs cold init)
|
|
9
|
+
* and is captured by the isolate generation, a counter persisted across
|
|
10
|
+
* hibernations.
|
|
11
|
+
*
|
|
12
|
+
* State is keyed weakly off the actor's `ctx`, which lives exactly as long as
|
|
13
|
+
* the incarnation: a fresh isolate gets a fresh `ctx`, adopts the persisted
|
|
14
|
+
* counter, and bumps it once. The embedder reads `generation(ctx)` wherever it
|
|
15
|
+
* needs the incarnation number — one source of truth instead of mirrored host
|
|
16
|
+
* fields.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { errorText } from '@nimbus-sh/core/_shared/error-text.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Storage key for the isolate-generation counter (cold-start +
|
|
23
|
+
* post-hibernation wake; one increment per fresh isolate).
|
|
24
|
+
*
|
|
25
|
+
* The VALUE is live production DO storage ('w9_isolate_gen') and must never
|
|
26
|
+
* change — renaming a storage key is a migration, and orphaned rows are the
|
|
27
|
+
* least of what it breaks.
|
|
28
|
+
*/
|
|
29
|
+
export const GENERATION_KEY = 'w9_isolate_gen';
|
|
30
|
+
|
|
31
|
+
/** The storage the generation counter persists through. */
|
|
32
|
+
export interface GenerationStorage {
|
|
33
|
+
get(key: string): Promise<unknown>;
|
|
34
|
+
put(key: string, value: unknown): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The hosting actor's context, as the generation clock reads it. */
|
|
38
|
+
export interface GenerationContext {
|
|
39
|
+
storage: GenerationStorage;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface GenerationState {
|
|
43
|
+
value: number;
|
|
44
|
+
adopted: boolean;
|
|
45
|
+
/** Deferred reconciliation tasks, drained by {@link runColdStart}. */
|
|
46
|
+
coldStart: Array<() => Promise<unknown>>;
|
|
47
|
+
/** Serializes drains so two callers never run one task twice. */
|
|
48
|
+
coldStartChain: Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const states = new WeakMap<object, GenerationState>();
|
|
52
|
+
|
|
53
|
+
function stateOf(ctx: object): GenerationState {
|
|
54
|
+
let state = states.get(ctx);
|
|
55
|
+
if (!state) {
|
|
56
|
+
state = { value: 0, adopted: false, coldStart: [], coldStartChain: Promise.resolve() };
|
|
57
|
+
states.set(ctx, state);
|
|
58
|
+
}
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** This incarnation's generation. Zero until {@link adoptGeneration} ran. */
|
|
63
|
+
export function generation(ctx: object): number {
|
|
64
|
+
return states.get(ctx)?.value ?? 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Increment + persist the generation counter once per fresh isolate. */
|
|
68
|
+
export async function adoptGeneration(ctx: GenerationContext): Promise<void> {
|
|
69
|
+
const state = stateOf(ctx);
|
|
70
|
+
if (state.adopted) return;
|
|
71
|
+
state.adopted = true;
|
|
72
|
+
try {
|
|
73
|
+
const prev = (await ctx.storage.get(GENERATION_KEY)) as number | undefined;
|
|
74
|
+
// Adopt the persisted truth first, and adopt the bump only after the
|
|
75
|
+
// put resolves. An unpersisted `next` would be re-read as `prev` by the
|
|
76
|
+
// NEXT boot and re-issued — two instances sharing one generation is
|
|
77
|
+
// exactly the pid-aliasing this counter exists to prevent. Running on
|
|
78
|
+
// the previous persisted generation is the lesser lapse, and the
|
|
79
|
+
// put-failure case is replica-only in practice (replicas never spawn).
|
|
80
|
+
//
|
|
81
|
+
// What holds the guarantee is the output gate, not this await: measured,
|
|
82
|
+
// the block body resolves in 0 ms even with a confirmed put, because
|
|
83
|
+
// `await storage.put()` returns before durability. The gate is what
|
|
84
|
+
// keeps a pid from generation N from escaping before N is durable, which
|
|
85
|
+
// is why marking this put `allowUnconfirmed` is not a free speedup — see
|
|
86
|
+
// scratchpad/coldstart-s1.md.
|
|
87
|
+
state.value = typeof prev === 'number' ? prev : 0;
|
|
88
|
+
const next = state.value + 1;
|
|
89
|
+
await ctx.storage.put(GENERATION_KEY, next);
|
|
90
|
+
state.value = next;
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.warn('[nimbus/W9] generation bump failed:', errorText(e));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Take on a generation without persisting it, and clear the adopted guard so
|
|
98
|
+
* a later {@link adoptGeneration} re-derives from storage. The
|
|
99
|
+
* destroy-and-recreate path uses this: it wipes storage, re-persists the
|
|
100
|
+
* pre-destroy counter, and runs the rest of this incarnation on the successor
|
|
101
|
+
* generation the next boot will derive.
|
|
102
|
+
*/
|
|
103
|
+
export function assumeGeneration(ctx: object, value: number): void {
|
|
104
|
+
const state = stateOf(ctx);
|
|
105
|
+
state.value = value;
|
|
106
|
+
state.adopted = false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Queue deferred async reconciliation for this incarnation.
|
|
111
|
+
*
|
|
112
|
+
* The task runs on the first {@link runColdStart} after registration — a turn
|
|
113
|
+
* the embedder already owns, NEVER the constructor's init gate. Awaiting
|
|
114
|
+
* recovery on the gate path is the trap this helper exists to avoid: the gate
|
|
115
|
+
* blocks every request to the object, and reconciliation wants a filesystem
|
|
116
|
+
* and a terminal that only a later turn has. The gate is also a wall, not
|
|
117
|
+
* just a stall: a `blockConcurrencyWhile` callback still pending at ~30 s
|
|
118
|
+
* (BLOCK_CONCURRENCY_CANCEL_MS, proven by probe) is cancelled and RESETS the
|
|
119
|
+
* object with every queued event — so never call {@link runColdStart} from
|
|
120
|
+
* inside one; the pump and the embedder's own turns are the places it runs.
|
|
121
|
+
*/
|
|
122
|
+
export function onColdStart(ctx: object, task: () => Promise<unknown>): void {
|
|
123
|
+
stateOf(ctx).coldStart.push(task);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Drain the queued cold-start tasks, serialized, each awaited so the turn
|
|
128
|
+
* that runs them pays for them. Idempotent between registrations: a drained
|
|
129
|
+
* queue is a cheap no-op, and a task registered after a drain runs on the
|
|
130
|
+
* next call.
|
|
131
|
+
*/
|
|
132
|
+
export function runColdStart(ctx: object): Promise<void> {
|
|
133
|
+
const state = stateOf(ctx);
|
|
134
|
+
if (state.coldStart.length === 0) return state.coldStartChain;
|
|
135
|
+
const tasks = state.coldStart;
|
|
136
|
+
state.coldStart = [];
|
|
137
|
+
const chained = state.coldStartChain.then(async () => {
|
|
138
|
+
for (const task of tasks) {
|
|
139
|
+
await task();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
state.coldStartChain = chained.catch(() => {});
|
|
143
|
+
return chained;
|
|
144
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* image-store.ts — materializing resident-process boot images into the
|
|
3
3
|
* content-addressed image store, and sweeping the ones nothing boots from.
|
|
4
4
|
*
|
|
5
5
|
* A resident process's module map is sized by the user's disk, so it does not
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
* table.
|
|
13
13
|
*
|
|
14
14
|
* The filesystem itself stays the embedder's, reached through the
|
|
15
|
-
* {@link
|
|
15
|
+
* {@link ImageBlobStore} port — the store decides what is written where
|
|
16
16
|
* and when; the port decides how bytes land on a disk and with what modes and
|
|
17
17
|
* credentials.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { MAX_TX_BLOB_BYTES, CHUNK_SIZE } from '@nimbus-sh/
|
|
20
|
+
import { MAX_TX_BLOB_BYTES, CHUNK_SIZE } from '@nimbus-sh/platform/limits.js';
|
|
21
21
|
import { FACET_IMAGE_DIR, facetImageDigest, facetImagePath } from './process-fabric.js';
|
|
22
|
-
import type {
|
|
22
|
+
import type { TurnBudget } from './turn-budget.js';
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Bytes of an image written in one storage transaction.
|
|
@@ -39,7 +39,7 @@ export const FACET_IMAGE_WRITE_SLICE_BYTES = Math.floor(MAX_TX_BLOB_BYTES / CHUN
|
|
|
39
39
|
* normalization are the implementation's: the store passes the same
|
|
40
40
|
* store-relative paths it later roots and sweeps by.
|
|
41
41
|
*/
|
|
42
|
-
export interface
|
|
42
|
+
export interface ImageBlobStore {
|
|
43
43
|
/** Create a directory (and its parents) if it does not exist. */
|
|
44
44
|
mkdirp(dir: string): void;
|
|
45
45
|
/** The file's current size in bytes, or null when it does not exist. */
|
|
@@ -62,7 +62,7 @@ export interface FacetImageBlobStore {
|
|
|
62
62
|
* hash's problem; everything else is idempotent — an image already present
|
|
63
63
|
* at its own digest is already the bytes we were about to write.
|
|
64
64
|
*/
|
|
65
|
-
export class
|
|
65
|
+
export class ImageStore {
|
|
66
66
|
/** pid → the boot images its facet loads from; the image sweep's root set. */
|
|
67
67
|
private residentImages = new Map<number, string[]>();
|
|
68
68
|
private dirReady = false;
|
|
@@ -75,7 +75,7 @@ export class FacetImageStore {
|
|
|
75
75
|
* is the process table, reached through this one predicate.
|
|
76
76
|
*/
|
|
77
77
|
constructor(
|
|
78
|
-
private readonly blobs: () =>
|
|
78
|
+
private readonly blobs: () => ImageBlobStore,
|
|
79
79
|
private readonly isLive: (pid: number) => boolean,
|
|
80
80
|
) {}
|
|
81
81
|
|
|
@@ -113,7 +113,7 @@ export class FacetImageStore {
|
|
|
113
113
|
async materialize(
|
|
114
114
|
pid: number,
|
|
115
115
|
modules: Record<string, string>,
|
|
116
|
-
pacer:
|
|
116
|
+
pacer: TurnBudget,
|
|
117
117
|
): Promise<Record<string, string>> {
|
|
118
118
|
const fs = this.blobs();
|
|
119
119
|
const images: Record<string, string> = {};
|
|
@@ -177,7 +177,7 @@ export class FacetImageStore {
|
|
|
177
177
|
* Nothing is left for a TTL or an eviction heuristic to guess at, and after
|
|
178
178
|
* a DO reset the table is empty so every orphan goes.
|
|
179
179
|
*/
|
|
180
|
-
private sweep(fs:
|
|
180
|
+
private sweep(fs: ImageBlobStore): void {
|
|
181
181
|
const live = new Set<string>();
|
|
182
182
|
for (const [pid, paths] of this.residentImages) {
|
|
183
183
|
if (this.isLive(pid)) {
|
package/src/index.ts
CHANGED
|
@@ -7,16 +7,24 @@
|
|
|
7
7
|
* modules they need instead.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
export * from './
|
|
10
|
+
export * from './generation.js';
|
|
11
|
+
export * from './timers.js';
|
|
12
|
+
export * from './outbox.js';
|
|
13
|
+
export * from './journal.js';
|
|
14
|
+
export * from './do-calls.js';
|
|
15
|
+
export * from './facet-pool.js';
|
|
16
|
+
export * from './derived.js';
|
|
17
|
+
export * from './connections.js';
|
|
18
|
+
export * from './sealed.js';
|
|
11
19
|
export * from './bindings.js';
|
|
12
|
-
export * from './
|
|
13
|
-
export * from './
|
|
14
|
-
export * from './fanout
|
|
20
|
+
export * from './composition.js';
|
|
21
|
+
export * from './image-store.js';
|
|
22
|
+
export * from './fanout.js';
|
|
15
23
|
export * from './inner-do-registry.js';
|
|
16
|
-
export * from './
|
|
17
|
-
export * from './
|
|
18
|
-
export * from './
|
|
19
|
-
export * from './
|
|
24
|
+
export * from './fenced-work.js';
|
|
25
|
+
export * from './turn-budget.js';
|
|
26
|
+
export * from './budgets.js';
|
|
27
|
+
export * from './isolate-pool.js';
|
|
20
28
|
export * from './process-fabric.js';
|
|
21
29
|
export * from './process-host.js';
|
|
22
30
|
export * from './workerd-facet-host.js';
|