@optimystic/db-p2p 0.21.0 → 0.22.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/dist/src/cluster/cluster-policy.d.ts +13 -2
- package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
- package/dist/src/cluster/cluster-policy.js +51 -4
- package/dist/src/cluster/cluster-policy.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +3 -3
- package/dist/src/cluster/cluster-repo.js +3 -3
- package/dist/src/cluster/cluster-size-coupling.d.ts +28 -0
- package/dist/src/cluster/cluster-size-coupling.d.ts.map +1 -0
- package/dist/src/cluster/cluster-size-coupling.js +35 -0
- package/dist/src/cluster/cluster-size-coupling.js.map +1 -0
- package/dist/src/cluster/quorum-restore.d.ts +6 -0
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +1 -1
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
- package/dist/src/cluster/reconcile-block.js +15 -3
- package/dist/src/cluster/reconcile-block.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +46 -5
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +40 -9
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +3 -2
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +834 -777
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/libp2p-node-rn.d.ts +2 -2
- package/dist/src/libp2p-node-rn.d.ts.map +1 -1
- package/dist/src/libp2p-node-rn.js.map +1 -1
- package/dist/src/libp2p-node.d.ts +2 -2
- package/dist/src/libp2p-node.d.ts.map +1 -1
- package/dist/src/libp2p-node.js.map +1 -1
- package/dist/src/network/network-manager-service.d.ts +2 -0
- package/dist/src/network/network-manager-service.d.ts.map +1 -1
- package/dist/src/network/network-manager-service.js +4 -0
- package/dist/src/network/network-manager-service.js.map +1 -1
- package/dist/src/optimystic-node.d.ts +35 -0
- package/dist/src/optimystic-node.d.ts.map +1 -0
- package/dist/src/optimystic-node.js +2 -0
- package/dist/src/optimystic-node.js.map +1 -0
- package/dist/src/repo/coordinator-repo.d.ts +16 -5
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +24 -8
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/rn.d.ts +2 -0
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +2 -0
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +57 -5
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +20 -1
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +56 -3
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +124 -18
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
- package/dist/src/testing/raw-storage-conformance.js +17 -0
- package/dist/src/testing/raw-storage-conformance.js.map +1 -1
- package/package.json +2 -2
- package/readme.md +41 -26
- package/src/cluster/cluster-policy.ts +55 -4
- package/src/cluster/cluster-repo.ts +3 -3
- package/src/cluster/cluster-size-coupling.ts +45 -0
- package/src/cluster/quorum-restore.ts +1 -1
- package/src/cluster/reconcile-block.ts +15 -3
- package/src/index.ts +2 -0
- package/src/libp2p-key-network.ts +41 -9
- package/src/libp2p-node-base.ts +907 -847
- package/src/libp2p-node-rn.ts +2 -2
- package/src/libp2p-node.ts +2 -2
- package/src/network/network-manager-service.ts +5 -0
- package/src/optimystic-node.ts +36 -0
- package/src/repo/coordinator-repo.ts +24 -8
- package/src/rn.ts +2 -0
- package/src/storage/block-storage.ts +59 -6
- package/src/storage/i-block-storage.ts +20 -1
- package/src/storage/storage-repo.ts +129 -18
- package/src/testing/raw-storage-conformance.ts +20 -0
|
@@ -20,6 +20,7 @@ import { seedOwnedBlocksFromStorage } from './owned-block-seed.js';
|
|
|
20
20
|
import { clusterMember } from './cluster/cluster-repo.js';
|
|
21
21
|
import { createReconcileBlock } from './cluster/reconcile-block.js';
|
|
22
22
|
import { resolveClusterPolicy } from './cluster/cluster-policy.js';
|
|
23
|
+
import { assertClusterSizeCoupling } from './cluster/cluster-size-coupling.js';
|
|
23
24
|
import { createCommitCertStore, makeClusterCommitCertExtractor } from './cluster/commit-cert.js';
|
|
24
25
|
import { coordinatorRepo } from './repo/coordinator-repo.js';
|
|
25
26
|
import { Libp2pKeyPeerNetwork } from './libp2p-key-network.js';
|
|
@@ -177,6 +178,15 @@ export async function createLibp2pNodeBase(options, defaults) {
|
|
|
177
178
|
// the store first keeps origination correct regardless of the caller sink (`put` never throws).
|
|
178
179
|
? (actionId, cert) => { certStore.put(actionId, cert); options.onCommitCertificate?.(actionId, cert); }
|
|
179
180
|
: options.onCommitCertificate;
|
|
181
|
+
// Every cluster-policy default lives in `cluster/cluster-policy.ts` — including WHY the admission
|
|
182
|
+
// gate and the repair corroboration floor resolve the one operator field
|
|
183
|
+
// (`clusterPolicy.assumedClusterSize`) to different values when it is absent. Resolved ONCE, here,
|
|
184
|
+
// before anything that reads a cluster size is constructed: `networkManagerService` below,
|
|
185
|
+
// `Libp2pKeyPeerNetwork`, and the spread-on-churn monitor init must all read `consensusConfig.clusterSize`
|
|
186
|
+
// rather than `options.clusterSize` directly, or they can each apply their own fallback default and
|
|
187
|
+
// silently disagree (ticket bug-cluster-size-resolution-single-source). `assertClusterSizeCoupling`
|
|
188
|
+
// below is the fail-fast backstop if a future edit reintroduces that split.
|
|
189
|
+
const consensusConfig = resolveClusterPolicy(options);
|
|
180
190
|
const libp2pOptions = {
|
|
181
191
|
start: false,
|
|
182
192
|
privateKey: nodePrivateKey,
|
|
@@ -328,7 +338,7 @@ export async function createLibp2pNodeBase(options, defaults) {
|
|
|
328
338
|
},
|
|
329
339
|
networkManager: (components) => {
|
|
330
340
|
const svcFactory = networkManagerService({
|
|
331
|
-
clusterSize:
|
|
341
|
+
clusterSize: consensusConfig.clusterSize,
|
|
332
342
|
expectedRemotes: (options.bootstrapNodes?.length ?? 0) > 0,
|
|
333
343
|
allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
|
|
334
344
|
clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5
|
|
@@ -388,585 +398,596 @@ export async function createLibp2pNodeBase(options, defaults) {
|
|
|
388
398
|
// handler is live with a resolvable node from its first request.
|
|
389
399
|
wired.repo.setLibp2p(node);
|
|
390
400
|
await node.start();
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
//
|
|
396
|
-
// cohort selection is scoped to peers that serve THIS network's cluster/repo protocol.
|
|
397
|
-
// A peer that only belongs to another network sharing the same physical nodes/
|
|
398
|
-
// bootstraps registers a different (network-namespaced) identify protocol, so it is
|
|
399
|
-
// never selected and can't drag this network's super-majority below quorum.
|
|
400
|
-
const protocolPrefix = `/optimystic/${options.networkName}`;
|
|
401
|
-
const keyNetwork = new Libp2pKeyPeerNetwork(node, options.clusterSize, undefined, networkMode, options.persistence, reputation, protocolPrefix);
|
|
402
|
-
await keyNetwork.initFromPersistedState();
|
|
403
|
-
const createClusterClient = (peerId) => ClusterClient.create(peerId, keyNetwork, protocolPrefix);
|
|
404
|
-
// Inject reputation into NetworkManagerService. Load-bearing and non-optional: the service is
|
|
405
|
-
// unconditionally present, so a throw is a real wiring bug. Unlike the pre-start injections above
|
|
406
|
-
// the node has already started here, so stop it before rethrowing rather than leaking a started
|
|
407
|
-
// node + open transports (mirrors the cohortTopic hard-fail blocks below).
|
|
401
|
+
// Everything from here to the `return` runs against an ALREADY STARTED node (open transports,
|
|
402
|
+
// listening addresses, running services). A rejection out of that span used to hand the caller an
|
|
403
|
+
// error and no handle, leaving the node running with its listener port still bound — unrecoverable
|
|
404
|
+
// for the caller and enough to block the port for the next start attempt. So the whole post-start
|
|
405
|
+
// body rolls back: see the `catch` at the bottom of this function.
|
|
408
406
|
try {
|
|
407
|
+
// Initialize peer reputation service
|
|
408
|
+
const reputation = new PeerReputationService();
|
|
409
|
+
// Initialize cluster coordination components
|
|
410
|
+
const networkMode = (options.bootstrapNodes?.length ?? 0) > 0 ? 'joining' : 'forming';
|
|
411
|
+
// Network-namespaced protocol prefix, threaded into the key network so coordinator/
|
|
412
|
+
// cohort selection is scoped to peers that serve THIS network's cluster/repo protocol.
|
|
413
|
+
// A peer that only belongs to another network sharing the same physical nodes/
|
|
414
|
+
// bootstraps registers a different (network-namespaced) identify protocol, so it is
|
|
415
|
+
// never selected and can't drag this network's super-majority below quorum.
|
|
416
|
+
const protocolPrefix = `/optimystic/${options.networkName}`;
|
|
417
|
+
const keyNetwork = new Libp2pKeyPeerNetwork(node, consensusConfig.clusterSize, undefined, networkMode, options.persistence, reputation, protocolPrefix);
|
|
418
|
+
await keyNetwork.initFromPersistedState();
|
|
419
|
+
const createClusterClient = (peerId) => ClusterClient.create(peerId, keyNetwork, protocolPrefix);
|
|
420
|
+
// Inject reputation into NetworkManagerService. Load-bearing and non-optional: the service is
|
|
421
|
+
// unconditionally present, so a throw is a real wiring bug. The node has already started here, but
|
|
422
|
+
// no ad-hoc stop is needed: the post-start rollback `catch` at the bottom of this function stops it.
|
|
409
423
|
wired.networkManager.setReputation(reputation);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
// gate and the repair corroboration floor resolve the one operator field
|
|
420
|
-
// (`clusterPolicy.assumedClusterSize`) to different values when it is absent. Extracted so those
|
|
421
|
-
// defaults can be asserted without booting a node.
|
|
422
|
-
const consensusConfig = resolveClusterPolicy(options);
|
|
423
|
-
// Fetch a block archive from one cohort peer over the sync protocol, bounded by a
|
|
424
|
-
// per-peer timeout so an unreachable peer can't stall reconciliation. Mirrors the
|
|
425
|
-
// SyncClient query in `clusterLatestCallback`, but returns the full archive (which
|
|
426
|
-
// carries the materialized block) rather than only the latest ActionRev.
|
|
427
|
-
const fetchArchiveFromPeer = async (peerIdStr, blockId) => {
|
|
428
|
-
let peerId;
|
|
429
|
-
try {
|
|
430
|
-
peerId = peerIdFromString(peerIdStr);
|
|
431
|
-
}
|
|
432
|
-
catch {
|
|
433
|
-
return undefined;
|
|
434
|
-
}
|
|
435
|
-
if (peerId.equals(node.peerId))
|
|
436
|
-
return undefined;
|
|
437
|
-
const syncClient = new SyncClient(peerId, keyNetwork, protocolPrefix);
|
|
438
|
-
try {
|
|
439
|
-
const response = await Promise.race([
|
|
440
|
-
syncClient.requestBlock({ blockId, rev: undefined }),
|
|
441
|
-
new Promise(resolve => { setTimeout(() => resolve({ success: false }), 1000).unref(); })
|
|
442
|
-
]);
|
|
443
|
-
return response.success ? response.archive : undefined;
|
|
444
|
-
}
|
|
445
|
-
catch {
|
|
446
|
-
// Peer unreachable / no data — caller falls back to the next cohort peer.
|
|
447
|
-
return undefined;
|
|
448
|
-
}
|
|
449
|
-
};
|
|
450
|
-
// Active reconciliation for a block this member committed without a materializable base
|
|
451
|
-
// (cohort drift, or a refused `missing-base-revision` commit). See `reconcile-block.ts` for
|
|
452
|
-
// the corroboration rules — in particular why both quorums are capped by how many peers
|
|
453
|
-
// could answer at all, which is what lets a genuinely two-node cohort heal.
|
|
454
|
-
// NOTE: this and the CoordinatorRepo below must cap against the SAME
|
|
455
|
-
// repairCorroborationClusterSize, or the two restoration paths disagree about how much trust a
|
|
456
|
-
// lone peer gets. Safe today because both read the one `resolveClusterPolicy` result above; if
|
|
457
|
-
// either ever resolves its own value, add a fail-fast coupling check like
|
|
458
|
-
// `assertSuperMajorityCoupling` rather than relying on proximity.
|
|
459
|
-
const reconcileBlock = createReconcileBlock({
|
|
460
|
-
selfPeerId: node.peerId.toString(),
|
|
461
|
-
fetchArchive: fetchArchiveFromPeer,
|
|
462
|
-
saveReplicatedBlock: (blockId, block, source) => storageRepo.saveReplicatedBlock(blockId, block, source),
|
|
463
|
-
simpleMajorityThreshold: consensusConfig.simpleMajorityThreshold,
|
|
464
|
-
repairCorroborationClusterSize: consensusConfig.repairCorroborationClusterSize,
|
|
465
|
-
reputation
|
|
466
|
-
});
|
|
467
|
-
// Member-side membership derivation for the admission gate: independently re-derive this block's
|
|
468
|
-
// responsible cluster from the SAME source the coordinator uses (IKeyNetwork.findCluster), plus FRET's
|
|
469
|
-
// network-size confidence. A member gates a coordinator-declared peer set against this view before
|
|
470
|
-
// voting, so a self-shrunk minority-partition set cannot be voted into super-majority (see cluster-repo
|
|
471
|
-
// admitMembership). No FRET ⇒ confidence 0 ⇒ the gate fails closed for any downsize.
|
|
472
|
-
const deriveExpectedCluster = async (blockId) => {
|
|
473
|
-
const peers = await keyNetwork.findCluster(new TextEncoder().encode(blockId));
|
|
474
|
-
let confidence = 0;
|
|
475
|
-
if (fretSvc) {
|
|
424
|
+
// Create partition detector and get FRET service
|
|
425
|
+
const partitionDetector = new PartitionDetector();
|
|
426
|
+
const fretSvc = node.services?.fret;
|
|
427
|
+
// Fetch a block archive from one cohort peer over the sync protocol, bounded by a
|
|
428
|
+
// per-peer timeout so an unreachable peer can't stall reconciliation. Mirrors the
|
|
429
|
+
// SyncClient query in `clusterLatestCallback`, but returns the full archive (which
|
|
430
|
+
// carries the materialized block) rather than only the latest ActionRev.
|
|
431
|
+
const fetchArchiveFromPeer = async (peerIdStr, blockId) => {
|
|
432
|
+
let peerId;
|
|
476
433
|
try {
|
|
477
|
-
|
|
434
|
+
peerId = peerIdFromString(peerIdStr);
|
|
478
435
|
}
|
|
479
436
|
catch {
|
|
480
|
-
|
|
437
|
+
return undefined;
|
|
481
438
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
clusterImpl = clusterMember({
|
|
486
|
-
storageRepo,
|
|
487
|
-
peerNetwork: keyNetwork,
|
|
488
|
-
peerId: node.peerId,
|
|
489
|
-
privateKey: nodePrivateKey,
|
|
490
|
-
protocolPrefix,
|
|
491
|
-
partitionDetector,
|
|
492
|
-
fretService: fretSvc,
|
|
493
|
-
validator: options.validator,
|
|
494
|
-
reputation,
|
|
495
|
-
consensusConfig,
|
|
496
|
-
stateStore: options.transactionStateStore,
|
|
497
|
-
reconcileBlock,
|
|
498
|
-
onCommitCertificate,
|
|
499
|
-
deriveExpectedCluster
|
|
500
|
-
// `recomputeArbitratorSet` (invalidation layer-2) is intentionally NOT wired here yet: a live FRET
|
|
501
|
-
// recompute needs a churn-tolerance window so it does not false-reject legitimate certificates from
|
|
502
|
-
// late-joiners (a liveness regression). Until that is tuned against live topology — and the
|
|
503
|
-
// cohort-topic membership-cert trust anchor (layer 3) lands — invalidation verification runs on the
|
|
504
|
-
// challenger-bound set + membership + dedup (layer 1) and LOGS the residual anchoring gap. See
|
|
505
|
-
// `verifyInvalidationCertificate` and `tickets/plan/cohort-topic-membership-cert-trust-anchoring.md`.
|
|
506
|
-
});
|
|
507
|
-
const coordinatorRepoFactory = coordinatorRepo(keyNetwork, createClusterClient, {
|
|
508
|
-
// clusterSize is now part of consensusConfig (member + coordinator share one reference).
|
|
509
|
-
...consensusConfig
|
|
510
|
-
}, fretSvc, reputation, options.transactionStateStore);
|
|
511
|
-
// Create callback for querying cluster peers for their latest block revision. Three-way
|
|
512
|
-
// contract (see ClusterLatestCallback): an ActionRev is the peer's claim, a resolved
|
|
513
|
-
// `undefined` is the peer answering "I hold nothing", and a REJECTION is silence — the
|
|
514
|
-
// coordinator counts it as "did not answer" and refuses to report an authoritative absent
|
|
515
|
-
// over it. Transport errors must therefore propagate, not collapse into `undefined` (that
|
|
516
|
-
// collapse let a slow two-node cohort report a missing block as authoritatively absent —
|
|
517
|
-
// ticket cluster-read-consult-cannot-report-unreachable).
|
|
518
|
-
const clusterLatestCallback = async (peerId, blockId, context) => {
|
|
519
|
-
// Self-read short-circuit: dialling self via SyncClient is a round trip
|
|
520
|
-
// with no remote on the other end, and on nodes without listen addresses
|
|
521
|
-
// (solo WebSocket-only, bare-RN, etc.) the self-dial can hang the dial
|
|
522
|
-
// queue. Read directly from the local storage repo instead. The catch stays:
|
|
523
|
-
// a local storage error is not a cohort peer being unreachable, and the
|
|
524
|
-
// coordinator ignores a self rejection anyway.
|
|
525
|
-
if (peerId.equals(node.peerId)) {
|
|
439
|
+
if (peerId.equals(node.peerId))
|
|
440
|
+
return undefined;
|
|
441
|
+
const syncClient = new SyncClient(peerId, keyNetwork, protocolPrefix);
|
|
526
442
|
try {
|
|
527
|
-
const
|
|
528
|
-
|
|
443
|
+
const response = await Promise.race([
|
|
444
|
+
syncClient.requestBlock({ blockId, rev: undefined }),
|
|
445
|
+
new Promise(resolve => { setTimeout(() => resolve({ success: false }), 1000).unref(); })
|
|
446
|
+
]);
|
|
447
|
+
return response.success ? response.archive : undefined;
|
|
529
448
|
}
|
|
530
449
|
catch {
|
|
450
|
+
// Peer unreachable / no data — caller falls back to the next cohort peer.
|
|
531
451
|
return undefined;
|
|
532
452
|
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
453
|
+
};
|
|
454
|
+
// Active reconciliation for a block this member committed without a materializable base
|
|
455
|
+
// (cohort drift, or a refused `missing-base-revision` commit). See `reconcile-block.ts` for
|
|
456
|
+
// the corroboration rules — in particular why both quorums are capped by how many peers
|
|
457
|
+
// could answer at all, which is what lets a genuinely two-node cohort heal.
|
|
458
|
+
// NOTE: this and the CoordinatorRepo below must cap against the SAME
|
|
459
|
+
// repairCorroborationClusterSize, or the two restoration paths disagree about how much trust a
|
|
460
|
+
// lone peer gets. Safe today because both read the one `resolveClusterPolicy` result above; if
|
|
461
|
+
// either ever resolves its own value, add a fail-fast coupling check like
|
|
462
|
+
// `assertSuperMajorityCoupling` rather than relying on proximity.
|
|
463
|
+
const reconcileBlock = createReconcileBlock({
|
|
464
|
+
selfPeerId: node.peerId.toString(),
|
|
465
|
+
fetchArchive: fetchArchiveFromPeer,
|
|
466
|
+
saveReplicatedBlock: (blockId, block, source) => storageRepo.saveReplicatedBlock(blockId, block, source),
|
|
467
|
+
simpleMajorityThreshold: consensusConfig.simpleMajorityThreshold,
|
|
468
|
+
repairCorroborationClusterSize: consensusConfig.repairCorroborationClusterSize,
|
|
469
|
+
reputation
|
|
470
|
+
});
|
|
471
|
+
// Member-side membership derivation for the admission gate: independently re-derive this block's
|
|
472
|
+
// responsible cluster from the SAME source the coordinator uses (IKeyNetwork.findCluster), plus FRET's
|
|
473
|
+
// network-size confidence. A member gates a coordinator-declared peer set against this view before
|
|
474
|
+
// voting, so a self-shrunk minority-partition set cannot be voted into super-majority (see cluster-repo
|
|
475
|
+
// admitMembership). No FRET ⇒ confidence 0 ⇒ the gate fails closed for any downsize.
|
|
476
|
+
const deriveExpectedCluster = async (blockId) => {
|
|
477
|
+
const peers = await keyNetwork.findCluster(new TextEncoder().encode(blockId));
|
|
478
|
+
let confidence = 0;
|
|
479
|
+
if (fretSvc) {
|
|
480
|
+
try {
|
|
481
|
+
confidence = fretSvc.getNetworkSizeEstimate().confidence;
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// Leave confidence 0 → fail closed for downsizing.
|
|
545
485
|
}
|
|
546
486
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
assertSuperMajorityCoupling(clusterImpl, coordinatedRepo);
|
|
571
|
-
// Recover persisted transaction state before accepting new requests
|
|
572
|
-
if (options.transactionStateStore) {
|
|
573
|
-
await clusterImpl.recoverTransactions();
|
|
574
|
-
await coordinatedRepo.recoverTransactions();
|
|
575
|
-
}
|
|
576
|
-
// --- Shared owned-block set for the resilience monitors ---
|
|
577
|
-
// SpreadOnChurnMonitor (sender) and RebalanceMonitor (responsibility tracker) both act on "the
|
|
578
|
-
// blocks this node physically holds". They share ONE Set so the two can never drift: a single
|
|
579
|
-
// owned-block feed populates it, and the rebalance responsibility-loss signal evicts from it
|
|
580
|
-
// (in the rebalance block below). Both monitors take this exact instance via deps.trackedBlocks.
|
|
581
|
-
const networkManager = node.services?.networkManager;
|
|
582
|
-
const ownedBlocks = new Set();
|
|
583
|
-
// Single owned-block feed: every block this node commits OR receives as a replica fires
|
|
584
|
-
// storageRepo.onAnyCollectionChange. Subscribe to storageRepo DIRECTLY (not
|
|
585
|
-
// node.blockChangeNotifier): the cohort-topic activation block below may replace
|
|
586
|
-
// blockChangeNotifier with a decorating bridge, but storageRepo keeps emitting on its own
|
|
587
|
-
// surface regardless of that opt-in. NOTE: this feed does NOT re-emit blocks already durable
|
|
588
|
-
// from a previous run; those are seeded once at startup by the storage-enumeration scan wired
|
|
589
|
-
// below (seedOwnedBlocksFromStorage), so a restarted node protects on-disk data without waiting
|
|
590
|
-
// for each block to be touched again. Registered lazily the first time a
|
|
591
|
-
// monitor that reads ownedBlocks is wired, so when BOTH monitors are disabled no subscription
|
|
592
|
-
// leaks; torn down exactly once in the stop wrapper below.
|
|
593
|
-
let offOwnedBlockFeed;
|
|
594
|
-
const ensureOwnedBlockFeed = () => {
|
|
595
|
-
if (offOwnedBlockFeed)
|
|
596
|
-
return;
|
|
597
|
-
offOwnedBlockFeed = storageRepo.onAnyCollectionChange((e) => {
|
|
598
|
-
for (const blockId of e.blockIds)
|
|
599
|
-
ownedBlocks.add(blockId);
|
|
487
|
+
return { peers: peers ?? {}, confidence };
|
|
488
|
+
};
|
|
489
|
+
clusterImpl = clusterMember({
|
|
490
|
+
storageRepo,
|
|
491
|
+
peerNetwork: keyNetwork,
|
|
492
|
+
peerId: node.peerId,
|
|
493
|
+
privateKey: nodePrivateKey,
|
|
494
|
+
protocolPrefix,
|
|
495
|
+
partitionDetector,
|
|
496
|
+
fretService: fretSvc,
|
|
497
|
+
validator: options.validator,
|
|
498
|
+
reputation,
|
|
499
|
+
consensusConfig,
|
|
500
|
+
stateStore: options.transactionStateStore,
|
|
501
|
+
reconcileBlock,
|
|
502
|
+
onCommitCertificate,
|
|
503
|
+
deriveExpectedCluster
|
|
504
|
+
// `recomputeArbitratorSet` (invalidation layer-2) is intentionally NOT wired here yet: a live FRET
|
|
505
|
+
// recompute needs a churn-tolerance window so it does not false-reject legitimate certificates from
|
|
506
|
+
// late-joiners (a liveness regression). Until that is tuned against live topology — and the
|
|
507
|
+
// cohort-topic membership-cert trust anchor (layer 3) lands — invalidation verification runs on the
|
|
508
|
+
// challenger-bound set + membership + dedup (layer 1) and LOGS the residual anchoring gap. See
|
|
509
|
+
// `verifyInvalidationCertificate` and `tickets/plan/cohort-topic-membership-cert-trust-anchoring.md`.
|
|
600
510
|
});
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
511
|
+
// Cleanup cluster member intervals on node stop. Installed HERE, immediately after clusterImpl
|
|
512
|
+
// exists, rather than further down: the post-start rollback only unwinds resources whose stop
|
|
513
|
+
// wrapper is already installed at the moment of the throw, so a wrapper trailing its resource by
|
|
514
|
+
// hundreds of lines leaves those intervals running on a failed startup. Same reasoning as the
|
|
515
|
+
// owned-block-feed wrapper below.
|
|
516
|
+
{
|
|
517
|
+
const previousStop = node.stop.bind(node);
|
|
518
|
+
node.stop = async () => {
|
|
519
|
+
try {
|
|
520
|
+
clusterImpl.dispose();
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
// Never let a dispose failure strand the transports — same try/finally shape every
|
|
524
|
+
// other wrapper in this chain uses.
|
|
525
|
+
await previousStop();
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
const coordinatorRepoFactory = coordinatorRepo(keyNetwork, createClusterClient, {
|
|
530
|
+
// clusterSize is now part of consensusConfig (member + coordinator share one reference).
|
|
531
|
+
...consensusConfig
|
|
532
|
+
}, fretSvc, reputation, options.transactionStateStore);
|
|
533
|
+
// Create callback for querying cluster peers for their latest block revision. Three-way
|
|
534
|
+
// contract (see ClusterLatestCallback): an ActionRev is the peer's claim, a resolved
|
|
535
|
+
// `undefined` is the peer answering "I hold nothing", and a REJECTION is silence — the
|
|
536
|
+
// coordinator counts it as "did not answer" and refuses to report an authoritative absent
|
|
537
|
+
// over it. Transport errors must therefore propagate, not collapse into `undefined` (that
|
|
538
|
+
// collapse let a slow two-node cohort report a missing block as authoritatively absent —
|
|
539
|
+
// ticket cluster-read-consult-cannot-report-unreachable).
|
|
540
|
+
const clusterLatestCallback = async (peerId, blockId, context) => {
|
|
541
|
+
// Self-read short-circuit: dialling self via SyncClient is a round trip
|
|
542
|
+
// with no remote on the other end, and on nodes without listen addresses
|
|
543
|
+
// (solo WebSocket-only, bare-RN, etc.) the self-dial can hang the dial
|
|
544
|
+
// queue. Read directly from the local storage repo instead. The catch stays:
|
|
545
|
+
// a local storage error is not a cohort peer being unreachable, and the
|
|
546
|
+
// coordinator ignores a self rejection anyway.
|
|
547
|
+
if (peerId.equals(node.peerId)) {
|
|
548
|
+
try {
|
|
549
|
+
const result = await storageRepo.get({ blockIds: [blockId], context });
|
|
550
|
+
return result[blockId]?.state?.latest;
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
610
555
|
}
|
|
611
|
-
|
|
612
|
-
|
|
556
|
+
const syncClient = new SyncClient(peerId, keyNetwork, protocolPrefix);
|
|
557
|
+
// No try/catch: a dial or protocol failure rejects through to the coordinator, whose
|
|
558
|
+
// per-peer deadline also bounds a hung request — slowness needs no race here.
|
|
559
|
+
const response = await syncClient.requestBlock({ blockId, rev: undefined });
|
|
560
|
+
if (response.success && response.archive) {
|
|
561
|
+
const revisions = Object.keys(response.archive.revisions).map(Number);
|
|
562
|
+
if (revisions.length > 0) {
|
|
563
|
+
const maxRev = Math.max(...revisions);
|
|
564
|
+
const revisionData = response.archive.revisions[maxRev];
|
|
565
|
+
if (revisionData?.action) {
|
|
566
|
+
return { actionId: revisionData.action.actionId, rev: maxRev };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
613
569
|
}
|
|
570
|
+
// The peer DID answer, without data: `success:false` is the sync service's "Block not
|
|
571
|
+
// found in local storage", and an archive with no usable revisions holds nothing either
|
|
572
|
+
// way. Both are absent claims, not silence.
|
|
573
|
+
return undefined;
|
|
614
574
|
};
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
575
|
+
coordinatedRepo = coordinatorRepoFactory({
|
|
576
|
+
storageRepo,
|
|
577
|
+
localCluster: clusterImpl,
|
|
578
|
+
localPeerId: node.peerId,
|
|
579
|
+
clusterLatestCallback,
|
|
580
|
+
// Read-driven acquisition shares the commit path's reconcile callback verbatim: same bounded
|
|
581
|
+
// archive fetch, same (rev, actionId) and content quorums, same monotonic saveReplicatedBlock
|
|
582
|
+
// funnel. `clusterLatestCallback` alone can only tell the reader WHICH revision the cohort
|
|
583
|
+
// holds; this is what moves the bytes. Only reached once a corroborated revision exists, so a
|
|
584
|
+
// genuinely absent block still costs no archive fetch.
|
|
585
|
+
acquireBlockFromCohort: reconcileBlock
|
|
586
|
+
});
|
|
587
|
+
// Fail-fast coupling: the cluster member (what accepts a super-majority as sufficient) and the
|
|
588
|
+
// coordinator (what declares a transaction committed on that super-majority) MUST run the same
|
|
589
|
+
// threshold, or the node would come up able to disagree with itself mid-consensus. Both are fed from
|
|
590
|
+
// the single `consensusConfig` above; this asserts on their RESOLVED values so any future drift throws
|
|
591
|
+
// HERE at construction. See `assertSuperMajorityCoupling`.
|
|
592
|
+
assertSuperMajorityCoupling(clusterImpl, coordinatedRepo);
|
|
593
|
+
// Recover persisted transaction state before accepting new requests
|
|
594
|
+
if (options.transactionStateStore) {
|
|
595
|
+
await clusterImpl.recoverTransactions();
|
|
596
|
+
await coordinatedRepo.recoverTransactions();
|
|
627
597
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
598
|
+
// --- Shared owned-block set for the resilience monitors ---
|
|
599
|
+
// SpreadOnChurnMonitor (sender) and RebalanceMonitor (responsibility tracker) both act on "the
|
|
600
|
+
// blocks this node physically holds". They share ONE Set so the two can never drift: a single
|
|
601
|
+
// owned-block feed populates it, and the rebalance responsibility-loss signal evicts from it
|
|
602
|
+
// (in the rebalance block below). Both monitors take this exact instance via deps.trackedBlocks.
|
|
603
|
+
const networkManager = node.services?.networkManager;
|
|
604
|
+
// See the comment above `consensusConfig` for why every cluster-size consumer must read the SAME
|
|
605
|
+
// resolved value. This throws at construction (rather than letting a node come up mismatched) if a
|
|
606
|
+
// future edit gives `keyNetwork` or `networkManager` their own fallback again.
|
|
607
|
+
assertClusterSizeCoupling(consensusConfig.clusterSize, { keyNetwork, networkManager });
|
|
608
|
+
const ownedBlocks = new Set();
|
|
609
|
+
// Single owned-block feed: every block this node commits OR receives as a replica fires
|
|
610
|
+
// storageRepo.onAnyCollectionChange. Subscribe to storageRepo DIRECTLY (not
|
|
611
|
+
// node.blockChangeNotifier): the cohort-topic activation block below may replace
|
|
612
|
+
// blockChangeNotifier with a decorating bridge, but storageRepo keeps emitting on its own
|
|
613
|
+
// surface regardless of that opt-in. NOTE: this feed does NOT re-emit blocks already durable
|
|
614
|
+
// from a previous run; those are seeded once at startup by the storage-enumeration scan wired
|
|
615
|
+
// below (seedOwnedBlocksFromStorage), so a restarted node protects on-disk data without waiting
|
|
616
|
+
// for each block to be touched again. Registered lazily the first time a
|
|
617
|
+
// monitor that reads ownedBlocks is wired, so when BOTH monitors are disabled no subscription
|
|
618
|
+
// leaks; torn down exactly once in the stop wrapper below.
|
|
619
|
+
let offOwnedBlockFeed;
|
|
620
|
+
const ensureOwnedBlockFeed = () => {
|
|
621
|
+
if (offOwnedBlockFeed)
|
|
622
|
+
return;
|
|
623
|
+
offOwnedBlockFeed = storageRepo.onAnyCollectionChange((e) => {
|
|
624
|
+
for (const blockId of e.blockIds)
|
|
625
|
+
ownedBlocks.add(blockId);
|
|
626
|
+
});
|
|
627
|
+
};
|
|
628
|
+
// Single owned-block-feed teardown. Registered up front (before either monitor's own stop
|
|
629
|
+
// wrapper) so it runs regardless of WHICH monitor subscribed the feed - including the
|
|
630
|
+
// spread-disabled / rebalance-only case. Idempotent: offOwnedBlockFeed is undefined-guarded.
|
|
631
|
+
{
|
|
632
|
+
const previousStop = node.stop.bind(node);
|
|
633
|
+
node.stop = async () => {
|
|
634
|
+
try {
|
|
635
|
+
offOwnedBlockFeed?.();
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
await previousStop();
|
|
639
|
+
}
|
|
640
|
+
};
|
|
633
641
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
// wrapper above (shared across both monitors).
|
|
642
|
-
{
|
|
643
|
-
const previousStop = node.stop.bind(node);
|
|
644
|
-
node.stop = async () => {
|
|
642
|
+
// --- Churn-resilient spread: drive SpreadOnChurnMonitor on a live node ---
|
|
643
|
+
// Nothing previously activated the SENDING side of the churn-resilient spread protocol on a
|
|
644
|
+
// real node. Here we init + start the monitor (sharing ownedBlocks) and ensure the single
|
|
645
|
+
// owned-block feed is live, so a debounced connection:close re-pushes the node's blocks to
|
|
646
|
+
// expansion-cohort peers (the receiver durably persists each push via saveReplicatedBlock).
|
|
647
|
+
let spreadMonitor;
|
|
648
|
+
if (networkManager && (options.spreadOnChurn?.enabled ?? true) !== false) {
|
|
645
649
|
try {
|
|
646
|
-
|
|
647
|
-
|
|
650
|
+
spreadMonitor = networkManager.initSpreadOnChurnMonitor(partitionDetector, storageRepo, keyNetwork, consensusConfig.clusterSize, protocolPrefix, ownedBlocks, options.spreadOnChurn);
|
|
651
|
+
await spreadMonitor.start();
|
|
652
|
+
ensureOwnedBlockFeed();
|
|
648
653
|
}
|
|
649
|
-
|
|
650
|
-
|
|
654
|
+
catch (err) {
|
|
655
|
+
// Spread is a resilience optimization, not a correctness requirement - a wiring
|
|
656
|
+
// failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup, unlike the
|
|
657
|
+
// operator-opted-in cohortTopic block. Log and continue with spread inert.
|
|
658
|
+
(node.logger?.forComponent?.('db-p2p:spread-on-churn'))?.('init failed: %o', err);
|
|
651
659
|
}
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
// (`st-storage-sweep-archival-and-capacity-estimate`) must consult: a block's local bytes may
|
|
664
|
-
// be reclaimed ONLY once it appears here, so an unconfirmed / still-served range is never
|
|
665
|
-
// swept. Populated strictly after replication is confirmed. See
|
|
666
|
-
// docs/arachnode-ring-handoff.md § Part 2 (Local bytes vs. tracking).
|
|
667
|
-
// NOTE: no sweep consumes this set yet; it is the coordinated eligibility handoff the sweep
|
|
668
|
-
// ticket will read. Until then it grows unbounded — bound it when the sweep lands.
|
|
669
|
-
const gcEligible = new Set();
|
|
670
|
-
node.gcEligibleBlocks = gcEligible;
|
|
671
|
-
// The ring-shift state machine (advertise→confirm→release). Wired inside the rebalance block
|
|
672
|
-
// below (it needs the BlockTransferCoordinator confirmer + the cohort-size floor); left
|
|
673
|
-
// undefined when the rebalance reaction is not wired, in which case ring shifts stay inert —
|
|
674
|
-
// a move-out is unsafe without the confirm/release path.
|
|
675
|
-
let ringShift;
|
|
676
|
-
const storageMonitor = new StorageMonitor(rawStorage, options.arachnode?.storage ?? {});
|
|
677
|
-
const ringSelector = new RingSelector(fretAdapter, storageMonitor, {
|
|
678
|
-
minCapacity: 100 * 1024 * 1024,
|
|
679
|
-
thresholds: {
|
|
680
|
-
moveOut: 0.85,
|
|
681
|
-
moveIn: 0.40
|
|
682
|
-
},
|
|
683
|
-
// Damping so the ring decision cannot thrash near a boundary
|
|
684
|
-
// (docs/arachnode-ring-handoff.md § Part 1).
|
|
685
|
-
smoothingAlpha: 0.2,
|
|
686
|
-
deadband: 0.5,
|
|
687
|
-
minDwellMs: 10 * 60 * 1000
|
|
688
|
-
});
|
|
689
|
-
// Determine and announce ring membership
|
|
690
|
-
const peerId = node.peerId.toString();
|
|
691
|
-
const arachnodeInfo = await ringSelector.createArachnodeInfo(peerId);
|
|
692
|
-
fretAdapter.setArachnodeInfo(arachnodeInfo);
|
|
693
|
-
log?.('Announced Arachnode membership: Ring %d', arachnodeInfo.ringDepth);
|
|
694
|
-
// Setup restoration coordinator with FRET adapter
|
|
695
|
-
const restorationCoordinatorV2 = new RestorationCoordinator(fretAdapter, { connect: (pid, protocol) => node.dialProtocol(pid, [protocol]) }, `/optimystic/${options.networkName}`, node.peerId.toString());
|
|
696
|
-
// Update restore callback to use new coordinator
|
|
697
|
-
const newRestoreCallback = async (blockId, rev) => {
|
|
698
|
-
return await restorationCoordinatorV2.restore(blockId, rev);
|
|
699
|
-
};
|
|
700
|
-
// Replace the restore callback (this is a bit hacky, but works for now)
|
|
701
|
-
storageRepo.createBlockStorage = (blockId) => new BlockStorage(blockId, rawStorage, newRestoreCallback);
|
|
702
|
-
// --- Rebalance reaction: drive RebalanceMonitor + react via BlockTransferCoordinator ---
|
|
703
|
-
// Nothing previously activated the rebalance path on a real node: initRebalanceMonitor was
|
|
704
|
-
// never called, the monitor was never start()ed, and BlockTransferCoordinator (the
|
|
705
|
-
// pull-gained / push-lost reaction primitive) was never constructed in src. This block lives
|
|
706
|
-
// inside the arachnode `if (fret)` gate because both dependencies only exist here — the
|
|
707
|
-
// fretAdapter and the RestorationCoordinator. When arachnode is disabled or FRET is absent the
|
|
708
|
-
// rebalance path stays inert (acceptable: rebalance is a resilience optimization). A wiring
|
|
709
|
-
// failure here is non-fatal (log + continue), unlike the operator-opted-in cohortTopic block.
|
|
710
|
-
if (networkManager && (options.rebalance?.enabled ?? true) !== false) {
|
|
660
|
+
}
|
|
661
|
+
// Expose for tests/diagnostics (mirrors node.keyNetwork / node.reputation).
|
|
662
|
+
node.spreadOnChurnMonitor = spreadMonitor;
|
|
663
|
+
// Disposal: stop the spread monitor deterministically before the transports close. Composes
|
|
664
|
+
// with the arachnode / clusterMember / cohort-topic stop wrappers (each calls its captured
|
|
665
|
+
// previousStop last). Idempotent (SpreadOnChurnMonitor.stop early-returns when not running), so
|
|
666
|
+
// a double node.stop() does not throw. The owned-block feed teardown is the separate up-front
|
|
667
|
+
// wrapper above (shared across both monitors).
|
|
668
|
+
{
|
|
669
|
+
const previousStop = node.stop.bind(node);
|
|
670
|
+
node.stop = async () => {
|
|
711
671
|
try {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
// blockTransfer service handler registration. protocolPrefix (/optimystic/<networkName>)
|
|
715
|
-
// MUST match the prefix the node registers its block-transfer handler under, or every
|
|
716
|
-
// lost-block push dials the wrong protocol and fails to connect.
|
|
717
|
-
const coordinator = new BlockTransferCoordinator(storageRepo, keyNetwork, restorationCoordinatorV2, partitionDetector, protocolPrefix);
|
|
718
|
-
const rebalanceMonitor = networkManager.initRebalanceMonitor(partitionDetector, fretAdapter, ownedBlocks, options.rebalance);
|
|
719
|
-
await rebalanceMonitor.start();
|
|
720
|
-
// onRebalance fires synchronously from the monitor's debounced check; the coordinator's
|
|
721
|
-
// reaction (pull gained / push lost, each partition-guarded) is async, so hop it off the
|
|
722
|
-
// handler rather than blocking the monitor's emit loop. handleRebalanceEvent can REJECT
|
|
723
|
-
// (e.g. RestorationCoordinator.restore() throws while pulling a gained block) and a bare
|
|
724
|
-
// `void` would surface that as an unhandled rejection (process-fatal on Node >=15); the
|
|
725
|
-
// reaction is a resilience optimization, so swallow + log instead.
|
|
726
|
-
//
|
|
727
|
-
// ALONGSIDE dispatching to the coordinator, drive the shared owned-block set off this
|
|
728
|
-
// authoritative responsibility signal. A GAINED block is added immediately so it is
|
|
729
|
-
// tracked even before its next commit/replica touches the feed.
|
|
730
|
-
//
|
|
731
|
-
// A LOST block is NO LONGER released synchronously: doing so stopped spreading a block
|
|
732
|
-
// whose push to the new owners might fail, drop it below the replication floor, and let a
|
|
733
|
-
// later sweep reclaim it (docs/arachnode-ring-handoff.md § Why the current code violates
|
|
734
|
-
// it #2). Instead the release is GATED on confirmation — the coordinator returns the lost
|
|
735
|
-
// blocks it confirmed replicated to ≥ floor new owners, and ONLY those are untracked
|
|
736
|
-
// (authoritative eviction from the shared set — complements spread's lazy self-prune) and
|
|
737
|
-
// marked GC-eligible. A lost block whose push failed / was partition-skipped stays
|
|
738
|
-
// tracked and served, and is retried on the next rebalance.
|
|
739
|
-
//
|
|
740
|
-
// Best-effort iteration safety: this eviction can mutate ownedBlocks while
|
|
741
|
-
// SpreadOnChurnMonitor (or this monitor) is mid for...of over the same Set inside an
|
|
742
|
-
// async loop. Adding/deleting a Set entry during iteration does not throw in JS — entries
|
|
743
|
-
// are visited best-effort — which is acceptable for a resilience mechanism, so we
|
|
744
|
-
// document it here rather than add locking.
|
|
745
|
-
rebalanceMonitor.onRebalance((event) => {
|
|
746
|
-
for (const blockId of event.gained)
|
|
747
|
-
ownedBlocks.add(blockId);
|
|
748
|
-
coordinator.handleRebalanceEvent(event).then((result) => {
|
|
749
|
-
for (const blockId of result.released) {
|
|
750
|
-
rebalanceMonitor.untrackBlock(blockId); // also evicts from the shared ownedBlocks set
|
|
751
|
-
gcEligible.add(blockId); // confirmed replicated → safe to sweep
|
|
752
|
-
}
|
|
753
|
-
}).catch((err) => {
|
|
754
|
-
log?.('rebalance reaction failed: %o', err);
|
|
755
|
-
});
|
|
756
|
-
});
|
|
757
|
-
// Ring-shift handoff (advertise→confirm→release). It needs the confirmer (this
|
|
758
|
-
// coordinator) and the cohort-size floor (this monitor), so it is wired here. The
|
|
759
|
-
// `onRelease` callback runs Phase C's local effect: stop serving/spreading the shed
|
|
760
|
-
// range and mark it GC-eligible — the same authoritative eviction the confirmed-rebalance
|
|
761
|
-
// release performs.
|
|
762
|
-
ringShift = new RingShiftCoordinator({
|
|
763
|
-
fretAdapter,
|
|
764
|
-
ringSelector,
|
|
765
|
-
fret,
|
|
766
|
-
partitionDetector,
|
|
767
|
-
confirmer: coordinator,
|
|
768
|
-
ownedBlocks,
|
|
769
|
-
selfPeerId: peerId,
|
|
770
|
-
getFloor: () => rebalanceMonitor.getCohortSize(),
|
|
771
|
-
onRelease: (blockIds) => {
|
|
772
|
-
for (const blockId of blockIds) {
|
|
773
|
-
rebalanceMonitor.untrackBlock(blockId);
|
|
774
|
-
gcEligible.add(blockId);
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
});
|
|
778
|
-
// Reconcile any stale `moving` advertisement left by a crash mid-handoff (no-op unless
|
|
779
|
-
// arachnode metadata survived a restart still marked `moving`).
|
|
780
|
-
ringShift.reconcileOnStart();
|
|
781
|
-
// Feed owned blocks via the SINGLE shared feed (idempotent — already live if the spread
|
|
782
|
-
// block above wired it). Both monitors read the same ownedBlocks set this populates.
|
|
783
|
-
ensureOwnedBlockFeed();
|
|
784
|
-
// Expose for tests/diagnostics (mirrors node.spreadOnChurnMonitor).
|
|
785
|
-
node.rebalanceMonitor = rebalanceMonitor;
|
|
786
|
-
node.blockTransferCoordinator = coordinator;
|
|
787
|
-
node.ringShiftCoordinator = ringShift;
|
|
788
|
-
// Disposal: stop the monitor before transports close. Composes with the other stop
|
|
789
|
-
// wrappers (each calls its captured previousStop last). Idempotent — RebalanceMonitor.stop()
|
|
790
|
-
// early-returns when not running (NetworkManagerService.stop() also stops it). The shared
|
|
791
|
-
// owned-block feed teardown is the separate up-front wrapper (not duplicated here).
|
|
792
|
-
const previousStop = node.stop.bind(node);
|
|
793
|
-
node.stop = async () => {
|
|
794
|
-
try {
|
|
795
|
-
await rebalanceMonitor.stop();
|
|
796
|
-
}
|
|
797
|
-
finally {
|
|
798
|
-
await previousStop();
|
|
799
|
-
}
|
|
800
|
-
};
|
|
672
|
+
if (spreadMonitor)
|
|
673
|
+
await spreadMonitor.stop();
|
|
801
674
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
// failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup.
|
|
805
|
-
log?.('rebalance wiring init failed: %o', err);
|
|
675
|
+
finally {
|
|
676
|
+
await previousStop();
|
|
806
677
|
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
// Initialize Arachnode ring membership and restoration
|
|
681
|
+
const enableArachnode = options.arachnode?.enableRingZulu ?? true;
|
|
682
|
+
if (enableArachnode) {
|
|
683
|
+
const log = node.logger?.forComponent?.('db-p2p:arachnode');
|
|
684
|
+
const fret = node.services?.fret;
|
|
685
|
+
if (fret) {
|
|
686
|
+
const fretAdapter = new ArachnodeFretAdapter(fret, node.peerId.toString());
|
|
687
|
+
// Blocks whose shed range has been RELEASED (Phase C of a ring shift, or a confirmed
|
|
688
|
+
// rebalance release). This is the GC-eligibility signal the future storage sweep
|
|
689
|
+
// (`st-storage-sweep-archival-and-capacity-estimate`) must consult: a block's local bytes may
|
|
690
|
+
// be reclaimed ONLY once it appears here, so an unconfirmed / still-served range is never
|
|
691
|
+
// swept. Populated strictly after replication is confirmed. See
|
|
692
|
+
// docs/arachnode-ring-handoff.md § Part 2 (Local bytes vs. tracking).
|
|
693
|
+
// NOTE: no sweep consumes this set yet; it is the coordinated eligibility handoff the sweep
|
|
694
|
+
// ticket will read. Until then it grows unbounded — bound it when the sweep lands.
|
|
695
|
+
const gcEligible = new Set();
|
|
696
|
+
node.gcEligibleBlocks = gcEligible;
|
|
697
|
+
// The ring-shift state machine (advertise→confirm→release). Wired inside the rebalance block
|
|
698
|
+
// below (it needs the BlockTransferCoordinator confirmer + the cohort-size floor); left
|
|
699
|
+
// undefined when the rebalance reaction is not wired, in which case ring shifts stay inert —
|
|
700
|
+
// a move-out is unsafe without the confirm/release path.
|
|
701
|
+
let ringShift;
|
|
702
|
+
const storageMonitor = new StorageMonitor(rawStorage, options.arachnode?.storage ?? {});
|
|
703
|
+
const ringSelector = new RingSelector(fretAdapter, storageMonitor, {
|
|
704
|
+
minCapacity: 100 * 1024 * 1024,
|
|
705
|
+
thresholds: {
|
|
706
|
+
moveOut: 0.85,
|
|
707
|
+
moveIn: 0.40
|
|
708
|
+
},
|
|
709
|
+
// Damping so the ring decision cannot thrash near a boundary
|
|
710
|
+
// (docs/arachnode-ring-handoff.md § Part 1).
|
|
711
|
+
smoothingAlpha: 0.2,
|
|
712
|
+
deadband: 0.5,
|
|
713
|
+
minDwellMs: 10 * 60 * 1000
|
|
714
|
+
});
|
|
715
|
+
// Determine and announce ring membership
|
|
716
|
+
const peerId = node.peerId.toString();
|
|
717
|
+
const arachnodeInfo = await ringSelector.createArachnodeInfo(peerId);
|
|
718
|
+
fretAdapter.setArachnodeInfo(arachnodeInfo);
|
|
719
|
+
log?.('Announced Arachnode membership: Ring %d', arachnodeInfo.ringDepth);
|
|
720
|
+
// Setup restoration coordinator with FRET adapter
|
|
721
|
+
const restorationCoordinatorV2 = new RestorationCoordinator(fretAdapter, { connect: (pid, protocol) => node.dialProtocol(pid, [protocol]) }, `/optimystic/${options.networkName}`, node.peerId.toString());
|
|
722
|
+
// Update restore callback to use new coordinator
|
|
723
|
+
const newRestoreCallback = async (blockId, rev) => {
|
|
724
|
+
return await restorationCoordinatorV2.restore(blockId, rev);
|
|
725
|
+
};
|
|
726
|
+
// Replace the restore callback (this is a bit hacky, but works for now)
|
|
727
|
+
storageRepo.createBlockStorage = (blockId) => new BlockStorage(blockId, rawStorage, newRestoreCallback);
|
|
728
|
+
// --- Rebalance reaction: drive RebalanceMonitor + react via BlockTransferCoordinator ---
|
|
729
|
+
// Nothing previously activated the rebalance path on a real node: initRebalanceMonitor was
|
|
730
|
+
// never called, the monitor was never start()ed, and BlockTransferCoordinator (the
|
|
731
|
+
// pull-gained / push-lost reaction primitive) was never constructed in src. This block lives
|
|
732
|
+
// inside the arachnode `if (fret)` gate because both dependencies only exist here — the
|
|
733
|
+
// fretAdapter and the RestorationCoordinator. When arachnode is disabled or FRET is absent the
|
|
734
|
+
// rebalance path stays inert (acceptable: rebalance is a resilience optimization). A wiring
|
|
735
|
+
// failure here is non-fatal (log + continue), unlike the operator-opted-in cohortTopic block.
|
|
736
|
+
if (networkManager && (options.rebalance?.enabled ?? true) !== false) {
|
|
823
737
|
try {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
738
|
+
// repo → the LOCAL storageRepo (not repoProxy/coordinatedRepo): a pulled/pushed replica
|
|
739
|
+
// must land in / be read from this node's own storage, same reasoning as the
|
|
740
|
+
// blockTransfer service handler registration. protocolPrefix (/optimystic/<networkName>)
|
|
741
|
+
// MUST match the prefix the node registers its block-transfer handler under, or every
|
|
742
|
+
// lost-block push dials the wrong protocol and fails to connect.
|
|
743
|
+
const coordinator = new BlockTransferCoordinator(storageRepo, keyNetwork, restorationCoordinatorV2, partitionDetector, protocolPrefix);
|
|
744
|
+
const rebalanceMonitor = networkManager.initRebalanceMonitor(partitionDetector, fretAdapter, ownedBlocks, options.rebalance);
|
|
745
|
+
await rebalanceMonitor.start();
|
|
746
|
+
// onRebalance fires synchronously from the monitor's debounced check; the coordinator's
|
|
747
|
+
// reaction (pull gained / push lost, each partition-guarded) is async, so hop it off the
|
|
748
|
+
// handler rather than blocking the monitor's emit loop. handleRebalanceEvent can REJECT
|
|
749
|
+
// (e.g. RestorationCoordinator.restore() throws while pulling a gained block) and a bare
|
|
750
|
+
// `void` would surface that as an unhandled rejection (process-fatal on Node >=15); the
|
|
751
|
+
// reaction is a resilience optimization, so swallow + log instead.
|
|
752
|
+
//
|
|
753
|
+
// ALONGSIDE dispatching to the coordinator, drive the shared owned-block set off this
|
|
754
|
+
// authoritative responsibility signal. A GAINED block is added immediately so it is
|
|
755
|
+
// tracked even before its next commit/replica touches the feed.
|
|
756
|
+
//
|
|
757
|
+
// A LOST block is NO LONGER released synchronously: doing so stopped spreading a block
|
|
758
|
+
// whose push to the new owners might fail, drop it below the replication floor, and let a
|
|
759
|
+
// later sweep reclaim it (docs/arachnode-ring-handoff.md § Why the current code violates
|
|
760
|
+
// it #2). Instead the release is GATED on confirmation — the coordinator returns the lost
|
|
761
|
+
// blocks it confirmed replicated to ≥ floor new owners, and ONLY those are untracked
|
|
762
|
+
// (authoritative eviction from the shared set — complements spread's lazy self-prune) and
|
|
763
|
+
// marked GC-eligible. A lost block whose push failed / was partition-skipped stays
|
|
764
|
+
// tracked and served, and is retried on the next rebalance.
|
|
765
|
+
//
|
|
766
|
+
// Best-effort iteration safety: this eviction can mutate ownedBlocks while
|
|
767
|
+
// SpreadOnChurnMonitor (or this monitor) is mid for...of over the same Set inside an
|
|
768
|
+
// async loop. Adding/deleting a Set entry during iteration does not throw in JS — entries
|
|
769
|
+
// are visited best-effort — which is acceptable for a resilience mechanism, so we
|
|
770
|
+
// document it here rather than add locking.
|
|
771
|
+
rebalanceMonitor.onRebalance((event) => {
|
|
772
|
+
for (const blockId of event.gained)
|
|
773
|
+
ownedBlocks.add(blockId);
|
|
774
|
+
coordinator.handleRebalanceEvent(event).then((result) => {
|
|
775
|
+
for (const blockId of result.released) {
|
|
776
|
+
rebalanceMonitor.untrackBlock(blockId); // also evicts from the shared ownedBlocks set
|
|
777
|
+
gcEligible.add(blockId); // confirmed replicated → safe to sweep
|
|
778
|
+
}
|
|
779
|
+
}).catch((err) => {
|
|
780
|
+
log?.('rebalance reaction failed: %o', err);
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
// Ring-shift handoff (advertise→confirm→release). It needs the confirmer (this
|
|
784
|
+
// coordinator) and the cohort-size floor (this monitor), so it is wired here. The
|
|
785
|
+
// `onRelease` callback runs Phase C's local effect: stop serving/spreading the shed
|
|
786
|
+
// range and mark it GC-eligible — the same authoritative eviction the confirmed-rebalance
|
|
787
|
+
// release performs.
|
|
788
|
+
ringShift = new RingShiftCoordinator({
|
|
789
|
+
fretAdapter,
|
|
790
|
+
ringSelector,
|
|
791
|
+
fret,
|
|
792
|
+
partitionDetector,
|
|
793
|
+
confirmer: coordinator,
|
|
794
|
+
ownedBlocks,
|
|
795
|
+
selfPeerId: peerId,
|
|
796
|
+
getFloor: () => rebalanceMonitor.getCohortSize(),
|
|
797
|
+
onRelease: (blockIds) => {
|
|
798
|
+
for (const blockId of blockIds) {
|
|
799
|
+
rebalanceMonitor.untrackBlock(blockId);
|
|
800
|
+
gcEligible.add(blockId);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
827
803
|
});
|
|
828
|
-
|
|
804
|
+
// Reconcile any stale `moving` advertisement left by a crash mid-handoff (no-op unless
|
|
805
|
+
// arachnode metadata survived a restart still marked `moving`).
|
|
806
|
+
ringShift.reconcileOnStart();
|
|
807
|
+
// Feed owned blocks via the SINGLE shared feed (idempotent — already live if the spread
|
|
808
|
+
// block above wired it). Both monitors read the same ownedBlocks set this populates.
|
|
809
|
+
ensureOwnedBlockFeed();
|
|
810
|
+
// Expose for tests/diagnostics (mirrors node.spreadOnChurnMonitor).
|
|
811
|
+
node.rebalanceMonitor = rebalanceMonitor;
|
|
812
|
+
node.blockTransferCoordinator = coordinator;
|
|
813
|
+
node.ringShiftCoordinator = ringShift;
|
|
814
|
+
// Disposal: stop the monitor before transports close. Composes with the other stop
|
|
815
|
+
// wrappers (each calls its captured previousStop last). Idempotent — RebalanceMonitor.stop()
|
|
816
|
+
// early-returns when not running (NetworkManagerService.stop() also stops it). The shared
|
|
817
|
+
// owned-block feed teardown is the separate up-front wrapper (not duplicated here).
|
|
818
|
+
const previousStop = node.stop.bind(node);
|
|
819
|
+
node.stop = async () => {
|
|
820
|
+
try {
|
|
821
|
+
await rebalanceMonitor.stop();
|
|
822
|
+
}
|
|
823
|
+
finally {
|
|
824
|
+
await previousStop();
|
|
825
|
+
}
|
|
826
|
+
};
|
|
829
827
|
}
|
|
830
828
|
catch (err) {
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
// Measure the minimum dwell from the SETTLED shift (completed or rolled back), not
|
|
835
|
-
// just the trigger stamped inside shouldTransition (docs/arachnode-ring-handoff.md §1.3).
|
|
836
|
-
ringSelector.recordShiftSettled();
|
|
829
|
+
// Rebalance is a resilience optimization, not a correctness requirement - a wiring
|
|
830
|
+
// failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup.
|
|
831
|
+
log?.('rebalance wiring init failed: %o', err);
|
|
837
832
|
}
|
|
838
833
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
834
|
+
// Monitor capacity and adjust ring periodically. The damped `shouldTransition()` decides
|
|
835
|
+
// WHETHER/where to move (docs/arachnode-ring-handoff.md § Part 1); the RingShiftCoordinator
|
|
836
|
+
// carries the move out through the advertise→confirm→release handoff (§ Part 2) so a shift
|
|
837
|
+
// never drops a key below its replication floor. The old unilateral `setArachnodeInfo` flip —
|
|
838
|
+
// which changed advertised responsibility instantly with no data handoff — is gone.
|
|
839
|
+
//
|
|
840
|
+
// Ring shifts run ONLY when `ringShift` is wired (i.e. the rebalance reaction is enabled): a
|
|
841
|
+
// move-out is unsafe without the confirm/release path, so a node with the rebalance reaction
|
|
842
|
+
// disabled stays at its bootstrap ring rather than flipping unsafely.
|
|
843
|
+
const monitorInterval = setInterval(async () => {
|
|
844
|
+
if (!ringShift)
|
|
845
|
+
return;
|
|
846
|
+
const transition = await ringSelector.shouldTransition();
|
|
847
|
+
if (transition.shouldMove && transition.direction && transition.newRingDepth !== undefined) {
|
|
848
|
+
log?.('Ring transition needed: moving %s to Ring %d', transition.direction, transition.newRingDepth);
|
|
849
|
+
try {
|
|
850
|
+
const outcome = await ringShift.executeShift({
|
|
851
|
+
direction: transition.direction,
|
|
852
|
+
newRingDepth: transition.newRingDepth
|
|
853
|
+
});
|
|
854
|
+
log?.('Ring shift outcome: %o', outcome);
|
|
855
|
+
}
|
|
856
|
+
catch (err) {
|
|
857
|
+
log?.('Ring shift failed: %o', err);
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
// Measure the minimum dwell from the SETTLED shift (completed or rolled back), not
|
|
861
|
+
// just the trigger stamped inside shouldTransition (docs/arachnode-ring-handoff.md §1.3).
|
|
862
|
+
ringSelector.recordShiftSettled();
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}, 60_000);
|
|
866
|
+
// Cleanup on node stop
|
|
867
|
+
const originalStop = node.stop.bind(node);
|
|
868
|
+
node.stop = async () => {
|
|
869
|
+
clearInterval(monitorInterval);
|
|
870
|
+
await originalStop();
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
log?.('FRET service not available, Arachnode disabled');
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
// --- Seed the shared owned-block set from already-durable storage ---
|
|
878
|
+
// Blocks durable from a previous run are otherwise untracked until next touched (see the
|
|
879
|
+
// onAnyCollectionChange comment above where ownedBlocks is declared). Placed here, AFTER both
|
|
880
|
+
// monitor-wiring blocks (spread ~line 862, rebalance ~line 974) have had their chance to call
|
|
881
|
+
// ensureOwnedBlockFeed():
|
|
882
|
+
// - Gate on offOwnedBlockFeed: only seed when a monitor actually consumes ownedBlocks; if both
|
|
883
|
+
// are disabled the set is unused and the scan (plus the background task) is wasted work.
|
|
884
|
+
// - Feed-before-scan ordering is load-bearing: because the feed is already live, a block
|
|
885
|
+
// committed/replicated DURING the scan is caught by the feed; Set.add is idempotent so the
|
|
886
|
+
// overlap is harmless. Scanning before subscribing would drop a block committed in the gap.
|
|
887
|
+
// - Fire-and-forget so a large store never blocks startup; the .catch keeps a scan rejection
|
|
888
|
+
// from becoming an unhandled rejection.
|
|
889
|
+
// - Cancellable: a stop wrapper flips seedStopping so the scan loop breaks against a
|
|
890
|
+
// stopping/closing backend rather than running the enumeration to completion.
|
|
891
|
+
// NOTE: a concurrent rebalance release can untrackBlock (delete from ownedBlocks) a confirmed-
|
|
892
|
+
// released block while this scan is still running, and the scan could then re-add that id. Benign
|
|
893
|
+
// transient: the block is still in the metadata store (no sweep reclaims metadata yet), so a
|
|
894
|
+
// re-added released block is simply re-evaluated and re-released on the next rebalance tick. Right
|
|
895
|
+
// after a restart, responsibility-loss detection lags this fast metadata scan, so the window is
|
|
896
|
+
// small. Accepted rather than synchronized.
|
|
897
|
+
if (offOwnedBlockFeed && typeof rawStorage.listBlockIds === 'function') {
|
|
898
|
+
let seedStopping = false;
|
|
899
|
+
const previousStop = node.stop.bind(node);
|
|
842
900
|
node.stop = async () => {
|
|
843
|
-
|
|
844
|
-
await
|
|
901
|
+
seedStopping = true;
|
|
902
|
+
await previousStop();
|
|
845
903
|
};
|
|
904
|
+
void seedOwnedBlocksFromStorage(rawStorage, ownedBlocks, () => seedStopping)
|
|
905
|
+
.catch((err) => (node.logger?.forComponent?.('db-p2p:owned-block-seed'))?.('seed failed: %o', err));
|
|
846
906
|
}
|
|
847
|
-
|
|
848
|
-
|
|
907
|
+
// [dispute-subsystem-dormant] The DisputeService object is constructed below so tests and
|
|
908
|
+
// getDisputeStatus() work, but it is unreachable from the live network path:
|
|
909
|
+
// - No inbound handler: disputeProtocolService is NOT in the services map above.
|
|
910
|
+
// - onInvalidation is deliberately unset: maybeInvalidate() is a no-op on live nodes.
|
|
911
|
+
// - revalidate is deliberately unset: handleChallenge always votes inconclusive on live nodes.
|
|
912
|
+
// Full activation requires arbitrator-set anchoring before a forged synthetic cohort can pass resolution.
|
|
913
|
+
// Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
|
|
914
|
+
// Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
|
|
915
|
+
// Initialize dispute service if enabled
|
|
916
|
+
let disputeServiceInstance;
|
|
917
|
+
if (options.dispute?.disputeEnabled) {
|
|
918
|
+
const createDisputeClient = (peerId) => DisputeClient.create(peerId, keyNetwork, protocolPrefix);
|
|
919
|
+
disputeServiceInstance = new DisputeService({
|
|
920
|
+
peerId: node.peerId,
|
|
921
|
+
privateKey: nodePrivateKey,
|
|
922
|
+
peerNetwork: keyNetwork,
|
|
923
|
+
createDisputeClient,
|
|
924
|
+
reputation,
|
|
925
|
+
validator: options.validator,
|
|
926
|
+
config: options.dispute,
|
|
927
|
+
selectArbitrators: async (blockId, excludePeers, count, round, epoch) => {
|
|
928
|
+
const { hashKey: fretHashKey } = await import('p2p-fret');
|
|
929
|
+
const fret = node.services?.fret;
|
|
930
|
+
if (!fret)
|
|
931
|
+
return [];
|
|
932
|
+
// Dispersed sampling: draw `count` peers from coordinates spread across the whole keyspace
|
|
933
|
+
// (hash(blockId ‖ round ‖ epoch ‖ i)) rather than the block's XOR neighborhood, so an attacker
|
|
934
|
+
// who owns the block's locale does not thereby own the arbitrators. `assembleCohort` already
|
|
935
|
+
// filters to known members; excluding the original cluster + self keeps arbitrators independent.
|
|
936
|
+
const excludeSet = new Set(excludePeers);
|
|
937
|
+
// NOTE: adding the local node's own id to `exclude` makes the draw node-relative. Cross-node
|
|
938
|
+
// determinism (the verifiable-recompute property) holds today only because the dissent
|
|
939
|
+
// coordinator running this is itself a member of the original cluster, so `self` is already in
|
|
940
|
+
// `excludePeers` — the add is a no-op and every honest node excludes the identical set. When a
|
|
941
|
+
// verify-path recompute lands, it MUST reconstruct `exclude` from the challenger's identity
|
|
942
|
+
// (`proof.challengerPeerId`) + original cluster, never the verifier's own id, or re-derivation diverges.
|
|
943
|
+
excludeSet.add(node.peerId.toString());
|
|
944
|
+
const picks = await sampleArbitrators({ blockId: new TextEncoder().encode(blockId), round, epoch, count, exclude: excludeSet }, (coord, wants) => fret.assembleCohort(coord, wants), fretHashKey);
|
|
945
|
+
return picks.map(pid => peerIdFromString(pid));
|
|
946
|
+
},
|
|
947
|
+
});
|
|
849
948
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
// transient: the block is still in the metadata store (no sweep reclaims metadata yet), so a
|
|
868
|
-
// re-added released block is simply re-evaluated and re-released on the next rebalance tick. Right
|
|
869
|
-
// after a restart, responsibility-loss detection lags this fast metadata scan, so the window is
|
|
870
|
-
// small. Accepted rather than synchronized.
|
|
871
|
-
if (offOwnedBlockFeed && typeof rawStorage.listBlockIds === 'function') {
|
|
872
|
-
let seedStopping = false;
|
|
873
|
-
const previousStop = node.stop.bind(node);
|
|
874
|
-
node.stop = async () => {
|
|
875
|
-
seedStopping = true;
|
|
876
|
-
await previousStop();
|
|
877
|
-
};
|
|
878
|
-
void seedOwnedBlocksFromStorage(rawStorage, ownedBlocks, () => seedStopping)
|
|
879
|
-
.catch((err) => (node.logger?.forComponent?.('db-p2p:owned-block-seed'))?.('seed failed: %o', err));
|
|
880
|
-
}
|
|
881
|
-
// [dispute-subsystem-dormant] The DisputeService object is constructed below so tests and
|
|
882
|
-
// getDisputeStatus() work, but it is unreachable from the live network path:
|
|
883
|
-
// - No inbound handler: disputeProtocolService is NOT in the services map above.
|
|
884
|
-
// - onInvalidation is deliberately unset: maybeInvalidate() is a no-op on live nodes.
|
|
885
|
-
// - revalidate is deliberately unset: handleChallenge always votes inconclusive on live nodes.
|
|
886
|
-
// Full activation requires arbitrator-set anchoring before a forged synthetic cohort can pass resolution.
|
|
887
|
-
// Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
|
|
888
|
-
// Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
|
|
889
|
-
// Initialize dispute service if enabled
|
|
890
|
-
let disputeServiceInstance;
|
|
891
|
-
if (options.dispute?.disputeEnabled) {
|
|
892
|
-
const createDisputeClient = (peerId) => DisputeClient.create(peerId, keyNetwork, protocolPrefix);
|
|
893
|
-
disputeServiceInstance = new DisputeService({
|
|
894
|
-
peerId: node.peerId,
|
|
895
|
-
privateKey: nodePrivateKey,
|
|
896
|
-
peerNetwork: keyNetwork,
|
|
897
|
-
createDisputeClient,
|
|
949
|
+
// The host-facing attachment surface, declared once in `optimystic-node.ts` and written here
|
|
950
|
+
// through ONE object literal so every field is type-checked AND a field added to
|
|
951
|
+
// `OptimysticNodeAttachments` but never assigned here is a compile error rather than an
|
|
952
|
+
// `undefined` a host reads as present. Keeping it typed is load-bearing: when
|
|
953
|
+
// `node.keyNetwork` was reachable only through a cast, three hosts found it easier to build a
|
|
954
|
+
// SECOND Libp2pKeyPeerNetwork from constructor defaults — a different cohort width and no
|
|
955
|
+
// network-membership filter than this node's own consensus path uses for the same key
|
|
956
|
+
// (ticket bug-second-key-network-built-with-defaults).
|
|
957
|
+
const attachments = {
|
|
958
|
+
coordinatedRepo,
|
|
959
|
+
storageRepo,
|
|
960
|
+
// The StorageRepo is the single commit funnel for both the coordinated and
|
|
961
|
+
// direct paths, so it is the node's per-collection change-notifier origin. This is the
|
|
962
|
+
// default; the cohort-topic activation block below REPLACES it with the origination-decorating
|
|
963
|
+
// bridge notifier when the substrate is enabled.
|
|
964
|
+
blockChangeNotifier: storageRepo,
|
|
965
|
+
keyNetwork,
|
|
898
966
|
reputation,
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
// Dispersed sampling: draw `count` peers from coordinates spread across the whole keyspace
|
|
907
|
-
// (hash(blockId ‖ round ‖ epoch ‖ i)) rather than the block's XOR neighborhood, so an attacker
|
|
908
|
-
// who owns the block's locale does not thereby own the arbitrators. `assembleCohort` already
|
|
909
|
-
// filters to known members; excluding the original cluster + self keeps arbitrators independent.
|
|
910
|
-
const excludeSet = new Set(excludePeers);
|
|
911
|
-
// NOTE: adding the local node's own id to `exclude` makes the draw node-relative. Cross-node
|
|
912
|
-
// determinism (the verifiable-recompute property) holds today only because the dissent
|
|
913
|
-
// coordinator running this is itself a member of the original cluster, so `self` is already in
|
|
914
|
-
// `excludePeers` — the add is a no-op and every honest node excludes the identical set. When a
|
|
915
|
-
// verify-path recompute lands, it MUST reconstruct `exclude` from the challenger's identity
|
|
916
|
-
// (`proof.challengerPeerId`) + original cluster, never the verifier's own id, or re-derivation diverges.
|
|
917
|
-
excludeSet.add(node.peerId.toString());
|
|
918
|
-
const picks = await sampleArbitrators({ blockId: new TextEncoder().encode(blockId), round, epoch, count, exclude: excludeSet }, (coord, wants) => fret.assembleCohort(coord, wants), fretHashKey);
|
|
919
|
-
return picks.map(pid => peerIdFromString(pid));
|
|
920
|
-
},
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
// Cleanup cluster member intervals on node stop
|
|
924
|
-
{
|
|
925
|
-
const previousStop = node.stop.bind(node);
|
|
926
|
-
node.stop = async () => {
|
|
927
|
-
clusterImpl.dispose();
|
|
928
|
-
await previousStop();
|
|
967
|
+
disputeService: disputeServiceInstance,
|
|
968
|
+
// The node's libp2p Ed25519 identity key. Exposed on the same attachment surface as
|
|
969
|
+
// coordinatedRepo/keyNetwork so a host can bind a client-transaction signer to it (the Quereus
|
|
970
|
+
// collection-factory's getSigner reuses this via signPeer). libp2p does not surface the private
|
|
971
|
+
// key on its public `Libp2p` interface, so this attachment is the sanctioned in-process handle.
|
|
972
|
+
// Ed25519 by construction (options.privateKey defaults to generateKeyPair('Ed25519')).
|
|
973
|
+
peerPrivateKey: nodePrivateKey,
|
|
929
974
|
};
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
// Ed25519 by construction (options.privateKey defaults to generateKeyPair('Ed25519')).
|
|
947
|
-
node.peerPrivateKey = nodePrivateKey;
|
|
948
|
-
// --- Cohort-topic origination activation (post-node: consumes the fully-assembled node + FRET) ---
|
|
949
|
-
// This is the only place that is after the node + FRET are assembled (node.start() done, fretSvc
|
|
950
|
-
// available) yet before any caller can capture `blockChangeNotifier` — the Quereus collection-factory
|
|
951
|
-
// captures it once, immediately after createLibp2pNode returns, and reuses that reference as
|
|
952
|
-
// `localChangeNotifier` for every NetworkTransactor it builds. Installing the bridge here makes the
|
|
953
|
-
// origination path live for ALL collections created on the node.
|
|
954
|
-
if (cohortEnabled) {
|
|
955
|
-
// The host needs the full FRET engine surface; node.services.fret is the wrapper (see resolveFretEngine).
|
|
956
|
-
const fret = resolveFretEngine(fretSvc);
|
|
957
|
-
if (!fret) {
|
|
958
|
-
// Operator opted in; degrading silently to the bare notifier would hide misconfiguration.
|
|
959
|
-
// The node has already started (transports open, FRET running), so tear it down before the
|
|
960
|
-
// hard-fail rather than leaking a started node + open transports on the rejection.
|
|
961
|
-
await node.stop();
|
|
962
|
-
throw new Error('cohortTopic enabled but the FRET service is unavailable on the node');
|
|
963
|
-
}
|
|
964
|
-
// A host-construction failure also hard-fails (operator opted in); stop the started node first so
|
|
965
|
-
// the rejection does not leak open transports / a running FRET service. node.stop() runs the
|
|
966
|
-
// already-installed arachnode + clusterMember teardown wrappers and closes the node's connections.
|
|
967
|
-
let host;
|
|
968
|
-
try {
|
|
969
|
-
host = await createCohortTopicHost(node, fret, {
|
|
975
|
+
Object.assign(node, attachments);
|
|
976
|
+
// --- Cohort-topic origination activation (post-node: consumes the fully-assembled node + FRET) ---
|
|
977
|
+
// This is the only place that is after the node + FRET are assembled (node.start() done, fretSvc
|
|
978
|
+
// available) yet before any caller can capture `blockChangeNotifier` — the Quereus collection-factory
|
|
979
|
+
// captures it once, immediately after createLibp2pNode returns, and reuses that reference as
|
|
980
|
+
// `localChangeNotifier` for every NetworkTransactor it builds. Installing the bridge here makes the
|
|
981
|
+
// origination path live for ALL collections created on the node.
|
|
982
|
+
if (cohortEnabled) {
|
|
983
|
+
// The host needs the full FRET engine surface; node.services.fret is the wrapper (see resolveFretEngine).
|
|
984
|
+
const fret = resolveFretEngine(fretSvc);
|
|
985
|
+
if (!fret) {
|
|
986
|
+
// Operator opted in; degrading silently to the bare notifier would hide misconfiguration.
|
|
987
|
+
// (The started node is torn down by the post-start rollback `catch` at the bottom of this function.)
|
|
988
|
+
throw new Error('cohortTopic enabled but the FRET service is unavailable on the node');
|
|
989
|
+
}
|
|
990
|
+
const host = await createCohortTopicHost(node, fret, {
|
|
970
991
|
...(options.cohortTopic.host ?? {}),
|
|
971
992
|
// Wire the node's reputation service in as the production backing for the bootstrap-evidence
|
|
972
993
|
// referee verifier (the `{ isBanned, getScore }` view `PeerReputationService` satisfies), so a
|
|
@@ -985,243 +1006,279 @@ export async function createLibp2pNodeBase(options, defaults) {
|
|
|
985
1006
|
privateKey: nodePrivateKey, // real k − x threshold signing
|
|
986
1007
|
wantK: cohortWantK,
|
|
987
1008
|
});
|
|
1009
|
+
// --- Cohort-topic + reactivity + matchmaking teardown ---
|
|
1010
|
+
// Installed HERE, immediately after `host` exists and BEFORE the ~230 lines of reactivity /
|
|
1011
|
+
// matchmaking wiring below, because the post-start rollback only unwinds resources whose stop
|
|
1012
|
+
// wrapper is already installed at the moment of the throw. With the wrapper at the END of the
|
|
1013
|
+
// block (where it used to live) a throw mid-wiring left the host's gossip timer and cohort-topic
|
|
1014
|
+
// protocol handlers running. The bindings it releases are therefore declared up front and
|
|
1015
|
+
// undefined-guarded — same idiom as `offOwnedBlockFeed` above — so this tears down exactly what
|
|
1016
|
+
// has been created so far, whether that is the host alone or the whole wiring.
|
|
1017
|
+
//
|
|
1018
|
+
// Ordering (load-bearing): release reactivity timers + protocol handlers BEFORE host.stop()
|
|
1019
|
+
// (which clears the cohort gossip timer + unhandles the cohort-topic protocols) BEFORE the node's
|
|
1020
|
+
// transports close (previousStop). Composes with the existing arachnode + clusterMember stop
|
|
1021
|
+
// wrappers (each calls its captured previousStop last). `node.unhandle` on a protocol that was
|
|
1022
|
+
// never registered does not throw — libp2p's registrar deletes each id from its handler map
|
|
1023
|
+
// (a miss is silently ignored) and then re-patches the peer store's advertised protocol list —
|
|
1024
|
+
// so the handler releases need no separate registration flags.
|
|
1025
|
+
const reactivityProtocols = DEFAULT_REACTIVITY_PROTOCOLS;
|
|
1026
|
+
const matchmakingProtocols = DEFAULT_MATCHMAKING_PROTOCOLS;
|
|
1027
|
+
let unsubscribeCohortBridge;
|
|
1028
|
+
let offInboundNotify;
|
|
1029
|
+
let pushStateGossip;
|
|
1030
|
+
let reactivityRotation;
|
|
1031
|
+
{
|
|
1032
|
+
const previousStop = node.stop.bind(node);
|
|
1033
|
+
node.stop = async () => {
|
|
1034
|
+
try {
|
|
1035
|
+
reactivityRotation?.stop();
|
|
1036
|
+
pushStateGossip?.stop();
|
|
1037
|
+
offInboundNotify?.();
|
|
1038
|
+
await node.unhandle(reactivityProtocolList(reactivityProtocols));
|
|
1039
|
+
await node.unhandle(matchmakingProtocolList(matchmakingProtocols));
|
|
1040
|
+
unsubscribeCohortBridge?.();
|
|
1041
|
+
await host.stop();
|
|
1042
|
+
}
|
|
1043
|
+
finally {
|
|
1044
|
+
await previousStop();
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
// selfIsCohortMember: this node owns the collection's reactivity-topic fan-out iff it is in the
|
|
1049
|
+
// FRET cohort around coord_0(H(currentTailId ‖ "reactivity")). Uses db-core's default hashes
|
|
1050
|
+
// (createReactivityTopicAnchor / createTierAddressing / createRingHash), byte-identical to the
|
|
1051
|
+
// host's internal `new RingHash()` and the subscriber-side anchor, and the SAME cohortWantK as
|
|
1052
|
+
// the host — so the coord + cohort line up across origination and subscription.
|
|
1053
|
+
const selfIsCohortMember = createReactivitySelfMembershipGate({
|
|
1054
|
+
fret,
|
|
1055
|
+
selfPeerId: node.peerId.toString(),
|
|
1056
|
+
wantK: cohortWantK,
|
|
1057
|
+
});
|
|
1058
|
+
unsubscribeCohortBridge = attachCohortChangeBridge(node, {
|
|
1059
|
+
source: storageRepo,
|
|
1060
|
+
service: host.service,
|
|
1061
|
+
selfIsCohortMember,
|
|
1062
|
+
extractCommitCert: makeClusterCommitCertExtractor(certStore),
|
|
1063
|
+
}).unsubscribe;
|
|
1064
|
+
// Expose the host so the reactivity origination wiring (and the activation test) can install
|
|
1065
|
+
// `CohortTopicService.onLocalCommit`.
|
|
1066
|
+
node.cohortTopicHost = host;
|
|
1067
|
+
// --- Reactivity notification transport (origination → fan-out → inbound delivery → push-state gossip) ---
|
|
1068
|
+
// Compose notify + forwarder-host + push-state-gossip onto the cohort-topic host so a committed change
|
|
1069
|
+
// on a tail-cohort member actually reaches subscribers on OTHER nodes over real sockets. The change
|
|
1070
|
+
// bridge above fires `onLocalCommit`; this is what the emitted notifications travel over.
|
|
1071
|
+
// (docs/reactivity.md §Notification origination / §Propagation.) Reactivity reuses the canonical,
|
|
1072
|
+
// network-agnostic protocol IDs, matching the cohort-topic family's production default.
|
|
1073
|
+
const selfPeerId = node.peerId.toString();
|
|
1074
|
+
const reactivityProfile = host.profile; // Edge ⇒ subscriber-only via the policy gate; Core forwards.
|
|
1075
|
+
const reactivityPolicy = reactivityNodePolicy(reactivityProfile);
|
|
1076
|
+
// db-core default anchor + tier addressing, byte-identical to the host's `new RingHash()`, the
|
|
1077
|
+
// origination gate, and the subscriber-side anchor — so coord_0 derivation lines up everywhere.
|
|
1078
|
+
const reactivityAddressing = createTierAddressing(createRingHash());
|
|
1079
|
+
// Reactivity's forwarder cohort sits at coord_0 — TREE tier 0 (peer-independent), distinct from the
|
|
1080
|
+
// CAPACITY tier T3 the verifier/willingness use. `registry.findServing` keys on the engine's tree
|
|
1081
|
+
// depth, so the served reactivity engine is found at tree tier 0, never at 3.
|
|
1082
|
+
const REACTIVITY_FORWARDER_TREE_TIER = 0;
|
|
1083
|
+
// Node-level subscriber registry: a constructed ReactivitySubscriptionManager registers here so a
|
|
1084
|
+
// socket-delivered NotificationV1 reaches it. (The Quereus Database.watch → manager bridge that
|
|
1085
|
+
// CONSTRUCTS managers stays the backlog item optimystic-network-reactive-watch-integration-test.)
|
|
1086
|
+
const reactivitySubscribers = new ReactivitySubscriberRegistry();
|
|
1087
|
+
node.reactivitySubscribers = reactivitySubscribers;
|
|
1088
|
+
// 1. Notify transport — unicast NotificationV1 send + inbound subscribe. selfPeerId guards self-dials.
|
|
1089
|
+
const notify = new Libp2pReactivityNotifyTransport(node, { selfPeerId });
|
|
1090
|
+
// 2. Forwarder host — turns the forward decision into live fan-out over the notify transport.
|
|
1091
|
+
const forwarderHost = new ReactivityForwarderHost({
|
|
1092
|
+
transport: notify,
|
|
1093
|
+
selfPeerId,
|
|
1094
|
+
profile: reactivityProfile,
|
|
1095
|
+
pushStateInit: (topicId, n) => ({
|
|
1096
|
+
collectionId: n.collectionId,
|
|
1097
|
+
topicId: bytesToB64url(topicId),
|
|
1098
|
+
tailIdAtJoin: n.tailId,
|
|
1099
|
+
deltaMaxBytes: reactivityPolicy.deltaMaxBytes,
|
|
1100
|
+
}),
|
|
1101
|
+
verifierFor: () => createNotificationVerifier({ verifier: host.service.verifier(), tier: Tier.T3 }),
|
|
1102
|
+
directSubscribers: (topicId) => {
|
|
1103
|
+
// Find the served reactivity engine at TREE tier 0 (see REACTIVITY_FORWARDER_TREE_TIER) and read
|
|
1104
|
+
// its direct-subscriber records. The adapter filters to reactivity appState and maps participantId
|
|
1105
|
+
// bytes → dialable peer-id strings (the transport's `peerIdFromString` space) — NOT base64url,
|
|
1106
|
+
// which would silently fail to dial. `undefined` (no subscriber has registered here yet) ⇒ [].
|
|
1107
|
+
const engine = host.registry.findServing(topicId, REACTIVITY_FORWARDER_TREE_TIER);
|
|
1108
|
+
return engine === undefined ? [] : reactivityDirectSubscribers(engine, topicId);
|
|
1109
|
+
},
|
|
1110
|
+
// No childCohorts until cohort-topic-parent-child-link populates PushState.childCohorts (single
|
|
1111
|
+
// tier-0 reach today); wire the resolver anyway. A child cohort's primary is the FRET-nearest member
|
|
1112
|
+
// of its coord, returned as a peer-id string (the dial space).
|
|
1113
|
+
resolveChildPrimary: (ref) => {
|
|
1114
|
+
const peers = fret.assembleCohort(b64urlToBytes(ref.coord), cohortWantK);
|
|
1115
|
+
return peers.length > 0 ? peers[0] : undefined;
|
|
1116
|
+
},
|
|
1117
|
+
deliverLocal: (topicId, n) => reactivitySubscribers.deliver(topicId, n),
|
|
1118
|
+
});
|
|
1119
|
+
// Inbound notify frames → forwarder host (subscriber role delivers in-process; forwarder role fans out).
|
|
1120
|
+
// NOTE: the four `register*Handler` helpers below (notify / pushStateGossip / recover /
|
|
1121
|
+
// matchmaking query) all call `node.handle(...)` fire-and-forget (`void`), so a rejected
|
|
1122
|
+
// registration escapes the post-start rollback `catch` as an UNHANDLED rejection instead of
|
|
1123
|
+
// failing node creation. Harmless today — every protocol id here is a fixed constant registered
|
|
1124
|
+
// exactly once, so the only realistic rejection is a duplicate, and that needs a caller to pass
|
|
1125
|
+
// overlapping custom `cohortTopic.host.protocols`. If any of these ids ever becomes
|
|
1126
|
+
// caller-configurable, or a helper grows a registration that can genuinely fail, make them await
|
|
1127
|
+
// their `node.handle` so the failure reaches the rollback.
|
|
1128
|
+
registerNotifyHandler(node, reactivityProtocols.notify, notify);
|
|
1129
|
+
offInboundNotify = notify.onNotification((from, n) => { void forwarderHost.onInbound(from, n); });
|
|
1130
|
+
// 3. Origination emit — install onLocalCommit: a member commit builds a NotificationV1 and ingests it.
|
|
1131
|
+
const origination = new ReactivityOriginationManager({
|
|
1132
|
+
service: host.service,
|
|
1133
|
+
resolveContext: (event) => {
|
|
1134
|
+
if (event.tailId === undefined) {
|
|
1135
|
+
return undefined; // tail-less (read-driven promotion) never originates (the gate also returns first)
|
|
1136
|
+
}
|
|
1137
|
+
return {
|
|
1138
|
+
// MUST reuse the gate's `reactivityTailBytes` (utf8), NOT db-core's double-hashing
|
|
1139
|
+
// blockIdToBytes — else origination derives a different coord than subscribers resolve.
|
|
1140
|
+
tailId: reactivityTailBytes(event.tailId),
|
|
1141
|
+
deltaMaxBytes: reactivityPolicy.deltaMaxBytes,
|
|
1142
|
+
// rotationHint stays undefined on a live node: the successor tail id is not knowable at the
|
|
1143
|
+
// filling commit (random block ids; gated on 6.5-block-id-derivation). The authoritative,
|
|
1144
|
+
// observable rotation signal is `event.tailId` CHANGING, which the manager observes via the
|
|
1145
|
+
// `markRotated` binding below. (The pre-announce remains exercised in the mock-tier harness +
|
|
1146
|
+
// the design simulator, both of which can synthesize the successor id.)
|
|
1147
|
+
};
|
|
1148
|
+
},
|
|
1149
|
+
// reactivityNotificationTopicId(n) = reactivityTopicId(b64urlToBytes(n.tailId)); since
|
|
1150
|
+
// n.tailId = b64url(reactivityTailBytes(tail)), this is the SAME topicId the gate assembled coord_0
|
|
1151
|
+
// around and the subscriber/forwarder verifier derives — closing the encoding loop.
|
|
1152
|
+
emit: (n) => { void forwarderHost.ingest(reactivityNotificationTopicId(n), n); },
|
|
1153
|
+
// Observe-rotation: when a collection's tail id changes between commits the OLD tail's reactivity
|
|
1154
|
+
// topic has rotated. Start its drain so the recover serve begins redirecting to the new tree (the
|
|
1155
|
+
// `reactivity-rotation-recover-redirect-drain` markRotated seam). `oldTopicId` is byte-identical to
|
|
1156
|
+
// the topic a subscriber subscribed under (both `reactivityTopicId(reactivityTailBytes(tail))`).
|
|
1157
|
+
markRotated: (oldTopicId, redirect, now) => forwarderHost.markRotated(oldTopicId, redirect, now),
|
|
1158
|
+
});
|
|
1159
|
+
origination.install();
|
|
1160
|
+
// 4. PushState gossip — periodic intra-cohort convergence so any member (not just the primary) can
|
|
1161
|
+
// serve a replay/backfill. Rides the host's cohort gossip transport (no second transport).
|
|
1162
|
+
pushStateGossip = new ReactivityPushStateGossipDriver({
|
|
1163
|
+
gossipTransport: host.gossipTransport,
|
|
1164
|
+
liveCollections: () => forwarderHost.livePushStates().map((pushState) => ({
|
|
1165
|
+
pushState,
|
|
1166
|
+
cohortCoord: reactivityAddressing.coord0(b64urlToBytes(pushState.topicId)),
|
|
1167
|
+
})),
|
|
1168
|
+
pushStateForGossip: (g) => forwarderHost.pushStateFor(b64urlToBytes(g.topicId)),
|
|
1169
|
+
// Authenticity gate: accept gossip only from a member of the cohort around the frame's reactivity
|
|
1170
|
+
// coord (per-frame peer-sig envelope signing is deferred — reactivity-pushstate-gossip's hardening backlog).
|
|
1171
|
+
isCohortMember: (fromPeerId, g) => fret.assembleCohort(reactivityAddressing.coord0(b64urlToBytes(g.topicId)), cohortWantK).includes(fromPeerId),
|
|
1172
|
+
});
|
|
1173
|
+
registerPushStateGossipHandler(node, reactivityProtocols.pushStateGossip, pushStateGossip);
|
|
1174
|
+
pushStateGossip.start();
|
|
1175
|
+
// 5. Recover RPC — the pull companion to notify (docs/reactivity.md §Backfill RPC / §Resume). A
|
|
1176
|
+
// subscriber that detected a gap, or woke from sleep past the live tail, asks a serving cohort member
|
|
1177
|
+
// "what did I miss?" and is brought current over a real request-reply socket. The SERVE side is live
|
|
1178
|
+
// here: this node answers RecoverRequestV1 frames against its live forwarder PushStates. The OUTBOUND
|
|
1179
|
+
// transport + signers are constructed and exposed for the subscribe factory that CONSTRUCTS managers
|
|
1180
|
+
// (the Quereus Database.watch app-bridge — backlog optimystic-network-reactive-watch-integration-test);
|
|
1181
|
+
// no node-internal manager calls them yet, exactly as the notify subscriber side is constructed against
|
|
1182
|
+
// `reactivitySubscribers` rather than from a watch.
|
|
1183
|
+
//
|
|
1184
|
+
// Node-level sticky cohort-hint cache (keyed by collectionId), shared between the outbound transport's
|
|
1185
|
+
// sticky-primary lookup and a future manager's rotation-invalidation so both see ONE cache. It starts
|
|
1186
|
+
// empty ⇒ the transport falls through to the cohort-walk (any member holding the gossiped PushState
|
|
1187
|
+
// answers); populating the sticky primary is a one-RT optimization, not a correctness need.
|
|
1188
|
+
const reactivityCohortHintCache = createStickyCohortHintCache();
|
|
1189
|
+
// topicId → dialable cohort member peer-id strings: the SAME FRET coord_0 assembly the push-state-gossip
|
|
1190
|
+
// authenticity gate uses (`reactivityAddressing.coord0` → `fret.assembleCohort`), so a recover walk
|
|
1191
|
+
// reaches exactly the cohort that holds the topic's gossiped PushState. `assembleCohort` returns peer-id
|
|
1192
|
+
// strings (the recover dialer's `peerIdFromString` space), matching the notify dial-target space.
|
|
1193
|
+
const resolveReactivityCohort = (topicId) => fret.assembleCohort(reactivityAddressing.coord0(topicId), cohortWantK);
|
|
1194
|
+
// Outbound transport: exposes the db-core BackfillTransport / ResumeTransport seams against this node.
|
|
1195
|
+
// maxBytes is omitted so the dialer + handler default to DEFAULT_STREAM_MAX_BYTES, matching the notify
|
|
1196
|
+
// transport's default (constructed above without an override) — one frame ceiling across the family.
|
|
1197
|
+
const recover = new Libp2pReactivityRecoverTransport({
|
|
1198
|
+
dialer: createLibp2pRecoverDialer(node, reactivityProtocols.recover),
|
|
1199
|
+
selfPeerId,
|
|
1200
|
+
cohortHintCache: reactivityCohortHintCache,
|
|
1201
|
+
resolveCohort: resolveReactivityCohort,
|
|
1202
|
+
});
|
|
1203
|
+
// Inbound serve handler: decode (bounded) → verify the dialing peer's signature → freshness/replay gate →
|
|
1204
|
+
// resolve the live PushState off the forwarder host → serveBackfill/serveResume → reply (no reply on any
|
|
1205
|
+
// failure; the stream aborts and the subscriber walks/chain-reads). One node-level replay guard is shared
|
|
1206
|
+
// across all recover requests — a plain pruned-on-access map, so no new timer to tear down.
|
|
1207
|
+
registerRecoverHandler(node, reactivityProtocols.recover, {
|
|
1208
|
+
pushStateFor: forwarderHost.pushStateFor.bind(forwarderHost),
|
|
1209
|
+
pushStateForCollection: forwarderHost.pushStateForCollection.bind(forwarderHost),
|
|
1210
|
+
replayGuard: createCorrelationReplayGuard(),
|
|
1211
|
+
rotationFor: (req, now) => {
|
|
1212
|
+
// Drain-window redirect: a recover reaching an OLD (rotated, still-draining) tail is bounced to
|
|
1213
|
+
// the new tree (reactivity-rotation-recover-redirect-drain). A resume carries the stale topic
|
|
1214
|
+
// (topicId = reactivityTopicId(latestKnownTailId)); a backfill carries no topic, so resolve the
|
|
1215
|
+
// collection's current served topic. rotationRedirectFor returns the gate's redirect while
|
|
1216
|
+
// draining and undefined once drained (then evicting the gate + the old tail's served PushState).
|
|
1217
|
+
const oldTopicId = req.topicId ?? resolveCurrentServedTopic(forwarderHost, req.collectionId);
|
|
1218
|
+
return oldTopicId === undefined ? undefined : forwarderHost.rotationRedirectFor(oldTopicId, now);
|
|
1219
|
+
},
|
|
1220
|
+
});
|
|
1221
|
+
// The subscriber's synchronous request signers over the node's Ed25519 key (resolves the recover wiring's
|
|
1222
|
+
// lone design point — see recover-transport.ts §createRecoverRequestSigners). Fed to a manager by the
|
|
1223
|
+
// subscribe factory alongside recover.backfillTransport(topicId, collectionId) /
|
|
1224
|
+
// recover.resumeTransport(topicId, collectionId).
|
|
1225
|
+
const recoverSigners = createRecoverRequestSigners(nodePrivateKey);
|
|
1226
|
+
// Expose the recover seams so the subscribe factory wires backfill/resume RPC + signers + the shared
|
|
1227
|
+
// sticky cache (mirrors `reactivitySubscribers` above).
|
|
1228
|
+
node.reactivityRecover = recover;
|
|
1229
|
+
node.reactivityRecoverSigners = recoverSigners;
|
|
1230
|
+
node.reactivityCohortHintCache = reactivityCohortHintCache;
|
|
1231
|
+
// 6. Rotation re-registration scheduler — the host timer that moves a subscriber to the rotated tree
|
|
1232
|
+
// when its manager surfaces a `RotationNotice` (`reactivity-rotation-rereg-scheduler`). Constructed with
|
|
1233
|
+
// the default unref'd `setTimeout` timer so an idle re-registration never pins the process. The
|
|
1234
|
+
// `reRegister(plan)` MOVE belongs to the subscribe factory that CONSTRUCTS managers (the deferred Quereus
|
|
1235
|
+
// `Database.watch` bridge — backlog optimystic-network-reactive-watch-integration-test): on fire it builds
|
|
1236
|
+
// a fresh `ReactivitySubscriptionManager` under `plan.newTopicId` carrying `plan.lastRevision`, registers
|
|
1237
|
+
// it, and swaps the `ReactivitySubscriberRegistry` entry — registering the NEW-topic handler BEFORE
|
|
1238
|
+
// unregistering the old, so a notification mid-swap is never dropped. Until that factory lands no
|
|
1239
|
+
// node-internal manager drives `schedule()`, so this seam is a logged no-op — exactly as 12.33 exposed
|
|
1240
|
+
// `reactivitySubscribers` / `reactivityRecover` without a live manager constructor.
|
|
1241
|
+
reactivityRotation = new RotationReRegistrationScheduler({
|
|
1242
|
+
reRegister: (plan) => {
|
|
1243
|
+
reactivityWiringLog("reactivity rotation re-registration fired for successor topic=%s (lastRevision=%d) but no subscribe factory is wired yet — deferred to optimystic-network-reactive-watch-integration-test", bytesToB64url(plan.newTopicId), plan.lastRevision);
|
|
1244
|
+
return Promise.resolve();
|
|
1245
|
+
},
|
|
1246
|
+
});
|
|
1247
|
+
node.reactivityRotation = reactivityRotation;
|
|
1248
|
+
// --- Matchmaking QueryV1 RPC — cohort serve side (docs/matchmaking.md §Seeker query) ---
|
|
1249
|
+
// The server half of the seeker query transport: a remote seeker dials `/optimystic/matchmaking/1.0.0/query`
|
|
1250
|
+
// and this node answers with its cohort's locally-held provider/seeker registrations, signed by the node
|
|
1251
|
+
// peer key. Matchmaking is layered ABOVE the cohort-topic substrate, so it owns its own protocol family
|
|
1252
|
+
// and is wired here (the composition root) over the host's PUBLIC surface only — mirroring the reactivity
|
|
1253
|
+
// registration above; nothing reaches into host.ts internals. The OUTBOUND seeker walk client is the
|
|
1254
|
+
// prereq follow-on `matchmaking-query-rpc-seeker-walk`; only the serve side is live here.
|
|
1255
|
+
registerMatchmakingQueryHandler(node, matchmakingProtocols.query, {
|
|
1256
|
+
registry: host.registry,
|
|
1257
|
+
// Reuse the reactivity addressing: createTierAddressing(createRingHash()) is byte-identical to the
|
|
1258
|
+
// host's internal addressing for the tier-0 coord (peer- and fanout-independent), and the handler
|
|
1259
|
+
// only ever derives coord_0(topicId).
|
|
1260
|
+
addressing: reactivityAddressing,
|
|
1261
|
+
// Single-member reply signature over the node peer key (same pattern reactivity uses for its signers).
|
|
1262
|
+
sign: async (payload) => bytesToB64url(await signPeer(nodePrivateKey, payload)),
|
|
1263
|
+
// Anti-DoS rate-limit seam (backlog matchmaking-query-rate-limit) intentionally left unwired here:
|
|
1264
|
+
// default-allow. When that ticket lands it passes a `gate: (from, topicId) => boolean` that limits on
|
|
1265
|
+
// the connection's verified `from` peer (NOT the self-asserted query.requesterId).
|
|
1266
|
+
});
|
|
988
1267
|
}
|
|
989
|
-
|
|
1268
|
+
return node;
|
|
1269
|
+
}
|
|
1270
|
+
catch (err) {
|
|
1271
|
+
// Post-start rollback. node.stop() runs whatever teardown wrappers were installed BEFORE the throw
|
|
1272
|
+
// (each wrapper is registered next to the resource it releases, precisely so this unwinds as much as
|
|
1273
|
+
// exists) and closes the transports. A rollback failure must never mask the real startup error, so it
|
|
1274
|
+
// is logged and swallowed; `err` is what the caller sees.
|
|
1275
|
+
try {
|
|
990
1276
|
await node.stop();
|
|
991
|
-
throw err;
|
|
992
1277
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
// the host — so the coord + cohort line up across origination and subscription.
|
|
998
|
-
const selfIsCohortMember = createReactivitySelfMembershipGate({
|
|
999
|
-
fret,
|
|
1000
|
-
selfPeerId: node.peerId.toString(),
|
|
1001
|
-
wantK: cohortWantK,
|
|
1002
|
-
});
|
|
1003
|
-
const { unsubscribe } = attachCohortChangeBridge(node, {
|
|
1004
|
-
source: storageRepo,
|
|
1005
|
-
service: host.service,
|
|
1006
|
-
selfIsCohortMember,
|
|
1007
|
-
extractCommitCert: makeClusterCommitCertExtractor(certStore),
|
|
1008
|
-
});
|
|
1009
|
-
// Expose the host so the reactivity origination wiring (and the activation test) can install
|
|
1010
|
-
// `CohortTopicService.onLocalCommit`.
|
|
1011
|
-
node.cohortTopicHost = host;
|
|
1012
|
-
// --- Reactivity notification transport (origination → fan-out → inbound delivery → push-state gossip) ---
|
|
1013
|
-
// Compose notify + forwarder-host + push-state-gossip onto the cohort-topic host so a committed change
|
|
1014
|
-
// on a tail-cohort member actually reaches subscribers on OTHER nodes over real sockets. The change
|
|
1015
|
-
// bridge above fires `onLocalCommit`; this is what the emitted notifications travel over.
|
|
1016
|
-
// (docs/reactivity.md §Notification origination / §Propagation.) Reactivity reuses the canonical,
|
|
1017
|
-
// network-agnostic protocol IDs, matching the cohort-topic family's production default.
|
|
1018
|
-
const selfPeerId = node.peerId.toString();
|
|
1019
|
-
const reactivityProtocols = DEFAULT_REACTIVITY_PROTOCOLS;
|
|
1020
|
-
const reactivityProfile = host.profile; // Edge ⇒ subscriber-only via the policy gate; Core forwards.
|
|
1021
|
-
const reactivityPolicy = reactivityNodePolicy(reactivityProfile);
|
|
1022
|
-
// db-core default anchor + tier addressing, byte-identical to the host's `new RingHash()`, the
|
|
1023
|
-
// origination gate, and the subscriber-side anchor — so coord_0 derivation lines up everywhere.
|
|
1024
|
-
const reactivityAddressing = createTierAddressing(createRingHash());
|
|
1025
|
-
// Reactivity's forwarder cohort sits at coord_0 — TREE tier 0 (peer-independent), distinct from the
|
|
1026
|
-
// CAPACITY tier T3 the verifier/willingness use. `registry.findServing` keys on the engine's tree
|
|
1027
|
-
// depth, so the served reactivity engine is found at tree tier 0, never at 3.
|
|
1028
|
-
const REACTIVITY_FORWARDER_TREE_TIER = 0;
|
|
1029
|
-
// Node-level subscriber registry: a constructed ReactivitySubscriptionManager registers here so a
|
|
1030
|
-
// socket-delivered NotificationV1 reaches it. (The Quereus Database.watch → manager bridge that
|
|
1031
|
-
// CONSTRUCTS managers stays the backlog item optimystic-network-reactive-watch-integration-test.)
|
|
1032
|
-
const reactivitySubscribers = new ReactivitySubscriberRegistry();
|
|
1033
|
-
node.reactivitySubscribers = reactivitySubscribers;
|
|
1034
|
-
// 1. Notify transport — unicast NotificationV1 send + inbound subscribe. selfPeerId guards self-dials.
|
|
1035
|
-
const notify = new Libp2pReactivityNotifyTransport(node, { selfPeerId });
|
|
1036
|
-
// 2. Forwarder host — turns the forward decision into live fan-out over the notify transport.
|
|
1037
|
-
const forwarderHost = new ReactivityForwarderHost({
|
|
1038
|
-
transport: notify,
|
|
1039
|
-
selfPeerId,
|
|
1040
|
-
profile: reactivityProfile,
|
|
1041
|
-
pushStateInit: (topicId, n) => ({
|
|
1042
|
-
collectionId: n.collectionId,
|
|
1043
|
-
topicId: bytesToB64url(topicId),
|
|
1044
|
-
tailIdAtJoin: n.tailId,
|
|
1045
|
-
deltaMaxBytes: reactivityPolicy.deltaMaxBytes,
|
|
1046
|
-
}),
|
|
1047
|
-
verifierFor: () => createNotificationVerifier({ verifier: host.service.verifier(), tier: Tier.T3 }),
|
|
1048
|
-
directSubscribers: (topicId) => {
|
|
1049
|
-
// Find the served reactivity engine at TREE tier 0 (see REACTIVITY_FORWARDER_TREE_TIER) and read
|
|
1050
|
-
// its direct-subscriber records. The adapter filters to reactivity appState and maps participantId
|
|
1051
|
-
// bytes → dialable peer-id strings (the transport's `peerIdFromString` space) — NOT base64url,
|
|
1052
|
-
// which would silently fail to dial. `undefined` (no subscriber has registered here yet) ⇒ [].
|
|
1053
|
-
const engine = host.registry.findServing(topicId, REACTIVITY_FORWARDER_TREE_TIER);
|
|
1054
|
-
return engine === undefined ? [] : reactivityDirectSubscribers(engine, topicId);
|
|
1055
|
-
},
|
|
1056
|
-
// No childCohorts until cohort-topic-parent-child-link populates PushState.childCohorts (single
|
|
1057
|
-
// tier-0 reach today); wire the resolver anyway. A child cohort's primary is the FRET-nearest member
|
|
1058
|
-
// of its coord, returned as a peer-id string (the dial space).
|
|
1059
|
-
resolveChildPrimary: (ref) => {
|
|
1060
|
-
const peers = fret.assembleCohort(b64urlToBytes(ref.coord), cohortWantK);
|
|
1061
|
-
return peers.length > 0 ? peers[0] : undefined;
|
|
1062
|
-
},
|
|
1063
|
-
deliverLocal: (topicId, n) => reactivitySubscribers.deliver(topicId, n),
|
|
1064
|
-
});
|
|
1065
|
-
// Inbound notify frames → forwarder host (subscriber role delivers in-process; forwarder role fans out).
|
|
1066
|
-
registerNotifyHandler(node, reactivityProtocols.notify, notify);
|
|
1067
|
-
const offInboundNotify = notify.onNotification((from, n) => { void forwarderHost.onInbound(from, n); });
|
|
1068
|
-
// 3. Origination emit — install onLocalCommit: a member commit builds a NotificationV1 and ingests it.
|
|
1069
|
-
const origination = new ReactivityOriginationManager({
|
|
1070
|
-
service: host.service,
|
|
1071
|
-
resolveContext: (event) => {
|
|
1072
|
-
if (event.tailId === undefined) {
|
|
1073
|
-
return undefined; // tail-less (read-driven promotion) never originates (the gate also returns first)
|
|
1074
|
-
}
|
|
1075
|
-
return {
|
|
1076
|
-
// MUST reuse the gate's `reactivityTailBytes` (utf8), NOT db-core's double-hashing
|
|
1077
|
-
// blockIdToBytes — else origination derives a different coord than subscribers resolve.
|
|
1078
|
-
tailId: reactivityTailBytes(event.tailId),
|
|
1079
|
-
deltaMaxBytes: reactivityPolicy.deltaMaxBytes,
|
|
1080
|
-
// rotationHint stays undefined on a live node: the successor tail id is not knowable at the
|
|
1081
|
-
// filling commit (random block ids; gated on 6.5-block-id-derivation). The authoritative,
|
|
1082
|
-
// observable rotation signal is `event.tailId` CHANGING, which the manager observes via the
|
|
1083
|
-
// `markRotated` binding below. (The pre-announce remains exercised in the mock-tier harness +
|
|
1084
|
-
// the design simulator, both of which can synthesize the successor id.)
|
|
1085
|
-
};
|
|
1086
|
-
},
|
|
1087
|
-
// reactivityNotificationTopicId(n) = reactivityTopicId(b64urlToBytes(n.tailId)); since
|
|
1088
|
-
// n.tailId = b64url(reactivityTailBytes(tail)), this is the SAME topicId the gate assembled coord_0
|
|
1089
|
-
// around and the subscriber/forwarder verifier derives — closing the encoding loop.
|
|
1090
|
-
emit: (n) => { void forwarderHost.ingest(reactivityNotificationTopicId(n), n); },
|
|
1091
|
-
// Observe-rotation: when a collection's tail id changes between commits the OLD tail's reactivity
|
|
1092
|
-
// topic has rotated. Start its drain so the recover serve begins redirecting to the new tree (the
|
|
1093
|
-
// `reactivity-rotation-recover-redirect-drain` markRotated seam). `oldTopicId` is byte-identical to
|
|
1094
|
-
// the topic a subscriber subscribed under (both `reactivityTopicId(reactivityTailBytes(tail))`).
|
|
1095
|
-
markRotated: (oldTopicId, redirect, now) => forwarderHost.markRotated(oldTopicId, redirect, now),
|
|
1096
|
-
});
|
|
1097
|
-
origination.install();
|
|
1098
|
-
// 4. PushState gossip — periodic intra-cohort convergence so any member (not just the primary) can
|
|
1099
|
-
// serve a replay/backfill. Rides the host's cohort gossip transport (no second transport).
|
|
1100
|
-
const pushStateGossip = new ReactivityPushStateGossipDriver({
|
|
1101
|
-
gossipTransport: host.gossipTransport,
|
|
1102
|
-
liveCollections: () => forwarderHost.livePushStates().map((pushState) => ({
|
|
1103
|
-
pushState,
|
|
1104
|
-
cohortCoord: reactivityAddressing.coord0(b64urlToBytes(pushState.topicId)),
|
|
1105
|
-
})),
|
|
1106
|
-
pushStateForGossip: (g) => forwarderHost.pushStateFor(b64urlToBytes(g.topicId)),
|
|
1107
|
-
// Authenticity gate: accept gossip only from a member of the cohort around the frame's reactivity
|
|
1108
|
-
// coord (per-frame peer-sig envelope signing is deferred — reactivity-pushstate-gossip's hardening backlog).
|
|
1109
|
-
isCohortMember: (fromPeerId, g) => fret.assembleCohort(reactivityAddressing.coord0(b64urlToBytes(g.topicId)), cohortWantK).includes(fromPeerId),
|
|
1110
|
-
});
|
|
1111
|
-
registerPushStateGossipHandler(node, reactivityProtocols.pushStateGossip, pushStateGossip);
|
|
1112
|
-
pushStateGossip.start();
|
|
1113
|
-
// 5. Recover RPC — the pull companion to notify (docs/reactivity.md §Backfill RPC / §Resume). A
|
|
1114
|
-
// subscriber that detected a gap, or woke from sleep past the live tail, asks a serving cohort member
|
|
1115
|
-
// "what did I miss?" and is brought current over a real request-reply socket. The SERVE side is live
|
|
1116
|
-
// here: this node answers RecoverRequestV1 frames against its live forwarder PushStates. The OUTBOUND
|
|
1117
|
-
// transport + signers are constructed and exposed for the subscribe factory that CONSTRUCTS managers
|
|
1118
|
-
// (the Quereus Database.watch app-bridge — backlog optimystic-network-reactive-watch-integration-test);
|
|
1119
|
-
// no node-internal manager calls them yet, exactly as the notify subscriber side is constructed against
|
|
1120
|
-
// `reactivitySubscribers` rather than from a watch.
|
|
1121
|
-
//
|
|
1122
|
-
// Node-level sticky cohort-hint cache (keyed by collectionId), shared between the outbound transport's
|
|
1123
|
-
// sticky-primary lookup and a future manager's rotation-invalidation so both see ONE cache. It starts
|
|
1124
|
-
// empty ⇒ the transport falls through to the cohort-walk (any member holding the gossiped PushState
|
|
1125
|
-
// answers); populating the sticky primary is a one-RT optimization, not a correctness need.
|
|
1126
|
-
const reactivityCohortHintCache = createStickyCohortHintCache();
|
|
1127
|
-
// topicId → dialable cohort member peer-id strings: the SAME FRET coord_0 assembly the push-state-gossip
|
|
1128
|
-
// authenticity gate uses (`reactivityAddressing.coord0` → `fret.assembleCohort`), so a recover walk
|
|
1129
|
-
// reaches exactly the cohort that holds the topic's gossiped PushState. `assembleCohort` returns peer-id
|
|
1130
|
-
// strings (the recover dialer's `peerIdFromString` space), matching the notify dial-target space.
|
|
1131
|
-
const resolveReactivityCohort = (topicId) => fret.assembleCohort(reactivityAddressing.coord0(topicId), cohortWantK);
|
|
1132
|
-
// Outbound transport: exposes the db-core BackfillTransport / ResumeTransport seams against this node.
|
|
1133
|
-
// maxBytes is omitted so the dialer + handler default to DEFAULT_STREAM_MAX_BYTES, matching the notify
|
|
1134
|
-
// transport's default (constructed above without an override) — one frame ceiling across the family.
|
|
1135
|
-
const recover = new Libp2pReactivityRecoverTransport({
|
|
1136
|
-
dialer: createLibp2pRecoverDialer(node, reactivityProtocols.recover),
|
|
1137
|
-
selfPeerId,
|
|
1138
|
-
cohortHintCache: reactivityCohortHintCache,
|
|
1139
|
-
resolveCohort: resolveReactivityCohort,
|
|
1140
|
-
});
|
|
1141
|
-
// Inbound serve handler: decode (bounded) → verify the dialing peer's signature → freshness/replay gate →
|
|
1142
|
-
// resolve the live PushState off the forwarder host → serveBackfill/serveResume → reply (no reply on any
|
|
1143
|
-
// failure; the stream aborts and the subscriber walks/chain-reads). One node-level replay guard is shared
|
|
1144
|
-
// across all recover requests — a plain pruned-on-access map, so no new timer to tear down.
|
|
1145
|
-
registerRecoverHandler(node, reactivityProtocols.recover, {
|
|
1146
|
-
pushStateFor: forwarderHost.pushStateFor.bind(forwarderHost),
|
|
1147
|
-
pushStateForCollection: forwarderHost.pushStateForCollection.bind(forwarderHost),
|
|
1148
|
-
replayGuard: createCorrelationReplayGuard(),
|
|
1149
|
-
rotationFor: (req, now) => {
|
|
1150
|
-
// Drain-window redirect: a recover reaching an OLD (rotated, still-draining) tail is bounced to
|
|
1151
|
-
// the new tree (reactivity-rotation-recover-redirect-drain). A resume carries the stale topic
|
|
1152
|
-
// (topicId = reactivityTopicId(latestKnownTailId)); a backfill carries no topic, so resolve the
|
|
1153
|
-
// collection's current served topic. rotationRedirectFor returns the gate's redirect while
|
|
1154
|
-
// draining and undefined once drained (then evicting the gate + the old tail's served PushState).
|
|
1155
|
-
const oldTopicId = req.topicId ?? resolveCurrentServedTopic(forwarderHost, req.collectionId);
|
|
1156
|
-
return oldTopicId === undefined ? undefined : forwarderHost.rotationRedirectFor(oldTopicId, now);
|
|
1157
|
-
},
|
|
1158
|
-
});
|
|
1159
|
-
// The subscriber's synchronous request signers over the node's Ed25519 key (resolves the recover wiring's
|
|
1160
|
-
// lone design point — see recover-transport.ts §createRecoverRequestSigners). Fed to a manager by the
|
|
1161
|
-
// subscribe factory alongside recover.backfillTransport(topicId, collectionId) /
|
|
1162
|
-
// recover.resumeTransport(topicId, collectionId).
|
|
1163
|
-
const recoverSigners = createRecoverRequestSigners(nodePrivateKey);
|
|
1164
|
-
// Expose the recover seams so the subscribe factory wires backfill/resume RPC + signers + the shared
|
|
1165
|
-
// sticky cache (mirrors `reactivitySubscribers` above).
|
|
1166
|
-
node.reactivityRecover = recover;
|
|
1167
|
-
node.reactivityRecoverSigners = recoverSigners;
|
|
1168
|
-
node.reactivityCohortHintCache = reactivityCohortHintCache;
|
|
1169
|
-
// 6. Rotation re-registration scheduler — the host timer that moves a subscriber to the rotated tree
|
|
1170
|
-
// when its manager surfaces a `RotationNotice` (`reactivity-rotation-rereg-scheduler`). Constructed with
|
|
1171
|
-
// the default unref'd `setTimeout` timer so an idle re-registration never pins the process. The
|
|
1172
|
-
// `reRegister(plan)` MOVE belongs to the subscribe factory that CONSTRUCTS managers (the deferred Quereus
|
|
1173
|
-
// `Database.watch` bridge — backlog optimystic-network-reactive-watch-integration-test): on fire it builds
|
|
1174
|
-
// a fresh `ReactivitySubscriptionManager` under `plan.newTopicId` carrying `plan.lastRevision`, registers
|
|
1175
|
-
// it, and swaps the `ReactivitySubscriberRegistry` entry — registering the NEW-topic handler BEFORE
|
|
1176
|
-
// unregistering the old, so a notification mid-swap is never dropped. Until that factory lands no
|
|
1177
|
-
// node-internal manager drives `schedule()`, so this seam is a logged no-op — exactly as 12.33 exposed
|
|
1178
|
-
// `reactivitySubscribers` / `reactivityRecover` without a live manager constructor.
|
|
1179
|
-
const reactivityRotation = new RotationReRegistrationScheduler({
|
|
1180
|
-
reRegister: (plan) => {
|
|
1181
|
-
reactivityWiringLog("reactivity rotation re-registration fired for successor topic=%s (lastRevision=%d) but no subscribe factory is wired yet — deferred to optimystic-network-reactive-watch-integration-test", bytesToB64url(plan.newTopicId), plan.lastRevision);
|
|
1182
|
-
return Promise.resolve();
|
|
1183
|
-
},
|
|
1184
|
-
});
|
|
1185
|
-
node.reactivityRotation = reactivityRotation;
|
|
1186
|
-
// --- Matchmaking QueryV1 RPC — cohort serve side (docs/matchmaking.md §Seeker query) ---
|
|
1187
|
-
// The server half of the seeker query transport: a remote seeker dials `/optimystic/matchmaking/1.0.0/query`
|
|
1188
|
-
// and this node answers with its cohort's locally-held provider/seeker registrations, signed by the node
|
|
1189
|
-
// peer key. Matchmaking is layered ABOVE the cohort-topic substrate, so it owns its own protocol family
|
|
1190
|
-
// and is wired here (the composition root) over the host's PUBLIC surface only — mirroring the reactivity
|
|
1191
|
-
// registration above; nothing reaches into host.ts internals. The OUTBOUND seeker walk client is the
|
|
1192
|
-
// prereq follow-on `matchmaking-query-rpc-seeker-walk`; only the serve side is live here.
|
|
1193
|
-
const matchmakingProtocols = DEFAULT_MATCHMAKING_PROTOCOLS;
|
|
1194
|
-
registerMatchmakingQueryHandler(node, matchmakingProtocols.query, {
|
|
1195
|
-
registry: host.registry,
|
|
1196
|
-
// Reuse the reactivity addressing: createTierAddressing(createRingHash()) is byte-identical to the
|
|
1197
|
-
// host's internal addressing for the tier-0 coord (peer- and fanout-independent), and the handler
|
|
1198
|
-
// only ever derives coord_0(topicId).
|
|
1199
|
-
addressing: reactivityAddressing,
|
|
1200
|
-
// Single-member reply signature over the node peer key (same pattern reactivity uses for its signers).
|
|
1201
|
-
sign: async (payload) => bytesToB64url(await signPeer(nodePrivateKey, payload)),
|
|
1202
|
-
// Anti-DoS rate-limit seam (backlog matchmaking-query-rate-limit) intentionally left unwired here:
|
|
1203
|
-
// default-allow. When that ticket lands it passes a `gate: (from, topicId) => boolean` that limits on
|
|
1204
|
-
// the connection's verified `from` peer (NOT the self-asserted query.requesterId).
|
|
1205
|
-
});
|
|
1206
|
-
// Teardown: release reactivity timers + protocol handlers BEFORE host.stop() (which clears the cohort
|
|
1207
|
-
// gossip timer + unhandles the cohort-topic protocols) BEFORE the node's transports close (previousStop).
|
|
1208
|
-
// Composes with the existing arachnode + clusterMember stop wrappers (each calls its captured previousStop last).
|
|
1209
|
-
const previousStop = node.stop.bind(node);
|
|
1210
|
-
node.stop = async () => {
|
|
1211
|
-
try {
|
|
1212
|
-
reactivityRotation.stop();
|
|
1213
|
-
pushStateGossip.stop();
|
|
1214
|
-
offInboundNotify();
|
|
1215
|
-
await node.unhandle(reactivityProtocolList(reactivityProtocols));
|
|
1216
|
-
await node.unhandle(matchmakingProtocolList(matchmakingProtocols));
|
|
1217
|
-
unsubscribe();
|
|
1218
|
-
await host.stop();
|
|
1219
|
-
}
|
|
1220
|
-
finally {
|
|
1221
|
-
await previousStop();
|
|
1222
|
-
}
|
|
1223
|
-
};
|
|
1278
|
+
catch (stopErr) {
|
|
1279
|
+
wiringLog('rollback stop failed after startup error: %o', stopErr);
|
|
1280
|
+
}
|
|
1281
|
+
throw err;
|
|
1224
1282
|
}
|
|
1225
|
-
return node;
|
|
1226
1283
|
}
|
|
1227
1284
|
//# sourceMappingURL=libp2p-node-base.js.map
|