@optimystic/db-p2p 0.21.0 → 0.24.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 (219) hide show
  1. package/dist/src/cluster/client.d.ts +10 -0
  2. package/dist/src/cluster/client.d.ts.map +1 -1
  3. package/dist/src/cluster/client.js +30 -1
  4. package/dist/src/cluster/client.js.map +1 -1
  5. package/dist/src/cluster/cluster-policy.d.ts +13 -2
  6. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-policy.js +51 -4
  8. package/dist/src/cluster/cluster-policy.js.map +1 -1
  9. package/dist/src/cluster/cluster-repo.d.ts +42 -17
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +229 -122
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/cluster-size-coupling.d.ts +28 -0
  14. package/dist/src/cluster/cluster-size-coupling.d.ts.map +1 -0
  15. package/dist/src/cluster/cluster-size-coupling.js +35 -0
  16. package/dist/src/cluster/cluster-size-coupling.js.map +1 -0
  17. package/dist/src/cluster/quorum-restore.d.ts +6 -0
  18. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  19. package/dist/src/cluster/quorum-restore.js +1 -1
  20. package/dist/src/cluster/quorum-restore.js.map +1 -1
  21. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  22. package/dist/src/cluster/reconcile-block.js +15 -3
  23. package/dist/src/cluster/reconcile-block.js.map +1 -1
  24. package/dist/src/cluster/service.d.ts +32 -1
  25. package/dist/src/cluster/service.d.ts.map +1 -1
  26. package/dist/src/cluster/service.js +43 -2
  27. package/dist/src/cluster/service.js.map +1 -1
  28. package/dist/src/cohort-topic/stream-util.d.ts +22 -6
  29. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  30. package/dist/src/cohort-topic/stream-util.js +56 -10
  31. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  32. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  33. package/dist/src/dispute/dispute-service.js +9 -3
  34. package/dist/src/dispute/dispute-service.js.map +1 -1
  35. package/dist/src/index.d.ts +5 -0
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +5 -0
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/libp2p-key-network.d.ts +134 -7
  40. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  41. package/dist/src/libp2p-key-network.js +174 -37
  42. package/dist/src/libp2p-key-network.js.map +1 -1
  43. package/dist/src/libp2p-node-base.d.ts +3 -2
  44. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  45. package/dist/src/libp2p-node-base.js +859 -778
  46. package/dist/src/libp2p-node-base.js.map +1 -1
  47. package/dist/src/libp2p-node-rn.d.ts +2 -2
  48. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  49. package/dist/src/libp2p-node-rn.js.map +1 -1
  50. package/dist/src/libp2p-node.d.ts +2 -2
  51. package/dist/src/libp2p-node.d.ts.map +1 -1
  52. package/dist/src/libp2p-node.js.map +1 -1
  53. package/dist/src/logger.d.ts +17 -1
  54. package/dist/src/logger.d.ts.map +1 -1
  55. package/dist/src/logger.js +19 -2
  56. package/dist/src/logger.js.map +1 -1
  57. package/dist/src/network/network-manager-service.d.ts +2 -0
  58. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  59. package/dist/src/network/network-manager-service.js +4 -0
  60. package/dist/src/network/network-manager-service.js.map +1 -1
  61. package/dist/src/optimystic-node.d.ts +35 -0
  62. package/dist/src/optimystic-node.d.ts.map +1 -0
  63. package/dist/src/optimystic-node.js +2 -0
  64. package/dist/src/optimystic-node.js.map +1 -0
  65. package/dist/src/owned-block-seed.d.ts +6 -3
  66. package/dist/src/owned-block-seed.d.ts.map +1 -1
  67. package/dist/src/owned-block-seed.js +16 -3
  68. package/dist/src/owned-block-seed.js.map +1 -1
  69. package/dist/src/peer-address-book.d.ts +72 -0
  70. package/dist/src/peer-address-book.d.ts.map +1 -0
  71. package/dist/src/peer-address-book.js +123 -0
  72. package/dist/src/peer-address-book.js.map +1 -0
  73. package/dist/src/repo/client.d.ts.map +1 -1
  74. package/dist/src/repo/client.js +11 -2
  75. package/dist/src/repo/client.js.map +1 -1
  76. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  77. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  78. package/dist/src/repo/cluster-coordinator.js +95 -3
  79. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  80. package/dist/src/repo/coordinator-repo.d.ts +78 -14
  81. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  82. package/dist/src/repo/coordinator-repo.js +266 -81
  83. package/dist/src/repo/coordinator-repo.js.map +1 -1
  84. package/dist/src/rn.d.ts +5 -0
  85. package/dist/src/rn.d.ts.map +1 -1
  86. package/dist/src/rn.js +5 -0
  87. package/dist/src/rn.js.map +1 -1
  88. package/dist/src/storage/block-storage.d.ts.map +1 -1
  89. package/dist/src/storage/block-storage.js +57 -5
  90. package/dist/src/storage/block-storage.js.map +1 -1
  91. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  92. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  93. package/dist/src/storage/cached-raw-storage.js +152 -0
  94. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  95. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  96. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  97. package/dist/src/storage/cached-store-driver.js +775 -0
  98. package/dist/src/storage/cached-store-driver.js.map +1 -0
  99. package/dist/src/storage/i-block-storage.d.ts +20 -1
  100. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  101. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  102. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  103. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  104. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  105. package/dist/src/storage/shared-cache-pool.js +354 -0
  106. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  107. package/dist/src/storage/storage-repo.d.ts +56 -3
  108. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  109. package/dist/src/storage/storage-repo.js +124 -18
  110. package/dist/src/storage/storage-repo.js.map +1 -1
  111. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  112. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  113. package/dist/src/testing/raw-storage-conformance.js +52 -2
  114. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  115. package/package.json +3 -3
  116. package/readme.md +668 -653
  117. package/src/cluster/block-transfer.ts +424 -424
  118. package/src/cluster/client.ts +119 -88
  119. package/src/cluster/cluster-error.ts +64 -64
  120. package/src/cluster/cluster-policy.ts +203 -152
  121. package/src/cluster/cluster-repo.ts +245 -125
  122. package/src/cluster/cluster-size-coupling.ts +45 -0
  123. package/src/cluster/commit-cert.ts +139 -139
  124. package/src/cluster/i-transaction-state-store.ts +43 -43
  125. package/src/cluster/memory-transaction-state-store.ts +56 -56
  126. package/src/cluster/peer-key-binding.ts +37 -37
  127. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  128. package/src/cluster/quorum-restore.ts +223 -223
  129. package/src/cluster/reconcile-block.ts +203 -191
  130. package/src/cluster/service.ts +293 -241
  131. package/src/cluster/supermajority-coupling.ts +37 -37
  132. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  133. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  134. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  135. package/src/cohort-topic/change-bridge.ts +109 -109
  136. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  137. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  138. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  139. package/src/cohort-topic/host.ts +2901 -2901
  140. package/src/cohort-topic/index.ts +13 -13
  141. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  142. package/src/cohort-topic/membership-source.ts +68 -68
  143. package/src/cohort-topic/peer-codec.ts +31 -31
  144. package/src/cohort-topic/peer-sig.ts +86 -86
  145. package/src/cohort-topic/protocols.ts +71 -71
  146. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  147. package/src/cohort-topic/size-estimator.ts +16 -16
  148. package/src/cohort-topic/stream-util.ts +135 -87
  149. package/src/cohort-topic/threshold-crypto.ts +239 -239
  150. package/src/cohort-topic/topic-router.ts +77 -77
  151. package/src/dispute/arbitrator-selection.ts +138 -138
  152. package/src/dispute/cascade.ts +524 -524
  153. package/src/dispute/dispute-service.ts +11 -5
  154. package/src/dispute/invalidation.ts +625 -625
  155. package/src/inbound-authorization.ts +190 -190
  156. package/src/index.ts +52 -47
  157. package/src/libp2p-key-network.ts +1120 -958
  158. package/src/libp2p-node-base.ts +1675 -1591
  159. package/src/libp2p-node-rn.ts +30 -30
  160. package/src/libp2p-node.ts +36 -36
  161. package/src/logger.ts +19 -2
  162. package/src/matchmaking/aggregate-counts.ts +104 -104
  163. package/src/matchmaking/index.ts +20 -20
  164. package/src/matchmaking/module.ts +363 -363
  165. package/src/matchmaking/protocols.ts +51 -51
  166. package/src/matchmaking/provider-manager.ts +95 -95
  167. package/src/matchmaking/query-handler.ts +88 -88
  168. package/src/matchmaking/query-transport.ts +492 -492
  169. package/src/matchmaking/seeker-manager.ts +64 -64
  170. package/src/matchmaking/seeker-walk-client.ts +293 -293
  171. package/src/matchmaking/traffic-validation.ts +195 -195
  172. package/src/network/network-manager-service.ts +5 -0
  173. package/src/optimystic-node.ts +36 -0
  174. package/src/owned-block-seed.ts +53 -40
  175. package/src/peer-address-book.ts +149 -0
  176. package/src/protocol-limits.ts +33 -33
  177. package/src/reactivity/forwarder-host.ts +438 -438
  178. package/src/reactivity/index.ts +19 -19
  179. package/src/reactivity/notify-transport.ts +144 -144
  180. package/src/reactivity/origination-manager.ts +192 -192
  181. package/src/reactivity/protocols.ts +61 -61
  182. package/src/reactivity/push-state-gossip.ts +291 -291
  183. package/src/reactivity/recover-transport.ts +408 -408
  184. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  185. package/src/reactivity/subscriber-registry.ts +96 -96
  186. package/src/reactivity/subscription-manager.ts +450 -450
  187. package/src/reactivity/topic-bytes.ts +37 -37
  188. package/src/repo/client.ts +12 -2
  189. package/src/repo/cluster-coordinator.ts +99 -3
  190. package/src/repo/coordinator-repo.ts +305 -82
  191. package/src/repo/types.ts +7 -7
  192. package/src/rn.ts +39 -34
  193. package/src/rpc-deadline.ts +45 -45
  194. package/src/storage/arachnode-partition.ts +74 -74
  195. package/src/storage/block-storage.ts +59 -6
  196. package/src/storage/cached-raw-storage.ts +180 -0
  197. package/src/storage/cached-store-driver.ts +859 -0
  198. package/src/storage/i-block-storage.ts +20 -1
  199. package/src/storage/i-kv-store.ts +8 -8
  200. package/src/storage/i-raw-storage.ts +12 -5
  201. package/src/storage/kv-raw-storage.ts +135 -135
  202. package/src/storage/memory-kv-store.ts +28 -28
  203. package/src/storage/memory-storage.ts +25 -25
  204. package/src/storage/memory-store-driver.ts +157 -157
  205. package/src/storage/raw-store-codec.ts +42 -42
  206. package/src/storage/raw-store-driver.ts +80 -80
  207. package/src/storage/ring-selector.ts +317 -317
  208. package/src/storage/ring-shift-coordinator.ts +271 -271
  209. package/src/storage/shared-cache-pool.ts +452 -0
  210. package/src/storage/storage-repo.ts +1014 -903
  211. package/src/testing/cohort-topic-mesh-harness.ts +663 -663
  212. package/src/testing/index.ts +8 -8
  213. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  214. package/src/testing/raw-storage-conformance.ts +453 -397
  215. package/src/testing/reactivity-mesh-harness.ts +922 -922
  216. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  217. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  218. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  219. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,241 +1,293 @@
