@optimystic/db-p2p 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/src/cluster/cluster-policy.d.ts +13 -2
  2. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  3. package/dist/src/cluster/cluster-policy.js +51 -4
  4. package/dist/src/cluster/cluster-policy.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +3 -3
  6. package/dist/src/cluster/cluster-repo.js +3 -3
  7. package/dist/src/cluster/cluster-size-coupling.d.ts +28 -0
  8. package/dist/src/cluster/cluster-size-coupling.d.ts.map +1 -0
  9. package/dist/src/cluster/cluster-size-coupling.js +35 -0
  10. package/dist/src/cluster/cluster-size-coupling.js.map +1 -0
  11. package/dist/src/cluster/quorum-restore.d.ts +6 -0
  12. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  13. package/dist/src/cluster/quorum-restore.js +1 -1
  14. package/dist/src/cluster/quorum-restore.js.map +1 -1
  15. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  16. package/dist/src/cluster/reconcile-block.js +15 -3
  17. package/dist/src/cluster/reconcile-block.js.map +1 -1
  18. package/dist/src/index.d.ts +2 -0
  19. package/dist/src/index.d.ts.map +1 -1
  20. package/dist/src/index.js +2 -0
  21. package/dist/src/index.js.map +1 -1
  22. package/dist/src/libp2p-key-network.d.ts +46 -5
  23. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  24. package/dist/src/libp2p-key-network.js +40 -9
  25. package/dist/src/libp2p-key-network.js.map +1 -1
  26. package/dist/src/libp2p-node-base.d.ts +3 -2
  27. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  28. package/dist/src/libp2p-node-base.js +834 -777
  29. package/dist/src/libp2p-node-base.js.map +1 -1
  30. package/dist/src/libp2p-node-rn.d.ts +2 -2
  31. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  32. package/dist/src/libp2p-node-rn.js.map +1 -1
  33. package/dist/src/libp2p-node.d.ts +2 -2
  34. package/dist/src/libp2p-node.d.ts.map +1 -1
  35. package/dist/src/libp2p-node.js.map +1 -1
  36. package/dist/src/network/network-manager-service.d.ts +2 -0
  37. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  38. package/dist/src/network/network-manager-service.js +4 -0
  39. package/dist/src/network/network-manager-service.js.map +1 -1
  40. package/dist/src/optimystic-node.d.ts +35 -0
  41. package/dist/src/optimystic-node.d.ts.map +1 -0
  42. package/dist/src/optimystic-node.js +2 -0
  43. package/dist/src/optimystic-node.js.map +1 -0
  44. package/dist/src/repo/coordinator-repo.d.ts +16 -5
  45. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  46. package/dist/src/repo/coordinator-repo.js +24 -8
  47. package/dist/src/repo/coordinator-repo.js.map +1 -1
  48. package/dist/src/rn.d.ts +2 -0
  49. package/dist/src/rn.d.ts.map +1 -1
  50. package/dist/src/rn.js +2 -0
  51. package/dist/src/rn.js.map +1 -1
  52. package/dist/src/storage/block-storage.d.ts.map +1 -1
  53. package/dist/src/storage/block-storage.js +57 -5
  54. package/dist/src/storage/block-storage.js.map +1 -1
  55. package/dist/src/storage/i-block-storage.d.ts +20 -1
  56. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  57. package/dist/src/storage/storage-repo.d.ts +56 -3
  58. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  59. package/dist/src/storage/storage-repo.js +124 -18
  60. package/dist/src/storage/storage-repo.js.map +1 -1
  61. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  62. package/dist/src/testing/raw-storage-conformance.js +17 -0
  63. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  64. package/package.json +2 -2
  65. package/readme.md +41 -26
  66. package/src/cluster/cluster-policy.ts +55 -4
  67. package/src/cluster/cluster-repo.ts +3 -3
  68. package/src/cluster/cluster-size-coupling.ts +45 -0
  69. package/src/cluster/quorum-restore.ts +1 -1
  70. package/src/cluster/reconcile-block.ts +15 -3
  71. package/src/index.ts +2 -0
  72. package/src/libp2p-key-network.ts +41 -9
  73. package/src/libp2p-node-base.ts +907 -847
  74. package/src/libp2p-node-rn.ts +2 -2
  75. package/src/libp2p-node.ts +2 -2
  76. package/src/network/network-manager-service.ts +5 -0
  77. package/src/optimystic-node.ts +36 -0
  78. package/src/repo/coordinator-repo.ts +24 -8
  79. package/src/rn.ts +2 -0
  80. package/src/storage/block-storage.ts +59 -6
  81. package/src/storage/i-block-storage.ts +20 -1
  82. package/src/storage/storage-repo.ts +129 -18
  83. package/src/testing/raw-storage-conformance.ts +20 -0
