@serfab/cadre-core 1.6.0 → 1.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 (179) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +548 -137
  3. package/dist/cadre-node.js +1208 -383
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-authorization.d.ts +2 -2
  6. package/dist/control-authorization.js +1 -0
  7. package/dist/control-authorization.js.map +1 -1
  8. package/dist/control-cohort.d.ts +6 -0
  9. package/dist/control-cohort.js +6 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +46 -8
  12. package/dist/control-database.js +97 -18
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +37 -14
  15. package/dist/control-formation-recorder.js +60 -10
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +13 -12
  18. package/dist/control-read-retry.js +14 -12
  19. package/dist/control-read-retry.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +67 -5
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +9 -1
  24. package/dist/control-stream.js +24 -1
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +12 -2
  27. package/dist/control-write-retry.js +13 -2
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +11 -8
  30. package/dist/delegate-admission.js +19 -11
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/diagnostics/connection-path.d.ts +0 -7
  33. package/dist/diagnostics/connection-path.js +0 -7
  34. package/dist/diagnostics/connection-path.js.map +1 -1
  35. package/dist/formation-approval.d.ts +7 -0
  36. package/dist/formation-approval.js +9 -3
  37. package/dist/formation-approval.js.map +1 -1
  38. package/dist/fs-atomic.d.ts +11 -2
  39. package/dist/fs-atomic.js +13 -3
  40. package/dist/fs-atomic.js.map +1 -1
  41. package/dist/hibernation-manager.d.ts +111 -8
  42. package/dist/hibernation-manager.js +234 -49
  43. package/dist/hibernation-manager.js.map +1 -1
  44. package/dist/index.d.ts +13 -6
  45. package/dist/index.js +27 -5
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +177 -0
  48. package/dist/joined-strand-store.js +431 -0
  49. package/dist/joined-strand-store.js.map +1 -0
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/key-store.d.ts +6 -0
  53. package/dist/key-store.js.map +1 -1
  54. package/dist/link-budget.d.ts +367 -69
  55. package/dist/link-budget.js +375 -72
  56. package/dist/link-budget.js.map +1 -1
  57. package/dist/membership-connection-gater.d.ts +30 -43
  58. package/dist/membership-connection-gater.js +31 -42
  59. package/dist/membership-connection-gater.js.map +1 -1
  60. package/dist/node-local-snapshot.d.ts +5 -4
  61. package/dist/node-local-snapshot.js +6 -3
  62. package/dist/node-local-snapshot.js.map +1 -1
  63. package/dist/peer-dial.d.ts +24 -4
  64. package/dist/peer-dial.js +74 -5
  65. package/dist/peer-dial.js.map +1 -1
  66. package/dist/peer-join-backfill.d.ts +35 -15
  67. package/dist/peer-join-backfill.js +115 -55
  68. package/dist/peer-join-backfill.js.map +1 -1
  69. package/dist/push-fanout.d.ts +6 -0
  70. package/dist/push-fanout.js +6 -0
  71. package/dist/push-fanout.js.map +1 -1
  72. package/dist/relay-reservation.d.ts +3 -1
  73. package/dist/relay-reservation.js +3 -1
  74. package/dist/relay-reservation.js.map +1 -1
  75. package/dist/relay-server.d.ts +79 -0
  76. package/dist/relay-server.js +74 -0
  77. package/dist/relay-server.js.map +1 -0
  78. package/dist/seed-bootstrap.d.ts +57 -4
  79. package/dist/seed-bootstrap.js +107 -83
  80. package/dist/seed-bootstrap.js.map +1 -1
  81. package/dist/storage-scope.d.ts +33 -17
  82. package/dist/storage-scope.js +44 -23
  83. package/dist/storage-scope.js.map +1 -1
  84. package/dist/strand-addr-protocol.d.ts +58 -21
  85. package/dist/strand-addr-protocol.js +133 -85
  86. package/dist/strand-addr-protocol.js.map +1 -1
  87. package/dist/strand-database.d.ts +20 -2
  88. package/dist/strand-database.js +42 -5
  89. package/dist/strand-database.js.map +1 -1
  90. package/dist/strand-first-sync-gate.d.ts +59 -21
  91. package/dist/strand-first-sync-gate.js +58 -19
  92. package/dist/strand-first-sync-gate.js.map +1 -1
  93. package/dist/strand-formation-deadlines.d.ts +151 -0
  94. package/dist/strand-formation-deadlines.js +171 -0
  95. package/dist/strand-formation-deadlines.js.map +1 -0
  96. package/dist/strand-formation-manager.d.ts +63 -30
  97. package/dist/strand-formation-manager.js +67 -41
  98. package/dist/strand-formation-manager.js.map +1 -1
  99. package/dist/strand-formation-protocol.d.ts +45 -35
  100. package/dist/strand-formation-protocol.js +59 -122
  101. package/dist/strand-formation-protocol.js.map +1 -1
  102. package/dist/strand-instance-manager.d.ts +152 -6
  103. package/dist/strand-instance-manager.js +331 -42
  104. package/dist/strand-instance-manager.js.map +1 -1
  105. package/dist/strand-membership-writer.d.ts +6 -0
  106. package/dist/strand-membership-writer.js +11 -0
  107. package/dist/strand-membership-writer.js.map +1 -1
  108. package/dist/strand-peer-book-file.d.ts +12 -0
  109. package/dist/strand-peer-book-file.js +37 -0
  110. package/dist/strand-peer-book-file.js.map +1 -0
  111. package/dist/strand-peer-book-protocol.d.ts +168 -0
  112. package/dist/strand-peer-book-protocol.js +356 -0
  113. package/dist/strand-peer-book-protocol.js.map +1 -0
  114. package/dist/strand-peer-book-swap.d.ts +104 -0
  115. package/dist/strand-peer-book-swap.js +284 -0
  116. package/dist/strand-peer-book-swap.js.map +1 -0
  117. package/dist/strand-peer-book.d.ts +159 -0
  118. package/dist/strand-peer-book.js +361 -0
  119. package/dist/strand-peer-book.js.map +1 -0
  120. package/dist/strand-peer-observer.d.ts +88 -0
  121. package/dist/strand-peer-observer.js +180 -0
  122. package/dist/strand-peer-observer.js.map +1 -0
  123. package/dist/strand-revocation-enforcer.js +2 -1
  124. package/dist/strand-revocation-enforcer.js.map +1 -1
  125. package/dist/strand-solicitation.d.ts +36 -20
  126. package/dist/strand-solicitation.js +4 -4
  127. package/dist/strand-solicitation.js.map +1 -1
  128. package/dist/strand-wake-protocol.d.ts +80 -23
  129. package/dist/strand-wake-protocol.js +89 -64
  130. package/dist/strand-wake-protocol.js.map +1 -1
  131. package/dist/types.d.ts +181 -33
  132. package/dist/types.js +4 -3
  133. package/dist/types.js.map +1 -1
  134. package/package.json +11 -7
  135. package/src/cadre-node.ts +1316 -398
  136. package/src/control-authorization.ts +2 -1
  137. package/src/control-cohort.ts +6 -0
  138. package/src/control-database.ts +122 -18
  139. package/src/control-formation-recorder.ts +354 -305
  140. package/src/control-read-retry.ts +14 -12
  141. package/src/control-schema.ts +67 -5
  142. package/src/control-stream.ts +25 -1
  143. package/src/control-write-retry.ts +13 -2
  144. package/src/delegate-admission.ts +23 -13
  145. package/src/diagnostics/connection-path.ts +0 -7
  146. package/src/formation-approval.ts +610 -604
  147. package/src/fs-atomic.ts +14 -4
  148. package/src/hibernation-manager.ts +638 -437
  149. package/src/index.ts +91 -6
  150. package/src/joined-strand-store.ts +498 -0
  151. package/src/key-store-file.ts +14 -5
  152. package/src/key-store.ts +6 -0
  153. package/src/link-budget.ts +393 -74
  154. package/src/membership-connection-gater.ts +31 -45
  155. package/src/node-local-snapshot.ts +293 -289
  156. package/src/peer-dial.ts +81 -7
  157. package/src/peer-join-backfill.ts +120 -57
  158. package/src/push-fanout.ts +6 -0
  159. package/src/relay-reservation.ts +3 -1
  160. package/src/relay-server.ts +98 -0
  161. package/src/seed-bootstrap.ts +133 -91
  162. package/src/storage-scope.ts +44 -23
  163. package/src/strand-addr-protocol.ts +547 -458
  164. package/src/strand-database.ts +287 -242
  165. package/src/strand-first-sync-gate.ts +58 -19
  166. package/src/strand-formation-deadlines.ts +227 -0
  167. package/src/strand-formation-manager.ts +700 -647
  168. package/src/strand-formation-protocol.ts +103 -150
  169. package/src/strand-instance-manager.ts +387 -45
  170. package/src/strand-membership-writer.ts +12 -0
  171. package/src/strand-peer-book-file.ts +39 -0
  172. package/src/strand-peer-book-protocol.ts +466 -0
  173. package/src/strand-peer-book-swap.ts +338 -0
  174. package/src/strand-peer-book.ts +475 -0
  175. package/src/strand-peer-observer.ts +235 -0
  176. package/src/strand-revocation-enforcer.ts +2 -1
  177. package/src/strand-solicitation.ts +519 -500
  178. package/src/strand-wake-protocol.ts +401 -355
  179. package/src/types.ts +185 -33
