@optimystic/db-p2p 0.22.0 → 0.24.1

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 (194) 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-repo.d.ts +39 -14
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +226 -119
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/service.d.ts +32 -1
  10. package/dist/src/cluster/service.d.ts.map +1 -1
  11. package/dist/src/cluster/service.js +43 -2
  12. package/dist/src/cluster/service.js.map +1 -1
  13. package/dist/src/cohort-topic/host.js +34 -11
  14. package/dist/src/cohort-topic/host.js.map +1 -1
  15. package/dist/src/cohort-topic/stream-util.d.ts +37 -7
  16. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  17. package/dist/src/cohort-topic/stream-util.js +77 -19
  18. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  19. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  20. package/dist/src/dispute/dispute-service.js +9 -3
  21. package/dist/src/dispute/dispute-service.js.map +1 -1
  22. package/dist/src/index.d.ts +3 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +3 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/libp2p-key-network.d.ts +88 -2
  27. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  28. package/dist/src/libp2p-key-network.js +134 -28
  29. package/dist/src/libp2p-key-network.js.map +1 -1
  30. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  31. package/dist/src/libp2p-node-base.js +25 -1
  32. package/dist/src/libp2p-node-base.js.map +1 -1
  33. package/dist/src/logger.d.ts +17 -1
  34. package/dist/src/logger.d.ts.map +1 -1
  35. package/dist/src/logger.js +19 -2
  36. package/dist/src/logger.js.map +1 -1
  37. package/dist/src/matchmaking/query-transport.js +3 -3
  38. package/dist/src/matchmaking/query-transport.js.map +1 -1
  39. package/dist/src/owned-block-seed.d.ts +6 -3
  40. package/dist/src/owned-block-seed.d.ts.map +1 -1
  41. package/dist/src/owned-block-seed.js +16 -3
  42. package/dist/src/owned-block-seed.js.map +1 -1
  43. package/dist/src/peer-address-book.d.ts +72 -0
  44. package/dist/src/peer-address-book.d.ts.map +1 -0
  45. package/dist/src/peer-address-book.js +123 -0
  46. package/dist/src/peer-address-book.js.map +1 -0
  47. package/dist/src/reactivity/notify-transport.d.ts +4 -4
  48. package/dist/src/reactivity/notify-transport.js +6 -6
  49. package/dist/src/reactivity/notify-transport.js.map +1 -1
  50. package/dist/src/reactivity/push-state-gossip.js +2 -2
  51. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  52. package/dist/src/reactivity/recover-transport.d.ts +6 -2
  53. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  54. package/dist/src/reactivity/recover-transport.js +7 -3
  55. package/dist/src/reactivity/recover-transport.js.map +1 -1
  56. package/dist/src/repo/client.d.ts.map +1 -1
  57. package/dist/src/repo/client.js +11 -2
  58. package/dist/src/repo/client.js.map +1 -1
  59. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  60. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  61. package/dist/src/repo/cluster-coordinator.js +95 -3
  62. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  63. package/dist/src/repo/coordinator-repo.d.ts +62 -9
  64. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  65. package/dist/src/repo/coordinator-repo.js +242 -73
  66. package/dist/src/repo/coordinator-repo.js.map +1 -1
  67. package/dist/src/rn.d.ts +3 -0
  68. package/dist/src/rn.d.ts.map +1 -1
  69. package/dist/src/rn.js +3 -0
  70. package/dist/src/rn.js.map +1 -1
  71. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  72. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  73. package/dist/src/storage/cached-raw-storage.js +152 -0
  74. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  75. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  76. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  77. package/dist/src/storage/cached-store-driver.js +775 -0
  78. package/dist/src/storage/cached-store-driver.js.map +1 -0
  79. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  80. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  81. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  82. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  83. package/dist/src/storage/shared-cache-pool.js +354 -0
  84. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  85. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
  86. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  87. package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
  88. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  89. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  90. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  91. package/dist/src/testing/raw-storage-conformance.js +35 -2
  92. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  93. package/package.json +3 -3
  94. package/readme.md +668 -668
  95. package/src/cluster/block-transfer.ts +424 -424
  96. package/src/cluster/client.ts +119 -88
  97. package/src/cluster/cluster-error.ts +64 -64
  98. package/src/cluster/cluster-policy.ts +203 -203
  99. package/src/cluster/cluster-repo.ts +242 -122
  100. package/src/cluster/cluster-size-coupling.ts +45 -45
  101. package/src/cluster/commit-cert.ts +139 -139
  102. package/src/cluster/i-transaction-state-store.ts +43 -43
  103. package/src/cluster/memory-transaction-state-store.ts +56 -56
  104. package/src/cluster/peer-key-binding.ts +37 -37
  105. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  106. package/src/cluster/quorum-restore.ts +223 -223
  107. package/src/cluster/reconcile-block.ts +203 -203
  108. package/src/cluster/service.ts +293 -241
  109. package/src/cluster/supermajority-coupling.ts +37 -37
  110. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  111. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  112. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  113. package/src/cohort-topic/change-bridge.ts +109 -109
  114. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  115. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  116. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  117. package/src/cohort-topic/host.ts +42 -11
  118. package/src/cohort-topic/index.ts +13 -13
  119. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  120. package/src/cohort-topic/membership-source.ts +68 -68
  121. package/src/cohort-topic/peer-codec.ts +31 -31
  122. package/src/cohort-topic/peer-sig.ts +86 -86
  123. package/src/cohort-topic/protocols.ts +71 -71
  124. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  125. package/src/cohort-topic/size-estimator.ts +16 -16
  126. package/src/cohort-topic/stream-util.ts +79 -19
  127. package/src/cohort-topic/threshold-crypto.ts +239 -239
  128. package/src/cohort-topic/topic-router.ts +77 -77
  129. package/src/dispute/arbitrator-selection.ts +138 -138
  130. package/src/dispute/cascade.ts +524 -524
  131. package/src/dispute/dispute-service.ts +11 -5
  132. package/src/dispute/invalidation.ts +625 -625
  133. package/src/inbound-authorization.ts +190 -190
  134. package/src/index.ts +52 -49
  135. package/src/libp2p-key-network.ts +1120 -990
  136. package/src/libp2p-node-base.ts +1675 -1651
  137. package/src/libp2p-node-rn.ts +30 -30
  138. package/src/libp2p-node.ts +36 -36
  139. package/src/logger.ts +19 -2
  140. package/src/matchmaking/aggregate-counts.ts +104 -104
  141. package/src/matchmaking/index.ts +20 -20
  142. package/src/matchmaking/module.ts +363 -363
  143. package/src/matchmaking/protocols.ts +51 -51
  144. package/src/matchmaking/provider-manager.ts +95 -95
  145. package/src/matchmaking/query-handler.ts +88 -88
  146. package/src/matchmaking/query-transport.ts +3 -3
  147. package/src/matchmaking/seeker-manager.ts +64 -64
  148. package/src/matchmaking/seeker-walk-client.ts +293 -293
  149. package/src/matchmaking/traffic-validation.ts +195 -195
  150. package/src/optimystic-node.ts +36 -36
  151. package/src/owned-block-seed.ts +53 -40
  152. package/src/peer-address-book.ts +149 -0
  153. package/src/protocol-limits.ts +33 -33
  154. package/src/reactivity/forwarder-host.ts +438 -438
  155. package/src/reactivity/index.ts +19 -19
  156. package/src/reactivity/notify-transport.ts +144 -144
  157. package/src/reactivity/origination-manager.ts +192 -192
  158. package/src/reactivity/protocols.ts +61 -61
  159. package/src/reactivity/push-state-gossip.ts +291 -291
  160. package/src/reactivity/recover-transport.ts +7 -3
  161. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  162. package/src/reactivity/subscriber-registry.ts +96 -96
  163. package/src/reactivity/subscription-manager.ts +450 -450
  164. package/src/reactivity/topic-bytes.ts +37 -37
  165. package/src/repo/client.ts +12 -2
  166. package/src/repo/cluster-coordinator.ts +99 -3
  167. package/src/repo/coordinator-repo.ts +281 -74
  168. package/src/repo/types.ts +7 -7
  169. package/src/rn.ts +39 -36
  170. package/src/rpc-deadline.ts +45 -45
  171. package/src/storage/arachnode-partition.ts +74 -74
  172. package/src/storage/cached-raw-storage.ts +180 -0
  173. package/src/storage/cached-store-driver.ts +859 -0
  174. package/src/storage/i-kv-store.ts +8 -8
  175. package/src/storage/i-raw-storage.ts +12 -5
  176. package/src/storage/kv-raw-storage.ts +135 -135
  177. package/src/storage/memory-kv-store.ts +28 -28
  178. package/src/storage/memory-storage.ts +25 -25
  179. package/src/storage/memory-store-driver.ts +157 -157
  180. package/src/storage/raw-store-codec.ts +42 -42
  181. package/src/storage/raw-store-driver.ts +80 -80
  182. package/src/storage/ring-selector.ts +317 -317
  183. package/src/storage/ring-shift-coordinator.ts +271 -271
  184. package/src/storage/shared-cache-pool.ts +452 -0
  185. package/src/storage/storage-repo.ts +1014 -1014
  186. package/src/testing/cohort-topic-mesh-harness.ts +673 -663
  187. package/src/testing/index.ts +8 -8
  188. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  189. package/src/testing/raw-storage-conformance.ts +453 -417
  190. package/src/testing/reactivity-mesh-harness.ts +922 -922
  191. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  192. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  193. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  194. 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
+ }