@optimystic/db-p2p 0.17.0 → 0.19.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 +95 -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 +66 -4
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +130 -17
- 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 +45 -34
- 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 +88 -28
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +287 -81
- 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 +77 -7
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts +17 -0
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +27 -4
- 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 +100 -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 +958 -807
- package/src/libp2p-node-base.ts +65 -57
- package/src/repo/cluster-coordinator.ts +30 -6
- package/src/repo/coordinator-repo.ts +329 -91
- package/src/storage/storage-repo.ts +81 -10
- package/src/testing/mesh-harness.ts +34 -4
package/readme.md
CHANGED
|
@@ -353,6 +353,26 @@ const node = await createLibp2pNode({
|
|
|
353
353
|
Omitting it is the default and leaves behavior unchanged. See
|
|
354
354
|
[internals](../../docs/internals.md) § Inbound Stream Authorization for the full contract.
|
|
355
355
|
|
|
356
|
+
**Announcing a different address than you bind (optional).** A node behind a NAT, port forward,
|
|
357
|
+
reverse proxy or DNS front binds one address but is reachable at another. `announceAddrs` advertises
|
|
358
|
+
the reachable address *instead of* whatever the transports report; `appendAnnounceAddrs` advertises
|
|
359
|
+
it *in addition to* them:
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
const node = await createLibp2pNode({
|
|
363
|
+
networkName: 'my-net',
|
|
364
|
+
bootstrapNodes: ['...'],
|
|
365
|
+
listenAddrs: ['/ip4/0.0.0.0/tcp/4001'], // what the process binds
|
|
366
|
+
announceAddrs: ['/dns4/mynode.example.com/tcp/4001'] // what peers are told
|
|
367
|
+
});
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
`announceAddrs` replaces the advertised set **entirely** — observed and relayed addresses are
|
|
371
|
+
dropped from it, and `appendAnnounceAddrs` is ignored while it is non-empty. Use
|
|
372
|
+
`appendAnnounceAddrs` alone when the automatically discovered addresses are still worth advertising.
|
|
373
|
+
Both are straight passthroughs to libp2p's `addresses.announce` / `addresses.appendAnnounce`; an
|
|
374
|
+
empty array means "unset".
|
|
375
|
+
|
|
356
376
|
## Usage Examples
|
|
357
377
|
|
|
358
378
|
### Setting Up a Coordinator Node
|
|
@@ -69,6 +69,12 @@ export interface BlockTransferServiceInit extends InboundStreamAuthorizationInit
|
|
|
69
69
|
export interface BlockTransferServiceComponents {
|
|
70
70
|
registrar: { handle: (...args: any[]) => Promise<void>; unhandle: (...args: any[]) => Promise<void> };
|
|
71
71
|
repo: IBlockReplicaStore;
|
|
72
|
+
/**
|
|
73
|
+
* Optional libp2p component logger. Supplied by the node factory so authorization denials
|
|
74
|
+
* land on the same `logger.forComponent(...).error` sink as the repo/cluster/sync services;
|
|
75
|
+
* without it they fall back to this module's `debug` logger.
|
|
76
|
+
*/
|
|
77
|
+
logger?: { forComponent: (name: string) => { error: (message: string, ...args: unknown[]) => void } };
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/**
|
|
@@ -92,7 +98,9 @@ export class BlockTransferService implements Startable {
|
|
|
92
98
|
this.protocol = buildBlockTransferProtocol(init.protocolPrefix ?? '');
|
|
93
99
|
this.repo = components.repo;
|
|
94
100
|
this.registrar = components.registrar;
|
|
95
|
-
|
|
101
|
+
const componentLog = components.logger?.forComponent('db-p2p:block-transfer');
|
|
102
|
+
this.authorization = createInboundStreamAuthorization(init, this.protocol,
|
|
103
|
+
componentLog ? (msg, ...args) => componentLog.error(msg, ...args) : (msg, ...args) => log(msg, ...args));
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
async start(): Promise<void> {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { DEFAULT_SUPER_MAJORITY_THRESHOLD, type ClusterConsensusConfig } from "@optimystic/db-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the operator-facing cluster knobs (`clusterSize`, `clusterPolicy.*`) into the concrete
|
|
5
|
+
* numbers the consensus and block-restoration paths run on.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from `createLibp2pNodeBase` rather than left inline so the composition root's defaults
|
|
8
|
+
* are assertable without booting a libp2p node — the layer a real deployment actually uses, and
|
|
9
|
+
* therefore the layer where a default that relaxed the repair corroboration floor to a single voter
|
|
10
|
+
* survived unnoticed (see `test/cluster-policy.spec.ts`).
|
|
11
|
+
*
|
|
12
|
+
* ## Why two size yardsticks, not one
|
|
13
|
+
*
|
|
14
|
+
* One operator field — `clusterPolicy.assumedClusterSize`, "the smallest cohort this deployment can
|
|
15
|
+
* genuinely field" — feeds two consumers whose failure modes point in opposite directions, so its
|
|
16
|
+
* *default* cannot serve both:
|
|
17
|
+
*
|
|
18
|
+
* - **Membership admission gate** (`cluster/cluster-repo.ts`, `admitMembership`) reads it only on its
|
|
19
|
+
* fallback path, when this node has no confident network-size estimate. Too small: a
|
|
20
|
+
* partition-induced downsize slips past while the node is unconfident. Too large: the node refuses
|
|
21
|
+
* legitimate writes — unavailability. It wants a *permissive* default, because an unconfigured
|
|
22
|
+
* two-node mesh must still be able to transact. It gets {@link minAbsoluteClusterSize} (2).
|
|
23
|
+
* - **Repair corroboration floor** (`corroboratorCapacity` in `cluster/quorum-restore.ts`, called by
|
|
24
|
+
* `CoordinatorRepo.queryClusterForLatest` and `createReconcileBlock`) reads it on *every* repair,
|
|
25
|
+
* unconditionally. Too small: a shrunken — and always unauthenticated — cohort view buys a lone
|
|
26
|
+
* peer full trust. Too large: a block stays unrepaired, degraded rather than dead. It wants a
|
|
27
|
+
* *strict* default. It gets {@link ResolvedClusterPolicy.repairCorroborationClusterSize}, which
|
|
28
|
+
* falls back to `clusterSize` (the configured replication factor).
|
|
29
|
+
*
|
|
30
|
+
* A single explicit `clusterPolicy.assumedClusterSize` still sets BOTH — an operator declaring their
|
|
31
|
+
* real cohort size means it for both consumers. Only the unconfigured case diverges.
|
|
32
|
+
*
|
|
33
|
+
* So a genuine two-node mesh needs exactly one setting to self-repair: either
|
|
34
|
+
* `clusterPolicy.assumedClusterSize: 2` (which does not lower the replication factor) or an honest
|
|
35
|
+
* `clusterSize: 2`. Writes and voting still work with zero configuration.
|
|
36
|
+
*
|
|
37
|
+
* ## Future
|
|
38
|
+
*
|
|
39
|
+
* Deriving the yardstick from observation (the largest peer group this node has ever seen for the
|
|
40
|
+
* key) would remove the trade entirely and subsume both values. Filed as backlog
|
|
41
|
+
* `feat-admission-floor-from-observed-cohort-high-water-mark`; do not build it here.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Absolute floor below which no cohort is safe, whatever the size references say. Named rather than
|
|
46
|
+
* inlined because the admission gate's `assumedClusterSize` defaults to exactly this value — the two
|
|
47
|
+
* must not drift.
|
|
48
|
+
*/
|
|
49
|
+
export const minAbsoluteClusterSize = 2;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The operator-facing cluster knobs. `NodeOptions` (`libp2p-node-base.ts`) intersects this rather
|
|
53
|
+
* than restating it, so a knob added here is one `resolveClusterPolicy` is guaranteed to see — a
|
|
54
|
+
* second declaration would compile fine and be silently dropped.
|
|
55
|
+
*/
|
|
56
|
+
export interface ClusterPolicyOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Desired cluster size per key (default 10) — the replication factor / target cohort breadth
|
|
59
|
+
* the coordinator aims for. NOT a statement about how many peers actually exist, so the
|
|
60
|
+
* membership admission gate is never measured against it (see `cluster/cluster-repo.ts`).
|
|
61
|
+
*
|
|
62
|
+
* The read-repair/reconcile corroboration floor DOES fall back to it when
|
|
63
|
+
* `clusterPolicy.assumedClusterSize` is absent — the strict direction, so an unconfigured node
|
|
64
|
+
* cannot have its floor talked down by a shrunken cohort view. A deployment that genuinely runs
|
|
65
|
+
* fewer peers than this should declare `clusterPolicy.assumedClusterSize`.
|
|
66
|
+
*/
|
|
67
|
+
clusterSize?: number;
|
|
68
|
+
clusterPolicy?: {
|
|
69
|
+
allowDownsize?: boolean;
|
|
70
|
+
/** Acceptable relative difference (e.g. 0.5 = +/-50%). */
|
|
71
|
+
sizeTolerance?: number;
|
|
72
|
+
/** Fraction of peers needed for super-majority (default {@link DEFAULT_SUPER_MAJORITY_THRESHOLD}). */
|
|
73
|
+
superMajorityThreshold?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Opt in to transacting below the safe cluster-size floor when FRET has no confident
|
|
76
|
+
* network-size estimate — the membership-admission and coordinator small-cluster gates both
|
|
77
|
+
* fail closed without it. Default false. Turn on only for single-node / local dev meshes that
|
|
78
|
+
* knowingly run undersized.
|
|
79
|
+
*/
|
|
80
|
+
allowUnvalidatedSmallCluster?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* The smallest cohort this deployment can genuinely field — normally the number of nodes you
|
|
83
|
+
* actually run, capped at `clusterSize`. Two consumers read it: the membership admission gate,
|
|
84
|
+
* on its fallback path when the node has no confident network-size estimate; and the
|
|
85
|
+
* read-repair/reconcile corroboration floor (`corroboratorCapacity`), unconditionally.
|
|
86
|
+
*
|
|
87
|
+
* Declaring it sets BOTH. Leaving it unset does NOT — see the module doc for why the two
|
|
88
|
+
* defaults point in opposite directions. A large deployment should still set this to its real
|
|
89
|
+
* cohort size, otherwise the admission gate cannot police a partition-induced downsize while
|
|
90
|
+
* its size estimate is unconfident; a genuine two-node mesh needs it (or an honest
|
|
91
|
+
* `clusterSize: 2`) to self-repair.
|
|
92
|
+
*/
|
|
93
|
+
assumedClusterSize?: number;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Everything a node's consensus + restoration paths need, with every default already applied. */
|
|
98
|
+
export type ResolvedClusterPolicy = ClusterConsensusConfig & {
|
|
99
|
+
/** Replication factor / target cohort breadth. Always concrete after resolution. */
|
|
100
|
+
clusterSize: number;
|
|
101
|
+
/**
|
|
102
|
+
* Yardstick the repair corroboration floor measures a (possibly shrunken, always unauthenticated)
|
|
103
|
+
* cohort view against — see `corroboratorCapacity` in `cluster/quorum-restore.ts`.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately distinct from {@link ClusterConsensusConfig.assumedClusterSize}, which the
|
|
106
|
+
* membership admission gate reads: the two share an operator field but not a default, because
|
|
107
|
+
* over- and under-stating them cost opposite things. See the module doc.
|
|
108
|
+
*/
|
|
109
|
+
repairCorroborationClusterSize: number;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Apply every cluster-policy default a node needs. Pure — same options in, same numbers out — so the
|
|
114
|
+
* composition root's behavior is unit-testable (`test/cluster-policy.spec.ts`).
|
|
115
|
+
*/
|
|
116
|
+
export function resolveClusterPolicy(options: ClusterPolicyOptions): ResolvedClusterPolicy {
|
|
117
|
+
// undefined here means "the operator said nothing", which is the only case where the two
|
|
118
|
+
// yardsticks below diverge.
|
|
119
|
+
//
|
|
120
|
+
// NOTE: a declared value is passed through unvalidated. The admission gate floors a degenerate one
|
|
121
|
+
// (0, negative, NaN, Infinity) itself — see `cluster-repo.admissionFloor` and its specs — but
|
|
122
|
+
// `corroboratorCapacity` does not: NaN there makes every quorum comparison false, so repair
|
|
123
|
+
// silently declines forever. Fail-safe, and unreachable through the reference-peer CLI (which
|
|
124
|
+
// rejects non-positive integers). If another composition root starts accepting unvalidated config,
|
|
125
|
+
// clamp here rather than in each consumer.
|
|
126
|
+
const declaredCohortSize = options.clusterPolicy?.assumedClusterSize;
|
|
127
|
+
const clusterSize = options.clusterSize ?? 10;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
|
|
131
|
+
simpleMajorityThreshold: 0.51,
|
|
132
|
+
minAbsoluteClusterSize,
|
|
133
|
+
allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
|
|
134
|
+
clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
|
|
135
|
+
// Fail closed by default (an undersized cluster with no confident network-size estimate is
|
|
136
|
+
// rejected); embedders running knowingly-small meshes opt in through clusterPolicy.
|
|
137
|
+
allowUnvalidatedSmallCluster: options.clusterPolicy?.allowUnvalidatedSmallCluster ?? false,
|
|
138
|
+
partitionDetectionWindow: 60000,
|
|
139
|
+
// Replication factor / target cohort breadth — what the coordinator aims for when selecting a
|
|
140
|
+
// cohort. Deliberately NOT the membership admission gate's yardstick: it says nothing about how
|
|
141
|
+
// many peers actually exist, so an unconfigured small mesh would refuse every write.
|
|
142
|
+
clusterSize,
|
|
143
|
+
// Membership admission gate, fallback path only (no confident network-size estimate). Defaults
|
|
144
|
+
// permissive so a two- or three-node mesh transacts unconfigured; the cost of that default is
|
|
145
|
+
// bounded to the gate, since the repair floor no longer reads this field.
|
|
146
|
+
assumedClusterSize: declaredCohortSize ?? minAbsoluteClusterSize,
|
|
147
|
+
// Repair corroboration floor, every repair. Defaults strict — to the replication factor — so an
|
|
148
|
+
// unconfigured node cannot have its floor talked down to a single voter by a shrunken cohort
|
|
149
|
+
// view. A genuinely small mesh declares its size (either field) to regain self-repair.
|
|
150
|
+
repairCorroborationClusterSize: declaredCohortSize ?? clusterSize
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -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', {
|
|
@@ -1008,6 +1074,13 @@ export class ClusterMember implements ICluster {
|
|
|
1008
1074
|
requestedRev: pendRequest.rev,
|
|
1009
1075
|
latestRev
|
|
1010
1076
|
});
|
|
1077
|
+
// Deliberately prose-only: this reason is fed to computeSigningPayload, signed,
|
|
1078
|
+
// and carried as Signature.rejectReason, so adding a structured revision here
|
|
1079
|
+
// would change the signed byte layout and the Signature type — every peer would
|
|
1080
|
+
// have to agree on the new format or verification breaks across versions. This
|
|
1081
|
+
// is NOT a StaleFailure producer, so StaleFailure.staleAt does not apply; the
|
|
1082
|
+
// coordinator's own local re-read (CoordinatorRepo.classifyStaleRejection)
|
|
1083
|
+
// supplies that number when it can confirm the revision itself.
|
|
1011
1084
|
return { valid: false, reason: `stale revision: block ${blockId} at rev ${latestRev}, requested rev ${pendRequest.rev}` };
|
|
1012
1085
|
}
|
|
1013
1086
|
}
|
|
@@ -1395,7 +1468,12 @@ export class ClusterMember implements ICluster {
|
|
|
1395
1468
|
private async reconcileOneBlock(messageHash: string, blockId: BlockId, committed: ActionRev, cohortPeerIds: string[]): Promise<void> {
|
|
1396
1469
|
try {
|
|
1397
1470
|
await this.withReconcileTimeout(this.reconcileBlock!(blockId, committed, cohortPeerIds), blockId);
|
|
1398
|
-
|
|
1471
|
+
// "attempted", not "reconciled": the callback returns void, and a quorum decline is a
|
|
1472
|
+
// normal, non-throwing outcome — so reaching here means the pass ran to completion, NOT
|
|
1473
|
+
// that anything was restored. `reconcile:restored` (reconcile-block.ts) is the line that
|
|
1474
|
+
// says the bytes actually landed; `reconcile:no-rev-quorum` / `reconcile:no-content-quorum`
|
|
1475
|
+
// say they did not.
|
|
1476
|
+
log('cluster-member:consensus-commit-reconcile-attempted', { messageHash, blockId, rev: committed.rev });
|
|
1399
1477
|
} catch (err) {
|
|
1400
1478
|
log('cluster-member:consensus-commit-reconcile-failed', {
|
|
1401
1479
|
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
|
/**
|