@@ -4,6 +4,9 @@ import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
4
4
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
5
5
  import { StrandDatabase } from './strand-database.js';
6
6
  import { PeerJoinBackfill, type PeerJoinBackfillConfig } from './peer-join-backfill.js';
7
+ import { StrandPeerObserver, type StrandPeerObservation } from './strand-peer-observer.js';
8
+ import { StrandPeerBookSwap } from './strand-peer-book-swap.js';
9
+ import type { StrandPeerBookStore } from './strand-peer-book.js';
7
10
  import {
8
11
  StrandRevocationEnforcer,
9
12
  createRevocationConnectionGater,
@@ -40,8 +43,9 @@ import type {
40
43
  } from './types.js';
41
44
  import { DEFAULT_CONNECTION_MONITOR, resolveStrandClusterSize, strandClusterPolicy } from './types.js';
42
45
  import { strandNodeAddrs } from './strand-network-config.js';
46
+ import { resolveRelayServer } from './relay-server.js';
43
47
  import { superviseRelayReservation, type RelayReservationSupervisor } from './relay-reservation.js';
44
- import { connectionManagerTimeouts, peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
48
+ import { declaredCohortReadDeadlineMs, peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
45
49
 
46
50
  const log = debug('sereus:cadre:strand-manager');
47
51
  const timing = debug('sereus:cadre:timing');
@@ -86,8 +90,15 @@ export function liveStrandStatus(instance: StrandInstance): 'active' | 'syncing'
86
90
  */
87
91
  export interface StartStrandConfig {
88
92
  strandRow: StrandRow;
89
- /** sApp configuration provided by the hosting application */
90
- sAppConfig: SAppConfig;
93
+ /**
94
+ * sApp configuration provided by the hosting application. Absent for a **storage
95
+ * replica** — a strand this node stores and serves without the app installed
96
+ * (`CadreNodeConfig.hostUnclaimedStrands`): no schema signature check, no `App`
97
+ * schema, no `sAppInfo` on the instance, and the default latency hint. A replica is
98
+ * always a joiner; `StrandDatabase` refuses to found one. An app that later claims the
99
+ * strand upgrades the running replica in place ({@link StrandInstanceManager.attachSApp}).
100
+ */
101
+ sAppConfig?: SAppConfig;
91
102
  storage?: StorageConfig;
92
103
  network?: NetworkConfig;
93
104
  profile: NodeProfile;
@@ -261,6 +272,28 @@ export interface StartStrandConfig {
261
272
  * hibernation wake's rebuilt supervisors carry it too.
262
273
  */
263
274
  announceDelegateToRelay?: (strandId: string, relayAddr: string, delegatePeerId: string) => Promise<void>;
275
+
276
+ /**
277
+ * Called when the strand's libp2p node identifies a STRAND peer — one whose
278
+ * identify result names this strand's block-transfer protocol, so never the
279
+ * circuit relay or a bootstrap node — with the addresses it is dialable at
280
+ * (`strand-peer-observer.ts`). Throttled per peer; must not throw. `CadreNode`
281
+ * wires it to the node-local strand peer book, which is what lets a restarted
282
+ * machine dial the peers it was talking to. Absent ⇒ nothing is observed.
283
+ * Retained with the launch config, so a hibernation wake re-arms it on the
284
+ * rebuilt node.
285
+ */
286
+ onStrandPeerIdentified?: (strandId: string, observation: StrandPeerObservation) => void;
287
+
288
+ /**
289
+ * The node-local strand peer book the book SWAP reads and writes
290
+ * (`strand-peer-book-swap.ts`): the strand node signs its own addresses with
291
+ * {@link privateKey}, exchanges signed entries with every strand peer it identifies,
292
+ * and files what verifies. Absent ⇒ no swap runs — a peer that dials this node's
293
+ * swap handler gets no answer. `CadreNode` passes its book store. Retained with the
294
+ * launch config, so a hibernation wake re-arms the swap on the rebuilt node.
295
+ */
296
+ strandPeerBook?: StrandPeerBookStore;
264
297
  }
265
298
 
266
299
  /**
@@ -286,6 +319,11 @@ export interface ResumeStrandOverrides {
286
319
  servingMachines?: number;
287
320
  }
288
321
 
322
+ /** A {@link StrandInstanceManager.resumeStrand} or {@link StrandInstanceManager.quiesceStrand} of one strand. */
323
+ type RuntimeTransition =
324
+ | { kind: 'resume'; operation: Promise<StrandInstance> }
325
+ | { kind: 'quiesce'; operation: Promise<void> };
326
+
289
327
  /**
290
328
  * Get the isolated storage path for a specific strand.
291
329
  *
@@ -349,6 +387,26 @@ function resolveStrandStorage(
349
387
  return wrapStorageWithCache(storage, strandId);
350
388
  }
351
389
 
390
+ /**
391
+ * Verify an sApp's schema signature (fail-closed unless `requireSignedSchemas` is
392
+ * false) and project the config onto the instance's `sAppInfo`.
393
+ */
394
+ function verifiedSAppInfo(strandId: string, sAppConfig: SAppConfig, requireSignedSchemas: boolean | undefined): SAppInfo {
395
+ assertSchemaSignature(sAppConfig, { requireSignature: requireSignedSchemas ?? true });
396
+ log('Strand %s sApp schema signature verified (author: %s)', strandId, sAppConfig.id);
397
+ return {
398
+ id: sAppConfig.id,
399
+ version: sAppConfig.version,
400
+ schema: sAppConfig.schema,
401
+ signature: sAppConfig.signature
402
+ };
403
+ }
404
+
405
+ /** Log wording for what a launch runs: the sApp and its version, or a storage replica. */
406
+ function describeSApp(sAppConfig: SAppConfig | undefined): string {
407
+ return sAppConfig ? `sApp: ${sAppConfig.id} v${sAppConfig.version}` : 'storage replica';
408
+ }
409
+
352
410
  /**
353
411
  * Manages individual strand instances - creates and destroys isolated libp2p nodes
354
412
  * for each strand the cadre participates in.
@@ -370,6 +428,24 @@ export class StrandInstanceManager {
370
428
  * missed writes).
371
429
  */
372
430
  private backfills: Map<string, PeerJoinBackfill> = new Map();
431
+ /**
432
+ * The per-strand peer-book observation writer (`strand-peer-observer.ts`), keyed
433
+ * by strand id. Same lifecycle rationale as {@link backfills}: started in
434
+ * `buildStrandRuntime` right after the libp2p node exists, stopped and dropped in
435
+ * `releaseRuntime`, so quiesce → resume re-arms it on the rebuilt node. Only
436
+ * present when the launch config supplied `onStrandPeerIdentified`.
437
+ */
438
+ private peerObservers: Map<string, StrandPeerObserver> = new Map();
439
+
440
+ /**
441
+ * Per-strand strand peer book SWAP driver ({@link StrandPeerBookSwap}) — the signed
442
+ * exchange of book entries between strand peers. Same lifetime as the peer-book
443
+ * observer above: armed in `buildStrandRuntime` right after the libp2p node exists
444
+ * (its protocol handler must be registered before an early peer dials it), stopped
445
+ * and dropped in `releaseRuntime`. Only present when the launch config supplied
446
+ * `strandPeerBook`.
447
+ */
448
+ private peerBookSwaps: Map<string, StrandPeerBookSwap> = new Map();
373
449
  /**
374
450
  * The per-strand revoked-peer enforcer (closed strands only), keyed by strand
375
451
  * id. Same lifecycle rationale as {@link backfills}: created in
@@ -436,6 +512,23 @@ export class StrandInstanceManager {
436
512
  * wake becomes the better trade.
437
513
  */
438
514
  private strandStorages: Map<string, IRawStorage> = new Map();
515
+ /**
516
+ * The runtime build in flight per strand id (`buildStrandRuntime`, from `startStrand` or
517
+ * `resumeStrand`), present until it settles. The instance is tracked for the whole build,
518
+ * with its database not yet constructed or not yet initialized, so a caller that must act
519
+ * on the FINISHED runtime ({@link attachSApp}, {@link whenRuntimeBuilt}) waits on this instead. A failed build's
520
+ * rejection belongs to the call that started it.
521
+ */
522
+ private runtimeBuilds: Map<string, Promise<void>> = new Map();
523
+ /**
524
+ * The latest resume or quiesce requested per strand id, present until it settles. Each one
525
+ * starts only once the one requested before it has settled, so a wake that lands during a
526
+ * quiesce rebuilds after it, and a quiesce requested during a rebuild releases what that
527
+ * rebuild finished. The WHOLE operation, not its entry in {@link runtimeBuilds}: that settles
528
+ * before a resume's own catch records `'error'`, so a joiner awaiting it could read a stale
529
+ * status.
530
+ */
531
+ private transitions: Map<string, RuntimeTransition> = new Map();
439
532
  private stopping = false;
440
533
 
441
534
  constructor() {
@@ -500,7 +593,7 @@ export class StrandInstanceManager {
500
593
  return this.instances.get(strandId)!;
501
594
  }
502
595
 
503
- log('Starting strand instance: %s (sApp: %s v%s)', strandId, sAppConfig.id, sAppConfig.version);
596
+ log('Starting strand instance: %s (%s)', strandId, describeSApp(sAppConfig));
504
597
  const tTotal = performance.now();
505
598
 
506
599
  // The id becomes two names below: the storage scope key the embedder's provider
@@ -512,18 +605,7 @@ export class StrandInstanceManager {
512
605
  // still reaches buildStrandRuntime and still mints the protocol prefix.
513
606
  assertStrandScopeKey(strandId);
514
607
 
515
- // Verify schema signature before proceeding (fail-closed by default)
516
- const requireSignature = config.requireSignedSchemas ?? true;
517
- assertSchemaSignature(sAppConfig, { requireSignature });
518
- log('Strand %s sApp schema signature verified (author: %s)', strandId, sAppConfig.id);
519
-
520
- // Convert SAppConfig to SAppInfo for the instance
521
- const sAppInfo: SAppInfo = {
522
- id: sAppConfig.id,
523
- version: sAppConfig.version,
524
- schema: sAppConfig.schema,
525
- signature: sAppConfig.signature
526
- };
608
+ const sAppInfo = sAppConfig ? verifiedSAppInfo(strandId, sAppConfig, config.requireSignedSchemas) : undefined;
527
609
 
528
610
  // Resolve this strand's storage ONCE, before anything is recorded. If a factory
529
611
  // function is provided, it is called with the strandId to create strand-specific
@@ -535,7 +617,7 @@ export class StrandInstanceManager {
535
617
  }
536
618
 
537
619
  // Determine latency hint: sApp config > default
538
- const latencyHint = sAppConfig.latencyHint ?? config.defaultLatencyHint;
620
+ const latencyHint = sAppConfig?.latencyHint ?? config.defaultLatencyHint;
539
621
 
540
622
  const instance: StrandInstance = {
541
623
  strandId,
@@ -554,9 +636,9 @@ export class StrandInstanceManager {
554
636
  }
555
637
 
556
638
  try {
557
- await this.buildStrandRuntime(instance, config);
639
+ await this.trackRuntimeBuild(strandId, this.buildStrandRuntime(instance, config));
558
640
  timing('[startStrand:%s] total: %dms', strandId, Math.round(performance.now() - tTotal));
559
- log('Strand %s started successfully with sApp %s', strandId, sAppConfig.id);
641
+ log('Strand %s started successfully (%s)', strandId, describeSApp(sAppConfig));
560
642
  return instance;
561
643
  } catch (error) {
562
644
  // Status/error first — the (now discarded) record is still what `log` reports on.
@@ -575,6 +657,35 @@ export class StrandInstanceManager {
575
657
  }
576
658
  }
577
659
 
660
+ /** Hold `build` in {@link runtimeBuilds} until it settles, and settle as it does. */
661
+ private async trackRuntimeBuild(strandId: string, build: Promise<void>): Promise<void> {
662
+ this.runtimeBuilds.set(strandId, build);
663
+ try {
664
+ await build;
665
+ } finally {
666
+ if (this.runtimeBuilds.get(strandId) === build) {
667
+ this.runtimeBuilds.delete(strandId);
668
+ }
669
+ }
670
+ }
671
+
672
+ /**
673
+ * Wait until no runtime build of `strandId` is in flight. Never rejects: a failed build
674
+ * is reported by the call that started it, and the caller re-reads the instance after.
675
+ *
676
+ * NOTE: after a failed launch the re-read sees the instance gone only because
677
+ * `startStrand`'s catch deletes it synchronously, and its continuation is queued before
678
+ * this waiter's. If that cleanup ever awaits before `instances.delete`, a waiter can act
679
+ * on the dying record; move the cleanup inside the tracked promise then.
680
+ */
681
+ private async settleRuntimeBuilds(strandId: string): Promise<void> {
682
+ for (let build = this.runtimeBuilds.get(strandId); build; build = this.runtimeBuilds.get(strandId)) {
683
+ await build.catch((error: unknown) => {
684
+ log('Strand %s: the runtime build waited on failed (reported by its launcher): %o', strandId, error);
685
+ });
686
+ }
687
+ }
688
+
578
689
  /**
579
690
  * Build (or rebuild) the libp2p node + StrandDatabase for an instance and
580
691
  * attach them, transitioning it to `active`. Shared by `startStrand` (fresh
@@ -606,9 +717,9 @@ export class StrandInstanceManager {
606
717
  const networkName = `strand-${strandId}`;
607
718
  const protocolPrefix = `/optimystic/${networkName}`;
608
719
 
609
- // Determine relay mode: if explicitly set in config, use that;
610
- // otherwise default to true for storage profile nodes.
611
- const enableRelay = config.network?.enableRelay ?? (config.profile === 'storage');
720
+ // Whether this node runs the circuit-relay server, and its init — the same
721
+ // resolution the control node takes from the same config (`relay-server.ts`).
722
+ const relayServer = resolveRelayServer(config.network, config.profile);
612
723
  // The strand-node VIEW of the machine's one `NetworkConfig`, not the control
613
724
  // node's resolution: fixed direct listen ports become ephemeral (two nodes cannot
614
725
  // bind one port), the announce config is dropped (it names the control node's
@@ -682,7 +793,12 @@ export class StrandInstanceManager {
682
793
  networkName,
683
794
  storage: strandStorage,
684
795
  fretProfile: config.profile === 'storage' ? 'core' : 'edge',
685
- relay: enableRelay,
796
+ relay: relayServer.enabled,
797
+ // NOTE: unlike the control node, a strand node has no unauthorized-reservation budget,
798
+ // so with the per-connection cap off any peer that reaches this node can hold one of
799
+ // `maxReservations` slots and forward uncapped through it. If strand relays are ever
800
+ // abused for bandwidth, give strand nodes a budget or a capped init of their own.
801
+ ...(relayServer.enabled && { relayServerInit: relayServer.init }),
686
802
  clusterSize: strandClusterSize,
687
803
  // Deliberately NOT CONTROL_CLUSTER_POLICY: a strand is application data with its own
688
804
  // breadth reasoning, and the shape match with the control policy is a coincidence.
@@ -694,11 +810,12 @@ export class StrandInstanceManager {
694
810
  // returns the frozen STRAND_CLUSTER_POLICY itself, declaring nothing. Resolved
695
811
  // HERE rather than at `startStrand`, so a wake from hibernation would pick up a
696
812
  // serving set that changed while the strand slept.
697
- // The read deadline is the same field the control node reads, because the two
698
- // networks ride one link; absent, the base policy's COHORT_READ_DEADLINE_MS stands.
813
+ // The read deadline is settled by the same helper the control node uses, because the
814
+ // two networks ride one link: the host's own, else derived from its declared
815
+ // `linkRoundTripMs`, else nothing, and the base policy's COHORT_READ_DEADLINE_MS stands.
699
816
  clusterPolicy: strandClusterPolicy(strandClusterSize, {
700
817
  servingMachines: config.servingMachines,
701
- cohortQueryTimeoutMs: config.network?.cohortQueryTimeoutMs
818
+ cohortQueryTimeoutMs: declaredCohortReadDeadlineMs(config.network)
702
819
  }),
703
820
  arachnode: {
704
821
  enableRingZulu: config.profile === 'storage'
@@ -711,10 +828,10 @@ export class StrandInstanceManager {
711
828
  // widened ping deadline has to reach the strand nodes too (see
712
829
  // DEFAULT_CONNECTION_MONITOR).
713
830
  connectionMonitor: config.network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
714
- // The same declared-link limits the control node takes, and for the same reason: a
715
- // strand node is the listener for every other member's strand node (`link-budget.ts`,
716
- // `connectionManagerTimeouts`).
717
- connectionManager: connectionManagerTimeouts(config.network?.linkRoundTripMs),
831
+ // The same declared link the control node states, and for the same reason: a strand
832
+ // node is also the listener for every other member's strand node, and Optimystic
833
+ // derives that listener's limit from this (`link-budget.ts`).
834
+ linkRoundTripMs: resolveLinkRoundTripMs(config.network?.linkRoundTripMs),
718
835
  // Listen entries plus the WebSocket transport switch they imply — a strand node
719
836
  // announces nothing the operator configured (`strand-network-config.ts`), and
720
837
  // spreads AFTER `transports` above because the switch is a no-op whenever the
@@ -734,6 +851,48 @@ export class StrandInstanceManager {
734
851
 
735
852
  instance.libp2pNode = node;
736
853
 
854
+ // The peer-book observation writer, armed BEFORE the database bring-up below
855
+ // (unlike the backfill) because the bootstrap dials that follow `libp2p.start()`
856
+ // land during that bring-up, and they are exactly the peers worth remembering:
857
+ // identify for an early peer may already have fired by the time anything after
858
+ // the database initializes runs. Registered in the map immediately so the
859
+ // failure rollback (releaseRuntime) stops it like every other runtime component.
860
+ const onStrandPeerIdentified = config.onStrandPeerIdentified;
861
+ if (onStrandPeerIdentified) {
862
+ const observer = new StrandPeerObserver({
863
+ label: strandId,
864
+ libp2p: node,
865
+ protocolPrefix,
866
+ onObserved: (observation) => onStrandPeerIdentified(strandId, observation)
867
+ });
868
+ this.peerObservers.set(strandId, observer);
869
+ observer.start();
870
+ }
871
+
872
+ // The strand peer book SWAP, armed at the same moment and for the same reason as
873
+ // the observer: a peer that dials this node's swap handler before it is registered
874
+ // fails its exchange and sits out a ten-minute throttle. The own entry is signed
875
+ // now and re-signed on every address change, which covers the relay reservation
876
+ // landing below (`self:peer:update` fires when the circuit addr is added), so no
877
+ // separate post-relay step is needed. Registered in the map immediately so the
878
+ // failure rollback (releaseRuntime) stops it like every other runtime component.
879
+ //
880
+ // NOTE: this is a third `peer:identify` listener per running strand, beside the
881
+ // observer's and the backfill's — see the backfill's NOTE below for when to fold
882
+ // them into one dispatcher.
883
+ if (config.strandPeerBook) {
884
+ const swap = new StrandPeerBookSwap({
885
+ strandId,
886
+ libp2p: node,
887
+ protocolPrefix,
888
+ store: config.strandPeerBook,
889
+ privateKey: config.privateKey,
890
+ linkRoundTripMs: config.network?.linkRoundTripMs
891
+ });
892
+ this.peerBookSwaps.set(strandId, swap);
893
+ swap.start();
894
+ }
895
+
737
896
  // One reservation supervisor PER RELAY, started now so the first attempts
738
897
  // overlap the database bring-up below (the relay is never in a strand's
739
898
  // Optimystic cohort — its protocol ids are namespaced `/optimystic/strand-<id>/…`
@@ -850,7 +1009,7 @@ export class StrandInstanceManager {
850
1009
  // peer-join-backfill.ts for the strand-side argument (and why the control
851
1010
  // network, which DOES gate, is different).
852
1011
  //
853
- // NOTE: cost is one PeerJoinBackfill object + one `connection:open` listener
1012
+ // NOTE: cost is one PeerJoinBackfill object + one `peer:identify` listener
854
1013
  // per running strand — linear in strand count, negligible at the handful a
855
1014
  // device or host runs today. If a node ever hosts strands by the hundred,
856
1015
  // move to one shared listener that dispatches by strand id.
@@ -888,7 +1047,7 @@ export class StrandInstanceManager {
888
1047
  // failing here would only trade that for `StrandWatcher`'s full-rebuild retry.
889
1048
  //
890
1049
  // NOTE: a relay that is down costs this launch one full drive — the reservation budget
891
- // counted from the declared link round trip, 14 s at its default (`link-budget.ts`) — and
1050
+ // counted from the declared link round trip, 18 s at its default (`link-budget.ts`) — and
892
1051
  // `StrandWatcher` launches strands one at a time, so N strands cost N of those in
893
1052
  // bring-up during a relay outage, and MORE on a host that declared a slower link. If that
894
1053
  // ever matters, stop awaiting here (the circuit addr then lands after `active`) rather
@@ -897,8 +1056,9 @@ export class StrandInstanceManager {
897
1056
  await this.awaitFirstRelayAttempts(strandId);
898
1057
  timing('[buildStrandRuntime:%s] relay first attempts: %dms', strandId, Math.round(performance.now() - t0));
899
1058
 
1059
+ // No `lastActivity` stamp: bringing a runtime up is not activity. A check-in marks
1060
+ // activity before its resume and must see only what landed while the strand was up.
900
1061
  instance.status = liveStrandStatus(instance);
901
- instance.lastActivity = new Date();
902
1062
  if (instance.status === 'syncing') {
903
1063
  log('Strand %s launched as a joiner with no Strand.Header held yet — writes are withheld until ' +
904
1064
  'a member of the strand is reached', strandId);
@@ -946,6 +1106,20 @@ export class StrandInstanceManager {
946
1106
  backfill.stop();
947
1107
  this.backfills.delete(instance.strandId);
948
1108
  }
1109
+ // The peer-book observer goes with the node it listened on; a resume re-arms
1110
+ // one on the rebuilt node.
1111
+ const peerObserver = this.peerObservers.get(instance.strandId);
1112
+ if (peerObserver) {
1113
+ peerObserver.stop();
1114
+ this.peerObservers.delete(instance.strandId);
1115
+ }
1116
+ // The book swap likewise: unsubscribed and its handler unregistered while the node is
1117
+ // still up; a resume re-arms one, which re-signs the own entry over the new addresses.
1118
+ const peerBookSwap = this.peerBookSwaps.get(instance.strandId);
1119
+ if (peerBookSwap) {
1120
+ this.peerBookSwaps.delete(instance.strandId);
1121
+ await peerBookSwap.stop();
1122
+ }
949
1123
  // The revoked-peer enforcer goes with the runtime it gated: a resume
950
1124
  // rebuilds it with a fresh (initially empty, fail-open) snapshot.
951
1125
  const revocationEnforcer = this.revocationEnforcers.get(instance.strandId);
@@ -1158,24 +1332,54 @@ export class StrandInstanceManager {
1158
1332
  * Quiesce a strand: release its strand-network resources (stop the libp2p node,
1159
1333
  * close the StrandDatabase) while RETAINING the instance record — identity,
1160
1334
  * sAppInfo, keys, latency hint, metadata — and its launch config so it can be
1161
- * resumed later. Mechanically this is `stopStrand` minus the instance/config
1162
- * deletion. The caller sets the post-quiesce status (e.g. `hibernating`).
1163
- * No-ops when the strand is missing or already quiesced.
1335
+ * resumed later, then mark it `hibernating`. Mechanically this is `stopStrand` minus
1336
+ * the instance/config deletion. A tracked strand with no runtime left (a failed
1337
+ * rebuild already released it) is only marked; an untracked one is left alone.
1338
+ *
1339
+ * Ordered with {@link resumeStrand} (see {@link transitions}): it starts once the resume
1340
+ * or launch build before it has settled, so it releases a finished runtime, and the
1341
+ * status write is its last step, so a resume requested meanwhile writes after it. A call
1342
+ * made while another quiesce is the latest request joins it. A failed release leaves the
1343
+ * status as it was and rejects.
1164
1344
  */
1165
1345
  async quiesceStrand(strandId: string): Promise<void> {
1346
+ const previous = this.transitions.get(strandId);
1347
+ if (previous?.kind === 'quiesce') {
1348
+ log('quiesceStrand: strand %s — joining the quiesce already requested', strandId);
1349
+ return previous.operation;
1350
+ }
1351
+ const operation = this.runQuiesce(strandId, previous);
1352
+ this.holdTransition(strandId, { kind: 'quiesce', operation });
1353
+ return operation;
1354
+ }
1355
+
1356
+ /** Body of {@link quiesceStrand}. */
1357
+ private async runQuiesce(strandId: string, previous: RuntimeTransition | undefined): Promise<void> {
1358
+ await this.settleTransition(strandId, previous);
1359
+ await this.settleRuntimeBuilds(strandId);
1166
1360
  const instance = this.instances.get(strandId);
1167
1361
  if (!instance) {
1168
1362
  log('quiesceStrand: strand %s not found', strandId);
1169
1363
  return;
1170
1364
  }
1171
- if (!instance.libp2pNode && !instance.database) {
1365
+ if (instance.libp2pNode || instance.database) {
1366
+ log('Quiescing strand instance: %s', strandId);
1367
+ await this.releaseRuntime(instance);
1368
+ log('Strand %s quiesced (resources released, instance retained)', strandId);
1369
+ } else {
1172
1370
  log('quiesceStrand: strand %s already quiesced', strandId);
1173
- return;
1174
1371
  }
1372
+ instance.status = 'hibernating';
1373
+ }
1175
1374
 
1176
- log('Quiescing strand instance: %s', strandId);
1177
- await this.releaseRuntime(instance);
1178
- log('Strand %s quiesced (resources released, instance retained)', strandId);
1375
+ /**
1376
+ * Whether the latest runtime transition requested for `strandId` is a quiesce that has
1377
+ * not finished. Its handles may still be attached and its status still live, but it is on
1378
+ * its way to `hibernating`, so a caller that needs it up must {@link resumeStrand} it — the
1379
+ * resume runs after the quiesce — rather than treat it as live.
1380
+ */
1381
+ isQuiescing(strandId: string): boolean {
1382
+ return this.transitions.get(strandId)?.kind === 'quiesce';
1179
1383
  }
1180
1384
 
1181
1385
  /**
@@ -1185,11 +1389,65 @@ export class StrandInstanceManager {
1185
1389
  * launch (the cohort `bootstrapNodes` seed and the strand's `servingMachines`
1186
1390
  * count) and updates the retained config so a later resume reuses the latest
1187
1391
  * values. Returns the live instance unchanged if it is already running.
1392
+ *
1393
+ * Overlapping calls share one rebuild: a call made while a resume is the latest request
1394
+ * for the strand — a wake landing during a check-in — joins it and settles exactly as it
1395
+ * does, `'error'` status included. The joiner's own `overrides` are ignored; the first
1396
+ * resume's seed wins, and both callers resolved the cohort seed moments apart. A call made
1397
+ * while a quiesce is the latest request runs after that quiesce and rebuilds (see
1398
+ * {@link transitions}), and one made while `startStrand` is still building waits for that
1399
+ * build rather than starting a second.
1188
1400
  */
1189
1401
  async resumeStrand(strandId: string, overrides?: ResumeStrandOverrides): Promise<StrandInstance> {
1190
1402
  if (this.stopping) {
1191
1403
  throw new Error('StrandInstanceManager is stopping');
1192
1404
  }
1405
+ // No `await` between this read and `holdTransition`, or a second caller slips through
1406
+ // and builds.
1407
+ const previous = this.transitions.get(strandId);
1408
+ if (previous?.kind === 'resume') {
1409
+ log('resumeStrand: strand %s — joining the resume already in flight', strandId);
1410
+ return previous.operation;
1411
+ }
1412
+ const operation = this.runResume(strandId, previous, overrides);
1413
+ this.holdTransition(strandId, { kind: 'resume', operation });
1414
+ return operation;
1415
+ }
1416
+
1417
+ /** Record `transition` as the latest for `strandId` until it settles. */
1418
+ private holdTransition(strandId: string, transition: RuntimeTransition): void {
1419
+ this.transitions.set(strandId, transition);
1420
+ const release = (): void => {
1421
+ if (this.transitions.get(strandId) === transition) {
1422
+ this.transitions.delete(strandId);
1423
+ }
1424
+ };
1425
+ // The rejection itself reaches the caller through the operation returned to it.
1426
+ void transition.operation.then(release, release);
1427
+ }
1428
+
1429
+ /** Wait for the transition requested before this one. Never rejects: its failure belongs to its caller. */
1430
+ private async settleTransition(strandId: string, previous: RuntimeTransition | undefined): Promise<void> {
1431
+ if (!previous) {
1432
+ return;
1433
+ }
1434
+ await (previous.operation as Promise<unknown>).catch((error: unknown) => {
1435
+ log('Strand %s: the %s queued ahead failed (reported by its caller): %o', strandId, previous.kind, error);
1436
+ });
1437
+ }
1438
+
1439
+ /** Body of {@link resumeStrand}; at most one runs per strand at a time. */
1440
+ private async runResume(
1441
+ strandId: string,
1442
+ previous: RuntimeTransition | undefined,
1443
+ overrides?: ResumeStrandOverrides
1444
+ ): Promise<StrandInstance> {
1445
+ // Before any "already live" test: a quiesce ahead still has the handles attached, and
1446
+ // mid-build (a launch still building) the libp2p node is attached while the database is
1447
+ // not. A failed launch has already dropped the instance, and the check below reports it
1448
+ // untracked.
1449
+ await this.settleTransition(strandId, previous);
1450
+ await this.settleRuntimeBuilds(strandId);
1193
1451
  const instance = this.instances.get(strandId);
1194
1452
  if (!instance) {
1195
1453
  throw new Error(`Cannot resume strand ${strandId}: not tracked`);
@@ -1226,7 +1484,7 @@ export class StrandInstanceManager {
1226
1484
 
1227
1485
  instance.status = 'starting';
1228
1486
  try {
1229
- await this.buildStrandRuntime(instance, resumeConfig);
1487
+ await this.trackRuntimeBuild(strandId, this.buildStrandRuntime(instance, resumeConfig));
1230
1488
  timing('[resumeStrand:%s] total: %dms', strandId, Math.round(performance.now() - tTotal));
1231
1489
  log('Strand %s resumed successfully', strandId);
1232
1490
  return instance;
@@ -1254,7 +1512,8 @@ export class StrandInstanceManager {
1254
1512
  * - `'already-founder'` — the retained config already founds; nothing to do.
1255
1513
  * - `'bootstrapped'` — config flipped and the live database ran the bootstrap.
1256
1514
  * - `'needs-resume'` — config flipped, but the instance is quiesced (no live
1257
- * database), so the bootstrap could not run here: the CALLER must wake the
1515
+ * database) or being quiesced (its database is closing), so the bootstrap could
1516
+ * not run here: the CALLER must wake the
1258
1517
  * strand (`CadreNode.wakeStrand`, which owns the hibernation bookkeeping this
1259
1518
  * manager does not) so the rebuild — which now founds — runs it.
1260
1519
  * @param resolvePartyKey - Asked for the party's own membership key when (and only
@@ -1287,7 +1546,7 @@ export class StrandInstanceManager {
1287
1546
  // A fresh object rather than mutating in place: startStrand retains the CALLER'S
1288
1547
  // config object, which is not ours to rewrite.
1289
1548
  this.launchConfigs.set(strandId, { ...config, founder: true, partyMemberPrivateKey });
1290
- if (!instance.database && !this.firstSyncGates.has(strandId)) {
1549
+ if (this.isQuiescing(strandId) || (!instance.database && !this.firstSyncGates.has(strandId))) {
1291
1550
  return 'needs-resume';
1292
1551
  }
1293
1552
  try {
@@ -1299,6 +1558,75 @@ export class StrandInstanceManager {
1299
1558
  return 'bootstrapped';
1300
1559
  }
1301
1560
 
1561
+ /**
1562
+ * Give a tracked storage replica ({@link StartStrandConfig.sAppConfig} absent) the app's
1563
+ * schema in place — the seam for an app on this machine claiming a strand this node was
1564
+ * already hosting. No runtime rebuild: the libp2p node, its peer id and connections, the
1565
+ * store and its warm cache all stay, and the new `App` tables read the blocks the replica
1566
+ * already holds. Callers attach BEFORE {@link foundExistingStrand}: the founder bootstrap
1567
+ * writes the sApp into `Strand.Header` and refuses a database that has none.
1568
+ *
1569
+ * A runtime build in flight (the replica's launch, or a hibernation wake) is waited out
1570
+ * first, so the attach acts on the database that build produced. Then, in order: the
1571
+ * schema signature is checked before anything changes; the retained launch config takes
1572
+ * the sApp, so every later rebuild applies it through `composeStrand`; the live database —
1573
+ * published, or still held by the first-sync gate — gets the schema; and only then does
1574
+ * the instance record `sAppInfo` and the sApp's latency hint. A quiesced instance gets the
1575
+ * config alone, and its next resume applies the schema.
1576
+ *
1577
+ * @returns `'already-attached'` when the instance already runs an sApp — a DIFFERENT one
1578
+ * is logged and left as it is, as a second claim of a claimed strand always has been —
1579
+ * else `'attached'`.
1580
+ * @throws when the strand is not tracked (a caller bug, or a launch that failed while
1581
+ * this waited on it); when the schema signature is refused, having changed nothing; and
1582
+ * whatever the live apply throws (e.g. a quiesce closed the database mid-apply) — the
1583
+ * retained config then still carries the sApp and `sAppInfo` stays unset, so the next
1584
+ * claim retries the apply.
1585
+ *
1586
+ * NOTE: two claims of one replica racing each other both pass the `sAppInfo` check and
1587
+ * apply concurrently; the loser of the declarative diff can reject, and its `addStrand`
1588
+ * retry then resolves `'already-attached'`. Needs an app calling `addStrand` twice at once
1589
+ * (or beside a watcher retry); if it is ever seen, chain attaches per strand id.
1590
+ */
1591
+ async attachSApp(
1592
+ strandId: string,
1593
+ sAppConfig: SAppConfig,
1594
+ options: { requireSignedSchemas?: boolean } = {}
1595
+ ): Promise<'attached' | 'already-attached'> {
1596
+ await this.settleRuntimeBuilds(strandId);
1597
+ const instance = this.instances.get(strandId);
1598
+ const config = this.launchConfigs.get(strandId);
1599
+ if (!instance || !config) {
1600
+ throw new Error(`Cannot attach an sApp to strand ${strandId}: not tracked`);
1601
+ }
1602
+ if (instance.sAppInfo) {
1603
+ if (instance.sAppInfo.id !== sAppConfig.id) {
1604
+ log('attachSApp: strand %s already runs sApp %s — the claim for sApp %s is ignored',
1605
+ strandId, instance.sAppInfo.id, sAppConfig.id);
1606
+ }
1607
+ return 'already-attached';
1608
+ }
1609
+ const sAppInfo = verifiedSAppInfo(strandId, sAppConfig, options.requireSignedSchemas);
1610
+ // A fresh object rather than mutating in place: startStrand retains the CALLER'S
1611
+ // config object, which is not ours to rewrite.
1612
+ this.launchConfigs.set(strandId, { ...config, sAppConfig });
1613
+ const database = instance.database ?? this.firstSyncGates.get(strandId)?.database;
1614
+ if (database) {
1615
+ await database.attachAppSchema(sAppConfig);
1616
+ } else {
1617
+ log('attachSApp: strand %s is quiesced — its next resume applies the sApp schema', strandId);
1618
+ }
1619
+ instance.sAppInfo = sAppInfo;
1620
+ if (sAppConfig.latencyHint) {
1621
+ // HibernationManager reads the hint whenever it arms a timer, so the app's hint governs
1622
+ // from the next one: a timer already armed runs once at the old duration, and a replica
1623
+ // launched under a realtime default was never tracked, so it stays up until relaunched.
1624
+ instance.latencyHint = sAppConfig.latencyHint;
1625
+ }
1626
+ log('Strand %s upgraded from storage replica to %s', strandId, describeSApp(sAppConfig));
1627
+ return 'attached';
1628
+ }
1629
+
1302
1630
  /**
1303
1631
  * Whether `strandId` is launched but still waiting for its first sync — a joiner whose
1304
1632
  * runtime is up while its database is withheld (status `'syncing'`, or `'idle'` after
@@ -1354,6 +1682,18 @@ export class StrandInstanceManager {
1354
1682
  });
1355
1683
  }
1356
1684
 
1685
+ /**
1686
+ * The tracked instance once no runtime build of it is in flight, or `undefined` when none
1687
+ * is tracked by then — never launched, or the launch waited on failed and dropped its
1688
+ * record. For a caller about to hand a tracked instance to an app: until its build settles
1689
+ * the instance is `'starting'` with no database, and a failed launch leaves no instance.
1690
+ * Never rejects; a failed build is reported by the call that started it.
1691
+ */
1692
+ async whenRuntimeBuilt(strandId: string): Promise<StrandInstance | undefined> {
1693
+ await this.settleRuntimeBuilds(strandId);
1694
+ return this.instances.get(strandId);
1695
+ }
1696
+
1357
1697
  /**
1358
1698
  * Hand a launch's database to the app: set `instance.database`, retire the gate that
1359
1699
  * held it, release every {@link whenWritable} waiter, and — when the launch had already
@@ -1383,9 +1723,11 @@ export class StrandInstanceManager {
1383
1723
  this.firstSyncGates.delete(strandId);
1384
1724
  }
1385
1725
  instance.database = database;
1386
- instance.lastActivity = new Date();
1387
1726
  const wasGated = instance.status !== 'starting';
1388
1727
  if (wasGated) {
1728
+ // A Header delivered by a peer is strand activity; a publish during bring-up is not
1729
+ // (see the build's end in `buildStrandRuntime`).
1730
+ instance.lastActivity = new Date();
1389
1731
  instance.status = 'active';
1390
1732
  log('Strand %s is now writable: Strand.Header held', strandId);
1391
1733
  }