@@ -1,4 +1,8 @@
1
1
  import { DEFAULT_SUPER_MAJORITY_THRESHOLD, type ClusterConsensusConfig } from "@optimystic/db-core";
2
+ import { createLogger } from "../logger.js";
3
+ import { CORROBORATION_FLOOR } from "./quorum-restore.js";
4
+
5
+ const log = createLogger('cluster-policy');
2
6
 
3
7
  /**
4
8
  * Resolves the operator-facing cluster knobs (`clusterSize`, `clusterPolicy.*`) into the concrete
@@ -48,6 +52,16 @@ import { DEFAULT_SUPER_MAJORITY_THRESHOLD, type ClusterConsensusConfig } from "@
48
52
  */
49
53
  export const minAbsoluteClusterSize = 2;
50
54
 
55
+ /**
56
+ * Default replication factor / target cohort breadth when the operator declares no `clusterSize`.
57
+ *
58
+ * Exported (and re-exported from the package root) rather than left inline because a caller that
59
+ * must construct a `Libp2pKeyPeerNetwork` for a node it did not build has to state a cluster size —
60
+ * the constructor no longer supplies one — and the only defensible answer is "whatever a node built
61
+ * here would have resolved to". Repeating the literal is how the two drifted last time.
62
+ */
63
+ export const DEFAULT_CLUSTER_SIZE = 10;
64
+
51
65
  /**
52
66
  * The operator-facing cluster knobs. `NodeOptions` (`libp2p-node-base.ts`) intersects this rather
53
67
  * than restating it, so a knob added here is one `resolveClusterPolicy` is guaranteed to see — a
@@ -110,8 +124,10 @@ export type ResolvedClusterPolicy = ClusterConsensusConfig & {
110
124
  };
111
125
 
112
126
  /**
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`).
127
+ * Apply every cluster-policy default a node needs. Same options in, same numbers out, so the
128
+ * composition root's behavior is unit-testable (`test/cluster-policy.spec.ts`). Its one side effect
129
+ * is the `assumed-cluster-size-unset` advisory below, which lives here because this is the only place
130
+ * that knows the resolution produced a self-defeating combination.
115
131
  */
116
132
  export function resolveClusterPolicy(options: ClusterPolicyOptions): ResolvedClusterPolicy {
117
133
  // undefined here means "the operator said nothing", which is the only case where the two
@@ -124,7 +140,42 @@ export function resolveClusterPolicy(options: ClusterPolicyOptions): ResolvedClu
124
140
  // rejects non-positive integers). If another composition root starts accepting unvalidated config,
125
141
  // clamp here rather than in each consumer.
126
142
  const declaredCohortSize = options.clusterPolicy?.assumedClusterSize;
127
- const clusterSize = options.clusterSize ?? 10;
143
+ const clusterSize = options.clusterSize ?? DEFAULT_CLUSTER_SIZE;
144
+ const repairCorroborationClusterSize = declaredCohortSize ?? clusterSize;
145
+
146
+ // Called once per node (resolveClusterPolicy runs once at construction), so this fires once per
147
+ // node startup, not per repair — a per-attempt warn on a busy node would be noise that gets
148
+ // filtered, defeating the point. Fires purely off configuration (not an observed cohort), so a
149
+ // deployment that genuinely runs `clusterSize` machines sees it too; worded as a conditional
150
+ // ("if you run fewer than N machines") rather than a fault for exactly that reason.
151
+ //
152
+ // The machine count in the message is CORROBORATION_FLOOR + 1, NOT repairCorroborationClusterSize:
153
+ // `corroboratorCapacity` caps only the FLOOR of two (`quorum-restore.ts`), so a cohort with two
154
+ // peers besides the reader meets it whatever the declared size. What an undeclared size costs is
155
+ // the relaxation below two, which is only reachable when repairCorroborationClusterSize <= 2.
156
+ // NOTE: only the UNDECLARED case warns. An operator who declares an assumedClusterSize larger than
157
+ // the cohort they actually run is equally unable to repair and gets no warning — deliberate, since
158
+ // a declaration is an explicit assertion and this function has no observed cohort to contradict it
159
+ // with. If `feat-admission-floor-from-observed-cohort-high-water-mark` ever lands (deriving the
160
+ // yardstick from observation), that warning becomes cheap and worth adding here.
161
+ if (declaredCohortSize === undefined && clusterSize > minAbsoluteClusterSize) {
162
+ const minimumSelfHealingDeployment = CORROBORATION_FLOOR + 1;
163
+ log('assumed-cluster-size-unset', {
164
+ clusterSize,
165
+ repairCorroborationClusterSize,
166
+ corroborationFloor: CORROBORATION_FLOOR,
167
+ minimumSelfHealingDeployment,
168
+ message:
169
+ `No clusterPolicy.assumedClusterSize declared: block repair (read-repair and reconcile) requires ` +
170
+ `${CORROBORATION_FLOOR} distinct corroborating peers other than the reader, and that floor is ` +
171
+ `relaxed only for a cohort that DECLARES it is smaller — with ` +
172
+ `repairCorroborationClusterSize=${repairCorroborationClusterSize} it never relaxes. So a deployment ` +
173
+ `that actually runs fewer than ${minimumSelfHealingDeployment} machines can never supply the floor ` +
174
+ `and every repair declines, permanently. If you run fewer than ${minimumSelfHealingDeployment} ` +
175
+ `machines, set clusterPolicy.assumedClusterSize to your real cohort size; it does not lower ` +
176
+ `clusterSize=${clusterSize} (the replication factor). Larger deployments can ignore this.`
177
+ });
178
+ }
128
179
 
129
180
  return {
130
181
  superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
@@ -147,6 +198,6 @@ export function resolveClusterPolicy(options: ClusterPolicyOptions): ResolvedClu
147
198
  // Repair corroboration floor, every repair. Defaults strict — to the replication factor — so an
148
199
  // unconfigured node cannot have its floor talked down to a single voter by a shrunken cohort
149
200
  // view. A genuinely small mesh declares its size (either field) to regain self-repair.
150
- repairCorroborationClusterSize: declaredCohortSize ?? clusterSize
201
+ repairCorroborationClusterSize
151
202
  };
152
203
  }
