@optimystic/db-p2p 0.16.3 → 0.18.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/block-transfer-service.d.ts +14 -1
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +12 -3
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/cluster-policy.d.ts +112 -0
- package/dist/src/cluster/cluster-policy.d.ts.map +1 -0
- package/dist/src/cluster/cluster-policy.js +88 -0
- package/dist/src/cluster/cluster-policy.js.map +1 -0
- package/dist/src/cluster/cluster-repo.d.ts +41 -13
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +116 -23
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/quorum-restore.d.ts +64 -14
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +0 -0
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/reconcile-block.d.ts +60 -0
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -0
- package/dist/src/cluster/reconcile-block.js +133 -0
- package/dist/src/cluster/reconcile-block.js.map +1 -0
- package/dist/src/cluster/service.d.ts +4 -1
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +9 -1
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +8 -0
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/inbound-authorization.d.ts +117 -0
- package/dist/src/inbound-authorization.d.ts.map +1 -0
- package/dist/src/inbound-authorization.js +149 -0
- package/dist/src/inbound-authorization.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +14 -0
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +54 -4
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +48 -7
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +61 -83
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +21 -3
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +27 -5
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +123 -16
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +354 -49
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/service.d.ts +4 -1
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +9 -1
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +11 -0
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +56 -0
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +155 -13
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/sync/service.d.ts +4 -7
- package/dist/src/sync/service.d.ts.map +1 -1
- package/dist/src/sync/service.js +13 -10
- package/dist/src/sync/service.js.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts +10 -0
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +55 -49
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/package.json +2 -2
- package/{README.md → readme.md} +37 -0
- package/src/cluster/block-transfer-service.ts +20 -5
- package/src/cluster/cluster-policy.ts +152 -0
- package/src/cluster/cluster-repo.ts +121 -26
- package/src/cluster/quorum-restore.ts +0 -0
- package/src/cluster/reconcile-block.ts +191 -0
- package/src/cluster/service.ts +10 -3
- package/src/cluster/spread-on-churn.ts +8 -0
- package/src/inbound-authorization.ts +190 -0
- package/src/index.ts +1 -0
- package/src/libp2p-key-network.ts +54 -4
- package/src/libp2p-node-base.ts +111 -94
- package/src/repo/cluster-coordinator.ts +30 -6
- package/src/repo/coordinator-repo.ts +419 -58
- package/src/repo/service.ts +10 -3
- package/src/storage/block-storage.ts +11 -0
- package/src/storage/storage-repo.ts +172 -15
- package/src/sync/service.ts +14 -12
- package/src/testing/mesh-harness.ts +55 -49
package/src/libp2p-node-base.ts
CHANGED
|
@@ -20,12 +20,13 @@ import { MemoryRawStorage } from './storage/memory-storage.js';
|
|
|
20
20
|
import type { IRawStorage } from './storage/i-raw-storage.js';
|
|
21
21
|
import { seedOwnedBlocksFromStorage } from './owned-block-seed.js';
|
|
22
22
|
import { clusterMember, type ReconcileBlockCallback, type CommitCertificateSink, type DeriveExpectedClusterCallback } from './cluster/cluster-repo.js';
|
|
23
|
-
import {
|
|
23
|
+
import { createReconcileBlock } from './cluster/reconcile-block.js';
|
|
24
|
+
import { resolveClusterPolicy, type ClusterPolicyOptions } from './cluster/cluster-policy.js';
|
|
24
25
|
import { createCommitCertStore, makeClusterCommitCertExtractor, type CommitCertStore } from './cluster/commit-cert.js';
|
|
25
26
|
import { coordinatorRepo } from './repo/coordinator-repo.js';
|
|
26
27
|
import { Libp2pKeyPeerNetwork, type NetworkMode, type NetworkStatePersistence } from './libp2p-key-network.js';
|
|
27
28
|
import { ClusterClient } from './cluster/client.js';
|
|
28
|
-
import type { IRepo, ICluster, ITransactionValidator, BlockId,
|
|
29
|
+
import type { IRepo, ICluster, ITransactionValidator, BlockId, IBlockChangeNotifier } from '@optimystic/db-core';
|
|
29
30
|
import type { ITransactionStateStore } from './cluster/i-transaction-state-store.js';
|
|
30
31
|
import { networkManagerService, type NetworkManagerService } from './network/network-manager-service.js';
|
|
31
32
|
import type { SpreadOnChurnConfig, SpreadOnChurnMonitor } from './cluster/spread-on-churn.js';
|
|
@@ -70,7 +71,6 @@ import {
|
|
|
70
71
|
reactivityNodePolicy,
|
|
71
72
|
createTierAddressing,
|
|
72
73
|
createRingHash,
|
|
73
|
-
DEFAULT_SUPER_MAJORITY_THRESHOLD,
|
|
74
74
|
Tier,
|
|
75
75
|
b64urlToBytes,
|
|
76
76
|
bytesToB64url,
|
|
@@ -85,7 +85,7 @@ import { assertSuperMajorityCoupling } from './cluster/supermajority-coupling.js
|
|
|
85
85
|
import { createLogger } from './logger.js';
|
|
86
86
|
import { PeerReputationService } from './reputation/peer-reputation.js';
|
|
87
87
|
import type { IPeerReputation } from './reputation/types.js';
|
|
88
|
-
import {
|
|
88
|
+
import type { AuthorizeInboundStream, InboundStreamAuthorizationInit } from './inbound-authorization.js';
|
|
89
89
|
import { DisputeService } from './dispute/dispute-service.js';
|
|
90
90
|
import { DisputeClient } from './dispute/client.js';
|
|
91
91
|
import { sampleArbitrators } from './dispute/arbitrator-selection.js';
|
|
@@ -135,7 +135,12 @@ const wiringLog = createLogger('node-wiring');
|
|
|
135
135
|
/** Factory function or instance for creating raw storage */
|
|
136
136
|
export type RawStorageProvider = IRawStorage | (() => IRawStorage);
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
/**
|
|
139
|
+
* `ClusterPolicyOptions` is intersected in, not restated: `resolveClusterPolicy` consumes those
|
|
140
|
+
* fields structurally, so a second copy of the shape here would let a newly added knob compile and
|
|
141
|
+
* be silently ignored. See `cluster/cluster-policy.ts` for what each one resolves to.
|
|
142
|
+
*/
|
|
143
|
+
export type NodeOptions = ClusterPolicyOptions & {
|
|
139
144
|
/**
|
|
140
145
|
* Network port. Only used by the default `listenAddrs` fallback.
|
|
141
146
|
* For non-TCP transports (e.g. WebSockets), set `listenAddrs` explicitly.
|
|
@@ -174,15 +179,20 @@ export type NodeOptions = {
|
|
|
174
179
|
relayServerInit?: CircuitRelayServerInit;
|
|
175
180
|
/** Storage provider - either an IRawStorage instance or a factory function. Defaults to MemoryRawStorage if not provided. */
|
|
176
181
|
storage?: RawStorageProvider;
|
|
177
|
-
clusterSize?: number; // desired cluster size per key
|
|
178
|
-
clusterPolicy?: {
|
|
179
|
-
allowDownsize?: boolean;
|
|
180
|
-
sizeTolerance?: number; // acceptable relative difference (e.g. 0.5 = +/-50%)
|
|
181
|
-
superMajorityThreshold?: number; // fraction of peers needed for super-majority (default: DEFAULT_SUPER_MAJORITY_THRESHOLD = 0.75)
|
|
182
|
-
};
|
|
183
|
-
|
|
184
182
|
/** Override libp2p listen multiaddrs. */
|
|
185
183
|
listenAddrs?: string[];
|
|
184
|
+
/**
|
|
185
|
+
* Multiaddrs to advertise INSTEAD OF the listen addrs. For a node behind a NAT / reverse proxy /
|
|
186
|
+
* DNS front that binds one address but is reachable at another. When non-empty these REPLACE the
|
|
187
|
+
* advertised set entirely — observed/relayed addresses and {@link NodeOptions.appendAnnounceAddrs}
|
|
188
|
+
* are all dropped from it. An empty array means "unset" (libp2p's own semantics).
|
|
189
|
+
*/
|
|
190
|
+
announceAddrs?: string[];
|
|
191
|
+
/**
|
|
192
|
+
* Multiaddrs to advertise IN ADDITION TO the listen addrs. Ignored while
|
|
193
|
+
* {@link NodeOptions.announceAddrs} is non-empty.
|
|
194
|
+
*/
|
|
195
|
+
appendAnnounceAddrs?: string[];
|
|
186
196
|
/** Override libp2p transports. */
|
|
187
197
|
transports?: Libp2pTransports;
|
|
188
198
|
|
|
@@ -275,6 +285,36 @@ export type NodeOptions = {
|
|
|
275
285
|
*/
|
|
276
286
|
privateKey?: PrivateKey;
|
|
277
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Optional predicate deciding whether a remote peer may open one of the four Optimystic
|
|
290
|
+
* database protocols on this node (`repo`, `cluster`, `sync`, `block-transfer`). It is
|
|
291
|
+
* consulted once per inbound stream, before any frame is decoded or any operation executed.
|
|
292
|
+
*
|
|
293
|
+
* This is deliberately ONE node-level option threaded to all four services rather than four
|
|
294
|
+
* per-service options: "is this peer allowed to talk to my database?" is a property of the
|
|
295
|
+
* node, not of the protocol, and four independently-settable options make it easy to secure
|
|
296
|
+
* three surfaces and silently miss the fourth. (Each service still accepts the same option in
|
|
297
|
+
* its own init, so the services stay independently testable and usable outside this factory.)
|
|
298
|
+
*
|
|
299
|
+
* Absent → no check at all, and today's behavior exactly. Supplied → fail-closed: `false`, a
|
|
300
|
+
* throw, a rejection, or a timeout all deny and abort the stream. `remotePeerId` is the
|
|
301
|
+
* dialing peer's `PeerId.toString()`. See {@link AuthorizeInboundStream} and
|
|
302
|
+
* `docs/internals.md` § Inbound Stream Authorization.
|
|
303
|
+
*
|
|
304
|
+
* NOTE: this covers the four database protocols only. The dispute, reactivity, matchmaking,
|
|
305
|
+
* cohort-topic and libp2p built-in (identify/ping/…) protocols this node also registers are
|
|
306
|
+
* NOT gated by it. To refuse a peer at the connection level instead — every protocol at once,
|
|
307
|
+
* including identify — use {@link NodeOptions.connectionGater}.
|
|
308
|
+
*/
|
|
309
|
+
authorizeInboundStream?: AuthorizeInboundStream;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Deadline for {@link NodeOptions.authorizeInboundStream}; expiry denies the stream (a hanging
|
|
313
|
+
* predicate would otherwise pin an inbound stream slot). Defaults to
|
|
314
|
+
* `DEFAULT_INBOUND_AUTHORIZATION_TIMEOUT_MS` (5s). Ignored when no predicate is supplied.
|
|
315
|
+
*/
|
|
316
|
+
authorizeInboundStreamTimeoutMs?: number;
|
|
317
|
+
|
|
278
318
|
/**
|
|
279
319
|
* Optional libp2p connection gater. The libp2p browser default denies
|
|
280
320
|
* dialing insecure WebSockets and private/loopback addresses; callers
|
|
@@ -383,6 +423,19 @@ export async function createLibp2pNodeBase(
|
|
|
383
423
|
}
|
|
384
424
|
};
|
|
385
425
|
|
|
426
|
+
// The ONE authorization slice, spread verbatim into all four database-protocol service inits
|
|
427
|
+
// below. Building it once (rather than repeating two option reads per service) is what makes
|
|
428
|
+
// "secured three surfaces, missed the fourth" impossible: adding a fifth protocol service is a
|
|
429
|
+
// spread of this object, and dropping it from one is visible at the call site.
|
|
430
|
+
// Absent `authorizeInboundStream` → every service constructs its gate as `undefined` and the
|
|
431
|
+
// inbound path is byte-for-byte what it was before this option existed.
|
|
432
|
+
const inboundAuthorization: InboundStreamAuthorizationInit = {
|
|
433
|
+
...(options.authorizeInboundStream ? { authorizeInboundStream: options.authorizeInboundStream } : {}),
|
|
434
|
+
...(options.authorizeInboundStreamTimeoutMs !== undefined
|
|
435
|
+
? { authorizeInboundStreamTimeoutMs: options.authorizeInboundStreamTimeoutMs }
|
|
436
|
+
: {})
|
|
437
|
+
};
|
|
438
|
+
|
|
386
439
|
const nodePrivateKey = options.privateKey ?? await generateKeyPair('Ed25519');
|
|
387
440
|
|
|
388
441
|
const listenAddrs = options.listenAddrs ?? defaults.listenAddrs;
|
|
@@ -408,8 +461,13 @@ export async function createLibp2pNodeBase(
|
|
|
408
461
|
const libp2pOptions: Libp2pInit = {
|
|
409
462
|
start: false,
|
|
410
463
|
privateKey: nodePrivateKey,
|
|
464
|
+
// NOTE: libp2p's `AddressManagerInit` also carries `noAnnounce` and `announceFilter`; neither is
|
|
465
|
+
// exposed on `NodeOptions`. Add them here the same way if a deployment ever needs to suppress a
|
|
466
|
+
// specific advertised address rather than replace the whole set.
|
|
411
467
|
addresses: {
|
|
412
|
-
listen: listenAddrs
|
|
468
|
+
listen: listenAddrs,
|
|
469
|
+
...(options.announceAddrs ? { announce: options.announceAddrs } : {}),
|
|
470
|
+
...(options.appendAnnounceAddrs ? { appendAnnounce: options.appendAnnounceAddrs } : {})
|
|
413
471
|
},
|
|
414
472
|
connectionManager: {
|
|
415
473
|
// `autoDial`, `minConnections`, and `dialQueue` were stale libp2p option keys silently
|
|
@@ -479,7 +537,8 @@ export async function createLibp2pNodeBase(
|
|
|
479
537
|
cluster: (components: any) => {
|
|
480
538
|
const serviceFactory = clusterService({
|
|
481
539
|
protocolPrefix: `/optimystic/${options.networkName}`,
|
|
482
|
-
responsibilityK: options.responsibilityK ?? 1
|
|
540
|
+
responsibilityK: options.responsibilityK ?? 1,
|
|
541
|
+
...inboundAuthorization
|
|
483
542
|
});
|
|
484
543
|
return serviceFactory({
|
|
485
544
|
logger: components.logger,
|
|
@@ -505,7 +564,8 @@ export async function createLibp2pNodeBase(
|
|
|
505
564
|
repo: (components: any) => {
|
|
506
565
|
const serviceFactory = repoService({
|
|
507
566
|
protocolPrefix: `/optimystic/${options.networkName}`,
|
|
508
|
-
responsibilityK: options.responsibilityK ?? 1
|
|
567
|
+
responsibilityK: options.responsibilityK ?? 1,
|
|
568
|
+
...inboundAuthorization
|
|
509
569
|
});
|
|
510
570
|
// RepoService.checkRedirect needs the running node (network manager for the
|
|
511
571
|
// responsible-set computation, self id for the membership check, connection
|
|
@@ -525,7 +585,8 @@ export async function createLibp2pNodeBase(
|
|
|
525
585
|
|
|
526
586
|
sync: (components: any) => {
|
|
527
587
|
const serviceFactory = syncService({
|
|
528
|
-
protocolPrefix: `/optimystic/${options.networkName}
|
|
588
|
+
protocolPrefix: `/optimystic/${options.networkName}`,
|
|
589
|
+
...inboundAuthorization
|
|
529
590
|
});
|
|
530
591
|
return serviceFactory({
|
|
531
592
|
logger: components.logger,
|
|
@@ -539,11 +600,14 @@ export async function createLibp2pNodeBase(
|
|
|
539
600
|
// node's own storage, not be re-routed through the cluster-coordinated repo.
|
|
540
601
|
blockTransfer: (components: any) => {
|
|
541
602
|
const serviceFactory = blockTransferService({
|
|
542
|
-
protocolPrefix: `/optimystic/${options.networkName}
|
|
603
|
+
protocolPrefix: `/optimystic/${options.networkName}`,
|
|
604
|
+
...inboundAuthorization
|
|
543
605
|
});
|
|
544
606
|
return serviceFactory({
|
|
545
607
|
registrar: components.registrar,
|
|
546
|
-
repo: storageRepo
|
|
608
|
+
repo: storageRepo,
|
|
609
|
+
// So this service's authorization denials reach the same error sink as the other three.
|
|
610
|
+
logger: components.logger
|
|
547
611
|
});
|
|
548
612
|
},
|
|
549
613
|
|
|
@@ -636,18 +700,11 @@ export async function createLibp2pNodeBase(
|
|
|
636
700
|
const partitionDetector = new PartitionDetector();
|
|
637
701
|
const fretSvc = (node as any).services?.fret as FretService | undefined;
|
|
638
702
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
|
|
645
|
-
partitionDetectionWindow: 60000,
|
|
646
|
-
// Configured full cluster size — the member's own reference for "full size" in the membership
|
|
647
|
-
// admission gate (a below-full-size declared set under low FRET confidence is refused as a possible
|
|
648
|
-
// self-shrink). Matches the size threaded into the coordinator below.
|
|
649
|
-
clusterSize: options.clusterSize ?? 10
|
|
650
|
-
};
|
|
703
|
+
// Every cluster-policy default lives in `cluster/cluster-policy.ts` — including WHY the admission
|
|
704
|
+
// gate and the repair corroboration floor resolve the one operator field
|
|
705
|
+
// (`clusterPolicy.assumedClusterSize`) to different values when it is absent. Extracted so those
|
|
706
|
+
// defaults can be asserted without booting a node.
|
|
707
|
+
const consensusConfig = resolveClusterPolicy(options);
|
|
651
708
|
|
|
652
709
|
// Fetch a block archive from one cohort peer over the sync protocol, bounded by a
|
|
653
710
|
// per-peer timeout so an unreachable peer can't stall reconciliation. Mirrors the
|
|
@@ -674,69 +731,23 @@ export async function createLibp2pNodeBase(
|
|
|
674
731
|
}
|
|
675
732
|
};
|
|
676
733
|
|
|
677
|
-
// Active reconciliation for a block this member committed without
|
|
678
|
-
// (cohort drift
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
)
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
// NOTE: this quorum is corroboration-of-a-claim, NOT Sybil-resistant cohort
|
|
695
|
-
// membership — deferred to backlog `debt-read-repair-commit-cert-verification`.
|
|
696
|
-
const candidates: { peerIdStr: string; rev: number; actionId: string; block?: IBlock }[] = [];
|
|
697
|
-
for (const { peerIdStr, archive } of fetched) {
|
|
698
|
-
if (!archive) continue;
|
|
699
|
-
const revs = Object.keys(archive.revisions).map(Number);
|
|
700
|
-
if (revs.length === 0) continue;
|
|
701
|
-
const maxRev = Math.max(...revs);
|
|
702
|
-
if (maxRev < committed.rev) continue;
|
|
703
|
-
const data = archive.revisions[maxRev];
|
|
704
|
-
if (!data?.action) continue;
|
|
705
|
-
candidates.push({ peerIdStr, rev: maxRev, actionId: data.action.actionId, block: data.block });
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
const revClaims: RevClaim[] = candidates.map(c => ({ peerId: c.peerIdStr, rev: c.rev, actionId: c.actionId }));
|
|
709
|
-
const selected = selectQuorumRev(revClaims, consensusConfig.simpleMajorityThreshold);
|
|
710
|
-
if (!selected) return; // no rev corroborated by a quorum → leave block, churn/rebalance retries later
|
|
711
|
-
|
|
712
|
-
// Content agreement: among archives corroborating the chosen (rev, actionId)
|
|
713
|
-
// and actually carrying the block, the content must be byte-identical across
|
|
714
|
-
// a quorum. A cohort member serving content that hashes differently is rejected.
|
|
715
|
-
// NOTE: selectQuorumBlock recomputes its quorum over only the block-CARRYING
|
|
716
|
-
// corroborators, not the full rev-responder set. If most peers corroborate the
|
|
717
|
-
// rev but few carry block bytes (e.g. mid-prune), the content quorum can shrink
|
|
718
|
-
// to 2. Harmless with honest peers; if a colluding pair ever becomes the only
|
|
719
|
-
// block-servers for an agreed rev, that is the Sybil regime already deferred to
|
|
720
|
-
// backlog `debt-read-repair-commit-cert-verification`.
|
|
721
|
-
const corroborating = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
|
|
722
|
-
const hashCandidates: BlockHashCandidate[] = await Promise.all(
|
|
723
|
-
corroborating.map(async c => ({ peerId: c.peerIdStr, hash: await canonicalBlockHash(c.block!), block: c.block! }))
|
|
724
|
-
);
|
|
725
|
-
const agreed = selectQuorumBlock(hashCandidates, consensusConfig.simpleMajorityThreshold);
|
|
726
|
-
if (!agreed) return; // no content quorum → skip persist
|
|
727
|
-
|
|
728
|
-
// Best-effort: penalize cohort members that served content contradicting the
|
|
729
|
-
// agreed hash for the same committed (rev, actionId). Never let this throw.
|
|
730
|
-
try {
|
|
731
|
-
for (const c of hashCandidates) {
|
|
732
|
-
if (c.hash !== agreed.hash) {
|
|
733
|
-
reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
} catch { /* reputation write must never block restoration */ }
|
|
737
|
-
|
|
738
|
-
await storageRepo.saveReplicatedBlock(blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev });
|
|
739
|
-
};
|
|
734
|
+
// Active reconciliation for a block this member committed without a materializable base
|
|
735
|
+
// (cohort drift, or a refused `missing-base-revision` commit). See `reconcile-block.ts` for
|
|
736
|
+
// the corroboration rules — in particular why both quorums are capped by how many peers
|
|
737
|
+
// could answer at all, which is what lets a genuinely two-node cohort heal.
|
|
738
|
+
// NOTE: this and the CoordinatorRepo below must cap against the SAME
|
|
739
|
+
// repairCorroborationClusterSize, or the two restoration paths disagree about how much trust a
|
|
740
|
+
// lone peer gets. Safe today because both read the one `resolveClusterPolicy` result above; if
|
|
741
|
+
// either ever resolves its own value, add a fail-fast coupling check like
|
|
742
|
+
// `assertSuperMajorityCoupling` rather than relying on proximity.
|
|
743
|
+
const reconcileBlock: ReconcileBlockCallback = createReconcileBlock({
|
|
744
|
+
selfPeerId: node.peerId.toString(),
|
|
745
|
+
fetchArchive: fetchArchiveFromPeer,
|
|
746
|
+
saveReplicatedBlock: (blockId, block, source) => storageRepo.saveReplicatedBlock(blockId, block, source),
|
|
747
|
+
simpleMajorityThreshold: consensusConfig.simpleMajorityThreshold,
|
|
748
|
+
repairCorroborationClusterSize: consensusConfig.repairCorroborationClusterSize,
|
|
749
|
+
reputation
|
|
750
|
+
});
|
|
740
751
|
|
|
741
752
|
// Member-side membership derivation for the admission gate: independently re-derive this block's
|
|
742
753
|
// responsible cluster from the SAME source the coordinator uses (IKeyNetwork.findCluster), plus FRET's
|
|
@@ -828,7 +839,13 @@ export async function createLibp2pNodeBase(
|
|
|
828
839
|
storageRepo,
|
|
829
840
|
localCluster: clusterImpl,
|
|
830
841
|
localPeerId: node.peerId,
|
|
831
|
-
clusterLatestCallback
|
|
842
|
+
clusterLatestCallback,
|
|
843
|
+
// Read-driven acquisition shares the commit path's reconcile callback verbatim: same bounded
|
|
844
|
+
// archive fetch, same (rev, actionId) and content quorums, same monotonic saveReplicatedBlock
|
|
845
|
+
// funnel. `clusterLatestCallback` alone can only tell the reader WHICH revision the cohort
|
|
846
|
+
// holds; this is what moves the bytes. Only reached once a corroborated revision exists, so a
|
|
847
|
+
// genuinely absent block still costs no archive fetch.
|
|
848
|
+
acquireBlockFromCohort: reconcileBlock
|
|
832
849
|
});
|
|
833
850
|
|
|
834
851
|
// Fail-fast coupling: the cluster member (what accepts a super-majority as sufficient) and the
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { peerIdFromString } from "@libp2p/peer-id";
|
|
2
|
-
import type { ClusterRecord, IKeyNetwork, RepoMessage, BlockId, ClusterPeers, MessageOptions, ClusterConsensusConfig } from "@optimystic/db-core";
|
|
2
|
+
import type { ClusterRecord, IKeyNetwork, RepoMessage, BlockId, ClusterPeers, MessageOptions, ClusterConsensusConfig, ICluster } from "@optimystic/db-core";
|
|
3
3
|
import { CURRENT_MEMBERSHIP_VERSION, computeClusterMessageHash, membershipDigest } from "@optimystic/db-core";
|
|
4
|
-
import { ClusterClient } from "../cluster/client.js";
|
|
5
4
|
import { Pending } from "@optimystic/db-core";
|
|
6
5
|
import type { PeerId } from "@libp2p/interface";
|
|
7
6
|
import { createLogger, verbose } from '../logger.js'
|
|
@@ -13,6 +12,26 @@ import type { ITransactionStateStore } from "../cluster/i-transaction-state-stor
|
|
|
13
12
|
|
|
14
13
|
const log = createLogger('cluster')
|
|
15
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Consensus refused a transaction: enough members voted reject that super-majority became
|
|
17
|
+
* impossible. A typed error (rather than a bare `Error`) so the repo layer above can distinguish
|
|
18
|
+
* "the cluster voted this down" from transport/availability failures WITHOUT string-matching the
|
|
19
|
+
* rejection reasons — those are free-form text that is part of each member's signed vote payload
|
|
20
|
+
* (see cluster-repo's `computeSigningPayload`), so their wording must never become control flow.
|
|
21
|
+
* `CoordinatorRepo.pend` uses this to decide whether a rejection is a retryable stale-revision
|
|
22
|
+
* loss (confirmed against local storage) or a genuine validation fault.
|
|
23
|
+
*/
|
|
24
|
+
export class ValidatorRejectionError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
message: string,
|
|
27
|
+
/** Per-peer reject reasons, verbatim from the vote signatures (free-form, wire-visible). */
|
|
28
|
+
readonly rejectReasons: Record<string, string>
|
|
29
|
+
) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'ValidatorRejectionError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
16
35
|
/** Cancel handle for an injected timer; cancels a not-yet-fired timer (safe no-op after fire/cancel). */
|
|
17
36
|
export type TimerCancel = () => void;
|
|
18
37
|
|
|
@@ -76,7 +95,8 @@ export class ClusterCoordinator {
|
|
|
76
95
|
|
|
77
96
|
constructor(
|
|
78
97
|
private readonly keyNetwork: IKeyNetwork,
|
|
79
|
-
|
|
98
|
+
/** Factory for a per-peer cluster RPC handle; only `update` is ever called, hence `ICluster`. */
|
|
99
|
+
private readonly createClusterClient: (peerId: PeerId) => ICluster,
|
|
80
100
|
private readonly cfg: ClusterConsensusConfig & { clusterSize: number },
|
|
81
101
|
private readonly localCluster?: {
|
|
82
102
|
update: (record: ClusterRecord) => Promise<ClusterRecord>;
|
|
@@ -322,9 +342,11 @@ export class ClusterCoordinator {
|
|
|
322
342
|
// If more than (peerCount - superMajority) nodes reject, we can never reach super-majority
|
|
323
343
|
const maxAllowedRejections = peerCount - superMajority;
|
|
324
344
|
if (rejectionCount > maxAllowedRejections) {
|
|
325
|
-
const
|
|
345
|
+
const rejectReasonsByPeer = Object.fromEntries(Object.entries(promises)
|
|
326
346
|
.filter(([_, sig]) => sig.type === 'reject')
|
|
327
|
-
.map(([peerId, sig]) =>
|
|
347
|
+
.map(([peerId, sig]) => [peerId, sig.rejectReason ?? 'unknown']));
|
|
348
|
+
const rejectReasons = Object.entries(rejectReasonsByPeer)
|
|
349
|
+
.map(([peerId, reason]) => `${peerId}: ${reason}`)
|
|
328
350
|
.join('; ');
|
|
329
351
|
log('cluster-tx:rejected-by-validators', {
|
|
330
352
|
messageHash: record.messageHash,
|
|
@@ -334,7 +356,9 @@ export class ClusterCoordinator {
|
|
|
334
356
|
reasons: rejectReasons
|
|
335
357
|
});
|
|
336
358
|
this.updateTransactionRecord(promised.record, 'rejected-by-validators');
|
|
337
|
-
throw new
|
|
359
|
+
throw new ValidatorRejectionError(
|
|
360
|
+
`Transaction rejected by validators (${rejectionCount}/${peerCount} rejected): ${rejectReasons}`,
|
|
361
|
+
rejectReasonsByPeer);
|
|
338
362
|
}
|
|
339
363
|
|
|
340
364
|
if (peerCount > 1 && approvalCount < superMajority) {
|