@nimbus-sh/fabric 0.7.0 → 0.8.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.
Files changed (52) hide show
  1. package/README.md +19 -1
  2. package/dist/bindings.d.ts +11 -0
  3. package/dist/bindings.d.ts.map +1 -1
  4. package/dist/bindings.js +19 -8
  5. package/dist/connections.d.ts +1 -1
  6. package/dist/connections.js +1 -1
  7. package/dist/do-calls.d.ts +71 -9
  8. package/dist/do-calls.d.ts.map +1 -1
  9. package/dist/do-calls.js +148 -22
  10. package/dist/facet-pool.d.ts +3 -0
  11. package/dist/facet-pool.d.ts.map +1 -1
  12. package/dist/facet-pool.js +3 -0
  13. package/dist/fanout.d.ts.map +1 -1
  14. package/dist/fanout.js +6 -3
  15. package/dist/fenced-work.d.ts +3 -3
  16. package/dist/host-dispatch.d.ts +12 -4
  17. package/dist/host-dispatch.d.ts.map +1 -1
  18. package/dist/host-dispatch.js +11 -4
  19. package/dist/image-store.js +2 -2
  20. package/dist/inner-do-registry.d.ts +9 -0
  21. package/dist/inner-do-registry.d.ts.map +1 -1
  22. package/dist/inner-do-registry.js +35 -0
  23. package/dist/isolate-pool.d.ts +8 -1
  24. package/dist/isolate-pool.d.ts.map +1 -1
  25. package/dist/isolate-pool.js +9 -7
  26. package/dist/process-fabric.d.ts +16 -5
  27. package/dist/process-fabric.d.ts.map +1 -1
  28. package/dist/process-fabric.js +18 -6
  29. package/dist/process-host.d.ts +5 -0
  30. package/dist/process-host.d.ts.map +1 -1
  31. package/dist/process-host.js +13 -11
  32. package/dist/supervisor-props.d.ts +47 -0
  33. package/dist/supervisor-props.d.ts.map +1 -0
  34. package/dist/supervisor-props.js +37 -0
  35. package/dist/workerd-facet-host.d.ts +4 -17
  36. package/dist/workerd-facet-host.d.ts.map +1 -1
  37. package/dist/workerd-facet-host.js +98 -32
  38. package/package.json +6 -6
  39. package/src/bindings.ts +25 -10
  40. package/src/connections.ts +1 -1
  41. package/src/do-calls.ts +182 -25
  42. package/src/facet-pool.ts +5 -0
  43. package/src/fanout.ts +6 -3
  44. package/src/fenced-work.ts +3 -3
  45. package/src/host-dispatch.ts +22 -7
  46. package/src/image-store.ts +2 -2
  47. package/src/inner-do-registry.ts +31 -0
  48. package/src/isolate-pool.ts +16 -8
  49. package/src/process-fabric.ts +33 -10
  50. package/src/process-host.ts +18 -11
  51. package/src/supervisor-props.ts +56 -0
  52. package/src/workerd-facet-host.ts +99 -37
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
17
+ import { StorageLedger, forgetFacetStorage } from '@nimbus-sh/core/runtime/storage-ledger.js';
18
+ import type { SqlDatabase } from '@nimbus-sh/core/runtime/os-contracts.js';
17
19
  import {
18
20
  getCtxExports,
19
21
  stagedBootAssembler,
@@ -41,6 +43,7 @@ import {
41
43
  type ResidentDiskReader,
42
44
  type ResidentSupervisorProps,
43
45
  } from './process-fabric.js';
46
+ import { supervisorLoaderKey } from './supervisor-props.js';
44
47
 
45
48
  // ── Loaded-worker entrypoint plumbing ───────────────────────────────────────
46
49
 
@@ -56,7 +59,7 @@ export interface NimbusCtxExports {
56
59
  key: string;
57
60
  name: string | null;
58
61
  depth: number;
59
- supervisor: { doId: string; pid: number; writerId: string };
62
+ supervisor: ResidentSupervisorProps;
60
63
  stage?: unknown;
61
64
  };
62
65
  }) => LoadedWorkerEntrypointStub;
