@serfab/cadre-core 0.13.0 → 1.0.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 +20 -6
- package/dist/bootstrap-peer-store-file.js +2 -2
- package/dist/bootstrap-peer-store.d.ts +32 -12
- package/dist/bootstrap-peer-store.js +41 -15
- package/dist/bootstrap-peer-store.js.map +1 -1
- package/dist/cadre-node.d.ts +558 -80
- package/dist/cadre-node.js +1060 -170
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +13 -26
- package/dist/control-cohort.js +0 -26
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +299 -44
- package/dist/control-database.js +469 -69
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +40 -11
- package/dist/control-read-retry.js +48 -15
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-retry.d.ts +58 -3
- package/dist/control-retry.js +58 -3
- package/dist/control-retry.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +103 -19
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +80 -11
- package/dist/control-write-retry.js +168 -29
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store.js +2 -2
- package/dist/index.d.ts +14 -7
- package/dist/index.js +33 -6
- package/dist/index.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +15 -7
- package/dist/node-local-snapshot.js +23 -8
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -0
- package/dist/peer-dial.js +176 -0
- package/dist/peer-dial.js.map +1 -0
- package/dist/relay-addrs.d.ts +90 -52
- package/dist/relay-addrs.js +91 -59
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +71 -3
- package/dist/relay-reservation.js +116 -8
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +30 -1
- package/dist/seed-bootstrap.js +62 -17
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +62 -0
- package/dist/storage-scope.js +75 -0
- package/dist/storage-scope.js.map +1 -0
- package/dist/strand-database.d.ts +34 -16
- package/dist/strand-database.js +38 -24
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +152 -0
- package/dist/strand-first-sync-gate.js +208 -0
- package/dist/strand-first-sync-gate.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +87 -5
- package/dist/strand-formation-manager.js +109 -5
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +21 -1
- package/dist/strand-formation-protocol.js +30 -0
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +243 -6
- package/dist/strand-instance-manager.js +470 -37
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +20 -12
- package/dist/strand-member-key.js +20 -12
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.js +3 -0
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +361 -0
- package/dist/strand-membership-reconciler.js +548 -0
- package/dist/strand-membership-reconciler.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +131 -11
- package/dist/strand-membership-writer.js +396 -134
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +46 -14
- package/dist/strand-network-config.js +79 -23
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-solicitation.d.ts +10 -1
- package/dist/strand-solicitation.js +5 -0
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +11 -7
- package/dist/strand-wake-protocol.js +20 -52
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.js +6 -0
- package/dist/strand-watcher.js.map +1 -1
- package/dist/timed-step.d.ts +12 -0
- package/dist/timed-step.js +28 -0
- package/dist/timed-step.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +19 -0
- package/dist/timeout-scheduler.js +22 -0
- package/dist/timeout-scheduler.js.map +1 -0
- package/dist/types.d.ts +262 -30
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/bootstrap-peer-store-file.ts +2 -2
- package/src/bootstrap-peer-store.ts +69 -27
- package/src/cadre-node.ts +7301 -6353
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +122 -130
- package/src/control-database.ts +537 -69
- package/src/control-read-retry.ts +202 -167
- package/src/control-retry.ts +297 -190
- package/src/control-schema.ts +103 -19
- package/src/control-write-retry.ts +525 -383
- package/src/enrolled-machine-store.ts +2 -2
- package/src/index.ts +83 -6
- package/src/node-local-snapshot.ts +25 -8
- package/src/peer-dial.ts +215 -0
- package/src/relay-addrs.ts +458 -436
- package/src/relay-reservation.ts +951 -828
- package/src/seed-bootstrap.ts +76 -18
- package/src/storage-scope.ts +78 -0
- package/src/strand-database.ts +50 -27
- package/src/strand-first-sync-gate.ts +258 -0
- package/src/strand-formation-manager.ts +140 -5
- package/src/strand-formation-protocol.ts +43 -1
- package/src/strand-instance-manager.ts +577 -37
- package/src/strand-member-key.ts +20 -12
- package/src/strand-member-registry.ts +3 -0
- package/src/strand-membership-reconciler.ts +680 -0
- package/src/strand-membership-writer.ts +527 -189
- package/src/strand-network-config.ts +102 -24
- package/src/strand-solicitation.ts +16 -1
- package/src/strand-wake-protocol.ts +21 -66
- package/src/strand-watcher.ts +6 -0
- package/src/timed-step.ts +28 -0
- package/src/timeout-scheduler.ts +28 -0
- package/src/types.ts +1789 -1554
|
@@ -4,11 +4,43 @@ import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugi
|
|
|
4
4
|
import { StrandDatabase } from './strand-database.js';
|
|
5
5
|
import { PeerJoinBackfill } from './peer-join-backfill.js';
|
|
6
6
|
import { StrandRevocationEnforcer, createRevocationConnectionGater, readStrandRevocationRows } from './strand-revocation-enforcer.js';
|
|
7
|
+
import { StrandMembershipReconciler } from './strand-membership-reconciler.js';
|
|
8
|
+
import { removeMemberPeer } from './strand-membership-writer.js';
|
|
9
|
+
import { strandMemberKeyPair } from './strand-member-key.js';
|
|
7
10
|
import { assertSchemaSignature } from './schema-verification.js';
|
|
11
|
+
import { StrandFirstSyncGate, StrandAwaitingFirstSyncError, strandFirstSyncComplete, DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS } from './strand-first-sync-gate.js';
|
|
8
12
|
import { resolveStrandClusterSize, strandClusterPolicy } from './types.js';
|
|
9
13
|
import { strandNodeAddrs } from './strand-network-config.js';
|
|
14
|
+
import { superviseRelayReservation } from './relay-reservation.js';
|
|
10
15
|
const log = debug('sereus:cadre:strand-manager');
|
|
11
16
|
const timing = debug('sereus:cadre:timing');
|
|
17
|
+
/**
|
|
18
|
+
* Whether a tracked instance is launched but waiting for its first sync: the runtime is
|
|
19
|
+
* up (`libp2pNode` set) while the database is still withheld (`database` unset). That
|
|
20
|
+
* is the first-sync gate's state regardless of what `status` says — the hibernation
|
|
21
|
+
* manager's idle timer can flip a long-gated joiner to `'idle'` without changing it.
|
|
22
|
+
*
|
|
23
|
+
* NOTE: `status` is therefore not the source of truth for "gated": a joiner that reaches
|
|
24
|
+
* nobody for `idleTimeout` (5 min at the interactive hint) reads `'idle'`, then
|
|
25
|
+
* `'hibernating'`, while still holding no database. Harmless today — every decision the
|
|
26
|
+
* runtime makes goes through this predicate, {@link StrandInstanceManager.publishDatabase}
|
|
27
|
+
* announces on the idle-flipped instance exactly as on a `'syncing'` one, and an app that
|
|
28
|
+
* shows "waiting for the other member" on `'syncing'` merely sees "idle" instead after
|
|
29
|
+
* five minutes. If an app ever needs the gated LABEL to survive the idle flip, make the
|
|
30
|
+
* hibernation manager's idle transition preserve `'syncing'` rather than adding a flag.
|
|
31
|
+
*/
|
|
32
|
+
export function isAwaitingFirstSync(instance) {
|
|
33
|
+
return instance.libp2pNode !== undefined && instance.database === undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The status a LIVE instance (runtime rebuilt or woken) should report: `'active'` once its
|
|
37
|
+
* database is published, `'syncing'` while the first-sync gate still withholds it. Every
|
|
38
|
+
* site that would otherwise write `'active'` after a build or a wake goes through this,
|
|
39
|
+
* so a gated joiner never reads as writable.
|
|
40
|
+
*/
|
|
41
|
+
export function liveStrandStatus(instance) {
|
|
42
|
+
return instance.database ? 'active' : 'syncing';
|
|
43
|
+
}
|
|
12
44
|
/**
|
|
13
45
|
* Get the isolated storage path for a specific strand.
|
|
14
46
|
*
|
|
@@ -89,6 +121,45 @@ export class StrandInstanceManager {
|
|
|
89
121
|
* resume rebuilds it with a fresh (initially empty, fail-open) snapshot.
|
|
90
122
|
*/
|
|
91
123
|
this.revocationEnforcers = new Map();
|
|
124
|
+
/**
|
|
125
|
+
* The per-strand membership reconciler (closed strands launched with a party
|
|
126
|
+
* key only), keyed by strand id. Same lifecycle rationale as {@link backfills}:
|
|
127
|
+
* created in `buildStrandRuntime`, stopped and dropped in `releaseRuntime`, so
|
|
128
|
+
* quiesce → resume rebuilds it and re-runs the idempotent join ladder (redeem a
|
|
129
|
+
* staged invitation, write this machine's own `MemberPeer` binding) from scratch.
|
|
130
|
+
*/
|
|
131
|
+
this.membershipReconcilers = new Map();
|
|
132
|
+
/**
|
|
133
|
+
* The per-strand first-sync gate (`strand-first-sync-gate.ts`), keyed by strand id:
|
|
134
|
+
* holds a launch's `StrandDatabase` while it is NOT yet published on the instance.
|
|
135
|
+
* Present from the moment the database object exists in `buildStrandRuntime` until
|
|
136
|
+
* {@link publishDatabase} hands it to the app — so it doubles as the rollback handle
|
|
137
|
+
* for a failed `initialize()` — and, for a joiner that holds no `Strand.Header` yet,
|
|
138
|
+
* for as long as the launch stays `'syncing'`. Same lifecycle as {@link backfills}:
|
|
139
|
+
* stopped (its database closed) and dropped in `releaseRuntime`, so a quiesce →
|
|
140
|
+
* resume rebuild re-probes over the same store. An entry and `instance.database`
|
|
141
|
+
* are mutually exclusive.
|
|
142
|
+
*/
|
|
143
|
+
this.firstSyncGates = new Map();
|
|
144
|
+
/**
|
|
145
|
+
* Callers blocked in {@link whenWritable}, keyed by strand id. Resolved by
|
|
146
|
+
* {@link publishDatabase}, rejected by {@link stopStrand}; each waiter also carries
|
|
147
|
+
* its own timeout. Deliberately NOT cleared by `releaseRuntime`: a waiter outlives a
|
|
148
|
+
* quiesce → resume cycle, since the rebuild is what may finally publish the database.
|
|
149
|
+
*/
|
|
150
|
+
this.writableWaiters = new Map();
|
|
151
|
+
/**
|
|
152
|
+
* The per-strand relay-reservation supervisors — ONE PER CONFIGURED RELAY, each
|
|
153
|
+
* over the node's own bare `/p2p-circuit` listener for that relay
|
|
154
|
+
* (`strand-network-config.ts`) — keyed by strand id. Same lifecycle rationale as
|
|
155
|
+
* {@link backfills}: started in `buildStrandRuntime` right after the libp2p node
|
|
156
|
+
* exists, stopped FIRST and dropped in `releaseRuntime` (before the database
|
|
157
|
+
* closes and the node stops, so no re-drive dials a node being torn down), so
|
|
158
|
+
* quiesce → resume rebuilds them over the new node. What makes a strand node's
|
|
159
|
+
* lost relay slot come back on its own — the control node has the same loop in
|
|
160
|
+
* `CadreNode.reserveRelays`.
|
|
161
|
+
*/
|
|
162
|
+
this.relaySupervisors = new Map();
|
|
92
163
|
/**
|
|
93
164
|
* The resolved (cache-wrapped) raw storage per strand id — the instance's OWN
|
|
94
165
|
* store, resolved once in `startStrand` and held until `stopStrand` disposes it.
|
|
@@ -248,20 +319,19 @@ export class StrandInstanceManager {
|
|
|
248
319
|
// Determine relay mode: if explicitly set in config, use that;
|
|
249
320
|
// otherwise default to true for storage profile nodes.
|
|
250
321
|
const enableRelay = config.network?.enableRelay ?? (config.profile === 'storage');
|
|
251
|
-
// The CONFIGURED relay route (the default), deliberately NOT the control node's
|
|
252
|
-
// `'search'` route: nothing drives an explicit reservation for a strand node, so
|
|
253
|
-
// a bare `/p2p-circuit` search entry would register a pending reservation that
|
|
254
|
-
// never gets filled and leave the strand node undialable. The ordering hazard
|
|
255
|
-
// that pushed the control node onto the search route does not exist here — a
|
|
256
|
-
// strand node's protocol ids are namespaced `/optimystic/strand-<id>/…`, so a
|
|
257
|
-
// relay dialed from inside `libp2p.start()` is never in the strand's cohort and
|
|
258
|
-
// cannot refuse its database bring-up. See `relay-addrs.ts`.
|
|
259
|
-
//
|
|
260
322
|
// The strand-node VIEW of the machine's one `NetworkConfig`, not the control
|
|
261
323
|
// node's resolution: fixed direct listen ports become ephemeral (two nodes cannot
|
|
262
|
-
// bind one port)
|
|
263
|
-
// address)
|
|
264
|
-
|
|
324
|
+
// bind one port), the announce config is dropped (it names the control node's
|
|
325
|
+
// address), and every configured relay becomes one bare `/p2p-circuit` SEARCH
|
|
326
|
+
// listener plus a dial addr in `relayAddrs` for the per-relay supervisor started
|
|
327
|
+
// below — the same route the control node takes, because the configured
|
|
328
|
+
// `<relay>/p2p-circuit` shape loses its address on a relay restart, a hangup, or
|
|
329
|
+
// libp2p's own reservation refresh and recovers from none of them. See
|
|
330
|
+
// `strand-network-config.ts` for all three, and `relay-reservation.ts`.
|
|
331
|
+
//
|
|
332
|
+
// `relayAddrs` is destructured OFF: it is not a `createLibp2pNode` option, and
|
|
333
|
+
// spreading it would hand db-p2p a key it does not know.
|
|
334
|
+
const { relayAddrs: relayDialAddrs = [], ...addrOptions } = strandNodeAddrs(config.network);
|
|
265
335
|
// The CLOSED-strand revoked-peer gate (see strand-revocation-enforcer.ts).
|
|
266
336
|
// Constructed BEFORE the libp2p node because the node's options embed its
|
|
267
337
|
// predicates; its deny set starts empty (admit everything — fail-open, same
|
|
@@ -337,13 +407,12 @@ export class StrandInstanceManager {
|
|
|
337
407
|
// Listen entries plus the WebSocket transport switch they imply — a strand node
|
|
338
408
|
// announces nothing the operator configured (`strand-network-config.ts`), and
|
|
339
409
|
// spreads AFTER `transports` above because the switch is a no-op whenever the
|
|
340
|
-
// embedder supplied its own factories.
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
// into the same list on the configured route this call takes (see above).
|
|
410
|
+
// embedder supplied its own factories. The bare `/p2p-circuit` entries (one per
|
|
411
|
+
// relay) open no connection here; each registers a pending reservation that the
|
|
412
|
+
// matching supervisor below fills, which is what gives a NAT'd strand node a
|
|
413
|
+
// reachable relay slot — and works against a party-run relay because the launch
|
|
414
|
+
// path announced this strand's derived peerId to it first (delegate admission;
|
|
415
|
+
// see cadre-node.ts).
|
|
347
416
|
...addrOptions,
|
|
348
417
|
// The raw configured gater (open strands), or the revocation-composed
|
|
349
418
|
// gater plus the fail-closed per-stream revoked-peer gate (closed
|
|
@@ -352,10 +421,20 @@ export class StrandInstanceManager {
|
|
|
352
421
|
});
|
|
353
422
|
timing('[buildStrandRuntime:%s] createLibp2pNode: %dms', strandId, Math.round(performance.now() - t0));
|
|
354
423
|
instance.libp2pNode = node;
|
|
424
|
+
// One reservation supervisor PER RELAY, started now so the first attempts
|
|
425
|
+
// overlap the database bring-up below (the relay is never in a strand's
|
|
426
|
+
// Optimystic cohort — its protocol ids are namespaced `/optimystic/strand-<id>/…`
|
|
427
|
+
// — so an open relay connection cannot disturb that bring-up). Their first
|
|
428
|
+
// attempts are awaited before the strand goes `active`, below. Registered in the
|
|
429
|
+
// map immediately so the failure rollback (releaseRuntime) stops them like every
|
|
430
|
+
// other runtime component.
|
|
431
|
+
this.relaySupervisors.set(strandId, this.startRelaySupervisors(strandId, node, relayDialAddrs, config));
|
|
355
432
|
// Create and initialize the StrandDatabase.
|
|
356
433
|
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
434
|
+
// Held by the first-sync gate (registered before initialize) rather than attached
|
|
435
|
+
// to the instance: a failed init is then cleaned up by releaseRuntime below through
|
|
436
|
+
// the gate (close() is safe on a partially-initialized db), and the app only ever
|
|
437
|
+
// sees `instance.database` once this machine is allowed to write to the strand.
|
|
359
438
|
t0 = performance.now();
|
|
360
439
|
const strandDb = new StrandDatabase({
|
|
361
440
|
strandId,
|
|
@@ -363,15 +442,79 @@ export class StrandInstanceManager {
|
|
|
363
442
|
libp2pNode: node,
|
|
364
443
|
coordinatedRepo: node.coordinatedRepo,
|
|
365
444
|
// Founder bootstrap inputs: the strand's type drives which membership rows
|
|
366
|
-
// are written, and the
|
|
367
|
-
//
|
|
445
|
+
// are written, and the PARTY's own key (partyMemberPrivateKey, resolved by the
|
|
446
|
+
// caller from the control-layer StrandPartyKey row) derives the founding
|
|
447
|
+
// Member/Manager key. The row's shared MemberPrivateKey rides along as the
|
|
448
|
+
// read-gating secret only — it derives nobody's identity.
|
|
368
449
|
strandType: config.strandRow.Type,
|
|
369
450
|
memberPrivateKey: config.strandRow.MemberPrivateKey ?? undefined,
|
|
451
|
+
partyMemberPrivateKey: config.partyMemberPrivateKey,
|
|
370
452
|
founder: config.founder
|
|
371
453
|
});
|
|
372
|
-
|
|
454
|
+
const gate = new StrandFirstSyncGate({
|
|
455
|
+
label: strandId,
|
|
456
|
+
database: strandDb,
|
|
457
|
+
onHeaderHeld: () => this.publishDatabase(instance, strandDb)
|
|
458
|
+
}, config.firstSync);
|
|
459
|
+
this.firstSyncGates.set(strandId, gate);
|
|
373
460
|
await strandDb.initialize();
|
|
374
461
|
timing('[buildStrandRuntime:%s] strandDatabase.initialize: %dms', strandId, Math.round(performance.now() - t0));
|
|
462
|
+
// The first-sync write gate. A founder just wrote the Header in its bootstrap; any
|
|
463
|
+
// other machine may commit only once it holds the Header — which it can only have
|
|
464
|
+
// received from a peer (now, or on an earlier run over the same store) — and has read
|
|
465
|
+
// each App table once, so their collections are fetched too. Publishing the database
|
|
466
|
+
// is what makes the strand writable to the app; until then the instance stays
|
|
467
|
+
// `'syncing'` and the gate re-probes on its cadence. Everything armed below reads
|
|
468
|
+
// `instance.database` lazily, so the reconciler and enforcer wait with it — and
|
|
469
|
+
// `publishDatabase` kicks the reconciler when the wait ends, so a joiner's membership
|
|
470
|
+
// rows land with the strand rather than one retry later.
|
|
471
|
+
t0 = performance.now();
|
|
472
|
+
if (config.founder === true || await strandFirstSyncComplete(strandDb.getDatabase(), strandId)) {
|
|
473
|
+
this.publishDatabase(instance, strandDb);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
gate.start();
|
|
477
|
+
}
|
|
478
|
+
timing('[buildStrandRuntime:%s] first-sync probe: %dms', strandId, Math.round(performance.now() - t0));
|
|
479
|
+
// The membership reconciler: finish this party's join on every machine —
|
|
480
|
+
// redeem a staged formation invitation if one is pending, then write the
|
|
481
|
+
// durable machine→party binding (Strand.MemberPeer). Armed here, the one
|
|
482
|
+
// seam where the strand's transport peer id and a live Database both exist
|
|
483
|
+
// for launch AND hibernation resume, for closed strands that carry the
|
|
484
|
+
// party's own membership key. start() kicks an immediate pass WITHOUT
|
|
485
|
+
// awaiting it — a joiner at this instant has not synced the founder's rows
|
|
486
|
+
// and may lack write quorum, so bring-up is never blocked; an unfinished join
|
|
487
|
+
// then retries on a short doubling ladder capped at the configured
|
|
488
|
+
// pollIntervalMs (which is also the flat cadence while nobody has admitted this
|
|
489
|
+
// party yet), and the loop stops once the member row and this machine's own
|
|
490
|
+
// binding are both in place. Armed AFTER the gate above, so a gated launch
|
|
491
|
+
// always has a reconciler registered by the time the gate can publish.
|
|
492
|
+
if (config.strandRow.Type === 'c' && config.partyMemberPrivateKey
|
|
493
|
+
&& config.membershipReconciliation?.enabled !== false) {
|
|
494
|
+
const reconciler = new StrandMembershipReconciler({
|
|
495
|
+
label: strandId,
|
|
496
|
+
partyMemberPrivateKey: config.partyMemberPrivateKey,
|
|
497
|
+
// Read per pass, never captured — same lifecycle argument as the
|
|
498
|
+
// enforcer's getNetwork: quiesce drops both handles.
|
|
499
|
+
getDatabase: () => instance.database?.getDatabase(),
|
|
500
|
+
getOwnPeerId: () => instance.libp2pNode?.peerId.toString(),
|
|
501
|
+
pendingInvite: config.pendingMembershipInvite,
|
|
502
|
+
// The enforcer's CURRENT snapshot view of this node's own peer id — the
|
|
503
|
+
// reconciler stops rather than fight a self-revocation. Absent when the
|
|
504
|
+
// gate is disarmed (fail-open: the loop just runs).
|
|
505
|
+
isSelfRevoked: revocationEnforcer
|
|
506
|
+
? () => {
|
|
507
|
+
const ownPeerId = instance.libp2pNode?.peerId.toString();
|
|
508
|
+
return ownPeerId !== undefined && revocationEnforcer.isRevoked(ownPeerId);
|
|
509
|
+
}
|
|
510
|
+
: undefined
|
|
511
|
+
}, {
|
|
512
|
+
pollIntervalMs: config.membershipReconciliation?.pollIntervalMs
|
|
513
|
+
?? config.revocationEnforcement?.pollIntervalMs
|
|
514
|
+
});
|
|
515
|
+
this.membershipReconcilers.set(strandId, reconciler);
|
|
516
|
+
reconciler.start();
|
|
517
|
+
}
|
|
375
518
|
// Arm the revoked-peer deny-set poll now that the database it reads
|
|
376
519
|
// exists. start() kicks an immediate refresh WITHOUT awaiting it —
|
|
377
520
|
// bring-up and resume are never blocked on a membership read; until the
|
|
@@ -411,8 +554,26 @@ export class StrandInstanceManager {
|
|
|
411
554
|
log('Strand %s: libp2p node exposes no keyNetwork; peer-join block catch-up is inert', strandId);
|
|
412
555
|
}
|
|
413
556
|
}
|
|
414
|
-
|
|
557
|
+
// The relay supervisors' FIRST attempts, all of them, before `active`: the happy
|
|
558
|
+
// path still publishes its circuit addr before `addStrand` resolves (the
|
|
559
|
+
// strand-addr RPC answers read it, and the same-party circuit scenario asserts
|
|
560
|
+
// it). Fail-SOFT — a first attempt that lands nothing does not fail the launch.
|
|
561
|
+
// The strand's database is up, and the supervisor keeps trying on its backoff;
|
|
562
|
+
// failing here would only trade that for `StrandWatcher`'s full-rebuild retry.
|
|
563
|
+
//
|
|
564
|
+
// NOTE: a relay that is down costs this launch one full drive (10 s), and
|
|
565
|
+
// `StrandWatcher` launches strands one at a time — so N strands cost N × 10 s
|
|
566
|
+
// of bring-up during a relay outage. If that ever matters, stop awaiting here
|
|
567
|
+
// (the circuit addr then lands after `active`) rather than shortening the drive.
|
|
568
|
+
t0 = performance.now();
|
|
569
|
+
await this.awaitFirstRelayAttempts(strandId);
|
|
570
|
+
timing('[buildStrandRuntime:%s] relay first attempts: %dms', strandId, Math.round(performance.now() - t0));
|
|
571
|
+
instance.status = liveStrandStatus(instance);
|
|
415
572
|
instance.lastActivity = new Date();
|
|
573
|
+
if (instance.status === 'syncing') {
|
|
574
|
+
log('Strand %s launched as a joiner with no Strand.Header held yet — writes are withheld until ' +
|
|
575
|
+
'a member of the strand is reached', strandId);
|
|
576
|
+
}
|
|
416
577
|
}
|
|
417
578
|
catch (error) {
|
|
418
579
|
// Roll back any partially-attached runtime so the instance is left with
|
|
@@ -426,17 +587,30 @@ export class StrandInstanceManager {
|
|
|
426
587
|
}
|
|
427
588
|
}
|
|
428
589
|
/**
|
|
429
|
-
* Release an instance's strand-network runtime:
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
590
|
+
* Release an instance's strand-network runtime: stop the relay supervisors and
|
|
591
|
+
* the other background loops, close the StrandDatabase, then stop the libp2p
|
|
592
|
+
* node (construction order in reverse), clearing both fields and zeroing
|
|
593
|
+
* connectedPeers. Tolerant of partially-built state — either handle may be
|
|
594
|
+
* absent — so it doubles as rollback for a failed `buildStrandRuntime`. Shared
|
|
595
|
+
* by `quiesceStrand`, `stopStrand`, and that rollback path.
|
|
434
596
|
*
|
|
435
597
|
* Leaves `strandStorages` untouched by design — the store outlives the runtime it
|
|
436
598
|
* was built into, which is what makes a resume warm. Only `stopStrand` disposes it.
|
|
437
599
|
*/
|
|
438
600
|
async releaseRuntime(instance) {
|
|
439
|
-
//
|
|
601
|
+
// Relay supervisors FIRST of all — before anything below is torn down — so no
|
|
602
|
+
// re-drive is scheduled against a node being stopped. `stop()` is synchronous
|
|
603
|
+
// and never awaits a drive, so a relay that is down cannot delay this teardown.
|
|
604
|
+
// NOTE: a drive ALREADY in flight cannot be aborted (the drive takes no
|
|
605
|
+
// AbortSignal — `backlog/bug-relay-drive-not-cancellable`); it fails soft
|
|
606
|
+
// against the stopped node within its own 10 s deadline and its result is
|
|
607
|
+
// discarded.
|
|
608
|
+
const relaySupervisors = this.relaySupervisors.get(instance.strandId);
|
|
609
|
+
if (relaySupervisors) {
|
|
610
|
+
relaySupervisors.forEach((supervisor) => supervisor.stop());
|
|
611
|
+
this.relaySupervisors.delete(instance.strandId);
|
|
612
|
+
}
|
|
613
|
+
// Backfill next — before the database closes and the libp2p node stops — so
|
|
440
614
|
// no NEW catch-up push is issued against a torn-down transport. A push already
|
|
441
615
|
// in flight is not awaited; it fails into the module's own per-chunk catch.
|
|
442
616
|
const backfill = this.backfills.get(instance.strandId);
|
|
@@ -451,6 +625,22 @@ export class StrandInstanceManager {
|
|
|
451
625
|
revocationEnforcer.stop();
|
|
452
626
|
this.revocationEnforcers.delete(instance.strandId);
|
|
453
627
|
}
|
|
628
|
+
// The membership reconciler likewise: a resume rebuilds it and re-runs the
|
|
629
|
+
// idempotent ladder, which is what makes resume-after-partial-join heal.
|
|
630
|
+
const membershipReconciler = this.membershipReconcilers.get(instance.strandId);
|
|
631
|
+
if (membershipReconciler) {
|
|
632
|
+
membershipReconciler.stop();
|
|
633
|
+
this.membershipReconcilers.delete(instance.strandId);
|
|
634
|
+
}
|
|
635
|
+
// A still-gated database (a joiner that never received the Header, or a launch whose
|
|
636
|
+
// initialize() failed) is closed through its gate; a published one through the
|
|
637
|
+
// instance. Never both — publishing moves the handle from the gate to the instance.
|
|
638
|
+
const gate = this.firstSyncGates.get(instance.strandId);
|
|
639
|
+
if (gate) {
|
|
640
|
+
gate.stop();
|
|
641
|
+
this.firstSyncGates.delete(instance.strandId);
|
|
642
|
+
await gate.database.close();
|
|
643
|
+
}
|
|
454
644
|
if (instance.database) {
|
|
455
645
|
await instance.database.close();
|
|
456
646
|
instance.database = undefined;
|
|
@@ -461,6 +651,45 @@ export class StrandInstanceManager {
|
|
|
461
651
|
}
|
|
462
652
|
instance.connectedPeers = 0;
|
|
463
653
|
}
|
|
654
|
+
/**
|
|
655
|
+
* One {@link superviseRelayReservation} per relay dial addr, each over exactly
|
|
656
|
+
* that relay so "held" is judged per relay (`circuitMultiaddrsVia`) and losing
|
|
657
|
+
* one relay re-drives only that one. Default timings — the control node's (2 s
|
|
658
|
+
* doubling to 60 s between failed attempts, a 5 s liveness check while held).
|
|
659
|
+
*
|
|
660
|
+
* Each supervisor's `beforeRedrive` is the caller's
|
|
661
|
+
* {@link StartStrandConfig.announceDelegateToRelay} for THIS relay and THIS
|
|
662
|
+
* node's peer id, so a re-drive after a relay restart is preceded by a fresh
|
|
663
|
+
* delegate grant on the relay about to be dialed. Returns `[]` when no relay is
|
|
664
|
+
* configured — the strand then simply has no circuit listener to fill.
|
|
665
|
+
*/
|
|
666
|
+
startRelaySupervisors(strandId, node, relayDialAddrs, config) {
|
|
667
|
+
if (relayDialAddrs.length === 0) {
|
|
668
|
+
return [];
|
|
669
|
+
}
|
|
670
|
+
const announce = config.announceDelegateToRelay;
|
|
671
|
+
const delegatePeerId = node.peerId.toString();
|
|
672
|
+
return relayDialAddrs.map((relayAddr) => superviseRelayReservation(node, [relayAddr], {
|
|
673
|
+
...(announce && { beforeRedrive: () => announce(strandId, relayAddr, delegatePeerId) })
|
|
674
|
+
}));
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Wait for every relay supervisor's first attempt to settle, concurrently, and log
|
|
678
|
+
* the ones that landed nothing. Never throws — a strand whose relay is unreachable
|
|
679
|
+
* at launch still comes up, with its supervisors retrying in the background.
|
|
680
|
+
*/
|
|
681
|
+
async awaitFirstRelayAttempts(strandId) {
|
|
682
|
+
const supervisors = this.relaySupervisors.get(strandId) ?? [];
|
|
683
|
+
if (supervisors.length === 0) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
await Promise.all(supervisors.map((supervisor) => supervisor.firstAttempt));
|
|
687
|
+
supervisors.forEach((supervisor) => {
|
|
688
|
+
if (supervisor.lastError !== null) {
|
|
689
|
+
log('Strand %s: relay reservation not held after the first attempt (retrying in the background): %s', strandId, supervisor.lastError);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
}
|
|
464
693
|
/**
|
|
465
694
|
* Drop the strand's owned store and release this strand's claim on its cache. The
|
|
466
695
|
* wrapper counts holders, so the cache is emptied and unregistered from the shared
|
|
@@ -510,6 +739,59 @@ export class StrandInstanceManager {
|
|
|
510
739
|
}
|
|
511
740
|
await enforcer.refresh();
|
|
512
741
|
}
|
|
742
|
+
/**
|
|
743
|
+
* Best-effort removal of THIS machine's own `Strand.MemberPeer` binding — the
|
|
744
|
+
* self arm of `removeMemberPeer`, signed with the retained launch config's party
|
|
745
|
+
* key. Called by `CadreNode.unpublishStrand` BEFORE the local stop, while the
|
|
746
|
+
* strand database and transport are still live: after the unpublish commits, the
|
|
747
|
+
* party's `StrandPartyKey` control row is gone, so no restart could ever sign
|
|
748
|
+
* this removal again — the in-memory retained key is the last chance.
|
|
749
|
+
*
|
|
750
|
+
* Never throws. Quiet no-op for a strand that is untracked, open, or launched
|
|
751
|
+
* without a party key; a quiesced (hibernating) strand, or a strand whose write
|
|
752
|
+
* quorum is already unreachable, leaves the stale binding behind with a log —
|
|
753
|
+
* it grants nothing today (admission is not allowlist-gated) and only
|
|
754
|
+
* mis-credits diversity. The strand's own membership reconciler is stopped —
|
|
755
|
+
* and its in-flight pass awaited — FIRST, so a pass that was already past its
|
|
756
|
+
* own stopped check cannot re-register the binding this is about to clear.
|
|
757
|
+
*
|
|
758
|
+
* NOTE: deliberately NOT handled here (or anywhere yet): clearing the bindings
|
|
759
|
+
* of a machine removed from the CADRE at the control layer, or of a party's
|
|
760
|
+
* OTHER machines when the party unpublishes — the party still holds the key on
|
|
761
|
+
* every surviving machine and can clear from any of them, and the stale rows
|
|
762
|
+
* grant nothing while admission is deny-list only. Revisit when
|
|
763
|
+
* `feat-strand-member-allowlist-admission` lands and a stale binding starts
|
|
764
|
+
* granting admission rather than merely mis-crediting diversity.
|
|
765
|
+
*/
|
|
766
|
+
async clearOwnMemberPeerBinding(strandId) {
|
|
767
|
+
const instance = this.instances.get(strandId);
|
|
768
|
+
const config = this.launchConfigs.get(strandId);
|
|
769
|
+
if (!instance || !config || config.strandRow.Type !== 'c' || !config.partyMemberPrivateKey) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const reconciler = this.membershipReconcilers.get(strandId);
|
|
773
|
+
if (reconciler) {
|
|
774
|
+
reconciler.stop();
|
|
775
|
+
await reconciler.settle();
|
|
776
|
+
}
|
|
777
|
+
const db = instance.database?.getDatabase();
|
|
778
|
+
const peerId = instance.libp2pNode?.peerId.toString();
|
|
779
|
+
if (!db || !peerId) {
|
|
780
|
+
log('clearOwnMemberPeerBinding: strand %s has no live runtime — own MemberPeer binding left in place', strandId);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
// Background write on the app's database: never join a transaction the app has open.
|
|
785
|
+
await removeMemberPeer(db, {
|
|
786
|
+
memberKeyPair: strandMemberKeyPair(config.partyMemberPrivateKey),
|
|
787
|
+
peerId
|
|
788
|
+
}, { joinOpenTransaction: false });
|
|
789
|
+
log('clearOwnMemberPeerBinding: strand %s — own binding for peer %s cleared', strandId, peerId);
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
log('clearOwnMemberPeerBinding: strand %s — best-effort clear failed (strand may already be unreachable): %o', strandId, error);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
513
795
|
/**
|
|
514
796
|
* Quiesce a strand: release its strand-network resources (stop the libp2p node,
|
|
515
797
|
* close the StrandDatabase) while RETAINING the instance record — identity,
|
|
@@ -608,10 +890,19 @@ export class StrandInstanceManager {
|
|
|
608
890
|
* database), so the bootstrap could not run here: the CALLER must wake the
|
|
609
891
|
* strand (`CadreNode.wakeStrand`, which owns the hibernation bookkeeping this
|
|
610
892
|
* manager does not) so the rebuild — which now founds — runs it.
|
|
893
|
+
* @param resolvePartyKey - Asked for the party's own membership key when (and only
|
|
894
|
+
* when) a CLOSED strand's retained config carries none — the instance was launched
|
|
895
|
+
* as a joiner before its `StrandPartyKey` row existed or replicated. The resolved
|
|
896
|
+
* key is retained alongside the founder flip so the bootstrap (now, or on the
|
|
897
|
+
* caller's wake for `'needs-resume'`) can seat the founding Member/Manager.
|
|
611
898
|
* @throws when the strand is not tracked — this seam exists only for the
|
|
612
899
|
* tracked-instance launch path; an untracked id is a caller bug.
|
|
900
|
+
* @throws whatever the live bootstrap throws (e.g. `PreSplitStrandIdentityError`),
|
|
901
|
+
* after {@link withdrawFounderRequest} — so a retry re-attempts the founding rather
|
|
902
|
+
* than resolving `'already-founder'` over an instance that never founded. A caller
|
|
903
|
+
* whose `'needs-resume'` wake fails owes the same withdrawal.
|
|
613
904
|
*/
|
|
614
|
-
async foundExistingStrand(strandId) {
|
|
905
|
+
async foundExistingStrand(strandId, resolvePartyKey) {
|
|
615
906
|
const instance = this.instances.get(strandId);
|
|
616
907
|
const config = this.launchConfigs.get(strandId);
|
|
617
908
|
if (!instance || !config) {
|
|
@@ -620,15 +911,143 @@ export class StrandInstanceManager {
|
|
|
620
911
|
if (config.founder === true) {
|
|
621
912
|
return 'already-founder';
|
|
622
913
|
}
|
|
914
|
+
const partyMemberPrivateKey = config.partyMemberPrivateKey
|
|
915
|
+
?? (config.strandRow.Type === 'c' ? await resolvePartyKey?.() : undefined);
|
|
623
916
|
// A fresh object rather than mutating in place: startStrand retains the CALLER'S
|
|
624
917
|
// config object, which is not ours to rewrite.
|
|
625
|
-
this.launchConfigs.set(strandId, { ...config, founder: true });
|
|
626
|
-
if (!instance.database) {
|
|
918
|
+
this.launchConfigs.set(strandId, { ...config, founder: true, partyMemberPrivateKey });
|
|
919
|
+
if (!instance.database && !this.firstSyncGates.has(strandId)) {
|
|
627
920
|
return 'needs-resume';
|
|
628
921
|
}
|
|
629
|
-
|
|
922
|
+
try {
|
|
923
|
+
await this.ensureFounderBootstrap(strandId);
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
this.withdrawFounderRequest(strandId);
|
|
927
|
+
throw error;
|
|
928
|
+
}
|
|
630
929
|
return 'bootstrapped';
|
|
631
930
|
}
|
|
931
|
+
/**
|
|
932
|
+
* Whether `strandId` is launched but still waiting for its first sync — a joiner whose
|
|
933
|
+
* runtime is up while its database is withheld (status `'syncing'`, or `'idle'` after
|
|
934
|
+
* the hibernation manager's idle timer fired on it). `false` for a writable, quiesced,
|
|
935
|
+
* or untracked strand. The predicate {@link whenWritable}'s callers gate on.
|
|
936
|
+
*/
|
|
937
|
+
isAwaitingFirstSync(strandId) {
|
|
938
|
+
const instance = this.instances.get(strandId);
|
|
939
|
+
return instance !== undefined && isAwaitingFirstSync(instance);
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Resolve once the strand's database is published to the app — immediately for a
|
|
943
|
+
* writable strand — or reject with {@link StrandAwaitingFirstSyncError} after
|
|
944
|
+
* `timeoutMs` (default: the retained launch config's `firstSync.timeoutMs`, else
|
|
945
|
+
* {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}). The rejection is RETRYABLE: nothing is
|
|
946
|
+
* torn down, the gate keeps probing, and a later call waits afresh. A quiesced
|
|
947
|
+
* (hibernating) strand is not woken here — the wait spans a resume, so a caller that
|
|
948
|
+
* wants one wakes it (`CadreNode.wakeStrand`) and waits. Rejects immediately for an
|
|
949
|
+
* untracked strand, and whenever the strand is stopped while a wait is pending.
|
|
950
|
+
*/
|
|
951
|
+
whenWritable(strandId, options) {
|
|
952
|
+
const instance = this.instances.get(strandId);
|
|
953
|
+
if (!instance) {
|
|
954
|
+
return Promise.reject(new Error(`Cannot wait for strand ${strandId} to become writable: not tracked`));
|
|
955
|
+
}
|
|
956
|
+
if (instance.database) {
|
|
957
|
+
return Promise.resolve(instance);
|
|
958
|
+
}
|
|
959
|
+
const timeoutMs = options?.timeoutMs
|
|
960
|
+
?? this.launchConfigs.get(strandId)?.firstSync?.timeoutMs
|
|
961
|
+
?? DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS;
|
|
962
|
+
const startedAt = Date.now();
|
|
963
|
+
return new Promise((resolve, reject) => {
|
|
964
|
+
const waiters = this.writableWaiters.get(strandId) ?? new Set();
|
|
965
|
+
this.writableWaiters.set(strandId, waiters);
|
|
966
|
+
const settle = () => {
|
|
967
|
+
clearTimeout(timer);
|
|
968
|
+
waiters.delete(waiter);
|
|
969
|
+
if (waiters.size === 0) {
|
|
970
|
+
this.writableWaiters.delete(strandId);
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
const waiter = {
|
|
974
|
+
resolve: () => { settle(); resolve(instance); },
|
|
975
|
+
reject: (error) => { settle(); reject(error); }
|
|
976
|
+
};
|
|
977
|
+
const timer = setTimeout(() => waiter.reject(new StrandAwaitingFirstSyncError(strandId, Date.now() - startedAt)), timeoutMs);
|
|
978
|
+
timer.unref?.();
|
|
979
|
+
waiters.add(waiter);
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Hand a launch's database to the app: set `instance.database`, retire the gate that
|
|
984
|
+
* held it, release every {@link whenWritable} waiter, and — when the launch had already
|
|
985
|
+
* been reported gated — flip it `'active'` and announce it through the retained config's
|
|
986
|
+
* `onWritable`. "Reported gated" is any status but `'starting'`: `'syncing'`, or the
|
|
987
|
+
* `'idle'` the hibernation manager's idle timer flips a long-gated joiner to (see
|
|
988
|
+
* {@link isAwaitingFirstSync}) — the Header arriving after that flip must announce too,
|
|
989
|
+
* or an app hanging on `strand:writable` never learns the strand opened. A publish that
|
|
990
|
+
* lands while the instance is still `'starting'` (the Header arrived during the rest of
|
|
991
|
+
* bring-up) announces nothing: `startStrand` / `resumeStrand` are about to report the
|
|
992
|
+
* strand `'active'`, and `strand:writable` is defined as the follow-up to a gated
|
|
993
|
+
* launch, never a duplicate of `strand:started`.
|
|
994
|
+
*
|
|
995
|
+
* It is also the one seam where a WITHHELD database becomes available, so it kicks the
|
|
996
|
+
* membership reconciler: on a gated launch that loop's own immediate pass ran while
|
|
997
|
+
* `instance.database` was still unset, found no database, and would otherwise not try
|
|
998
|
+
* again until its retry timer fired. A launch that was never gated publishes here BEFORE
|
|
999
|
+
* the reconciler is constructed, so there is nothing registered to kick and the loop's own
|
|
1000
|
+
* immediate pass already sees the database — which is why the kick needs no `wasGated`
|
|
1001
|
+
* test of its own.
|
|
1002
|
+
*/
|
|
1003
|
+
publishDatabase(instance, database) {
|
|
1004
|
+
const strandId = instance.strandId;
|
|
1005
|
+
const gate = this.firstSyncGates.get(strandId);
|
|
1006
|
+
if (gate) {
|
|
1007
|
+
gate.open();
|
|
1008
|
+
this.firstSyncGates.delete(strandId);
|
|
1009
|
+
}
|
|
1010
|
+
instance.database = database;
|
|
1011
|
+
instance.lastActivity = new Date();
|
|
1012
|
+
const wasGated = instance.status !== 'starting';
|
|
1013
|
+
if (wasGated) {
|
|
1014
|
+
instance.status = 'active';
|
|
1015
|
+
log('Strand %s is now writable: Strand.Header held', strandId);
|
|
1016
|
+
}
|
|
1017
|
+
const waiters = this.writableWaiters.get(strandId);
|
|
1018
|
+
if (waiters) {
|
|
1019
|
+
this.writableWaiters.delete(strandId);
|
|
1020
|
+
waiters.forEach((waiter) => waiter.resolve());
|
|
1021
|
+
}
|
|
1022
|
+
if (wasGated) {
|
|
1023
|
+
this.launchConfigs.get(strandId)?.onWritable?.(strandId);
|
|
1024
|
+
}
|
|
1025
|
+
// Not awaited: publishing must stay synchronous, and the pass chains on the loop's own
|
|
1026
|
+
// tail, so it cannot overlap a pass already in flight.
|
|
1027
|
+
void this.membershipReconcilers.get(strandId)?.reconcile();
|
|
1028
|
+
}
|
|
1029
|
+
/** Reject every {@link whenWritable} waiter for a strand that is going away. */
|
|
1030
|
+
rejectWritableWaiters(strandId, reason) {
|
|
1031
|
+
const waiters = this.writableWaiters.get(strandId);
|
|
1032
|
+
if (!waiters) {
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
this.writableWaiters.delete(strandId);
|
|
1036
|
+
waiters.forEach((waiter) => waiter.reject(new Error(`Strand ${strandId} ${reason} before becoming writable`)));
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Undo {@link foundExistingStrand}'s founder flip after the founding it promised
|
|
1040
|
+
* failed, so the retained config again says what this instance actually runs as (a
|
|
1041
|
+
* joiner) and the next founder request re-attempts the bootstrap. The resolved party
|
|
1042
|
+
* key stays retained — it is this party's identity for the strand either way. No-op
|
|
1043
|
+
* when the strand is untracked or its config does not found.
|
|
1044
|
+
*/
|
|
1045
|
+
withdrawFounderRequest(strandId) {
|
|
1046
|
+
const config = this.launchConfigs.get(strandId);
|
|
1047
|
+
if (config?.founder === true) {
|
|
1048
|
+
this.launchConfigs.set(strandId, { ...config, founder: false });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
632
1051
|
/**
|
|
633
1052
|
* Run the (idempotent) founder bootstrap against a tracked strand's LIVE
|
|
634
1053
|
* database, independently of what the retained launch config says.
|
|
@@ -641,6 +1060,11 @@ export class StrandInstanceManager {
|
|
|
641
1060
|
* and is the only thing that makes "founding resolves once the Header is
|
|
642
1061
|
* written" true on that path.
|
|
643
1062
|
*
|
|
1063
|
+
* A strand still `'syncing'` (its database withheld behind the first-sync gate) is
|
|
1064
|
+
* founded through the gate: the bootstrap writes the Header this machine was waiting
|
|
1065
|
+
* to receive, so a successful run publishes the database and the strand goes
|
|
1066
|
+
* `'active'` — the one legitimate way a joiner launch becomes writable without a peer.
|
|
1067
|
+
*
|
|
644
1068
|
* @throws when the strand is not tracked, or is still quiesced (no live
|
|
645
1069
|
* database) — both mean the bootstrap did NOT run, which a founder request
|
|
646
1070
|
* must never swallow.
|
|
@@ -650,11 +1074,19 @@ export class StrandInstanceManager {
|
|
|
650
1074
|
if (!instance) {
|
|
651
1075
|
throw new Error(`Cannot run the founder bootstrap for strand ${strandId}: not tracked`);
|
|
652
1076
|
}
|
|
653
|
-
|
|
1077
|
+
const gate = this.firstSyncGates.get(strandId);
|
|
1078
|
+
const database = instance.database ?? gate?.database;
|
|
1079
|
+
if (!database) {
|
|
654
1080
|
throw new Error(`Cannot run the founder bootstrap for strand ${strandId}: it is quiesced, so there ` +
|
|
655
1081
|
'is no live database to write to — resume it first.');
|
|
656
1082
|
}
|
|
657
|
-
|
|
1083
|
+
// Forward the RETAINED config's party key: a foundExistingStrand that flipped a
|
|
1084
|
+
// joiner launch to founder may have resolved a key the live database's captured
|
|
1085
|
+
// config (built at the original launch) never saw.
|
|
1086
|
+
await database.ensureFounderBootstrap(this.launchConfigs.get(strandId)?.partyMemberPrivateKey);
|
|
1087
|
+
if (!instance.database) {
|
|
1088
|
+
this.publishDatabase(instance, database);
|
|
1089
|
+
}
|
|
658
1090
|
}
|
|
659
1091
|
/**
|
|
660
1092
|
* Stop a strand instance
|
|
@@ -667,6 +1099,7 @@ export class StrandInstanceManager {
|
|
|
667
1099
|
}
|
|
668
1100
|
log('Stopping strand instance: %s', strandId);
|
|
669
1101
|
instance.status = 'stopping';
|
|
1102
|
+
this.rejectWritableWaiters(strandId, 'was stopped');
|
|
670
1103
|
try {
|
|
671
1104
|
await this.releaseRuntime(instance);
|
|
672
1105
|
instance.status = 'stopped';
|