@@ -990,9 +990,9 @@ export class ClusterMember implements ICluster {
990
990
  *
991
991
  * NOTE: partition safety needs `2 · membershipAdmissionFraction · superMajorityThreshold > 1` — each
992
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.
993
+ * both find them in one K-peer cluster. At the shipped defaults (0.75 · 0.75 both default to
994
+ * `DEFAULT_SUPER_MAJORITY_THRESHOLD` / `membershipAdmissionFraction`'s own default) that product is
995
+ * 1.125. If either default is ever lowered, re-check Theorem 2 in `docs/correctness.md` before shipping it.
996
996
  */
997
997
  private admissionFloor(k: number): number {
998
998
  const scaled = Math.ceil(this.membershipAdmissionFraction * k);
@@ -0,0 +1,45 @@
1
+ /** A component that exposes the cluster size it actually resolved to. */
2
+ export interface HasEffectiveClusterSize {
3
+ readonly effectiveClusterSize: number;
4
+ }
5
+
6
+ /**
7
+ * Fail-fast coupling check for a live node's cluster-size wiring, mirroring
8
+ * `assertSuperMajorityCoupling`.
9
+ *
10
+ * `resolveClusterPolicy` (`cluster/cluster-policy.ts`) is the single place that settles the
11
+ * replication factor / target cohort breadth (`clusterSize`). Every consumer that resolves its
12
+ * own cluster size — `Libp2pKeyPeerNetwork` (peer selection) and `NetworkManagerService` (ring
13
+ * sizing) — must be constructed from that SAME resolved value, or the membership admission
14
+ * gate's "is this declared peer set suspiciously small?" yardstick silently diverges from the
15
+ * cohort width peer selection actually assembles (ticket
16
+ * `bug-cluster-size-resolution-single-source`: an unconfigured node ran peer selection at 16
17
+ * while consensus believed full size was 10, so a cohort that had quietly lost six members still
18
+ * measured as full).
19
+ *
20
+ * On a live node both are constructed from `resolveClusterPolicy(options).clusterSize`, so this
21
+ * check normally passes. It exists to catch *future* drift — a call site reverting to its own
22
+ * default, a new consumer added without threading the resolved value — by throwing at
23
+ * construction with every resolved value and its source, rather than letting the node come up
24
+ * mismatched.
25
+ *
26
+ * @throws Error naming the resolved size and every consumer that disagrees with it.
27
+ */
28
+ export function assertClusterSizeCoupling(
29
+ resolvedClusterSize: number,
30
+ consumers: Record<string, HasEffectiveClusterSize | undefined>
31
+ ): void {
32
+ const mismatches = Object.entries(consumers)
33
+ .filter((entry): entry is [string, HasEffectiveClusterSize] => entry[1] !== undefined)
34
+ .filter(([, consumer]) => consumer.effectiveClusterSize !== resolvedClusterSize)
35
+ .map(([name, consumer]) => `${name} resolved ${consumer.effectiveClusterSize}`);
36
+
37
+ if (mismatches.length > 0) {
38
+ throw new Error(
39
+ `Cluster size mismatch at node startup: resolveClusterPolicy() produced clusterSize=${resolvedClusterSize}, ` +
40
+ `but ${mismatches.join(', ')}. Every cluster-size consumer must be constructed from the SAME resolved ` +
41
+ `value (see cluster/cluster-policy.ts), or the membership admission gate's yardstick can diverge from ` +
42
+ `the cohort width peer selection actually assembles.`
43
+ );
44
+ }
45
+ }
@@ -38,7 +38,7 @@ export interface QuorumRev {
38
38
  * seconded by a second, independent peer. See {@link quorumSize} for the cap that
39
39
  * applies when the cohort is smaller than this.
40
40
  */
41
- const CORROBORATION_FLOOR = 2;
41
+ export const CORROBORATION_FLOOR = 2;
42
42
 
43
43
  /**
44
44
  * Number of distinct corroborating votes required to accept a claim:
@@ -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, corroboratorCapacity,
7
+ selectQuorumRev, selectQuorumBlock, canonicalBlockHash, corroboratorCapacity, quorumSize,
8
8
  type RevClaim, type BlockHashCandidate, type QuorumRev
9
9
  } from "./quorum-restore.js";
10
10
  import { createLogger } from '../logger.js';
@@ -172,14 +172,26 @@ export function createReconcileBlock(deps: ReconcileBlockDeps): ReconcileBlockCa
172
172
  const selected = selectQuorumRev(revClaims, deps.simpleMajorityThreshold, capacity);
173
173
  if (!selected) {
174
174
  // Leave the block behind; churn/rebalance and the next commit retry.
175
- log('reconcile:no-rev-quorum', { blockId, rev: committed.rev, responders: revClaims.length, capacity });
175
+ log('reconcile:no-rev-quorum', {
176
+ blockId,
177
+ rev: committed.rev,
178
+ responders: revClaims.length,
179
+ required: quorumSize(revClaims.length, deps.simpleMajorityThreshold, capacity),
180
+ repairCorroborationClusterSize: deps.repairCorroborationClusterSize
181
+ });
176
182
  return;
177
183
  }
178
184
 
179
185
  const hashCandidates = await hashCarriers(candidates, selected);
180
186
  const agreed = selectQuorumBlock(hashCandidates, deps.simpleMajorityThreshold, capacity);
181
187
  if (!agreed) {
182
- log('reconcile:no-content-quorum', { blockId, rev: selected.rev, carriers: hashCandidates.length, capacity });
188
+ log('reconcile:no-content-quorum', {
189
+ blockId,
190
+ rev: selected.rev,
191
+ carriers: hashCandidates.length,
192
+ required: quorumSize(hashCandidates.length, deps.simpleMajorityThreshold, capacity),
193
+ repairCorroborationClusterSize: deps.repairCorroborationClusterSize
194
+ });
183
195
  return;
184
196
  }
185
197
 
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./cluster/client.js";
2
2
  export * from "./cluster/cluster-repo.js";
3
+ export * from "./cluster/cluster-policy.js";
3
4
  export * from "./cluster/commit-cert.js";
4
5
  export * from "./cluster/service.js";
5
6
  export * from "./cluster/rebalance-monitor.js";
@@ -31,6 +32,7 @@ export * from "./sync/service.js";
31
32
  export * from "./it-utility.js";
32
33
  export * from "./libp2p-key-network.js";
33
34
  export * from "./libp2p-node.js";
35
+ export * from "./optimystic-node.js";
34
36
  export * from "./routing/responsibility.js";
35
37
  export * from "./routing/libp2p-known-peers.js";
36
38
  export * from "./network/network-manager-service.js";
@@ -149,9 +149,19 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
149
149
  private readonly networkMode: NetworkMode;
150
150
  private readonly persistence?: NetworkStatePersistence;
151
151
 
152
+ // NOTE: seven positional parameters, and the list stays that way for now — converting to an
153
+ // options bag would touch ~50 construction sites in `test/libp2p-key-network.spec.ts` alone.
154
+ // Revisit if an eighth parameter is ever needed, or if that spec is being rewritten anyway.
152
155
  constructor(
153
156
  private readonly libp2p: Libp2p,
154
- private readonly clusterSize: number = 16,
157
+ /**
158
+ * Replication factor / target cohort breadth for peer selection. REQUIRED, deliberately:
159
+ * a silent default here meant a caller that did not know the node's cluster size quietly
160
+ * selected a different-width cohort than the node's own consensus path used for the same
161
+ * key. Reuse the node's own instance (`node.keyNetwork`) where one exists; a caller that
162
+ * genuinely must construct standalone passes `DEFAULT_CLUSTER_SIZE` (`cluster/cluster-policy.ts`).
163
+ */
164
+ private readonly clusterSize: number,
155
165
  selfCoordinationConfig?: SelfCoordinationConfig,
156
166
  networkMode?: NetworkMode,
157
167
  persistence?: NetworkStatePersistence,
@@ -161,17 +171,25 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
161
171
  * provided, coordinator/cohort selection is scoped to peers that serve THIS
162
172
  * network's `cluster`/`repo` protocol, so a peer that only belongs to another
163
173
  * network sharing the same physical nodes/bootstraps is never chosen. When
164
- * ABSENT, the membership filter is disabled (today's exact behavior) — required
165
- * for backward compatibility because most call sites don't know the network name.
174
+ * ABSENT, the membership filter is disabled.
175
+ *
176
+ * NOTE: optional for the same reason `clusterSize` used to be — "most call sites don't
177
+ * know the network name" — and that reason no longer holds: both production sites now
178
+ * pass it (`libp2p-node-base.ts`, and the foreign-node fallback in the Quereus
179
+ * collection-factory), and only the mock-based cases in `test/libp2p-key-network.spec.ts`
180
+ * omit it. So a caller omitting it today gets the filter silently off, exactly the shape
181
+ * that let a second key network be built with a wrong cohort width. Left optional because
182
+ * making it required would touch ~50 construction sites in that one spec and no reachable
183
+ * caller is affected. Make it required (or take the whole list as an options bag) the
184
+ * moment a THIRD production construction site appears, or when that spec is rewritten.
166
185
  */
167
186
  private readonly protocolPrefix?: string
168
187
  ) {
169
- // NOTE: no construction site in this repo passes a SelfCoordinationConfig — every one
170
- // leaves it `undefined` (libp2p-node-base.ts, quereus-plugin-optimystic's
171
- // collection-factory.ts and key-network.ts, reference-peer's cli.ts), so these
172
- // defaults are always what is in force and no operator can tune them. If tuning
173
- // `gracePeriodMs` is ever needed, those four sites have to thread the config through
174
- // first. Low urgency: a grace-period denial no longer fails the caller, it only costs
188
+ // NOTE: no production construction site in this repo passes a SelfCoordinationConfig —
189
+ // both leave it `undefined` (libp2p-node-base.ts, and the foreign-node fallback in
190
+ // quereus-plugin-optimystic's collection-factory.ts), so these defaults are always what
191
+ // is in force and no operator can tune them. If tuning `gracePeriodMs` is ever needed,
192
+ // those two sites have to thread the config through first. Low urgency: a grace-period denial no longer fails the caller, it only costs
175
193
  // a write the ~1s findCoordinator retry window before self-coordinating.
176
194
  this.selfCoordinationConfig = {
177
195
  gracePeriodMs: selfCoordinationConfig?.gracePeriodMs ?? 30_000,
@@ -183,6 +201,20 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
183
201
  this.setupConnectionTracking();
184
202
  }
185
203
 
204
+ /** The cluster size this instance actually resolved to, for `assertClusterSizeCoupling`. */
205
+ get effectiveClusterSize(): number {
206
+ return this.clusterSize;
207
+ }
208
+
209
+ /**
210
+ * The network-namespaced protocol prefix (`/optimystic/<networkName>`) selection is scoped to,
211
+ * or `undefined` when the network-membership filter is off. Readable so a spec can assert the
212
+ * node's attached instance really is network-scoped without reaching into a private field.
213
+ */
214
+ get effectiveProtocolPrefix(): string | undefined {
215
+ return this.protocolPrefix;
216
+ }
217
+
186
218
  // coordinator cache: key (base64url) -> peerId until expiry (bounded LRU-ish via Map insertion order)
187
219
  private readonly coordinatorCache = new Map<string, { id: PeerId, expires: number }>()
188
220
  private static readonly MAX_CACHE_ENTRIES = 1000