@optimystic/db-p2p 0.14.1 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +616 -609
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +4 -3
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/block-transfer.d.ts +52 -3
- package/dist/src/cluster/block-transfer.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer.js +129 -6
- package/dist/src/cluster/block-transfer.js.map +1 -1
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +3 -1
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +182 -11
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +429 -69
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/peer-key-binding.d.ts +25 -0
- package/dist/src/cluster/peer-key-binding.d.ts.map +1 -0
- package/dist/src/cluster/peer-key-binding.js +39 -0
- package/dist/src/cluster/peer-key-binding.js.map +1 -0
- package/dist/src/cluster/quorum-restore.d.ts +72 -0
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -0
- package/dist/src/cluster/quorum-restore.js +0 -0
- package/dist/src/cluster/quorum-restore.js.map +1 -0
- package/dist/src/cluster/rebalance-monitor.d.ts +23 -1
- package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
- package/dist/src/cluster/rebalance-monitor.js +17 -2
- package/dist/src/cluster/rebalance-monitor.js.map +1 -1
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +7 -1
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts +7 -0
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +4 -1
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/cluster/supermajority-coupling.d.ts +22 -0
- package/dist/src/cluster/supermajority-coupling.d.ts.map +1 -0
- package/dist/src/cluster/supermajority-coupling.js +27 -0
- package/dist/src/cluster/supermajority-coupling.js.map +1 -0
- package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +46 -4
- package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -1
- package/dist/src/cohort-topic/cohort-gossip-driver.js +62 -7
- package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -1
- package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +7 -0
- package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -1
- package/dist/src/cohort-topic/cohort-gossip-transport.js +9 -0
- package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -1
- package/dist/src/cohort-topic/host.d.ts +197 -27
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +643 -87
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/dispute/arbitrator-selection.d.ts +68 -7
- package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -1
- package/dist/src/dispute/arbitrator-selection.js +105 -16
- package/dist/src/dispute/arbitrator-selection.js.map +1 -1
- package/dist/src/dispute/cascade.d.ts +7 -0
- package/dist/src/dispute/cascade.d.ts.map +1 -1
- package/dist/src/dispute/cascade.js +1 -1
- package/dist/src/dispute/cascade.js.map +1 -1
- package/dist/src/dispute/client.d.ts.map +1 -1
- package/dist/src/dispute/client.js +3 -2
- package/dist/src/dispute/client.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts +32 -3
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +82 -9
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/dispute/index.d.ts +1 -1
- package/dist/src/dispute/index.d.ts.map +1 -1
- package/dist/src/dispute/index.js +1 -1
- package/dist/src/dispute/index.js.map +1 -1
- package/dist/src/dispute/invalidation.d.ts +25 -0
- package/dist/src/dispute/invalidation.d.ts.map +1 -1
- package/dist/src/dispute/invalidation.js +14 -2
- package/dist/src/dispute/invalidation.js.map +1 -1
- package/dist/src/dispute/service.d.ts.map +1 -1
- package/dist/src/dispute/service.js +7 -1
- package/dist/src/dispute/service.js.map +1 -1
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/it-utility.d.ts +1 -1
- package/dist/src/it-utility.d.ts.map +1 -1
- package/dist/src/it-utility.js +4 -13
- package/dist/src/it-utility.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +98 -1
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +272 -17
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +12 -0
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +415 -87
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/matchmaking/query-transport.d.ts +2 -0
- package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
- package/dist/src/matchmaking/query-transport.js +6 -0
- package/dist/src/matchmaking/query-transport.js.map +1 -1
- package/dist/src/matchmaking/traffic-validation.js +1 -0
- package/dist/src/matchmaking/traffic-validation.js.map +1 -1
- package/dist/src/network/network-manager-service.d.ts +2 -2
- package/dist/src/network/network-manager-service.d.ts.map +1 -1
- package/dist/src/network/network-manager-service.js +8 -4
- package/dist/src/network/network-manager-service.js.map +1 -1
- package/dist/src/owned-block-seed.d.ts +27 -0
- package/dist/src/owned-block-seed.d.ts.map +1 -0
- package/dist/src/owned-block-seed.js +37 -0
- package/dist/src/owned-block-seed.js.map +1 -0
- package/dist/src/protocol-client.d.ts +1 -0
- package/dist/src/protocol-client.d.ts.map +1 -1
- package/dist/src/protocol-client.js +9 -2
- package/dist/src/protocol-client.js.map +1 -1
- package/dist/src/protocol-limits.d.ts +32 -0
- package/dist/src/protocol-limits.d.ts.map +1 -0
- package/dist/src/protocol-limits.js +32 -0
- package/dist/src/protocol-limits.js.map +1 -0
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +3 -0
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +41 -4
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +128 -80
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +38 -1
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +92 -13
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +7 -1
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/reputation/peer-reputation.js +1 -1
- package/dist/src/reputation/peer-reputation.js.map +1 -1
- package/dist/src/reputation/types.d.ts +3 -1
- package/dist/src/reputation/types.d.ts.map +1 -1
- package/dist/src/reputation/types.js +3 -0
- package/dist/src/reputation/types.js.map +1 -1
- package/dist/src/rn.d.ts +5 -1
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +7 -1
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/arachnode-fret-adapter.d.ts +30 -1
- package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
- package/dist/src/storage/arachnode-fret-adapter.js +15 -2
- package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
- package/dist/src/storage/arachnode-partition.d.ts +49 -0
- package/dist/src/storage/arachnode-partition.d.ts.map +1 -0
- package/dist/src/storage/arachnode-partition.js +61 -0
- package/dist/src/storage/arachnode-partition.js.map +1 -0
- package/dist/src/storage/block-storage.d.ts +40 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +150 -57
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +8 -0
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +13 -0
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.d.ts +45 -0
- package/dist/src/storage/kv-raw-storage.d.ts.map +1 -0
- package/dist/src/storage/kv-raw-storage.js +113 -0
- package/dist/src/storage/kv-raw-storage.js.map +1 -0
- package/dist/src/storage/memory-storage.d.ts +20 -58
- package/dist/src/storage/memory-storage.d.ts.map +1 -1
- package/dist/src/storage/memory-storage.js +22 -148
- package/dist/src/storage/memory-storage.js.map +1 -1
- package/dist/src/storage/memory-store-driver.d.ts +39 -0
- package/dist/src/storage/memory-store-driver.d.ts.map +1 -0
- package/dist/src/storage/memory-store-driver.js +129 -0
- package/dist/src/storage/memory-store-driver.js.map +1 -0
- package/dist/src/storage/raw-store-codec.d.ts +10 -0
- package/dist/src/storage/raw-store-codec.d.ts.map +1 -0
- package/dist/src/storage/raw-store-codec.js +36 -0
- package/dist/src/storage/raw-store-codec.js.map +1 -0
- package/dist/src/storage/raw-store-driver.d.ts +69 -0
- package/dist/src/storage/raw-store-driver.d.ts.map +1 -0
- package/dist/src/storage/raw-store-driver.js +2 -0
- package/dist/src/storage/raw-store-driver.js.map +1 -0
- package/dist/src/storage/restoration-coordinator-v2.d.ts.map +1 -1
- package/dist/src/storage/restoration-coordinator-v2.js +1 -1
- package/dist/src/storage/restoration-coordinator-v2.js.map +1 -1
- package/dist/src/storage/restoration-coordinator.d.ts +73 -0
- package/dist/src/storage/restoration-coordinator.d.ts.map +1 -0
- package/dist/src/storage/restoration-coordinator.js +181 -0
- package/dist/src/storage/restoration-coordinator.js.map +1 -0
- package/dist/src/storage/ring-selector.d.ts +92 -5
- package/dist/src/storage/ring-selector.d.ts.map +1 -1
- package/dist/src/storage/ring-selector.js +153 -51
- package/dist/src/storage/ring-selector.js.map +1 -1
- package/dist/src/storage/ring-shift-coordinator.d.ts +128 -0
- package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -0
- package/dist/src/storage/ring-shift-coordinator.js +196 -0
- package/dist/src/storage/ring-shift-coordinator.js.map +1 -0
- package/dist/src/storage/storage-monitor.d.ts +28 -0
- package/dist/src/storage/storage-monitor.d.ts.map +1 -1
- package/dist/src/storage/storage-monitor.js +42 -2
- package/dist/src/storage/storage-monitor.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +19 -0
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +172 -22
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/sync/client.d.ts.map +1 -1
- package/dist/src/sync/client.js +5 -1
- package/dist/src/sync/client.js.map +1 -1
- package/dist/src/sync/service.d.ts.map +1 -1
- package/dist/src/sync/service.js +2 -1
- package/dist/src/sync/service.js.map +1 -1
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts +28 -4
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/cohort-topic-mesh-harness.js +51 -10
- package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
- package/dist/src/testing/index.d.ts +1 -0
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +15 -7
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +24 -0
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -0
- package/dist/src/testing/raw-storage-conformance.js +313 -0
- package/dist/src/testing/raw-storage-conformance.js.map +1 -0
- package/package.json +4 -4
- package/src/cluster/block-transfer-service.ts +4 -3
- package/src/cluster/block-transfer.ts +424 -265
- package/src/cluster/client.ts +3 -1
- package/src/cluster/cluster-repo.ts +481 -71
- package/src/cluster/peer-key-binding.ts +37 -0
- package/src/cluster/quorum-restore.ts +0 -0
- package/src/cluster/rebalance-monitor.ts +34 -3
- package/src/cluster/service.ts +7 -1
- package/src/cluster/spread-on-churn.ts +11 -1
- package/src/cluster/supermajority-coupling.ts +37 -0
- package/src/cohort-topic/cohort-gossip-driver.ts +96 -9
- package/src/cohort-topic/cohort-gossip-transport.ts +10 -0
- package/src/cohort-topic/host.ts +831 -105
- package/src/dispute/arbitrator-selection.ts +138 -28
- package/src/dispute/cascade.ts +8 -1
- package/src/dispute/client.ts +3 -2
- package/src/dispute/dispute-service.ts +94 -9
- package/src/dispute/index.ts +7 -1
- package/src/dispute/invalidation.ts +40 -2
- package/src/dispute/service.ts +7 -1
- package/src/index.ts +46 -43
- package/src/it-utility.ts +5 -13
- package/src/libp2p-key-network.ts +281 -18
- package/src/libp2p-node-base.ts +485 -79
- package/src/matchmaking/query-transport.ts +9 -0
- package/src/network/network-manager-service.ts +8 -2
- package/src/owned-block-seed.ts +40 -0
- package/src/protocol-client.ts +10 -3
- package/src/protocol-limits.ts +33 -0
- package/src/repo/client.ts +3 -0
- package/src/repo/cluster-coordinator.ts +145 -82
- package/src/repo/coordinator-repo.ts +96 -16
- package/src/repo/service.ts +7 -1
- package/src/reputation/types.ts +3 -0
- package/src/rn.ts +7 -1
- package/src/storage/arachnode-fret-adapter.ts +33 -2
- package/src/storage/arachnode-partition.ts +74 -0
- package/src/storage/block-storage.ts +169 -61
- package/src/storage/i-block-storage.ts +9 -0
- package/src/storage/i-raw-storage.ts +14 -0
- package/src/storage/kv-raw-storage.ts +135 -0
- package/src/storage/memory-storage.ts +25 -173
- package/src/storage/memory-store-driver.ts +157 -0
- package/src/storage/raw-store-codec.ts +42 -0
- package/src/storage/raw-store-driver.ts +80 -0
- package/src/storage/{restoration-coordinator-v2.ts → restoration-coordinator.ts} +43 -37
- package/src/storage/ring-selector.ts +317 -176
- package/src/storage/ring-shift-coordinator.ts +271 -0
- package/src/storage/storage-monitor.ts +70 -3
- package/src/storage/storage-repo.ts +710 -558
- package/src/sync/client.ts +5 -1
- package/src/sync/service.ts +2 -1
- package/src/testing/cohort-topic-mesh-harness.ts +61 -11
- package/src/testing/index.ts +1 -0
- package/src/testing/mesh-harness.ts +14 -6
- package/src/testing/raw-storage-conformance.ts +397 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { blockIdsForTransforms } from "@optimystic/db-core";
|
|
1
|
+
import { blockIdsForTransforms, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
|
|
2
|
+
import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clampPriority } from "@optimystic/db-core";
|
|
2
3
|
import { verifyInvalidationCertificate } from "../dispute/invalidation.js";
|
|
3
4
|
import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
|
|
4
5
|
import { ClusterClient } from "./client.js";
|
|
5
6
|
import { peerIdFromString } from "@libp2p/peer-id";
|
|
6
7
|
import { publicKeyFromRaw } from "@libp2p/crypto/keys";
|
|
7
|
-
import {
|
|
8
|
-
import { base58btc } from "multiformats/bases/base58";
|
|
8
|
+
import { peerIdBindsPublicKey } from "./peer-key-binding.js";
|
|
9
9
|
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
10
10
|
import { createLogger } from '../logger.js';
|
|
11
11
|
import { PenaltyReason } from "../reputation/types.js";
|
|
@@ -20,8 +20,10 @@ var TransactionPhase;
|
|
|
20
20
|
TransactionPhase[TransactionPhase["Rejected"] = 4] = "Rejected";
|
|
21
21
|
TransactionPhase[TransactionPhase["Propagating"] = 5] = "Propagating"; // Transaction is being propagated
|
|
22
22
|
})(TransactionPhase || (TransactionPhase = {}));
|
|
23
|
+
/** Stable reject reason a member emits when a declared peer set fails the membership admission gate. */
|
|
24
|
+
export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
|
|
23
25
|
export function clusterMember(components) {
|
|
24
|
-
return new ClusterMember(components.storageRepo, components.peerNetwork, components.peerId, components.privateKey, components.protocolPrefix, components.partitionDetector, components.fretService, components.validator, components.reputation, components.consensusConfig, components.stateStore, components.reconcileBlock, components.onCommitCertificate, components.onInvalidate, components.recomputeArbitratorSet);
|
|
26
|
+
return new ClusterMember(components.storageRepo, components.peerNetwork, components.peerId, components.privateKey, components.protocolPrefix, components.partitionDetector, components.fretService, components.validator, components.reputation, components.consensusConfig, components.stateStore, components.reconcileBlock, components.onCommitCertificate, components.onInvalidate, components.recomputeArbitratorSet, components.deriveExpectedCluster);
|
|
25
27
|
}
|
|
26
28
|
// How long to keep executed transaction records (10 minutes)
|
|
27
29
|
const ExecutedTransactionTtlMs = 10 * 60 * 1000;
|
|
@@ -56,6 +58,7 @@ export class ClusterMember {
|
|
|
56
58
|
onCommitCertificate;
|
|
57
59
|
onInvalidate;
|
|
58
60
|
recomputeArbitratorSet;
|
|
61
|
+
deriveExpectedCluster;
|
|
59
62
|
// Track active transactions by their message hash
|
|
60
63
|
activeTransactions = new Map();
|
|
61
64
|
// Track executed consensus transactions to prevent duplicate execution (messageHash -> executedAt timestamp)
|
|
@@ -72,11 +75,30 @@ export class ClusterMember {
|
|
|
72
75
|
// Interval handles for periodic cleanup (stored so dispose() can clear them)
|
|
73
76
|
expirationInterval;
|
|
74
77
|
cleanupInterval;
|
|
75
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Confidence floor at/below which FRET's network-size view is treated as untrustworthy for the
|
|
80
|
+
* membership gate. Above it the member trusts its derived view (confident path); at/below it the gate
|
|
81
|
+
* fails closed for downsizing. Matches the coordinator's `validateSmallCluster` confidence gate (> 0.5).
|
|
82
|
+
*/
|
|
83
|
+
static MembershipConfidenceThreshold = 0.5;
|
|
84
|
+
/**
|
|
85
|
+
* Effective super-majority threshold this member accepts as sufficient for a commit. Defaults to
|
|
86
|
+
* {@link DEFAULT_SUPER_MAJORITY_THRESHOLD} (0.75) when no config is supplied — the SAME default the
|
|
87
|
+
* coordinator uses, so the two can never silently disagree about whether a transaction is final.
|
|
88
|
+
* (Previously defaulted to 1.0/unanimity, which split the member from a coordinator committing at 0.75.)
|
|
89
|
+
*/
|
|
76
90
|
superMajorityThreshold;
|
|
91
|
+
// Membership admission gate parameters (see {@link admitMembership}). Read once from consensusConfig
|
|
92
|
+
// so the gate has stable thresholds independent of the (untrusted) values a record declares.
|
|
93
|
+
minAbsoluteClusterSize;
|
|
94
|
+
clusterSizeTolerance;
|
|
95
|
+
membershipAdmissionFraction;
|
|
96
|
+
/** Configured full cluster size, or undefined when unknown (then the gate cannot judge a downsize). */
|
|
97
|
+
configuredClusterSize;
|
|
98
|
+
allowUnvalidatedSmallCluster;
|
|
77
99
|
constructor(storageRepo, peerNetwork, peerId, privateKey, protocolPrefix,
|
|
78
100
|
// Reserved for partition-healing (backlog ticket 6.5-partition-healing); held but not yet consumed.
|
|
79
|
-
_partitionDetector, fretService, validator, reputation, consensusConfig, stateStore, reconcileBlock, onCommitCertificate, onInvalidate, recomputeArbitratorSet) {
|
|
101
|
+
_partitionDetector, fretService, validator, reputation, consensusConfig, stateStore, reconcileBlock, onCommitCertificate, onInvalidate, recomputeArbitratorSet, deriveExpectedCluster) {
|
|
80
102
|
this.storageRepo = storageRepo;
|
|
81
103
|
this.peerNetwork = peerNetwork;
|
|
82
104
|
this.peerId = peerId;
|
|
@@ -90,7 +112,13 @@ export class ClusterMember {
|
|
|
90
112
|
this.onCommitCertificate = onCommitCertificate;
|
|
91
113
|
this.onInvalidate = onInvalidate;
|
|
92
114
|
this.recomputeArbitratorSet = recomputeArbitratorSet;
|
|
93
|
-
this.
|
|
115
|
+
this.deriveExpectedCluster = deriveExpectedCluster;
|
|
116
|
+
this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD;
|
|
117
|
+
this.minAbsoluteClusterSize = consensusConfig?.minAbsoluteClusterSize ?? 3;
|
|
118
|
+
this.clusterSizeTolerance = consensusConfig?.clusterSizeTolerance ?? 0.5;
|
|
119
|
+
this.membershipAdmissionFraction = consensusConfig?.membershipAdmissionFraction ?? 0.75;
|
|
120
|
+
this.configuredClusterSize = consensusConfig?.clusterSize;
|
|
121
|
+
this.allowUnvalidatedSmallCluster = consensusConfig?.allowUnvalidatedSmallCluster ?? false;
|
|
94
122
|
// Periodically clean up expired transactions (.unref() so tests/short-lived processes can exit)
|
|
95
123
|
this.expirationInterval = setInterval(() => this.queueExpiredTransactions(), 60000);
|
|
96
124
|
this.expirationInterval.unref();
|
|
@@ -98,6 +126,15 @@ export class ClusterMember {
|
|
|
98
126
|
this.cleanupInterval = setInterval(() => this.processCleanupQueue(), 1000);
|
|
99
127
|
this.cleanupInterval.unref();
|
|
100
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* The resolved super-majority threshold this member runs on. Exposed so the composition root can
|
|
131
|
+
* fail-fast if the member and the coordinator would run different thresholds (see the coupling
|
|
132
|
+
* assertion in `libp2p-node-base.ts`) — a mismatch is a latent phase-disagreement, caught at startup
|
|
133
|
+
* rather than mid-consensus.
|
|
134
|
+
*/
|
|
135
|
+
get effectiveSuperMajorityThreshold() {
|
|
136
|
+
return this.superMajorityThreshold;
|
|
137
|
+
}
|
|
101
138
|
/**
|
|
102
139
|
* Clears all interval and timeout handles and empties active state.
|
|
103
140
|
* Called during node shutdown to prevent leaked timers.
|
|
@@ -227,10 +264,7 @@ export class ClusterMember {
|
|
|
227
264
|
log('cluster-member:action-consensus-after-commit', {
|
|
228
265
|
messageHash: record.messageHash
|
|
229
266
|
});
|
|
230
|
-
|
|
231
|
-
if (!await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
|
|
232
|
-
await this.handleConsensus(currentRecord);
|
|
233
|
-
}
|
|
267
|
+
await this.handleConsensus(currentRecord);
|
|
234
268
|
}
|
|
235
269
|
}
|
|
236
270
|
shouldPersist = false;
|
|
@@ -239,13 +273,7 @@ export class ClusterMember {
|
|
|
239
273
|
log('cluster-member:action-consensus', {
|
|
240
274
|
messageHash: record.messageHash
|
|
241
275
|
});
|
|
242
|
-
|
|
243
|
-
if (await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
|
|
244
|
-
log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
|
|
245
|
-
}
|
|
246
|
-
else {
|
|
247
|
-
await this.handleConsensus(currentRecord);
|
|
248
|
-
}
|
|
276
|
+
await this.handleConsensus(currentRecord);
|
|
249
277
|
// Don't call clearTransaction here - it happens in handleConsensus
|
|
250
278
|
shouldPersist = false;
|
|
251
279
|
break;
|
|
@@ -319,10 +347,33 @@ export class ClusterMember {
|
|
|
319
347
|
if (existing.messageHash !== incoming.messageHash) {
|
|
320
348
|
throw new Error('Message hash mismatch');
|
|
321
349
|
}
|
|
322
|
-
if (
|
|
350
|
+
if (ClusterMember.canonicalJson(existing.message) !== ClusterMember.canonicalJson(incoming.message)) {
|
|
323
351
|
throw new Error('Message content mismatch');
|
|
324
352
|
}
|
|
325
|
-
if (
|
|
353
|
+
if (existing.membershipVersion === 2 || incoming.membershipVersion === 2) {
|
|
354
|
+
// v2: the sorted peer-id set (captured by membershipDigest) is bound into messageHash, so equal
|
|
355
|
+
// messageHash MUST imply equal membership on any honest path. A mismatch here — different digest
|
|
356
|
+
// or version at equal hash — is a protocol violation (a bug or a hash-collision attack), NOT an
|
|
357
|
+
// honest divergence: two honest members with different views now hold two DIFFERENT hashes, i.e.
|
|
358
|
+
// two competing transactions the race machinery resolves, not one contested record. Log loudly
|
|
359
|
+
// and reject; never silently adopt the incoming set. (validateRecord already proved each record's
|
|
360
|
+
// own digest matches its own peers, so multiaddr / pubkey churn within the SAME id set — which
|
|
361
|
+
// keeps the same digest and hash — does NOT trip this.)
|
|
362
|
+
if (existing.membershipVersion !== incoming.membershipVersion || existing.membershipDigest !== incoming.membershipDigest) {
|
|
363
|
+
log('cluster-member:peers-mismatch-invariant-violation', {
|
|
364
|
+
messageHash: existing.messageHash,
|
|
365
|
+
existingVersion: existing.membershipVersion,
|
|
366
|
+
incomingVersion: incoming.membershipVersion,
|
|
367
|
+
existingDigest: existing.membershipDigest,
|
|
368
|
+
incomingDigest: incoming.membershipDigest,
|
|
369
|
+
existingPeers: Object.keys(existing.peers ?? {}).sort(),
|
|
370
|
+
incomingPeers: Object.keys(incoming.peers ?? {}).sort()
|
|
371
|
+
});
|
|
372
|
+
throw new Error('Peers mismatch');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
else if (ClusterMember.canonicalJson(existing.peers) !== ClusterMember.canonicalJson(incoming.peers)) {
|
|
376
|
+
// v1 (legacy, membership unbound): full peer-object equality is the only available guard.
|
|
326
377
|
throw new Error('Peers mismatch');
|
|
327
378
|
}
|
|
328
379
|
// Merge signatures with equivocation detection
|
|
@@ -341,6 +392,10 @@ export class ClusterMember {
|
|
|
341
392
|
* New peers are accepted normally.
|
|
342
393
|
*/
|
|
343
394
|
detectEquivocation(existing, incoming, phase, messageHash) {
|
|
395
|
+
// NOTE: relies on validateSignatures() (via validateRecord in processUpdate) having already run on
|
|
396
|
+
// every record reaching here, so each peerId's signature is key-bound. Without that guarantee the
|
|
397
|
+
// Equivocation penalty below would act on self-asserted, unverified peer ids and could frame an
|
|
398
|
+
// honest peer. Do not call this on unvalidated signatures.
|
|
344
399
|
const merged = { ...existing };
|
|
345
400
|
for (const [peerId, incomingSig] of Object.entries(incoming)) {
|
|
346
401
|
const existingSig = existing[peerId];
|
|
@@ -367,8 +422,25 @@ export class ClusterMember {
|
|
|
367
422
|
return merged;
|
|
368
423
|
}
|
|
369
424
|
async validateRecord(record) {
|
|
370
|
-
//
|
|
371
|
-
|
|
425
|
+
// Reject a record whose membership-binding version this code does not implement. The cluster
|
|
426
|
+
// consensus code is a single deployable unit (all cluster members upgrade together), so a version
|
|
427
|
+
// we don't understand is rejected rather than cross-version-consensus'd.
|
|
428
|
+
if (record.membershipVersion !== undefined && record.membershipVersion !== 1 && record.membershipVersion !== 2) {
|
|
429
|
+
throw new Error(`Unsupported membershipVersion: ${record.membershipVersion}`);
|
|
430
|
+
}
|
|
431
|
+
// v2: the declared membership digest must match the record's own peer set. A record whose declared
|
|
432
|
+
// digest doesn't match its peers is malformed (and its messageHash — computed over that digest —
|
|
433
|
+
// would not bind the real membership).
|
|
434
|
+
// NOTE: recomputes membershipDigest (one SHA256 over the sorted peer-id list) on every incoming v2
|
|
435
|
+
// record; if a hot cluster ever shows this as a cost, memoize per (messageHash → digest).
|
|
436
|
+
if (record.membershipVersion === 2) {
|
|
437
|
+
const expectedDigest = await membershipDigest(record.peers);
|
|
438
|
+
if (expectedDigest !== record.membershipDigest) {
|
|
439
|
+
throw new Error(`Membership digest mismatch: expected=${expectedDigest}, received=${record.membershipDigest ?? 'undefined'}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Validate message hash matches the message content (v2 folds in the membership digest)
|
|
443
|
+
const expectedHash = await this.computeMessageHash(record);
|
|
372
444
|
if (expectedHash !== record.messageHash) {
|
|
373
445
|
throw new Error(`Message hash mismatch: expected=${expectedHash}, received=${record.messageHash}`);
|
|
374
446
|
}
|
|
@@ -380,47 +452,56 @@ export class ClusterMember {
|
|
|
380
452
|
}
|
|
381
453
|
}
|
|
382
454
|
/**
|
|
383
|
-
* Compute message hash using the same algorithm as the coordinator.
|
|
384
|
-
*
|
|
455
|
+
* Compute message hash using the same algorithm as the coordinator. Version-dispatched: a v2 record
|
|
456
|
+
* folds its membership digest into the preimage, a v1 / unversioned record hashes byte-identically to
|
|
457
|
+
* before this change. Must match cluster-coordinator.ts createMessageHash().
|
|
385
458
|
*/
|
|
386
|
-
async computeMessageHash(
|
|
387
|
-
|
|
388
|
-
const hashBytes = await sha256.digest(msgBytes);
|
|
389
|
-
return base58btc.encode(hashBytes.digest);
|
|
459
|
+
async computeMessageHash(record) {
|
|
460
|
+
return computeClusterMessageHash(record.message, recordMembershipDigest(record));
|
|
390
461
|
}
|
|
391
462
|
async validateSignatures(record) {
|
|
392
|
-
// Validate promise signatures
|
|
463
|
+
// Validate promise signatures. Reject on any failure, but only report an InvalidSignature
|
|
464
|
+
// penalty when the key was proven to belong to `peerId` (outcome.penalize) — otherwise the id
|
|
465
|
+
// is attacker-chosen and reporting it would let a coordinator frame an honest peer.
|
|
393
466
|
const promiseHash = await this.computePromiseHash(record);
|
|
394
467
|
for (const [peerId, signature] of Object.entries(record.promises)) {
|
|
395
|
-
|
|
396
|
-
|
|
468
|
+
const outcome = await this.verifySignature(record, peerId, promiseHash, signature);
|
|
469
|
+
if (!outcome.valid) {
|
|
470
|
+
if (outcome.penalize) {
|
|
471
|
+
this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `promise:${record.messageHash}`);
|
|
472
|
+
}
|
|
397
473
|
throw new Error(`Invalid promise signature from ${peerId}`);
|
|
398
474
|
}
|
|
399
475
|
}
|
|
400
476
|
// Validate commit signatures
|
|
401
477
|
const commitHash = await this.computeCommitHash(record);
|
|
402
478
|
for (const [peerId, signature] of Object.entries(record.commits)) {
|
|
403
|
-
|
|
404
|
-
|
|
479
|
+
const outcome = await this.verifySignature(record, peerId, commitHash, signature);
|
|
480
|
+
if (!outcome.valid) {
|
|
481
|
+
if (outcome.penalize) {
|
|
482
|
+
this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `commit:${record.messageHash}`);
|
|
483
|
+
}
|
|
405
484
|
throw new Error(`Invalid commit signature from ${peerId}`);
|
|
406
485
|
}
|
|
407
486
|
}
|
|
408
487
|
}
|
|
409
|
-
/**
|
|
488
|
+
/**
|
|
489
|
+
* Deterministic JSON: sorts object keys so comparisons are order-independent.
|
|
490
|
+
* NOTE: this is a second copy of the canonicalization in db-core `membership.ts` (which feeds the hash
|
|
491
|
+
* preimages). It is used here only for equality checks (message-content / v1-peers compare), and those
|
|
492
|
+
* run *after* a messageHash-equality gate, so a drift between the two can't silently forge agreement —
|
|
493
|
+
* but keep them byte-identical. If a third caller appears, promote to a single exported helper.
|
|
494
|
+
*/
|
|
410
495
|
static canonicalJson(value) {
|
|
411
496
|
return JSON.stringify(value, (_, v) => v && typeof v === 'object' && !Array.isArray(v)
|
|
412
497
|
? Object.keys(v).sort().reduce((o, k) => { o[k] = v[k]; return o; }, {})
|
|
413
498
|
: v);
|
|
414
499
|
}
|
|
415
500
|
async computePromiseHash(record) {
|
|
416
|
-
|
|
417
|
-
const hashBytes = await sha256.digest(msgBytes);
|
|
418
|
-
return uint8ArrayToString(hashBytes.digest, 'base64url');
|
|
501
|
+
return computeClusterPromiseHash(record.messageHash, record.message, recordMembershipDigest(record));
|
|
419
502
|
}
|
|
420
503
|
async computeCommitHash(record) {
|
|
421
|
-
|
|
422
|
-
const hashBytes = await sha256.digest(msgBytes);
|
|
423
|
-
return uint8ArrayToString(hashBytes.digest, 'base64url');
|
|
504
|
+
return computeClusterCommitHash(record.messageHash, record.message, record.promises, recordMembershipDigest(record));
|
|
424
505
|
}
|
|
425
506
|
computeSigningPayload(hash, type, rejectReason) {
|
|
426
507
|
const payload = hash + ':' + type + (rejectReason ? ':' + rejectReason : '');
|
|
@@ -431,17 +512,55 @@ export class ClusterMember {
|
|
|
431
512
|
const sigBytes = await this.privateKey.sign(payload);
|
|
432
513
|
return uint8ArrayToString(sigBytes, 'base64url');
|
|
433
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* Verify one vote signature and classify the outcome (see {@link VerifyOutcome}). Total on hostile
|
|
517
|
+
* input: a missing/empty key, a non-Ed25519 id, a key not bound to `peerId`, or malformed bytes all
|
|
518
|
+
* yield `{ valid:false, penalize:false }` (reject without penalizing an unproven identity) rather
|
|
519
|
+
* than throwing. Only after the key is proven to be the one `peerId` names does a failed
|
|
520
|
+
* cryptographic verify yield `{ valid:false, penalize:true }`.
|
|
521
|
+
*
|
|
522
|
+
* NOTE: the binding check (`peerIdBindsPublicKey`) proves the vote was signed by the key `peerId`
|
|
523
|
+
* names — it does NOT establish that `peerId` is legitimately in the cohort. A coordinator minting
|
|
524
|
+
* fresh keypairs and using each key's own derived id passes this for every one. Sybil/cohort
|
|
525
|
+
* membership is a separate layer (cohort-topic membership certificates), not solved here.
|
|
526
|
+
*/
|
|
434
527
|
async verifySignature(record, peerId, hash, signature) {
|
|
435
528
|
const peerInfo = record.peers[peerId];
|
|
436
529
|
if (!peerInfo?.publicKey?.length) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
530
|
+
// No key to check against — identity not proven. Reject without penalty.
|
|
531
|
+
return { valid: false, penalize: false };
|
|
532
|
+
}
|
|
533
|
+
let keyBytes;
|
|
534
|
+
try {
|
|
535
|
+
// publicKey is base64url-encoded string (JSON-serialization safe)
|
|
536
|
+
keyBytes = uint8ArrayFromString(peerInfo.publicKey, 'base64url');
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return { valid: false, penalize: false };
|
|
540
|
+
}
|
|
541
|
+
// The key must be the one `peerId` provably names, else the vote could be attributed to any peer
|
|
542
|
+
// id while signed by a key the coordinator controls. Binding failure ⇒ identity unproven ⇒ no penalty.
|
|
543
|
+
if (!peerIdBindsPublicKey(peerId, keyBytes)) {
|
|
544
|
+
return { valid: false, penalize: false };
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
const pubKey = publicKeyFromRaw(keyBytes);
|
|
548
|
+
const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
|
|
549
|
+
const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
|
|
550
|
+
const ok = await pubKey.verify(payload, sigBytes);
|
|
551
|
+
// Key is bound to peerId: a failed verify is a genuine bad vote from a proven identity → penalize.
|
|
552
|
+
// NOTE: residual — an Ed25519 peer's public key is derivable from its (public) id, so an attacker
|
|
553
|
+
// can attach a victim's REAL key with a garbage signature and still trip this InvalidSignature
|
|
554
|
+
// penalty on the victim. Binding narrows framing (the attacker must use the victim's own key, not
|
|
555
|
+
// an arbitrary one) but cannot eliminate it here: a single signature can't distinguish "victim
|
|
556
|
+
// signed badly" from "someone pasted the victim's public key + junk". Fully closing it needs an
|
|
557
|
+
// authenticated membership/channel layer (cohort-topic membership certs), out of scope for this fix.
|
|
558
|
+
return ok ? { valid: true } : { valid: false, penalize: true };
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
// Malformed signature bytes / key decode failure: reject, but do not penalize on unparseable input.
|
|
562
|
+
return { valid: false, penalize: false };
|
|
563
|
+
}
|
|
445
564
|
}
|
|
446
565
|
async getTransactionPhase(record) {
|
|
447
566
|
const peerCount = Object.keys(record.peers).length;
|
|
@@ -479,8 +598,11 @@ export class ClusterMember {
|
|
|
479
598
|
return count > total / 2;
|
|
480
599
|
}
|
|
481
600
|
async handlePromiseNeeded(record) {
|
|
482
|
-
//
|
|
483
|
-
|
|
601
|
+
// Membership admission gate runs BEFORE pend validation: a member independently checks the declared
|
|
602
|
+
// peer set is a legitimate cluster it belongs to, and refuses (reject vote) rather than rubber-stamping
|
|
603
|
+
// a set the coordinator chose (e.g. a self-shrunk minority-partition set). On admission failure we skip
|
|
604
|
+
// pend validation entirely and emit the membership rejection.
|
|
605
|
+
const validationResult = await this.evaluatePromise(record);
|
|
484
606
|
const promiseHash = await this.computePromiseHash(record);
|
|
485
607
|
const type = validationResult.valid ? 'approve' : 'reject';
|
|
486
608
|
const rejectReason = validationResult.valid ? undefined : validationResult.reason;
|
|
@@ -502,6 +624,154 @@ export class ClusterMember {
|
|
|
502
624
|
}
|
|
503
625
|
};
|
|
504
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* The full promise-phase decision for a record: admit the declared membership FIRST, then (only if
|
|
629
|
+
* admitted) validate its pend operations. Failing either yields a `{ valid:false, reason }` the caller
|
|
630
|
+
* turns into a `reject` vote. Splitting membership from pend validation keeps the reason strings
|
|
631
|
+
* distinct — a `membership-not-admitted` reject is a different signal (feeds the dispute path) than a
|
|
632
|
+
* stale-revision / custom-validator reject.
|
|
633
|
+
*/
|
|
634
|
+
async evaluatePromise(record) {
|
|
635
|
+
const admission = await this.admitMembership(record);
|
|
636
|
+
if (!admission.admit) {
|
|
637
|
+
return { valid: false, reason: admission.reason ?? MEMBERSHIP_NOT_ADMITTED };
|
|
638
|
+
}
|
|
639
|
+
return await this.validatePendOperations(record);
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Membership admission gate. Decides whether the coordinator-declared peer set (`record.peers`, call it
|
|
643
|
+
* `D`) is a *legitimate* cluster this member may vote inside, judged against the member's OWN
|
|
644
|
+
* independently-derived view — not against anything the (untrusted) record declares about its size.
|
|
645
|
+
* Evaluated on the promise path before the member signs an approve.
|
|
646
|
+
*
|
|
647
|
+
* The predicate admits `D` iff ALL hold:
|
|
648
|
+
* 1. **Self-membership** — this member's id ∈ `D`; else this block is not its responsibility (and a
|
|
649
|
+
* coordinator must not route a record to a non-member to pad approval counts).
|
|
650
|
+
* 2. **Not a self-shrink below the floor** — with a confident derived view `E`, `|D| ≥ ⌈fraction·|E|⌉`
|
|
651
|
+
* (and ≥ minAbsoluteClusterSize). `|E|` is the member's own confident cluster-size estimate `K_est`,
|
|
652
|
+
* so a minority-partition set (small `D`) is rejected against the member's larger view.
|
|
653
|
+
* 3. **Consistency with the derived view** — `|D △ E|` within `clusterSizeTolerance·|E|`; honest churn
|
|
654
|
+
* of a peer or two is absorbed, a wholesale-disjoint or half-size set is not.
|
|
655
|
+
*
|
|
656
|
+
* **Fail-closed posture.** When the member cannot confidently derive `E` (no capability, low FRET
|
|
657
|
+
* confidence — exactly what a partition induces), it must refuse any *downsizing* decision: a
|
|
658
|
+
* below-full-size `D` is rejected against the configured full `clusterSize`. With NEITHER a confident
|
|
659
|
+
* view NOR a configured full size the gate cannot judge a downsize at all, so it preserves the legacy
|
|
660
|
+
* approve behavior (backward-compatible for nodes/tests with no derivation wired). `allowUnvalidatedSmallCluster`
|
|
661
|
+
* is the explicit opt-in (single-node / local dev knowingly below the safe floor), matching the
|
|
662
|
+
* coordinator's `validateSmallCluster` semantics.
|
|
663
|
+
*/
|
|
664
|
+
async admitMembership(record) {
|
|
665
|
+
const ourId = this.peerId.toString();
|
|
666
|
+
const declared = Object.keys(record.peers ?? {});
|
|
667
|
+
// Predicate 1: self-membership. Always enforced (independent of any opt-in): a member does not vote
|
|
668
|
+
// in a cluster it is not part of.
|
|
669
|
+
if (!declared.includes(ourId)) {
|
|
670
|
+
log('cluster-member:admission-reject', { messageHash: record.messageHash, reason: 'self-not-member', declaredSize: declared.length });
|
|
671
|
+
return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:self-not-member` };
|
|
672
|
+
}
|
|
673
|
+
// Explicit opt-in: knowingly transact below the safe floor (single-node / local dev). Skips the
|
|
674
|
+
// size/consistency gates but not self-membership above.
|
|
675
|
+
if (this.allowUnvalidatedSmallCluster) {
|
|
676
|
+
return { admit: true };
|
|
677
|
+
}
|
|
678
|
+
const derived = await this.deriveExpectedClusterView(record);
|
|
679
|
+
// An empty derived view (kEst === 0) carries no usable reference set: measured against it every
|
|
680
|
+
// non-empty declared set is wholly "inconsistent" (maxDiff = ceil(tol·0) = 0), which would spuriously
|
|
681
|
+
// reject a legitimate full cluster — a stricter, worse outcome than an absent view. Treat empty as
|
|
682
|
+
// not-confident so it takes the fail-closed-or-legacy branch below instead. (Not normally reachable:
|
|
683
|
+
// a responsible member's findCluster includes at least itself; this guards a transient empty read.)
|
|
684
|
+
const derivedSize = derived !== undefined ? Object.keys(derived.peers ?? {}).length : 0;
|
|
685
|
+
const confident = derived !== undefined
|
|
686
|
+
&& derived.confidence > ClusterMember.MembershipConfidenceThreshold
|
|
687
|
+
&& derivedSize > 0;
|
|
688
|
+
if (!confident) {
|
|
689
|
+
// Fail closed for downsizing under low/absent confidence. A full-size (or larger) declared set is
|
|
690
|
+
// still admitted — there is nothing to shrink. Without a configured full-size reference we cannot
|
|
691
|
+
// tell a downsize from a legitimate small cluster, so we preserve legacy approve behavior.
|
|
692
|
+
if (this.configuredClusterSize === undefined) {
|
|
693
|
+
return { admit: true };
|
|
694
|
+
}
|
|
695
|
+
if (declared.length >= this.configuredClusterSize) {
|
|
696
|
+
return { admit: true };
|
|
697
|
+
}
|
|
698
|
+
log('cluster-member:admission-reject', {
|
|
699
|
+
messageHash: record.messageHash,
|
|
700
|
+
reason: 'low-confidence-downsize',
|
|
701
|
+
declaredSize: declared.length,
|
|
702
|
+
configuredClusterSize: this.configuredClusterSize,
|
|
703
|
+
confidence: derived?.confidence
|
|
704
|
+
});
|
|
705
|
+
return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:low-confidence-downsize` };
|
|
706
|
+
}
|
|
707
|
+
const expected = Object.keys(derived.peers ?? {});
|
|
708
|
+
const kEst = expected.length;
|
|
709
|
+
// Predicate 2: floor derived from the member's OWN confident estimate.
|
|
710
|
+
const floor = Math.max(this.minAbsoluteClusterSize, Math.ceil(this.membershipAdmissionFraction * kEst));
|
|
711
|
+
if (declared.length < floor) {
|
|
712
|
+
log('cluster-member:admission-reject', {
|
|
713
|
+
messageHash: record.messageHash,
|
|
714
|
+
reason: 'below-floor',
|
|
715
|
+
declaredSize: declared.length,
|
|
716
|
+
floor,
|
|
717
|
+
kEst
|
|
718
|
+
});
|
|
719
|
+
return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:below-floor` };
|
|
720
|
+
}
|
|
721
|
+
// Predicate 3: consistency with the derived view within tolerance.
|
|
722
|
+
const symmetricDiff = ClusterMember.symmetricDifferenceSize(declared, expected);
|
|
723
|
+
const maxDiff = Math.ceil(this.clusterSizeTolerance * kEst);
|
|
724
|
+
if (symmetricDiff > maxDiff) {
|
|
725
|
+
log('cluster-member:admission-reject', {
|
|
726
|
+
messageHash: record.messageHash,
|
|
727
|
+
reason: 'inconsistent-with-derived-view',
|
|
728
|
+
declaredSize: declared.length,
|
|
729
|
+
kEst,
|
|
730
|
+
symmetricDiff,
|
|
731
|
+
maxDiff
|
|
732
|
+
});
|
|
733
|
+
return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:inconsistent-with-derived-view` };
|
|
734
|
+
}
|
|
735
|
+
return { admit: true };
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Derive this member's own view of the record's block cluster via the injected capability, or
|
|
739
|
+
* `undefined` when it cannot (no capability, no coordinating block id, or a derivation error — all of
|
|
740
|
+
* which the gate treats as "not confident"). Derived from the record's coordinating block, the same key
|
|
741
|
+
* the coordinator used to select the cluster.
|
|
742
|
+
*/
|
|
743
|
+
async deriveExpectedClusterView(record) {
|
|
744
|
+
if (!this.deriveExpectedCluster) {
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
const blockId = record.coordinatingBlockIds?.[0];
|
|
748
|
+
if (blockId === undefined) {
|
|
749
|
+
return undefined;
|
|
750
|
+
}
|
|
751
|
+
try {
|
|
752
|
+
// NOTE: derives (findCluster) once per inbound record on the promise path — one routing lookup
|
|
753
|
+
// per vote. If this shows up as hot, cache the derived view per (blockId, short TTL): it is a
|
|
754
|
+
// pure read of current topology, so a few-seconds-stale view is safe for admission.
|
|
755
|
+
return await this.deriveExpectedCluster(blockId);
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
log('cluster-member:derive-expected-cluster-error', { messageHash: record.messageHash, error: err.message });
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/** |A △ B| over two id lists (order-independent set symmetric difference). */
|
|
763
|
+
static symmetricDifferenceSize(a, b) {
|
|
764
|
+
const setA = new Set(a);
|
|
765
|
+
const setB = new Set(b);
|
|
766
|
+
let count = 0;
|
|
767
|
+
for (const x of setA)
|
|
768
|
+
if (!setB.has(x))
|
|
769
|
+
count++;
|
|
770
|
+
for (const x of setB)
|
|
771
|
+
if (!setA.has(x))
|
|
772
|
+
count++;
|
|
773
|
+
return count;
|
|
774
|
+
}
|
|
505
775
|
/**
|
|
506
776
|
* Validates pend operations in a cluster record using the transaction validator.
|
|
507
777
|
* Also checks for stale revisions to prevent consensus on operations that would fail.
|
|
@@ -566,9 +836,12 @@ export class ClusterMember {
|
|
|
566
836
|
* @warning This method executes on ALL cluster peers, not just the coordinator.
|
|
567
837
|
* Each peer independently applies the operations to its local storage.
|
|
568
838
|
*
|
|
569
|
-
* @pitfall **Check-then-act race** -
|
|
570
|
-
* (before any `await`) to prevent duplicate execution
|
|
571
|
-
* nature makes synchronous check-and-set atomic.
|
|
839
|
+
* @pitfall **Check-then-act race** - The in-memory guard must be checked AND set
|
|
840
|
+
* atomically (before any `await`) to prevent duplicate execution; JavaScript's
|
|
841
|
+
* single-threaded nature makes that synchronous check-and-set atomic. The durable
|
|
842
|
+
* marker, by contrast, is persisted only *after* apply succeeds — writing it eagerly
|
|
843
|
+
* would leave a stuck marker on a caught fault or a crash mid-apply, silently dropping
|
|
844
|
+
* the transaction on this member on redelivery.
|
|
572
845
|
*
|
|
573
846
|
* @pitfall **Independent node storage** - Each node has its own storage. After consensus,
|
|
574
847
|
* each node applies operations locally. Nodes must fetch missing blocks from cluster
|
|
@@ -577,31 +850,51 @@ export class ClusterMember {
|
|
|
577
850
|
* @see docs/internals.md "Check-Then-Act Race in Consensus" and "Independent Node Storage" pitfalls
|
|
578
851
|
*/
|
|
579
852
|
async handleConsensus(record) {
|
|
853
|
+
// Check persistent store first for post-recovery dedup (in-memory map is cleared on restart).
|
|
854
|
+
// wasTransactionExecutedAsync also checks the in-memory map as a fast path.
|
|
855
|
+
if (await this.wasTransactionExecutedAsync(record.messageHash)) {
|
|
856
|
+
log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
580
859
|
// Check-and-set ATOMICALLY to prevent race condition where multiple calls
|
|
581
|
-
// pass the check before any completes. Since JavaScript is single-threaded,
|
|
860
|
+
// pass the async check before any completes. Since JavaScript is single-threaded,
|
|
582
861
|
// this synchronous check-and-set is atomic before any await.
|
|
583
862
|
if (this.executedTransactions.has(record.messageHash)) {
|
|
584
863
|
log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
|
|
585
864
|
return;
|
|
586
865
|
}
|
|
587
|
-
//
|
|
866
|
+
// Set the in-memory guard IMMEDIATELY, before any async operations: its synchronous
|
|
867
|
+
// check-and-set (line above's `has` + this `set`) is what prevents the concurrent
|
|
868
|
+
// apply-window race where two handleConsensus calls for the same hash both pass the
|
|
869
|
+
// async check. The durable marker is deliberately NOT written here — see below.
|
|
588
870
|
const executedAt = Date.now();
|
|
589
871
|
this.executedTransactions.set(record.messageHash, executedAt);
|
|
590
|
-
this.stateStore?.markExecuted(record.messageHash, executedAt)
|
|
591
|
-
.catch(err => log('cluster-member:persist-executed-error', { messageHash: record.messageHash, error: err.message }));
|
|
592
872
|
try {
|
|
593
873
|
for (const operation of record.message.operations) {
|
|
594
874
|
await this.applyConsensusOperation(record, operation);
|
|
595
875
|
}
|
|
596
876
|
}
|
|
597
877
|
catch (err) {
|
|
598
|
-
// A genuinely unexpected fault (e.g. storage I/O) — roll back the
|
|
878
|
+
// A genuinely unexpected fault (e.g. storage I/O) — roll back the in-memory
|
|
599
879
|
// marker so a corrected retry can re-run, and propagate so the caller learns
|
|
600
|
-
// the real cause.
|
|
601
|
-
//
|
|
880
|
+
// the real cause. The durable marker was never written (it lands only after
|
|
881
|
+
// apply succeeds, below), so there is nothing to roll back. Recoverable local
|
|
882
|
+
// divergence is absorbed inside applyConsensusOperation and never reaches here.
|
|
602
883
|
this.executedTransactions.delete(record.messageHash);
|
|
603
884
|
throw err;
|
|
604
885
|
}
|
|
886
|
+
// Persist the durable marker only now that apply has actually succeeded. Writing it
|
|
887
|
+
// eagerly (before the loop) would leave a stuck marker on a caught fault OR a crash
|
|
888
|
+
// mid-apply, and on redelivery handleConsensus short-circuits at the async
|
|
889
|
+
// wasTransactionExecuted check — silently dropping the transaction on this member
|
|
890
|
+
// forever. The durable marker exists only for post-restart dedup (the in-memory map
|
|
891
|
+
// is empty after restart), and the narrow window between "apply succeeded" and
|
|
892
|
+
// "durable write landed" is safe to re-run on restart: re-applying an
|
|
893
|
+
// already-applied consensus transaction is idempotent (the "ahead" divergence path
|
|
894
|
+
// in applyConsensusOperation tolerates it as a no-op), so it converges rather than
|
|
895
|
+
// dropping. Fire-and-forget: a persist failure must not fail the apply that succeeded.
|
|
896
|
+
this.stateStore?.markExecuted(record.messageHash, executedAt)
|
|
897
|
+
.catch(err => log('cluster-member:persist-executed-error', { messageHash: record.messageHash, error: err.message }));
|
|
605
898
|
}
|
|
606
899
|
/**
|
|
607
900
|
* Applies one consensus-approved operation to local storage.
|
|
@@ -940,22 +1233,89 @@ export class ClusterMember {
|
|
|
940
1233
|
return false; // No blocking conflicts
|
|
941
1234
|
}
|
|
942
1235
|
/**
|
|
943
|
-
* Resolve race between two conflicting transactions.
|
|
944
|
-
*
|
|
1236
|
+
* Resolve a race between two conflicting transactions. Total and deterministic, so every honest
|
|
1237
|
+
* member computes the identical winner (the Theorem 1 Case-2 premise). Order:
|
|
1238
|
+
* 1. more promise signatures wins (progress monotonicity — see safety note below);
|
|
1239
|
+
* 2. equal promise counts → higher aged priority wins (fairness — see {@link recordPriority});
|
|
1240
|
+
* 3. still tied → higher message hash wins.
|
|
1241
|
+
*
|
|
1242
|
+
* Promise count is FIRST so this comparison never displaces a transaction that is further along.
|
|
1243
|
+
* That restores the pre-priority safety invariant: a member commits purely on promise supermajority
|
|
1244
|
+
* (`handleCommitNeeded` signs whenever `approvedPromises >= superMajority`; the commit path has NO
|
|
1245
|
+
* conflict re-check), so `resolveRace` is the ONLY arbiter among concurrently-pending conflicts.
|
|
1246
|
+
* With promises-first, once transaction X holds a promise supermajority every conflicting rival Y has
|
|
1247
|
+
* strictly fewer promises — Y can only match X's count by getting the intersecting quorum member to
|
|
1248
|
+
* promise it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
|
|
1249
|
+
* `keep-existing` on X's higher count, so it never does. By quorum intersection any Y-supermajority
|
|
1250
|
+
* overlaps X's in ≥1 honest member, and that member rejects Y. One winner (docs/correctness.md
|
|
1251
|
+
* Theorem 9). Priority-first would break this: it could displace an already-quorum-reached X for a
|
|
1252
|
+
* higher-priority Y with fewer promises, letting BOTH commit (split brain) — the regression fixed by
|
|
1253
|
+
* ticket occ-priority-first-breaks-promise-monotonicity.
|
|
1254
|
+
*
|
|
1255
|
+
* Priority is now a tie-break that runs only at EQUAL promise counts, which is exactly the
|
|
1256
|
+
* concurrent-starvation case aging targets (two fresh rivals, 0 promises each, otherwise coin-flipping
|
|
1257
|
+
* on the hash). Priority still breaks those ties deterministically, so aging still solves the stated
|
|
1258
|
+
* fairness problem in its common case. It only orders two *concurrently-pending* conflicts; it does NOT
|
|
1259
|
+
* defer a fresh pend for an absent aged transaction (that residual — sequential sub-window starvation —
|
|
1260
|
+
* is the deferred feat-occ-priority-reservation).
|
|
1261
|
+
*
|
|
1262
|
+
* NOTE: residual-fairness tripwire. Under promises-first an aged transaction can still lose to a fresh
|
|
1263
|
+
* rival that has *legitimately* gathered even one more promise — that is not the pure-coin-flip
|
|
1264
|
+
* starvation aging targets (equal counts, priority wins), it is the monotonicity behaviour we WANT (a
|
|
1265
|
+
* more-progressed rival is never displaced). If deeper fairness against a genuinely-more-progressed
|
|
1266
|
+
* rival is ever needed, it belongs to feat-occ-priority-reservation (reserve/defer at pend time), NOT
|
|
1267
|
+
* to this race tie-break.
|
|
1268
|
+
*
|
|
1269
|
+
* NOTE: Byzantine self-assert is a fairness DoS, not a safety hole. A coordinator can stamp
|
|
1270
|
+
* priority == MaxPriority on every transaction; recordPriority clamps to the cap so it cannot
|
|
1271
|
+
* exceed it, and priority never influences validity/operationsHash/stale-read checks — and now sits
|
|
1272
|
+
* below the promise count, so it can only break equal-count ties it might have ~50% won anyway,
|
|
1273
|
+
* degrading to at-worst-status-quo fairness (the same graceful-degradation class as spam under
|
|
1274
|
+
* honest-majority). Binding priority to provable age is out of scope (feat-occ-priority-reservation).
|
|
1275
|
+
*
|
|
1276
|
+
* NOTE: keep priority a self-contained additive message field + this one comparison key so it
|
|
1277
|
+
* composes with — does not block — a future HLC/crdt-sync redesign of this same path
|
|
1278
|
+
* (design-hot-log-tail-sharding-guidance).
|
|
945
1279
|
*/
|
|
946
1280
|
resolveRace(existing, incoming) {
|
|
1281
|
+
// 1. Transaction with more promises wins — never displace a more-progressed rival (safety, see above).
|
|
947
1282
|
const existingCount = Object.keys(existing.promises).length;
|
|
948
1283
|
const incomingCount = Object.keys(incoming.promises).length;
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
return 'keep-existing';
|
|
1284
|
+
if (existingCount !== incomingCount) {
|
|
1285
|
+
return existingCount > incomingCount ? 'keep-existing' : 'accept-incoming';
|
|
952
1286
|
}
|
|
953
|
-
|
|
954
|
-
|
|
1287
|
+
// 2. Equal promise counts → higher aged priority wins (fairness tie-break).
|
|
1288
|
+
const existingPriority = this.recordPriority(existing);
|
|
1289
|
+
const incomingPriority = this.recordPriority(incoming);
|
|
1290
|
+
if (existingPriority !== incomingPriority) {
|
|
1291
|
+
return existingPriority > incomingPriority ? 'keep-existing' : 'accept-incoming';
|
|
955
1292
|
}
|
|
956
|
-
// Tie-breaker: higher message hash wins (deterministic)
|
|
1293
|
+
// 3. Tie-breaker: higher message hash wins (deterministic).
|
|
957
1294
|
return existing.messageHash > incoming.messageHash ? 'keep-existing' : 'accept-incoming';
|
|
958
1295
|
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Aged advisory priority carried by a record's pend operation, clamped to [0, MaxPriority].
|
|
1298
|
+
* The multi-collection path carries it on `pend.transaction.priority`; the single-collection
|
|
1299
|
+
* (`Collection.sync`) path carries it as top-level `pend.priority`; a record with neither — a
|
|
1300
|
+
* legacy/unversioned coordinator's transaction, or a non-pend operation — is priority 0
|
|
1301
|
+
* (backward compatible: such transactions simply never age). Both carriers live inside the signed
|
|
1302
|
+
* `message`, so priority is integrity-protected in transit; clamping here bounds a self-asserted
|
|
1303
|
+
* out-of-range value to the cap.
|
|
1304
|
+
*
|
|
1305
|
+
* NOTE: `message` is fixed for a transaction's whole lifecycle (promises/commits accrue in the
|
|
1306
|
+
* separate `promises`/`commits` maps, never in `message`), so a transaction keeps its rank through
|
|
1307
|
+
* the commit phase — there is no "priority drops to 0 at commit" asymmetry. resolveRace is only
|
|
1308
|
+
* consulted at the promise decision (hasConflict), i.e. between two still-open conflicting
|
|
1309
|
+
* transactions, which is exactly the concurrent-contention case priority is meant to order.
|
|
1310
|
+
*/
|
|
1311
|
+
recordPriority(record) {
|
|
1312
|
+
for (const op of record.message.operations) {
|
|
1313
|
+
if ('pend' in op) {
|
|
1314
|
+
return clampPriority(op.pend.transaction?.priority ?? op.pend.priority);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return 0;
|
|
1318
|
+
}
|
|
959
1319
|
operationsConflict(ops1, ops2) {
|
|
960
1320
|
// Check if one is a commit for the same action as a pend - these don't conflict
|
|
961
1321
|
const actionId1 = this.getActionId(ops1);
|
|
@@ -1026,7 +1386,7 @@ export class ClusterMember {
|
|
|
1026
1386
|
promises.push(client.update(record));
|
|
1027
1387
|
}
|
|
1028
1388
|
catch (error) {
|
|
1029
|
-
|
|
1389
|
+
log('ERROR: Failed to propagate to peer %s: %o', peerId, error);
|
|
1030
1390
|
}
|
|
1031
1391
|
}
|
|
1032
1392
|
await Promise.allSettled(promises);
|