1
- import { pipe } from 'it-pipe';
2
- import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed';
3
- import { peerIdFromString } from '@libp2p/peer-id';
4
- import type { Startable, Logger, Stream, Connection, StreamHandler, PeerId } from '@libp2p/interface';
5
- import type { ICluster, ClusterRecord } from '@optimystic/db-core';
6
- import { encodePeers, type RedirectPayload } from '../repo/redirect.js';
7
- import { toClusterErrorEnvelope } from './cluster-error.js';
8
- import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
9
- import type { Uint8ArrayList } from 'uint8arraylist';
10
- import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
11
-
12
- interface BaseComponents {
13
- logger: { forComponent: (name: string) => Logger },
14
- registrar: {
15
- handle: (protocol: string, handler: StreamHandler, options: any) => Promise<void>,
16
- unhandle: (protocol: string) => Promise<void>
17
- }
18
- }
19
-
20
- export interface ClusterServiceComponents extends BaseComponents {
21
- cluster: ICluster
22
- /**
23
- * This node's own peer id, used to decide whether we are a member of a
24
- * cluster record's peer set. When absent the service cannot scope membership
25
- * and processes every update locally (no redirect).
26
- */
27
- peerId?: PeerId
28
- /**
29
- * Optional resolver for a peer's dialable multiaddrs, used as a fallback when
30
- * a redirect target has no multiaddrs embedded in `record.peers`.
31
- */
32
- getConnectionAddrs?: (peerId: PeerId) => string[]
33
- }
34
-
35
- export interface ClusterServiceInit extends InboundStreamAuthorizationInit {
36
- protocol?: string,
37
- protocolPrefix?: string,
38
- maxInboundStreams?: number,
39
- maxOutboundStreams?: number,
40
- logPrefix?: string,
41
- /**
42
- * Responsibility K - the replica set size for determining cluster membership.
43
- * When the cluster record's peer set is smaller than this, the mesh is treated
44
- * as "small" and the update is processed locally regardless of membership. When
45
- * the peer set is at least this size and we are not a member, the update is
46
- * redirected to the responsible peers.
47
- * Default: 1 (only members process; any larger non-member set redirects)
48
- */
49
- responsibilityK?: number,
50
- }
51
-
52
- export function clusterService(init: ClusterServiceInit = {}): (components: ClusterServiceComponents) => ClusterService {
53
- return (components: ClusterServiceComponents) => new ClusterService(components, init);
54
- }
55
-
56
- /**
57
- * A libp2p service that handles cluster protocol messages
58
- */
59
- export class ClusterService implements Startable {
60
- private readonly protocol: string;
61
- private readonly maxInboundStreams: number;
62
- private readonly maxOutboundStreams: number;
63
- private readonly log: Logger;
64
- private readonly cluster: ICluster;
65
- private readonly components: ClusterServiceComponents;
66
- private running: boolean;
67
- /** Responsibility K - small-mesh bypass threshold for redirect decisions */
68
- private readonly responsibilityK: number;
69
- /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
70
- private readonly authorization: InboundStreamAuthorization | undefined;
71
-
72
- constructor(components: ClusterServiceComponents, init: ClusterServiceInit = {}) {
73
- this.components = components;
74
- this.protocol = init.protocol ?? (init.protocolPrefix ?? '/db-p2p') + '/cluster/1.0.0';
75
- this.maxInboundStreams = init.maxInboundStreams ?? 32;
76
- this.maxOutboundStreams = init.maxOutboundStreams ?? 64;
77
- this.log = components.logger.forComponent(init.logPrefix ?? 'db-p2p:cluster');
78
- this.cluster = components.cluster;
79
- this.running = false;
80
- this.responsibilityK = init.responsibilityK ?? 1;
81
- this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args));
82
- }
83
-
84
- readonly [Symbol.toStringTag] = '@libp2p/cluster';
85
-
86
- private getSelfId(): PeerId | undefined {
87
- if (this.components.peerId) return this.components.peerId;
88
- return (this.components as any).libp2p?.peerId as PeerId | undefined;
89
- }
90
-
91
- private getPeerAddrs(id: string): string[] {
92
- let pid: PeerId;
93
- try {
94
- pid = peerIdFromString(id);
95
- } catch {
96
- return [];
97
- }
98
- if (this.components.getConnectionAddrs) return this.components.getConnectionAddrs(pid);
99
- const libp2p = (this.components as any).libp2p;
100
- if (!libp2p?.getConnections) return [];
101
- const conns: any[] = libp2p.getConnections(pid) ?? [];
102
- const addrs: string[] = [];
103
- for (const c of conns) {
104
- const addr = c.remoteAddr?.toString?.();
105
- if (addr) addrs.push(addr);
106
- }
107
- return addrs;
108
- }
109
-
110
- /**
111
- * Decide whether this node should redirect a cluster update instead of
112
- * participating in its consensus.
113
- *
114
- * Membership is scoped against `record.peers` — the authoritative set the
115
- * coordinator already computed and embedded (it only ever dials peers in this
116
- * set). Using it directly (rather than independently recomputing the cluster
117
- * from the key) is regression-proof against the "empty promises" symptom: a
118
- * peer the coordinator legitimately included is, by construction, present in
119
- * `record.peers` and is therefore never redirected.
120
- *
121
- * Returns a {@link RedirectPayload} when this node is not responsible, or null
122
- * when the update should be processed locally (we are a member, the mesh is too
123
- * small to scope, or we lack the identity/peer set to make a decision).
124
- */
125
- checkRedirect(record: ClusterRecord): RedirectPayload | null {
126
- const selfId = this.getSelfId();
127
- if (!selfId) return null; // no identity → can't scope, process locally
128
-
129
- const peers = record.peers ?? {};
130
- const peerIds = Object.keys(peers);
131
- if (peerIds.length === 0) return null; // nothing to scope against → process locally
132
-
133
- const selfStr = selfId.toString();
134
- const isMember = peerIds.includes(selfStr);
135
- const smallMesh = peerIds.length < this.responsibilityK;
136
-
137
- if (!smallMesh && !isMember) {
138
- const others = peerIds.filter(id => id !== selfStr);
139
- return encodePeers(others.map(id => {
140
- const recAddrs = peers[id]?.multiaddrs ?? [];
141
- const addrs = recAddrs.length > 0 ? recAddrs : this.getPeerAddrs(id);
142
- return { id, addrs };
143
- }));
144
- }
145
-
146
- return null;
147
- }
148
-
149
- async start(): Promise<void> {
150
- if (this.running) {
151
- return;
152
- }
153
-
154
- await this.components.registrar.handle(this.protocol, this.handleIncomingStream.bind(this), {
155
- maxInboundStreams: this.maxInboundStreams,
156
- maxOutboundStreams: this.maxOutboundStreams
157
- });
158
-
159
- this.running = true;
160
- }
161
-
162
- async stop(): Promise<void> {
163
- if (!this.running) {
164
- return;
165
- }
166
-
167
- await this.components.registrar.unhandle(this.protocol);
168
- this.running = false;
169
- }
170
-
171
- /**
172
- * Run a single decoded protocol message. An application-level throw
173
- * (validation / signature / merge / consensus failure inside `cluster.update`)
174
- * propagates to the caller, which turns it into a structured error envelope;
175
- * a redirect or a successful {@link ClusterRecord} is returned as-is.
176
- */
177
- private async processOperation(message: { operation: string; record: ClusterRecord }): Promise<unknown> {
178
- if (message.operation === 'update') {
179
- // Scope consensus to responsible peers: redirect when we are not a
180
- // member of the record's authoritative peer set, otherwise process.
181
- const redirect = this.checkRedirect(message.record);
182
- return redirect ?? await this.cluster.update(message.record);
183
- }
184
- throw new Error(`Unknown operation: ${message.operation}`);
185
- }
186
-
187
- private handleIncomingStream(stream: Stream, connection?: Connection): void {
188
- const peerId = connection?.remotePeer;
189
-
190
- const processStream = async function* (this: ClusterService, source: AsyncIterable<Uint8ArrayList>) {
191
- for await (const msg of source) {
192
- // Decode the framing. A malformed/undecodable message is a transport
193
- // fault handled by the outer abort path, not an application error.
194
- const decoded = new TextDecoder().decode(msg.subarray());
195
- const message = JSON.parse(decoded) as { operation: string; record: ClusterRecord };
196
-
197
- // Application-level processing: surface any throw to the coordinator as
198
- // a structured error envelope (closing the stream normally) instead of
199
- // aborting, so the real cause not an opaque StreamResetError reaches
200
- // the coordinator, which already enables debug logging. The abort path
201
- // is reserved for genuinely unrecoverable framing/transport faults.
202
- let response: unknown;
203
- try {
204
- response = await this.processOperation(message);
205
- } catch (err) {
206
- this.log.error('error processing cluster %s from %p - %e', message.operation, peerId, err);
207
- response = toClusterErrorEnvelope(err);
208
- }
209
-
210
- // Encode and yield the response
211
- yield new TextEncoder().encode(JSON.stringify(response));
212
- // One request per stream: every real ClusterClient sends exactly one
213
- // request per dial (see ProtocolClient.processMessage), so complete the
214
- // generator after the first response. A second frame a peer queued is
215
- // then never read or parsed. Mirrors sync/block-transfer.
216
- return;
217
- }
218
- };
219
-
220
- void (async () => {
221
- try {
222
- // Authorization runs before ANY decoding or execution. Guarded on the field so a
223
- // node without a predicate keeps the original path untouched.
224
- if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return;
225
- const responses = pipe(
226
- stream,
227
- (source) => lpDecode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
228
- processStream.bind(this),
229
- (source) => lpEncode(source)
230
- );
231
- for await (const chunk of responses) {
232
- stream.send(chunk);
233
- }
234
- await stream.close();
235
- } catch (err) {
236
- this.log.error('error handling cluster protocol message from %p - %e', peerId, err);
237
- stream.abort(err instanceof Error ? err : new Error(String(err)));
238
- }
239
- })();
240
- }
241
- }
1
+ import { pipe } from 'it-pipe';
2
+ import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed';
3
+ import { peerIdFromString } from '@libp2p/peer-id';
4
+ import type { Startable, Logger, Stream, Connection, StreamHandler, PeerId } from '@libp2p/interface';
5
+ import type { ICluster, ClusterRecord } from '@optimystic/db-core';
6
+ import { encodePeers, type RedirectPayload } from '../repo/redirect.js';
7
+ import { toClusterErrorEnvelope } from './cluster-error.js';
8
+ import { mergeRecordPeerAddresses } from '../peer-address-book.js';
9
+ import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
10
+ import type { Uint8ArrayList } from 'uint8arraylist';
11
+ import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
12
+
13
+ interface BaseComponents {
14
+ logger: { forComponent: (name: string) => Logger },
15
+ registrar: {
16
+ handle: (protocol: string, handler: StreamHandler, options: any) => Promise<void>,
17
+ unhandle: (protocol: string) => Promise<void>
18
+ }
19
+ }
20
+
21
+ export interface ClusterServiceComponents extends BaseComponents {
22
+ cluster: ICluster
23
+ /**
24
+ * This node's own peer id, used to decide whether we are a member of a
25
+ * cluster record's peer set. When absent the service cannot scope membership
26
+ * and processes every update locally (no redirect).
27
+ */
28
+ peerId?: PeerId
29
+ /**
30
+ * Optional resolver for a peer's dialable multiaddrs, used as a fallback when
31
+ * a redirect target has no multiaddrs embedded in `record.peers`.
32
+ */
33
+ getConnectionAddrs?: (peerId: PeerId) => string[]
34
+ /**
35
+ * Optional sink for dialable addresses carried by an inbound cluster record, so this
36
+ * node can later dial a cohort sibling it has never had a connection to. Omitted →
37
+ * no address learning (the pre-existing behavior).
38
+ */
39
+ recordPeerAddresses?: (peerId: PeerId, multiaddrs: string[]) => void
40
+ }
41
+
42
+ export interface ClusterServiceInit extends InboundStreamAuthorizationInit {
43
+ protocol?: string,
44
+ protocolPrefix?: string,
45
+ maxInboundStreams?: number,
46
+ maxOutboundStreams?: number,
47
+ logPrefix?: string,
48
+ /**
49
+ * Responsibility K - the replica set size for determining cluster membership.
50
+ * When the cluster record's peer set is smaller than this, the mesh is treated
51
+ * as "small" and the update is processed locally regardless of membership. When
52
+ * the peer set is at least this size and we are not a member, the update is
53
+ * redirected to the responsible peers.
54
+ * Default: 1 (only members process; any larger non-member set redirects)
55
+ */
56
+ responsibilityK?: number,
57
+ }
58
+
59
+ export function clusterService(init: ClusterServiceInit = {}): (components: ClusterServiceComponents) => ClusterService {
60
+ return (components: ClusterServiceComponents) => new ClusterService(components, init);
61
+ }
62
+
63
+ /**
64
+ * A libp2p service that handles cluster protocol messages
65
+ */
66
+ export class ClusterService implements Startable {
67
+ private readonly protocol: string;
68
+ private readonly maxInboundStreams: number;
69
+ private readonly maxOutboundStreams: number;
70
+ private readonly log: Logger;
71
+ private readonly cluster: ICluster;
72
+ private readonly components: ClusterServiceComponents;
73
+ private running: boolean;
74
+ /** Responsibility K - small-mesh bypass threshold for redirect decisions */
75
+ private readonly responsibilityK: number;
76
+ /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
77
+ private readonly authorization: InboundStreamAuthorization | undefined;
78
+
79
+ constructor(components: ClusterServiceComponents, init: ClusterServiceInit = {}) {
80
+ this.components = components;
81
+ this.protocol = init.protocol ?? (init.protocolPrefix ?? '/db-p2p') + '/cluster/1.0.0';
82
+ this.maxInboundStreams = init.maxInboundStreams ?? 32;
83
+ this.maxOutboundStreams = init.maxOutboundStreams ?? 64;
84
+ this.log = components.logger.forComponent(init.logPrefix ?? 'db-p2p:cluster');
85
+ this.cluster = components.cluster;
86
+ this.running = false;
87
+ this.responsibilityK = init.responsibilityK ?? 1;
88
+ this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args));
89
+ }
90
+
91
+ readonly [Symbol.toStringTag] = '@libp2p/cluster';
92
+
93
+ /**
94
+ * Best-effort read of `components.libp2p`. When `components` is libp2p's own Proxy, the getter
95
+ * THROWS `MissingServiceError('libp2p not set')` for any key it does not hold — and `libp2p` is
96
+ * not a component — so the read itself must be guarded; `?.` and a following null check are both
97
+ * too late. Every fallback below is a convenience for embedders that register this service
98
+ * directly; the production wiring supplies `peerId`/`getConnectionAddrs` explicitly.
99
+ */
100
+ private getLibp2p(): any {
101
+ try {
102
+ return (this.components as any).libp2p;
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ private getSelfId(): PeerId | undefined {
109
+ if (this.components.peerId) return this.components.peerId;
110
+ return this.getLibp2p()?.peerId as PeerId | undefined;
111
+ }
112
+
113
+ private getPeerAddrs(id: string): string[] {
114
+ let pid: PeerId;
115
+ try {
116
+ pid = peerIdFromString(id);
117
+ } catch {
118
+ return [];
119
+ }
120
+ if (this.components.getConnectionAddrs) return this.components.getConnectionAddrs(pid);
121
+ const libp2p = this.getLibp2p();
122
+ if (!libp2p?.getConnections) return [];
123
+ const conns: any[] = libp2p.getConnections(pid) ?? [];
124
+ const addrs: string[] = [];
125
+ for (const c of conns) {
126
+ const addr = c.remoteAddr?.toString?.();
127
+ if (addr) addrs.push(addr);
128
+ }
129
+ return addrs;
130
+ }
131
+
132
+ /**
133
+ * Decide whether this node should redirect a cluster update instead of
134
+ * participating in its consensus.
135
+ *
136
+ * Membership is scoped against `record.peers` — the authoritative set the
137
+ * coordinator already computed and embedded (it only ever dials peers in this
138
+ * set). Using it directly (rather than independently recomputing the cluster
139
+ * from the key) is regression-proof against the "empty promises" symptom: a
140
+ * peer the coordinator legitimately included is, by construction, present in
141
+ * `record.peers` and is therefore never redirected.
142
+ *
143
+ * Returns a {@link RedirectPayload} when this node is not responsible, or null
144
+ * when the update should be processed locally (we are a member, the mesh is too
145
+ * small to scope, or we lack the identity/peer set to make a decision).
146
+ */
147
+ checkRedirect(record: ClusterRecord): RedirectPayload | null {
148
+ const selfId = this.getSelfId();
149
+ if (!selfId) return null; // no identity → can't scope, process locally
150
+
151
+ const peers = record.peers ?? {};
152
+ const peerIds = Object.keys(peers);
153
+ if (peerIds.length === 0) return null; // nothing to scope against → process locally
154
+
155
+ const selfStr = selfId.toString();
156
+ const isMember = peerIds.includes(selfStr);
157
+ const smallMesh = peerIds.length < this.responsibilityK;
158
+
159
+ if (!smallMesh && !isMember) {
160
+ const others = peerIds.filter(id => id !== selfStr);
161
+ return encodePeers(others.map(id => {
162
+ const recAddrs = peers[id]?.multiaddrs ?? [];
163
+ const addrs = recAddrs.length > 0 ? recAddrs : this.getPeerAddrs(id);
164
+ return { id, addrs };
165
+ }));
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ async start(): Promise<void> {
172
+ if (this.running) {
173
+ return;
174
+ }
175
+
176
+ await this.components.registrar.handle(this.protocol, this.handleIncomingStream.bind(this), {
177
+ maxInboundStreams: this.maxInboundStreams,
178
+ maxOutboundStreams: this.maxOutboundStreams
179
+ });
180
+
181
+ this.running = true;
182
+ }
183
+
184
+ async stop(): Promise<void> {
185
+ if (!this.running) {
186
+ return;
187
+ }
188
+
189
+ await this.components.registrar.unhandle(this.protocol);
190
+ this.running = false;
191
+ }
192
+
193
+ /**
194
+ * Run a single decoded protocol message. An application-level throw
195
+ * (validation / signature / merge / consensus failure inside `cluster.update`)
196
+ * propagates to the caller, which turns it into a structured error envelope;
197
+ * a redirect or a successful {@link ClusterRecord} is returned as-is.
198
+ *
199
+ * Public for the same reason {@link checkRedirect} is: it is the whole wire-ingress decision
200
+ * for a cluster update, and a test that reconstructs it by hand stops proving anything about
201
+ * the real ordering (address learning before redirect before consensus).
202
+ */
203
+ async processOperation(message: { operation: string; record: ClusterRecord }): Promise<unknown> {
204
+ if (message.operation === 'update') {
205
+ // Learn the cohort's addresses FIRST — before both the redirect decision and
206
+ // local consensus, since either can go on to dial these same peers. libp2p only
207
+ // tells us the addresses of peers we are directly connected to, so for a cohort
208
+ // picked by key position this record is often the only place a relay-only
209
+ // sibling's address ever reaches us.
210
+ this.learnPeerAddresses(message.record);
211
+ // Scope consensus to responsible peers: redirect when we are not a
212
+ // member of the record's authoritative peer set, otherwise process.
213
+ const redirect = this.checkRedirect(message.record);
214
+ return redirect ?? await this.cluster.update(message.record);
215
+ }
216
+ throw new Error(`Unknown operation: ${message.operation}`);
217
+ }
218
+
219
+ /**
220
+ * Offer every address the record carries for its cohort members to the node's address book.
221
+ *
222
+ * This runs on a record NOTHING has validated yet before {@link checkRedirect} and before
223
+ * `cluster.update` checks a signature and inbound stream authorization is opt-in, so the
224
+ * peer map here is whatever the dialer chose to send. The traversal (and the cap on how many
225
+ * peers one record may introduce) is therefore shared with `ClusterClient`, in
226
+ * `peer-address-book.ts`, along with the per-address validation and the trust boundary.
227
+ */
228
+ private learnPeerAddresses(record: ClusterRecord): void {
229
+ const sink = this.components.recordPeerAddresses;
230
+ if (!sink) return;
231
+ mergeRecordPeerAddresses(
232
+ record.peers,
233
+ sink,
234
+ (fmt, ...args) => this.log.error(fmt, ...args),
235
+ this.getSelfId()?.toString()
236
+ );
237
+ }
238
+
239
+ private handleIncomingStream(stream: Stream, connection?: Connection): void {
240
+ const peerId = connection?.remotePeer;
241
+
242
+ const processStream = async function* (this: ClusterService, source: AsyncIterable<Uint8ArrayList>) {
243
+ for await (const msg of source) {
244
+ // Decode the framing. A malformed/undecodable message is a transport
245
+ // fault handled by the outer abort path, not an application error.
246
+ const decoded = new TextDecoder().decode(msg.subarray());
247
+ const message = JSON.parse(decoded) as { operation: string; record: ClusterRecord };
248
+
249
+ // Application-level processing: surface any throw to the coordinator as
250
+ // a structured error envelope (closing the stream normally) instead of
251
+ // aborting, so the real cause — not an opaque StreamResetError — reaches
252
+ // the coordinator, which already enables debug logging. The abort path
253
+ // is reserved for genuinely unrecoverable framing/transport faults.
254
+ let response: unknown;
255
+ try {
256
+ response = await this.processOperation(message);
257
+ } catch (err) {
258
+ this.log.error('error processing cluster %s from %p - %e', message.operation, peerId, err);
259
+ response = toClusterErrorEnvelope(err);
260
+ }
261
+
262
+ // Encode and yield the response
263
+ yield new TextEncoder().encode(JSON.stringify(response));
264
+ // One request per stream: every real ClusterClient sends exactly one
265
+ // request per dial (see ProtocolClient.processMessage), so complete the
266
+ // generator after the first response. A second frame a peer queued is
267
+ // then never read or parsed. Mirrors sync/block-transfer.
268
+ return;
269
+ }
270
+ };
271
+
272
+ void (async () => {
273
+ try {
274
+ // Authorization runs before ANY decoding or execution. Guarded on the field so a
275
+ // node without a predicate keeps the original path untouched.
276
+ if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return;
277
+ const responses = pipe(
278
+ stream,
279
+ (source) => lpDecode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
280
+ processStream.bind(this),
281
+ (source) => lpEncode(source)
282
+ );
283
+ for await (const chunk of responses) {
284
+ stream.send(chunk);
285
+ }
286
+ await stream.close();
287
+ } catch (err) {
288
+ this.log.error('error handling cluster protocol message from %p - %e', peerId, err);
289
+ stream.abort(err instanceof Error ? err : new Error(String(err)));
290
+ }
291
+ })();
292
+ }
293
+ }
@@ -1,37 +1,37 @@
1
- import { DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
2
-
3
- /** A component that exposes the super-majority threshold it actually runs on. */
4
- export interface HasEffectiveSuperMajorityThreshold {
5
- readonly effectiveSuperMajorityThreshold: number;
6
- }
7
-
8
- /**
9
- * Fail-fast coupling check for a live node's consensus wiring.
10
- *
11
- * The cluster **member** (what accepts a super-majority of promises as sufficient for a commit) and the
12
- * **coordinator** (what declares a transaction committed once it has that super-majority) MUST run the
13
- * SAME `superMajorityThreshold`. If they disagree, the coordinator can declare a transaction final on a
14
- * super-majority the member never accepts as final — a silent phase-disagreement that only surfaces
15
- * mid-consensus, far too late.
16
- *
17
- * On a live node both are fed from a single resolved `consensusConfig`, so this check normally passes. It
18
- * exists to catch *future* drift — divergent fallback defaults, a mis-threaded `clusterPolicy`, a
19
- * per-deployment config that reaches only one side — by throwing at construction with both values and
20
- * their provenance, rather than letting the node come up mismatched.
21
- *
22
- * @throws Error naming both resolved thresholds and where they come from, if they are not equal.
23
- */
24
- export function assertSuperMajorityCoupling(
25
- member: HasEffectiveSuperMajorityThreshold,
26
- coordinator: HasEffectiveSuperMajorityThreshold
27
- ): void {
28
- const memberThreshold = member.effectiveSuperMajorityThreshold;
29
- const coordinatorThreshold = coordinator.effectiveSuperMajorityThreshold;
30
- if (memberThreshold !== coordinatorThreshold) {
31
- throw new Error(
32
- `Super-majority threshold mismatch at node startup: cluster member resolved ${memberThreshold} but coordinator resolved ${coordinatorThreshold}. ` +
33
- `Both derive from consensusConfig.superMajorityThreshold (options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD=${DEFAULT_SUPER_MAJORITY_THRESHOLD}); ` +
34
- `they must be equal or the coordinator would declare transactions committed on a super-majority the member rejects as final.`
35
- );
36
- }
37
- }
1
+ import { DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
2
+
3
+ /** A component that exposes the super-majority threshold it actually runs on. */
4
+ export interface HasEffectiveSuperMajorityThreshold {
5
+ readonly effectiveSuperMajorityThreshold: number;
6
+ }
7
+
8
+ /**
9
+ * Fail-fast coupling check for a live node's consensus wiring.
10
+ *
11
+ * The cluster **member** (what accepts a super-majority of promises as sufficient for a commit) and the
12
+ * **coordinator** (what declares a transaction committed once it has that super-majority) MUST run the
13
+ * SAME `superMajorityThreshold`. If they disagree, the coordinator can declare a transaction final on a
14
+ * super-majority the member never accepts as final — a silent phase-disagreement that only surfaces
15
+ * mid-consensus, far too late.
16
+ *
17
+ * On a live node both are fed from a single resolved `consensusConfig`, so this check normally passes. It
18
+ * exists to catch *future* drift — divergent fallback defaults, a mis-threaded `clusterPolicy`, a
19
+ * per-deployment config that reaches only one side — by throwing at construction with both values and
20
+ * their provenance, rather than letting the node come up mismatched.
21
+ *
22
+ * @throws Error naming both resolved thresholds and where they come from, if they are not equal.
23
+ */
24
+ export function assertSuperMajorityCoupling(
25
+ member: HasEffectiveSuperMajorityThreshold,
26
+ coordinator: HasEffectiveSuperMajorityThreshold
27
+ ): void {
28
+ const memberThreshold = member.effectiveSuperMajorityThreshold;
29
+ const coordinatorThreshold = coordinator.effectiveSuperMajorityThreshold;
30
+ if (memberThreshold !== coordinatorThreshold) {
31
+ throw new Error(
32
+ `Super-majority threshold mismatch at node startup: cluster member resolved ${memberThreshold} but coordinator resolved ${coordinatorThreshold}. ` +
33
+ `Both derive from consensusConfig.superMajorityThreshold (options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD=${DEFAULT_SUPER_MAJORITY_THRESHOLD}); ` +
34
+ `they must be equal or the coordinator would declare transactions committed on a super-majority the member rejects as final.`
35
+ );
36
+ }
37
+ }