@@ -78,7 +81,7 @@ export function getNimbusCtxExports(): NimbusCtxExports {
78
81
  */
79
82
  export async function createLoadedWorkerEntrypoint(
80
83
  ctxExports: NimbusCtxExports,
81
- supervisor: { doId: string; pid: number; writerId: string },
84
+ supervisor: ResidentSupervisorProps,
82
85
  stage: unknown,
83
86
  name: string | null = null,
84
87
  ): Promise<LoadedWorkerEntrypointStub> {
@@ -87,7 +90,9 @@ export async function createLoadedWorkerEntrypoint(
87
90
  }
88
91
  return await ctxExports.NimbusLoadedEntrypoint({
89
92
  props: {
90
- key: `nimbus-process:${supervisor.doId}:${supervisor.pid}`,
93
+ // The entrypoint's loader outlives this instance, and a warm worker keeps
94
+ // the SUPERVISOR binding it was built with.
95
+ key: supervisorLoaderKey(`nimbus-process:${supervisor.doId}:${supervisor.pid}`, supervisor),
91
96
  name,
92
97
  depth: 0,
93
98
  supervisor,
@@ -262,26 +267,25 @@ interface SlotBook {
262
267
  next: number;
263
268
  /** Slot held by each live pid, so release can find it. */
264
269
  held: Map<number, number>;
270
+ /** Explicit (`app-slot-`) names this incarnation started a process under and has not released. */
271
+ live: Set<string>;
265
272
  }
266
273
 
267
274
  /**
268
275
  * Slot books, per hosting actor, because the facet index is per Durable
269
276
  * Object.
270
277
  *
271
- * Keyed weakly off `ctx`, and that is sound rather than lossy: a facet cannot
272
- * outlive the Durable Object hosting it, so a book that goes away with its
273
- * host describes nothing that still exists. A fresh incarnation restarts at
274
- * slot 0 and re-attaches to the SQLite a previous incarnation left there —
275
- * which is safe for the reason the store is sealed until it has reconciled.
276
- * Its persisted cursor is either datable against the current authority, in
277
- * which case the ACQUIRE delta brings it current, or it carries a different
278
- * VFS epoch, in which case `invalidatedSince` can only answer poison and the
279
- * whole store is dropped. A process therefore cannot boot onto a previous
280
- * tenant's filesystem even when release never ran.
278
+ * Keyed weakly off `ctx`, so a book describes one incarnation. A facet that
279
+ * is still running when that incarnation ends outlives it (measured: a timer
280
+ * or an outgoing call keeps it going), and a `get` of its name with a new
281
+ * class then resets the whole object. So a fresh incarnation's first get of
282
+ * each name ends whatever runs there first: a minted `proc-slot-` name is
283
+ * deleted, which also wipes the storage a previous incarnation left, and an
284
+ * explicit name is aborted, which keeps it.
281
285
  *
282
- * The book names only the `proc-slot-` space. Durable `app-slot-` names are
283
- * allocated against DO storage instead (their owner survives a reset), so a
284
- * fresh incarnation's `next` starting at 0 can never collide with them even
286
+ * The book allocates only the `proc-slot-` space. Durable `app-slot-` names
287
+ * are allocated against DO storage instead (their owner survives a reset), so
288
+ * a fresh incarnation's `next` starting at 0 can never collide with them even
285
289
  * before the durable ledger is adopted.
286
290
  */
287
291
  const slotBooks = new WeakMap<DurableObjectState, SlotBook>();
@@ -289,24 +293,28 @@ const slotBooks = new WeakMap<DurableObjectState, SlotBook>();
289
293
  function slotBook(ctx: DurableObjectState): SlotBook {
290
294
  let book = slotBooks.get(ctx);
291
295
  if (!book) {
292
- book = { free: [], next: 0, held: new Map() };
296
+ book = { free: [], next: 0, held: new Map(), live: new Set() };
293
297
  slotBooks.set(ctx, book);
294
298
  }
295
299
  return book;
296
300
  }
297
301
 
298
- /** Take a slot for `pid`, reusing a returned one before minting a new name. */
299
- function acquireSlot(ctx: DurableObjectState, pid: number): number {
302
+ /**
303
+ * Take a slot for `pid`, reusing a returned one before minting a new name.
304
+ * `minted` names may still hold storage a previous incarnation of this actor
305
+ * left there, so the caller deletes it before the first get.
306
+ */
307
+ function acquireSlot(ctx: DurableObjectState, pid: number): { slot: number; minted: boolean } {
300
308
  const book = slotBook(ctx);
301
309
  const existing = book.held.get(pid);
302
- if (existing !== undefined) return existing;
310
+ if (existing !== undefined) return { slot: existing, minted: false };
303
311
  const reused = book.free.length > 0;
304
312
  const slot = reused ? book.free.shift()! : book.next++;
305
313
  book.held.set(pid, slot);
306
314
  // A fresh name is a permanently consumed facet ID; the durable count lives
307
315
  // in the budgets ledger (see budgets.ts).
308
316
  if (!reused) recordFacetNameMinted(ctx, book.next);
309
- return slot;
317
+ return { slot, minted: !reused };
310
318
  }
311
319
 
312
320
  /** Return `pid`'s slot to the free list. */
@@ -320,14 +328,36 @@ function releaseSlot(ctx: DurableObjectState, pid: number): void {
320
328
  }
321
329
 
322
330
  /**
323
- * Drop one facet's SQLite by name — the ONLY call site that may delete facet
324
- * storage. `spawnResident` releases ephemeral processes with abort+delete
325
- * (storage is slot-reuse hygiene) and durable ones with abort alone (the
326
- * storage IS the durable application's state); explicit removal arrives here
327
- * through the coordinator's durable-slot book, owner-checked.
331
+ * Drop one facet's SQLite by name, and its row in the session's storage
332
+ * ledger (N18) in the same step: the only way a facet database is deleted.
333
+ * `spawnResident` releases ephemeral processes with abort+delete (storage is
334
+ * slot-reuse hygiene) and durable ones with abort alone (the storage IS the
335
+ * durable application's state); explicit removal arrives here through the
336
+ * coordinator's durable-slot book, owner-checked.
328
337
  */
338
+ /** The session's storage ledger (N18), over this actor's SQL; null where it has none. */
339
+ function sessionLedger(ctx: DurableObjectState): StorageLedger | null {
340
+ const sql = (ctx as { storage?: { sql?: SqlDatabase } }).storage?.sql;
341
+ return sql ? new StorageLedger(sql) : null;
342
+ }
343
+
344
+ const facetNames = new WeakMap<object, Map<number, string>>();
345
+
346
+ function facetOfPid(ctx: DurableObjectState): Map<number, string> {
347
+ let names = facetNames.get(ctx);
348
+ if (!names) facetNames.set(ctx, names = new Map());
349
+ return names;
350
+ }
351
+
352
+ /** The facet a running resident process `pid` lives in on this actor, for its storage ledger row. */
353
+ export function residentFacetOf(ctx: DurableObjectState, pid: number): string | undefined {
354
+ return facetNames.get(ctx)?.get(pid);
355
+ }
356
+
329
357
  export function deleteFacetStorage(ctx: DurableObjectState, name: string): void {
330
358
  facetContainer(ctx).delete(name);
359
+ const sql = (ctx as { storage?: { sql?: SqlDatabase } }).storage?.sql;
360
+ if (sql) forgetFacetStorage(sql, name);
331
361
  }
332
362
 
333
363
 
@@ -416,8 +446,12 @@ function spawnResident(
416
446
  + `prefix, got '${explicit.name}'`,
417
447
  );
418
448
  }
419
- const slot = explicit ? undefined : acquireSlot(ctx, params.pid);
449
+ const grant = explicit ? undefined : acquireSlot(ctx, params.pid);
450
+ const slot = grant?.slot;
420
451
  const name = explicit ? explicit.name : residentFacetName(slot!);
452
+ if (grant?.minted) {
453
+ try { deleteFacetStorage(ctx, name); } catch { /* nothing stored under this name */ }
454
+ }
421
455
  // The start callback is the ONLY way this facet is ever created, and it
422
456
  // fires AT MOST ONCE. Every later use goes through the stub below, so the
423
457
  // callback running a second time means the facet was released or died —
@@ -439,27 +473,40 @@ function spawnResident(
439
473
  evaluated = true;
440
474
  return { class: residentProcessClass(ctx, env, disk, supervisor, params) };
441
475
  };
476
+ const book = slotBook(ctx);
477
+ const ledger = sessionLedger(ctx);
442
478
  let facet: ResidentFacetStub;
443
479
  try {
480
+ // N18: the fill is admitted, and recorded under the facet's name, before
481
+ // the facet exists; a refusal leaves no facet.
482
+ if (ledger !== null && params.storageBytes !== undefined) ledger.fill(name, params.storageBytes);
483
+ // get() with a new class on a facet an earlier incarnation left running resets this object.
484
+ if (explicit && !book.live.has(name)) {
485
+ facets.abort(name, new Error('Nimbus: a new incarnation takes this facet name'));
486
+ }
444
487
  facet = facets.get(name, start);
445
488
  } catch (error) {
446
489
  if (slot !== undefined) releaseSlot(ctx, params.pid);
447
490
  throw withFacetBudgetNamed(facetNameCount(ctx), error);
448
491
  }
492
+ if (explicit) book.live.add(name);
493
+ facetOfPid(ctx).set(params.pid, name);
449
494
 
450
495
  let disposed = false;
451
496
  const release = async () => {
452
497
  if (disposed) return;
453
498
  disposed = true;
454
499
  released = true;
500
+ facetOfPid(ctx).delete(params.pid);
455
501
  try { facets.abort(name, new Error('Nimbus: resident process released')); } catch { /* already gone */ }
502
+ if (explicit) book.live.delete(name);
456
503
  // The two release classes: an ephemeral facet's SQLite is slot-reuse
457
504
  // hygiene — the name is handed out again, so the store must not be — and
458
505
  // a durable one's is the application itself: abort ends the process, the
459
506
  // data stays for the next boot, and only removeDurableApp's explicit
460
507
  // deleteFacetStorage call ever drops it.
461
508
  if (!explicit?.durable) {
462
- try { facets.delete(name); } catch { /* already gone */ }
509
+ try { deleteFacetStorage(ctx, name); } catch { /* already gone */ }
463
510
  }
464
511
  // Only after the facet is gone. A slot handed out while its previous
465
512
  // tenant were still being torn down would have two processes on one name.
@@ -468,7 +515,11 @@ function spawnResident(
468
515
 
469
516
  let started: Promise<unknown>;
470
517
  try {
471
- started = facet.startProcess(params.startArgs);
518
+ // The allowance the ledger admitted, for the facet's store to keep under.
519
+ const startArgs = ledger !== null && params.storageBytes !== undefined && params.startArgs !== null && typeof params.startArgs === 'object'
520
+ ? { ...(params.startArgs as Record<string, unknown>), storage: { facet: name, grant: params.storageBytes } }
521
+ : params.startArgs;
522
+ started = facet.startProcess(startArgs);
472
523
  } catch (error) {
473
524
  void release();
474
525
  throw withFacetBudgetNamed(facetNameCount(ctx), error);
@@ -477,7 +528,17 @@ function spawnResident(
477
528
  // this one, and it is annotated AFTER awaiting the ledger — the first
478
529
  // failure of a fresh incarnation must compare against the persisted count,
479
530
  // not the zero its adoption read has not yet replaced.
480
- started = started.catch(async (error) => {
531
+ started = started.then((payload) => {
532
+ // Once the facet is up (N18) its row is the cap its store keeps under
533
+ // (what it measures plus what it may still grow into), or what it
534
+ // measures if that is more (overshoot).
535
+ const { databaseSize: size, storageCap: cap } = (payload ?? {}) as { databaseSize?: unknown; storageCap?: unknown };
536
+ const measured = typeof size === 'number' && Number.isFinite(size) ? size : null;
537
+ const capped = typeof cap === 'number' && Number.isFinite(cap) ? cap : null;
538
+ const row = capped !== null ? Math.max(capped, measured ?? 0) : measured;
539
+ if (ledger !== null && row !== null) ledger.reportSize(name, row);
540
+ return payload;
541
+ }, async (error) => {
481
542
  throw withFacetBudgetNamed(await facetNameCountDurable(ctx), error);
482
543
  });
483
544
  // A caller reads whichever of `started` and the lifecycle it needs, so keep
@@ -516,11 +577,14 @@ function residentProcessClass(
516
577
  + 'the Worker Loader binding; add it via worker_loaders in wrangler.jsonc.',
517
578
  );
518
579
  }
580
+ // A warm worker keeps the SUPERVISOR binding it was built with, and the
581
+ // loader outlives this instance.
582
+ const loaderKey = supervisorLoaderKey(params.workerKey, supervisor);
519
583
  try {
520
584
  const worker = loader
521
- .get(params.workerKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
585
+ .get(loaderKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
522
586
  .getDurableObjectClass(RESIDENT_PROCESS_CLASS);
523
- recordLoaderId(ctx, params.workerKey);
587
+ recordLoaderId(ctx, loaderKey);
524
588
  return worker;
525
589
  } catch (error) {
526
590
  throw withDynamicWorkerCapNamed(ctx, error);
@@ -541,7 +605,7 @@ async function runOneShot<T>(
541
605
  + 'the Worker Loader binding; add it via worker_loaders in wrangler.jsonc.',
542
606
  );
543
607
  }
544
- const supervisorRpc = supervisorEntrypoint();
608
+ const supervisorRpc = supervisorEntrypoint(undefined, supervisor.route?.supervisorEntrypoint);
545
609
  let supervisorBinding: unknown;
546
610
  let worker: LoadedWorkerStub | undefined;
547
611
  let entrypoint: LoadedWorkerEntrypointStub | undefined;
@@ -626,11 +690,9 @@ export async function residentWorkerConfig(
626
690
  ? await stagedBootAssembler()(env, boot.stage)
627
691
  : await residentLoaderConfig(boot.code, disk());
628
692
  assertModuleMapWithinCodeLimit(configModules(config));
629
- const supervisorRpc = supervisorEntrypoint();
693
+ const supervisorRpc = supervisorEntrypoint(undefined, supervisor.route?.supervisorEntrypoint);
630
694
  if (!supervisorRpc) {
631
- throw new Error(
632
- `Nimbus: ctx.exports.${supervisorEntrypointName() ?? '<supervisor entrypoint>'} unavailable`,
633
- );
695
+ throw new Error(`Nimbus: ctx.exports.${supervisor.route?.supervisorEntrypoint ?? supervisorEntrypointName() ?? '<supervisor entrypoint>'} unavailable`);
634
696
  }
635
697
  return { ...config, env: { SUPERVISOR: supervisorRpc({ props: supervisor }) } };
636
698
  }