@optimystic/db-p2p 0.17.0 → 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 +10 -0
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +2 -1
- 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 +35 -11
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +88 -19
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/quorum-restore.d.ts +25 -3
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +27 -3
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/reconcile-block.d.ts +10 -2
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
- package/dist/src/cluster/reconcile-block.js +38 -18
- package/dist/src/cluster/reconcile-block.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 +6 -0
- package/dist/src/inbound-authorization.d.ts.map +1 -1
- package/dist/src/inbound-authorization.js +6 -0
- package/dist/src/inbound-authorization.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 +22 -23
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +22 -19
- 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 +60 -26
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +218 -65
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +9 -0
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +56 -4
- package/dist/src/storage/storage-repo.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 +14 -1
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/package.json +2 -2
- package/readme.md +20 -0
- package/src/cluster/block-transfer-service.ts +9 -1
- package/src/cluster/cluster-policy.ts +152 -0
- package/src/cluster/cluster-repo.ts +93 -22
- package/src/cluster/quorum-restore.ts +28 -3
- package/src/cluster/reconcile-block.ts +52 -19
- package/src/cluster/spread-on-churn.ts +8 -0
- package/src/inbound-authorization.ts +6 -0
- package/src/libp2p-key-network.ts +54 -4
- package/src/libp2p-node-base.ts +42 -43
- package/src/repo/cluster-coordinator.ts +30 -6
- package/src/repo/coordinator-repo.ts +235 -73
- package/src/storage/storage-repo.ts +58 -6
- package/src/testing/mesh-harness.ts +15 -1
|
@@ -117,9 +117,10 @@ export type ExpectedClusterView = {
|
|
|
117
117
|
* Independently derive this member's own view of a block's responsible cluster. Injected so
|
|
118
118
|
* {@link ClusterMember} stays transport-agnostic — the composition root supplies it from
|
|
119
119
|
* `IKeyNetwork.findCluster` + FRET (mirroring how the coordinator derives the cluster). Absent on nodes
|
|
120
|
-
* that cannot derive a view (no FRET, unit tests): with no derived view AND no
|
|
121
|
-
*
|
|
122
|
-
* fail closed on an unjustified downsize. See {@link ClusterMember}
|
|
120
|
+
* that cannot derive a view (no FRET, unit tests): with no derived view AND no asserted
|
|
121
|
+
* {@link ClusterConsensusConfig.assumedClusterSize} the gate preserves legacy approve behavior, but an
|
|
122
|
+
* asserted size still lets the gate fail closed on an unjustified downsize. See {@link ClusterMember}
|
|
123
|
+
* admission gate.
|
|
123
124
|
*/
|
|
124
125
|
export type DeriveExpectedClusterCallback = (blockId: BlockId) => Promise<ExpectedClusterView>;
|
|
125
126
|
|
|
@@ -232,8 +233,8 @@ export class ClusterMember implements ICluster {
|
|
|
232
233
|
private readonly minAbsoluteClusterSize: number;
|
|
233
234
|
private readonly clusterSizeTolerance: number;
|
|
234
235
|
private readonly membershipAdmissionFraction: number;
|
|
235
|
-
/**
|
|
236
|
-
private readonly
|
|
236
|
+
/** Operator-asserted smallest genuine cohort size, or undefined when unknown. */
|
|
237
|
+
private readonly assumedClusterSize: number | undefined;
|
|
237
238
|
private readonly allowUnvalidatedSmallCluster: boolean;
|
|
238
239
|
|
|
239
240
|
constructor(
|
|
@@ -259,8 +260,17 @@ export class ClusterMember implements ICluster {
|
|
|
259
260
|
this.minAbsoluteClusterSize = consensusConfig?.minAbsoluteClusterSize ?? 3;
|
|
260
261
|
this.clusterSizeTolerance = consensusConfig?.clusterSizeTolerance ?? 0.5;
|
|
261
262
|
this.membershipAdmissionFraction = consensusConfig?.membershipAdmissionFraction ?? 0.75;
|
|
262
|
-
this.
|
|
263
|
+
this.assumedClusterSize = consensusConfig?.assumedClusterSize;
|
|
263
264
|
this.allowUnvalidatedSmallCluster = consensusConfig?.allowUnvalidatedSmallCluster ?? false;
|
|
265
|
+
// State the resolved gate parameters once, so an operator diagnosing a membership rejection can see
|
|
266
|
+
// what this node actually resolved. A fact, not a warning: `assumedClusterSize < clusterSize` is the
|
|
267
|
+
// normal default state, so warning on it would fire for every node and be ignored.
|
|
268
|
+
log('cluster-member:admission-config', {
|
|
269
|
+
assumedClusterSize: this.assumedClusterSize,
|
|
270
|
+
minAbsoluteClusterSize: this.minAbsoluteClusterSize,
|
|
271
|
+
membershipAdmissionFraction: this.membershipAdmissionFraction,
|
|
272
|
+
allowUnvalidatedSmallCluster: this.allowUnvalidatedSmallCluster
|
|
273
|
+
});
|
|
264
274
|
// Periodically clean up expired transactions (.unref() so tests/short-lived processes can exit)
|
|
265
275
|
this.expirationInterval = setInterval(() => this.queueExpiredTransactions(), 60000);
|
|
266
276
|
this.expirationInterval.unref();
|
|
@@ -858,12 +868,18 @@ export class ClusterMember implements ICluster {
|
|
|
858
868
|
* of a peer or two is absorbed, a wholesale-disjoint or half-size set is not.
|
|
859
869
|
*
|
|
860
870
|
* **Fail-closed posture.** When the member cannot confidently derive `E` (no capability, low FRET
|
|
861
|
-
* confidence — exactly what a partition induces), it must refuse any *downsizing* decision
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
871
|
+
* confidence — exactly what a partition induces), it must refuse any *downsizing* decision — but it
|
|
872
|
+
* needs a size reference to judge "downsize" against, and it may NOT borrow `clusterSize` for that:
|
|
873
|
+
* `clusterSize` is the replication factor (what a cohort should aim for), not a claim about how many
|
|
874
|
+
* peers exist, so a small deployment configured with the default 10 would refuse every write. The
|
|
875
|
+
* fallback yardstick is instead {@link ClusterConsensusConfig.assumedClusterSize} — the operator's own
|
|
876
|
+
* assertion of the smallest cohort this deployment can genuinely field — run through the SAME
|
|
877
|
+
* {@link admissionFloor} as the confident path, so the fallback can never be stricter than the measured
|
|
878
|
+
* path (it was: it demanded the full configured size, with no fraction and no slack for churn or a peer
|
|
879
|
+
* not yet discovered). With NEITHER a confident view NOR an asserted size the gate cannot judge a
|
|
880
|
+
* downsize at all, so it preserves the legacy approve behavior (backward-compatible for nodes/tests with
|
|
881
|
+
* no derivation wired). `allowUnvalidatedSmallCluster` is the explicit opt-in (single-node / local dev
|
|
882
|
+
* knowingly below the safe floor), matching the coordinator's `validateSmallCluster` semantics.
|
|
867
883
|
*/
|
|
868
884
|
private async admitMembership(record: ClusterRecord): Promise<{ admit: boolean; reason?: string }> {
|
|
869
885
|
const ourId = this.peerId.toString();
|
|
@@ -894,30 +910,42 @@ export class ClusterMember implements ICluster {
|
|
|
894
910
|
&& derivedSize > 0;
|
|
895
911
|
|
|
896
912
|
if (!confident) {
|
|
897
|
-
// Fail closed for downsizing under low/absent confidence
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
if (this.
|
|
913
|
+
// Fail closed for downsizing under low/absent confidence, measured against the operator's asserted
|
|
914
|
+
// cohort size rather than the replication factor. With no asserted size the gate cannot tell a
|
|
915
|
+
// downsize from a legitimately small cluster at all, so it preserves legacy approve behavior.
|
|
916
|
+
if (this.assumedClusterSize === undefined) {
|
|
901
917
|
return { admit: true };
|
|
902
918
|
}
|
|
903
|
-
|
|
919
|
+
const floor = this.admissionFloor(this.assumedClusterSize);
|
|
920
|
+
if (declared.length >= floor) {
|
|
904
921
|
return { admit: true };
|
|
905
922
|
}
|
|
906
923
|
log('cluster-member:admission-reject', {
|
|
907
924
|
messageHash: record.messageHash,
|
|
908
925
|
reason: 'low-confidence-downsize',
|
|
909
926
|
declaredSize: declared.length,
|
|
910
|
-
|
|
927
|
+
floor,
|
|
928
|
+
assumedClusterSize: this.assumedClusterSize,
|
|
911
929
|
confidence: derived?.confidence
|
|
912
930
|
});
|
|
913
|
-
|
|
931
|
+
// The numbers ride along in the reason: this rejection is caused by *local* configuration, and
|
|
932
|
+
// without them a coordinator (or an operator reading a dispute record) has no hint which knob did it.
|
|
933
|
+
// NOTE: two honest members with different local config now emit *different* reason strings for the
|
|
934
|
+
// same record. Nothing compares reasons across peers today (`disputeEvidence.rejectReasons` is a
|
|
935
|
+
// per-peer map, and the signed payload hashes the string each vote carries); if anything ever groups
|
|
936
|
+
// or dedupes dispute reasons by string equality, group on the `membership-not-admitted:<variant>`
|
|
937
|
+
// prefix, not the whole string.
|
|
938
|
+
return {
|
|
939
|
+
admit: false,
|
|
940
|
+
reason: `${MEMBERSHIP_NOT_ADMITTED}:low-confidence-downsize (declared=${declared.length}, floor=${floor}, assumedClusterSize=${this.assumedClusterSize})`
|
|
941
|
+
};
|
|
914
942
|
}
|
|
915
943
|
|
|
916
944
|
const expected = Object.keys(derived!.peers ?? {});
|
|
917
945
|
const kEst = expected.length;
|
|
918
946
|
|
|
919
947
|
// Predicate 2: floor derived from the member's OWN confident estimate.
|
|
920
|
-
const floor =
|
|
948
|
+
const floor = this.admissionFloor(kEst);
|
|
921
949
|
if (declared.length < floor) {
|
|
922
950
|
log('cluster-member:admission-reject', {
|
|
923
951
|
messageHash: record.messageHash,
|
|
@@ -926,7 +954,10 @@ export class ClusterMember implements ICluster {
|
|
|
926
954
|
floor,
|
|
927
955
|
kEst
|
|
928
956
|
});
|
|
929
|
-
return {
|
|
957
|
+
return {
|
|
958
|
+
admit: false,
|
|
959
|
+
reason: `${MEMBERSHIP_NOT_ADMITTED}:below-floor (declared=${declared.length}, floor=${floor}, kEst=${kEst})`
|
|
960
|
+
};
|
|
930
961
|
}
|
|
931
962
|
|
|
932
963
|
// Predicate 3: consistency with the derived view within tolerance.
|
|
@@ -947,6 +978,27 @@ export class ClusterMember implements ICluster {
|
|
|
947
978
|
return { admit: true };
|
|
948
979
|
}
|
|
949
980
|
|
|
981
|
+
/**
|
|
982
|
+
* The smallest declared peer set admissible against a cohort-size reference `k`, whether `k` is
|
|
983
|
+
* measured (the confident path's `kEst`) or asserted (`assumedClusterSize`). One function so the
|
|
984
|
+
* fallback can never be stricter than the measured path — which it was, demanding the full configured
|
|
985
|
+
* size with no fraction and no slack. Clamped at `minAbsoluteClusterSize`, so a degenerate `k` of 0, 1
|
|
986
|
+
* or negative yields the absolute floor rather than a floor that admits everything. A non-finite scaled
|
|
987
|
+
* size (a `NaN` or `Infinity` config value) is likewise treated as no usable reference rather than
|
|
988
|
+
* propagating: an unguarded `NaN` floor fails EVERY comparison, which would silently make the node
|
|
989
|
+
* reject every unconfident write.
|
|
990
|
+
*
|
|
991
|
+
* NOTE: partition safety needs `2 · membershipAdmissionFraction · superMajorityThreshold > 1` — each
|
|
992
|
+
* side of a split must recruit `fraction · threshold · K` distinct honest members, and two sides cannot
|
|
993
|
+
* both find them in one K-peer cluster. At the shipped defaults (0.75 · 0.67) that product is 1.005 —
|
|
994
|
+
* true, but with almost no margin. If either default is ever lowered, re-check Theorem 2 in
|
|
995
|
+
* `docs/correctness.md` before shipping it.
|
|
996
|
+
*/
|
|
997
|
+
private admissionFloor(k: number): number {
|
|
998
|
+
const scaled = Math.ceil(this.membershipAdmissionFraction * k);
|
|
999
|
+
return Math.max(this.minAbsoluteClusterSize, Number.isFinite(scaled) ? scaled : 0);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
950
1002
|
/**
|
|
951
1003
|
* Derive this member's own view of the record's block cluster via the injected capability, or
|
|
952
1004
|
* `undefined` when it cannot (no capability, no coordinating block id, or a derivation error — all of
|
|
@@ -1000,6 +1052,20 @@ export class ClusterMember implements ICluster {
|
|
|
1000
1052
|
const blockResults = await this.storageRepo.get({ blockIds });
|
|
1001
1053
|
for (const blockId of blockIds) {
|
|
1002
1054
|
const blockResult = blockResults[blockId];
|
|
1055
|
+
if (blockResult?.unavailable !== undefined) {
|
|
1056
|
+
// This member cannot establish the block's revision, so it cannot judge
|
|
1057
|
+
// staleness. Vote reject rather than approve on an answer it knows is a
|
|
1058
|
+
// guess — approving would let a stale pend reach consensus on the strength
|
|
1059
|
+
// of a member that could not check it. (Before StorageRepo caught
|
|
1060
|
+
// materialization faults per block, this read threw out of the promise
|
|
1061
|
+
// handler; rejecting keeps the fail-closed posture with a signed reason.)
|
|
1062
|
+
log('cluster-member:validation-block-unavailable', {
|
|
1063
|
+
messageHash: record.messageHash,
|
|
1064
|
+
blockId,
|
|
1065
|
+
reason: blockResult.unavailable
|
|
1066
|
+
});
|
|
1067
|
+
return { valid: false, reason: `block ${blockId} unavailable (${blockResult.unavailable}): cannot verify revision` };
|
|
1068
|
+
}
|
|
1003
1069
|
const latestRev = blockResult?.state?.latest?.rev;
|
|
1004
1070
|
if (latestRev !== undefined && latestRev >= pendRequest.rev) {
|
|
1005
1071
|
log('cluster-member:validation-stale-revision', {
|
|
@@ -1395,7 +1461,12 @@ export class ClusterMember implements ICluster {
|
|
|
1395
1461
|
private async reconcileOneBlock(messageHash: string, blockId: BlockId, committed: ActionRev, cohortPeerIds: string[]): Promise<void> {
|
|
1396
1462
|
try {
|
|
1397
1463
|
await this.withReconcileTimeout(this.reconcileBlock!(blockId, committed, cohortPeerIds), blockId);
|
|
1398
|
-
|
|
1464
|
+
// "attempted", not "reconciled": the callback returns void, and a quorum decline is a
|
|
1465
|
+
// normal, non-throwing outcome — so reaching here means the pass ran to completion, NOT
|
|
1466
|
+
// that anything was restored. `reconcile:restored` (reconcile-block.ts) is the line that
|
|
1467
|
+
// says the bytes actually landed; `reconcile:no-rev-quorum` / `reconcile:no-content-quorum`
|
|
1468
|
+
// say they did not.
|
|
1469
|
+
log('cluster-member:consensus-commit-reconcile-attempted', { messageHash, blockId, rev: committed.rev });
|
|
1399
1470
|
} catch (err) {
|
|
1400
1471
|
log('cluster-member:consensus-commit-reconcile-failed', {
|
|
1401
1472
|
messageHash,
|
|
@@ -63,6 +63,31 @@ export function quorumSize(
|
|
|
63
63
|
return Math.max(floor, Math.floor(simpleMajorityThreshold * responderCount));
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The `corroboratorCapacity` to hand {@link quorumSize}: how many peers other than the asking node
|
|
68
|
+
* could answer for a block at all, given `cohortPeerCount` peers currently visible (self already
|
|
69
|
+
* excluded) and `repairCorroborationClusterSize` — the cohort size this deployment is measured
|
|
70
|
+
* against, resolved by `resolveClusterPolicy` in `cluster/cluster-policy.ts`.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately the MAX of the two: the corroboration floor may be relaxed only for a cohort that is
|
|
73
|
+
* *genuinely* small, never for one that merely looks small. Cohort views are unauthenticated — the
|
|
74
|
+
* read path takes them from `IKeyNetwork.findCluster`, the commit path from a coordinator-declared
|
|
75
|
+
* peer set — so a partition, a self-shrunk record, or an attacker with routing influence could
|
|
76
|
+
* otherwise talk the requirement down to a single voter. Measuring against the resolved size keeps a
|
|
77
|
+
* shrunken view out of the relaxed branch.
|
|
78
|
+
*
|
|
79
|
+
* An unconfigured node resolves this to its `clusterSize` (default 10), so the floor of two binds and
|
|
80
|
+
* a shrunken view gains nothing. The escape hatch for a real two-node deployment is one explicit
|
|
81
|
+
* operator declaration — `clusterPolicy.assumedClusterSize: 2`, which does NOT also drop the
|
|
82
|
+
* replication factor, or an honest `clusterSize: 2`.
|
|
83
|
+
*
|
|
84
|
+
* Shared by both restoration paths so the two can never drift apart on the rule that decides how
|
|
85
|
+
* much trust a lone peer gets.
|
|
86
|
+
*/
|
|
87
|
+
export function corroboratorCapacity(cohortPeerCount: number, repairCorroborationClusterSize: number): number {
|
|
88
|
+
return Math.max(cohortPeerCount, repairCorroborationClusterSize - 1);
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
/**
|
|
67
92
|
* Select the highest revision corroborated by a quorum of distinct peers.
|
|
68
93
|
*
|
|
@@ -80,7 +105,7 @@ export function quorumSize(
|
|
|
80
105
|
* all — lets a genuinely tiny cohort still converge: a cohort with exactly one other peer
|
|
81
106
|
* cannot produce two corroborators, so requiring two makes divergence permanent rather
|
|
82
107
|
* than making it safe. Pass a capacity that a shrunken view of the network cannot talk
|
|
83
|
-
* down (see
|
|
108
|
+
* down (see {@link corroboratorCapacity}), or omit it to keep the floor at two.
|
|
84
109
|
*
|
|
85
110
|
* Returns `undefined` when nothing is corroborated — an uncorroborated claim
|
|
86
111
|
* must never drive restoration.
|
|
@@ -165,8 +190,8 @@ export interface BlockHashCandidate {
|
|
|
165
190
|
* one the sole peer's content is therefore taken on its word. That extends no trust the cohort had
|
|
166
191
|
* not already extended: the same peer's `(rev, actionId)` claim is equally uncorroborable at that
|
|
167
192
|
* size, and a two-member cohort has no honest majority to appeal to. Pass a capacity a shrunken
|
|
168
|
-
* view of the network cannot talk down (see
|
|
169
|
-
*
|
|
193
|
+
* view of the network cannot talk down (see {@link corroboratorCapacity}), so only a cohort that is
|
|
194
|
+
* *genuinely* that small reaches this branch.
|
|
170
195
|
*/
|
|
171
196
|
export function selectQuorumBlock(
|
|
172
197
|
candidates: BlockHashCandidate[],
|
|
@@ -4,7 +4,7 @@ import type { ReconcileBlockCallback } from "./cluster-repo.js";
|
|
|
4
4
|
import type { IPeerReputation } from "../reputation/types.js";
|
|
5
5
|
import { PenaltyReason } from "../reputation/types.js";
|
|
6
6
|
import {
|
|
7
|
-
selectQuorumRev, selectQuorumBlock, canonicalBlockHash,
|
|
7
|
+
selectQuorumRev, selectQuorumBlock, canonicalBlockHash, corroboratorCapacity,
|
|
8
8
|
type RevClaim, type BlockHashCandidate, type QuorumRev
|
|
9
9
|
} from "./quorum-restore.js";
|
|
10
10
|
import { createLogger } from '../logger.js';
|
|
@@ -38,23 +38,32 @@ export interface ReconcileBlockDeps {
|
|
|
38
38
|
saveReplicatedBlock: (blockId: BlockId, block: IBlock, source: ActionRev) => Promise<void>;
|
|
39
39
|
/** Proportional corroboration threshold; the cohort's `simpleMajorityThreshold`. */
|
|
40
40
|
simpleMajorityThreshold: number;
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Yardstick the corroboration floor is measured against — the floor for
|
|
43
|
+
* {@link corroboratorCapacity}. Required, not optional: unlike the membership admission gate there
|
|
44
|
+
* is no "unknown" handling here, so a caller that cannot state an asserted cohort size should pass
|
|
45
|
+
* its configured `clusterSize` (the strict direction) rather than a small placeholder. The failure
|
|
46
|
+
* mode of overstating it is a block that stays unrepaired — degraded, not dead; of understating it,
|
|
47
|
+
* a shrunken cohort view that can relax the floor to a single voter. `resolveClusterPolicy`
|
|
48
|
+
* (`cluster/cluster-policy.ts`) resolves it for a real node and defaults it to `clusterSize`.
|
|
49
|
+
*/
|
|
50
|
+
repairCorroborationClusterSize: number;
|
|
43
51
|
/** Best-effort misbehavior reporting; a throwing implementation is swallowed. */
|
|
44
52
|
reputation?: Pick<IPeerReputation, 'reportPeer'>;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* looks small. `cohortPeerIds` come from the (untrusted) coordinator-declared peer set, so a
|
|
52
|
-
* self-shrunk record could otherwise talk the requirement down to a single voter; taking the MAX
|
|
53
|
-
* against the configured `clusterSize` keeps that shrunken view out of the relaxed branch. The
|
|
54
|
-
* escape hatch for a real two-node deployment is to configure `clusterSize: 2`.
|
|
56
|
+
* Highest revision an archive covers. Keys arrive as strings off the wire from an untrusted peer,
|
|
57
|
+
* so a non-numeric one is skipped rather than poisoning the maximum with `NaN`; folding instead of
|
|
58
|
+
* `Math.max(...keys)` also keeps a wide archive off the argument-count limit.
|
|
55
59
|
*/
|
|
56
|
-
function
|
|
57
|
-
|
|
60
|
+
function maxRevision(revisions: BlockArchive['revisions']): number | undefined {
|
|
61
|
+
let max: number | undefined;
|
|
62
|
+
for (const key of Object.keys(revisions)) {
|
|
63
|
+
const rev = Number(key);
|
|
64
|
+
if (Number.isFinite(rev) && (max === undefined || rev > max)) max = rev;
|
|
65
|
+
}
|
|
66
|
+
return max;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
/**
|
|
@@ -64,16 +73,40 @@ function corroboratorCapacity(targets: string[], clusterSize: number): number {
|
|
|
64
73
|
*/
|
|
65
74
|
function toCandidate(peerId: string, archive: BlockArchive | undefined, committedRev: number): ReconcileCandidate | undefined {
|
|
66
75
|
if (!archive) return undefined;
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
const maxRev = Math.max(...revs);
|
|
70
|
-
if (maxRev < committedRev) return undefined;
|
|
76
|
+
const maxRev = maxRevision(archive.revisions);
|
|
77
|
+
if (maxRev === undefined || maxRev < committedRev) return undefined;
|
|
71
78
|
const data = archive.revisions[maxRev];
|
|
72
79
|
if (!data?.action) return undefined;
|
|
73
80
|
return { peerId, rev: maxRev, actionId: data.action.actionId, block: data.block };
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* One peer's answer, isolated. `fetchArchive` is contracted to answer `undefined` for an
|
|
85
|
+
* unreachable peer, but a raw `Promise.all` over the cohort would let a single rejecting fetch
|
|
86
|
+
* discard the answers every other peer already gave — turning a heal the cohort could complete
|
|
87
|
+
* into a decline. One peer's failure costs only that peer's vote.
|
|
88
|
+
*/
|
|
89
|
+
async function fetchCandidate(
|
|
90
|
+
deps: ReconcileBlockDeps,
|
|
91
|
+
peerId: string,
|
|
92
|
+
blockId: BlockId,
|
|
93
|
+
committedRev: number
|
|
94
|
+
): Promise<ReconcileCandidate | undefined> {
|
|
95
|
+
try {
|
|
96
|
+
return toCandidate(peerId, await deps.fetchArchive(peerId, blockId), committedRev);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
log('reconcile:fetch-error', { blockId, peerId, error: (err as Error).message });
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Hash the block bytes of every candidate that both corroborates `selected` and actually carried content.
|
|
105
|
+
*
|
|
106
|
+
* NOTE: this canonical-JSON-serializes and sha256s every carrier's whole block on every reconcile.
|
|
107
|
+
* Negligible at today's cohort widths and block sizes; if blocks grow large or cohorts wide enough
|
|
108
|
+
* for this to show up on a commit-path profile, hash incrementally at receive time instead.
|
|
109
|
+
*/
|
|
77
110
|
async function hashCarriers(candidates: ReconcileCandidate[], selected: QuorumRev): Promise<BlockHashCandidate[]> {
|
|
78
111
|
const carriers = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
|
|
79
112
|
return await Promise.all(
|
|
@@ -130,10 +163,10 @@ export function createReconcileBlock(deps: ReconcileBlockDeps): ReconcileBlockCa
|
|
|
130
163
|
if (targets.length === 0) return;
|
|
131
164
|
|
|
132
165
|
const fetched = await Promise.all(
|
|
133
|
-
targets.map(
|
|
166
|
+
targets.map(peerId => fetchCandidate(deps, peerId, blockId, committed.rev))
|
|
134
167
|
);
|
|
135
168
|
const candidates = fetched.filter((c): c is ReconcileCandidate => c !== undefined);
|
|
136
|
-
const capacity = corroboratorCapacity(targets, deps.
|
|
169
|
+
const capacity = corroboratorCapacity(targets.length, deps.repairCorroborationClusterSize);
|
|
137
170
|
|
|
138
171
|
const revClaims: RevClaim[] = candidates.map(({ peerId, rev, actionId }) => ({ peerId, rev, actionId }));
|
|
139
172
|
const selected = selectQuorumRev(revClaims, deps.simpleMajorityThreshold, capacity);
|
|
@@ -221,6 +221,14 @@ export class SpreadOnChurnMonitor implements Startable {
|
|
|
221
221
|
// Read block data from local storage
|
|
222
222
|
const result = await this.deps.repo.get({ blockIds: [blockId] })
|
|
223
223
|
const blockResult = result[blockId]
|
|
224
|
+
if (blockResult?.unavailable !== undefined) {
|
|
225
|
+
// The repo could not work out whether it still holds this block (unmaterializable
|
|
226
|
+
// history / failed restore). Untracking on that answer would silently drop the block
|
|
227
|
+
// from the spread set on a guess, and only a later re-commit would put it back — so
|
|
228
|
+
// keep it tracked and let the next sweep (or a heal) settle it.
|
|
229
|
+
log('unavailable block=%s reason=%s (keeping tracked)', blockId, blockResult.unavailable)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
224
232
|
if (!blockResult?.block) {
|
|
225
233
|
// The block has left local storage. No deletion event exists today to evict it
|
|
226
234
|
// from the tracked set, so prune here. Deleting the current element of a Set mid
|
|
@@ -32,6 +32,12 @@
|
|
|
32
32
|
* - **Cost**: the predicate sits in the hot path of every inbound stream, ahead of the work
|
|
33
33
|
* that stream would do. Embedders are expected to make it cheap — an in-memory set lookup —
|
|
34
34
|
* and to memoize anything that would otherwise hit storage or the network per stream.
|
|
35
|
+
*
|
|
36
|
+
* NOTE: denial is stateless and unthrottled — a denied peer may reopen streams as fast as
|
|
37
|
+
* libp2p's per-connection `maxInboundStreams` allows, and nothing here records the denial. That
|
|
38
|
+
* is fine while the predicate is an in-memory lookup. If a denied peer ever shows up as load, the
|
|
39
|
+
* fix is upstream of this module, not inside it: feed denials into `PeerReputationService`, or
|
|
40
|
+
* refuse the peer at the connection level with `NodeOptions.connectionGater`.
|
|
35
41
|
*/
|
|
36
42
|
|
|
37
43
|
/**
|
|
@@ -299,7 +299,25 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
|
|
|
299
299
|
return { allow: true, reason: 'extended-isolation', warn: true };
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Memoize the coordinator for a key. A pick of SELF is deliberately ignored — the
|
|
304
|
+
* cache is consulted ahead of every selection tier, so a self entry would keep the
|
|
305
|
+
* key routed at our own (possibly stale) replica for the full TTL long after a
|
|
306
|
+
* better-placed peer became reachable, and would return self without re-consulting
|
|
307
|
+
* {@link shouldAllowSelfCoordination}, letting a partitioned node silently serve its
|
|
308
|
+
* own data. Self needs no memoizing anyway: every tier that can select it re-derives
|
|
309
|
+
* it from a local lookup with no dial and no retry sleep.
|
|
310
|
+
*
|
|
311
|
+
* The gate lives here rather than at each call site because most writers are OUTSIDE
|
|
312
|
+
* this class — `recordCoordinator` is public and is fed self-valued picks by
|
|
313
|
+
* `NetworkTransactor` (it writes back whatever `findCoordinator` returned, including
|
|
314
|
+
* self) and by `RepoClient`/`ClusterClient` on redirect responses.
|
|
315
|
+
*/
|
|
302
316
|
public recordCoordinator(key: Uint8Array, peerId: PeerId, ttlMs = 30 * 60 * 1000): void {
|
|
317
|
+
if (peerId.toString() === this.libp2p.peerId.toString()) {
|
|
318
|
+
this.log('coordinator-cache:self-write-ignored key=%s', this.toCacheKey(key).substring(0, 12))
|
|
319
|
+
return
|
|
320
|
+
}
|
|
303
321
|
const k = this.toCacheKey(key)
|
|
304
322
|
const now = Date.now()
|
|
305
323
|
for (const [ck, entry] of this.coordinatorCache) {
|
|
@@ -417,11 +435,39 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
|
|
|
417
435
|
this.log('findCoordinator:fret-neighbors key=%s candidates=%d', keyStr, ids.length)
|
|
418
436
|
if (verbose) this.log('findCoordinator:fret-candidates key=%s ids=%o connected=%o', keyStr, ids, Array.from(connectedSet))
|
|
419
437
|
|
|
420
|
-
// Filter to only connected FRET neighbors, excluding banned peers
|
|
438
|
+
// Filter to only connected FRET neighbors, excluding banned peers. Self is
|
|
439
|
+
// never "connected" to itself, so it is admitted by the explicit self clause
|
|
440
|
+
// below — but ONLY when the self-coordination guard allows it, otherwise a
|
|
441
|
+
// node whose FRET neighborhood contains self (essentially always on a small or
|
|
442
|
+
// forming network) would bypass the guard and the last-resort tier's
|
|
443
|
+
// SELF_COORDINATION_BLOCKED would never fire. On refusal self is merely DROPPED
|
|
444
|
+
// from the candidate list, so the connected-peer fallback below still gets its
|
|
445
|
+
// chance at a good remote peer; only if that also comes up empty does the
|
|
446
|
+
// last-resort tier raise the accurate error.
|
|
447
|
+
const selfStr = this.libp2p.peerId.toString()
|
|
448
|
+
let selfAllowedThisAttempt: boolean | undefined
|
|
449
|
+
// Memoized per ATTEMPT, and evaluated lazily so an all-remote neighborhood never
|
|
450
|
+
// pays detectPartition() / getNetworkSizeEstimate(). Re-evaluated on each attempt
|
|
451
|
+
// because a connection can land during the 500ms inter-attempt sleep and
|
|
452
|
+
// legitimately flip the answer — as filterByMembership re-reads the peerStore.
|
|
453
|
+
// NOTE: on a small network self is a neighbor of nearly every key, so this runs
|
|
454
|
+
// per findCoordinator call and self-coordinated keys are never cached to absorb
|
|
455
|
+
// it. Fine while detectPartition()/getNetworkSizeEstimate() stay local FRET
|
|
456
|
+
// table reads; if either ever grows a probe or other network round-trip, cache
|
|
457
|
+
// the decision with a short TTL on the instance instead of per attempt.
|
|
458
|
+
const isSelfAdmissible = (): boolean => {
|
|
459
|
+
if (selfAllowedThisAttempt === undefined) {
|
|
460
|
+
const decision = this.shouldAllowSelfCoordination()
|
|
461
|
+
selfAllowedThisAttempt = decision.allow
|
|
462
|
+
if (!decision.allow) {
|
|
463
|
+
this.log('findCoordinator:fret-self-dropped key=%s reason=%s attempt=%d', keyStr, decision.reason, attempt)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return selfAllowedThisAttempt
|
|
467
|
+
}
|
|
421
468
|
const connectedFretIds = ids
|
|
422
|
-
.filter(id =>
|
|
423
|
-
|
|
424
|
-
&& !(this.reputation?.isBanned(id)))
|
|
469
|
+
.filter(id => !excludedSet.has(id) && !(this.reputation?.isBanned(id)))
|
|
470
|
+
.filter(id => connectedSet.has(id) || (id === selfStr && isSelfAdmissible()))
|
|
425
471
|
.sort((a, b) => (this.reputation?.getScore(a) ?? 0) - (this.reputation?.getScore(b) ?? 0))
|
|
426
472
|
this.log('findCoordinator:fret-connected key=%s count=%d peers=%o', keyStr, connectedFretIds.length, connectedFretIds.map(s => s.substring(0, 12)))
|
|
427
473
|
|
|
@@ -438,6 +484,8 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
|
|
|
438
484
|
const pick = ranked[0]
|
|
439
485
|
if (pick) {
|
|
440
486
|
const pid = peerIdFromString(pick)
|
|
487
|
+
// A self pick is a no-op here — recordCoordinator ignores self-valued
|
|
488
|
+
// writes (see its doc comment), matching the last-resort self tier below.
|
|
441
489
|
this.recordCoordinator(key, pid)
|
|
442
490
|
this.log('findCoordinator:done key=%s ms=%d source=%s', keyStr, Date.now() - t0, 'fret')
|
|
443
491
|
return pid
|
|
@@ -451,6 +499,8 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
|
|
|
451
499
|
// `unknown` peer is never picked). Note this candidate set is built from
|
|
452
500
|
// connected REMOTE peers and never includes self, so when no serving peer is
|
|
453
501
|
// present selection falls through to the last-resort self-coordination block.
|
|
502
|
+
// Being remote-only, this tier needs no self-coordination guard check, unlike the
|
|
503
|
+
// FRET tier above.
|
|
454
504
|
const connectedCandidates = connected
|
|
455
505
|
.filter(p => !excludedSet.has(p.toString()) && !(this.reputation?.isBanned(p.toString())))
|
|
456
506
|
.sort((a, b) => (this.reputation?.getScore(a.toString()) ?? 0) - (this.reputation?.getScore(b.toString()) ?? 0))
|
package/src/libp2p-node-base.ts
CHANGED
|
@@ -21,6 +21,7 @@ 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
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';
|
|
@@ -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,
|
|
@@ -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,29 +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
|
-
/**
|
|
178
|
-
* Desired cluster size per key (default 10). Beyond sizing the cohort, this is the
|
|
179
|
-
* node's declaration of how many peers *should* exist to corroborate a claim: the
|
|
180
|
-
* read-repair corroboration floor is measured against it, so a genuine two-node
|
|
181
|
-
* deployment must set `clusterSize: 2` for its members to be able to repair each
|
|
182
|
-
* other (see `CoordinatorRepo.corroboratorCapacity`).
|
|
183
|
-
*/
|
|
184
|
-
clusterSize?: number;
|
|
185
|
-
clusterPolicy?: {
|
|
186
|
-
allowDownsize?: boolean;
|
|
187
|
-
sizeTolerance?: number; // acceptable relative difference (e.g. 0.5 = +/-50%)
|
|
188
|
-
superMajorityThreshold?: number; // fraction of peers needed for super-majority (default: DEFAULT_SUPER_MAJORITY_THRESHOLD = 0.75)
|
|
189
|
-
/**
|
|
190
|
-
* Opt in to transacting below the safe cluster-size floor when FRET has no confident
|
|
191
|
-
* network-size estimate — the membership-admission and coordinator small-cluster gates
|
|
192
|
-
* both fail closed without it. Default false. Turn on only for single-node / local dev
|
|
193
|
-
* meshes that knowingly run undersized.
|
|
194
|
-
*/
|
|
195
|
-
allowUnvalidatedSmallCluster?: boolean;
|
|
196
|
-
};
|
|
197
|
-
|
|
198
182
|
/** Override libp2p listen multiaddrs. */
|
|
199
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[];
|
|
200
196
|
/** Override libp2p transports. */
|
|
201
197
|
transports?: Libp2pTransports;
|
|
202
198
|
|
|
@@ -305,9 +301,10 @@ export type NodeOptions = {
|
|
|
305
301
|
* dialing peer's `PeerId.toString()`. See {@link AuthorizeInboundStream} and
|
|
306
302
|
* `docs/internals.md` § Inbound Stream Authorization.
|
|
307
303
|
*
|
|
308
|
-
* NOTE: this covers the four database protocols only. The reactivity, matchmaking,
|
|
304
|
+
* NOTE: this covers the four database protocols only. The dispute, reactivity, matchmaking,
|
|
309
305
|
* cohort-topic and libp2p built-in (identify/ping/…) protocols this node also registers are
|
|
310
|
-
* NOT gated by it.
|
|
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}.
|
|
311
308
|
*/
|
|
312
309
|
authorizeInboundStream?: AuthorizeInboundStream;
|
|
313
310
|
|
|
@@ -464,8 +461,13 @@ export async function createLibp2pNodeBase(
|
|
|
464
461
|
const libp2pOptions: Libp2pInit = {
|
|
465
462
|
start: false,
|
|
466
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.
|
|
467
467
|
addresses: {
|
|
468
|
-
listen: listenAddrs
|
|
468
|
+
listen: listenAddrs,
|
|
469
|
+
...(options.announceAddrs ? { announce: options.announceAddrs } : {}),
|
|
470
|
+
...(options.appendAnnounceAddrs ? { appendAnnounce: options.appendAnnounceAddrs } : {})
|
|
469
471
|
},
|
|
470
472
|
connectionManager: {
|
|
471
473
|
// `autoDial`, `minConnections`, and `dialQueue` were stale libp2p option keys silently
|
|
@@ -603,7 +605,9 @@ export async function createLibp2pNodeBase(
|
|
|
603
605
|
});
|
|
604
606
|
return serviceFactory({
|
|
605
607
|
registrar: components.registrar,
|
|
606
|
-
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
|
|
607
611
|
});
|
|
608
612
|
},
|
|
609
613
|
|
|
@@ -696,21 +700,11 @@ export async function createLibp2pNodeBase(
|
|
|
696
700
|
const partitionDetector = new PartitionDetector();
|
|
697
701
|
const fretSvc = (node as any).services?.fret as FretService | undefined;
|
|
698
702
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
|
|
705
|
-
// Fail closed by default (an undersized cluster with no confident network-size estimate is
|
|
706
|
-
// rejected); embedders running knowingly-small meshes opt in through clusterPolicy.
|
|
707
|
-
allowUnvalidatedSmallCluster: options.clusterPolicy?.allowUnvalidatedSmallCluster ?? false,
|
|
708
|
-
partitionDetectionWindow: 60000,
|
|
709
|
-
// Configured full cluster size — the member's own reference for "full size" in the membership
|
|
710
|
-
// admission gate (a below-full-size declared set under low FRET confidence is refused as a possible
|
|
711
|
-
// self-shrink). Matches the size threaded into the coordinator below.
|
|
712
|
-
clusterSize: options.clusterSize ?? 10
|
|
713
|
-
};
|
|
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);
|
|
714
708
|
|
|
715
709
|
// Fetch a block archive from one cohort peer over the sync protocol, bounded by a
|
|
716
710
|
// per-peer timeout so an unreachable peer can't stall reconciliation. Mirrors the
|
|
@@ -741,12 +735,17 @@ export async function createLibp2pNodeBase(
|
|
|
741
735
|
// (cohort drift, or a refused `missing-base-revision` commit). See `reconcile-block.ts` for
|
|
742
736
|
// the corroboration rules — in particular why both quorums are capped by how many peers
|
|
743
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.
|
|
744
743
|
const reconcileBlock: ReconcileBlockCallback = createReconcileBlock({
|
|
745
744
|
selfPeerId: node.peerId.toString(),
|
|
746
745
|
fetchArchive: fetchArchiveFromPeer,
|
|
747
746
|
saveReplicatedBlock: (blockId, block, source) => storageRepo.saveReplicatedBlock(blockId, block, source),
|
|
748
747
|
simpleMajorityThreshold: consensusConfig.simpleMajorityThreshold,
|
|
749
|
-
|
|
748
|
+
repairCorroborationClusterSize: consensusConfig.repairCorroborationClusterSize,
|
|
750
749
|
reputation
|
|
751
750
|
});
|
|
752
751
|
|