@optimystic/db-p2p 0.22.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 (177) 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/stream-util.d.ts +22 -6
  14. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/stream-util.js +56 -10
  16. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  17. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  18. package/dist/src/dispute/dispute-service.js +9 -3
  19. package/dist/src/dispute/dispute-service.js.map +1 -1
  20. package/dist/src/index.d.ts +3 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +3 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/libp2p-key-network.d.ts +88 -2
  25. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  26. package/dist/src/libp2p-key-network.js +134 -28
  27. package/dist/src/libp2p-key-network.js.map +1 -1
  28. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  29. package/dist/src/libp2p-node-base.js +25 -1
  30. package/dist/src/libp2p-node-base.js.map +1 -1
  31. package/dist/src/logger.d.ts +17 -1
  32. package/dist/src/logger.d.ts.map +1 -1
  33. package/dist/src/logger.js +19 -2
  34. package/dist/src/logger.js.map +1 -1
  35. package/dist/src/owned-block-seed.d.ts +6 -3
  36. package/dist/src/owned-block-seed.d.ts.map +1 -1
  37. package/dist/src/owned-block-seed.js +16 -3
  38. package/dist/src/owned-block-seed.js.map +1 -1
  39. package/dist/src/peer-address-book.d.ts +72 -0
  40. package/dist/src/peer-address-book.d.ts.map +1 -0
  41. package/dist/src/peer-address-book.js +123 -0
  42. package/dist/src/peer-address-book.js.map +1 -0
  43. package/dist/src/repo/client.d.ts.map +1 -1
  44. package/dist/src/repo/client.js +11 -2
  45. package/dist/src/repo/client.js.map +1 -1
  46. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  47. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  48. package/dist/src/repo/cluster-coordinator.js +95 -3
  49. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  50. package/dist/src/repo/coordinator-repo.d.ts +62 -9
  51. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  52. package/dist/src/repo/coordinator-repo.js +242 -73
  53. package/dist/src/repo/coordinator-repo.js.map +1 -1
  54. package/dist/src/rn.d.ts +3 -0
  55. package/dist/src/rn.d.ts.map +1 -1
  56. package/dist/src/rn.js +3 -0
  57. package/dist/src/rn.js.map +1 -1
  58. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  59. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  60. package/dist/src/storage/cached-raw-storage.js +152 -0
  61. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  62. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  63. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  64. package/dist/src/storage/cached-store-driver.js +775 -0
  65. package/dist/src/storage/cached-store-driver.js.map +1 -0
  66. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  67. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  68. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  69. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  70. package/dist/src/storage/shared-cache-pool.js +354 -0
  71. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  72. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  73. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  74. package/dist/src/testing/raw-storage-conformance.js +35 -2
  75. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  76. package/package.json +3 -3
  77. package/readme.md +668 -668
  78. package/src/cluster/block-transfer.ts +424 -424
  79. package/src/cluster/client.ts +119 -88
  80. package/src/cluster/cluster-error.ts +64 -64
  81. package/src/cluster/cluster-policy.ts +203 -203
  82. package/src/cluster/cluster-repo.ts +242 -122
  83. package/src/cluster/cluster-size-coupling.ts +45 -45
  84. package/src/cluster/commit-cert.ts +139 -139
  85. package/src/cluster/i-transaction-state-store.ts +43 -43
  86. package/src/cluster/memory-transaction-state-store.ts +56 -56
  87. package/src/cluster/peer-key-binding.ts +37 -37
  88. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  89. package/src/cluster/quorum-restore.ts +223 -223
  90. package/src/cluster/reconcile-block.ts +203 -203
  91. package/src/cluster/service.ts +293 -241
  92. package/src/cluster/supermajority-coupling.ts +37 -37
  93. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  94. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  95. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  96. package/src/cohort-topic/change-bridge.ts +109 -109
  97. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  98. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  99. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  100. package/src/cohort-topic/host.ts +2901 -2901
  101. package/src/cohort-topic/index.ts +13 -13
  102. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  103. package/src/cohort-topic/membership-source.ts +68 -68
  104. package/src/cohort-topic/peer-codec.ts +31 -31
  105. package/src/cohort-topic/peer-sig.ts +86 -86
  106. package/src/cohort-topic/protocols.ts +71 -71
  107. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  108. package/src/cohort-topic/size-estimator.ts +16 -16
  109. package/src/cohort-topic/stream-util.ts +135 -87
  110. package/src/cohort-topic/threshold-crypto.ts +239 -239
  111. package/src/cohort-topic/topic-router.ts +77 -77
  112. package/src/dispute/arbitrator-selection.ts +138 -138
  113. package/src/dispute/cascade.ts +524 -524
  114. package/src/dispute/dispute-service.ts +11 -5
  115. package/src/dispute/invalidation.ts +625 -625
  116. package/src/inbound-authorization.ts +190 -190
  117. package/src/index.ts +52 -49
  118. package/src/libp2p-key-network.ts +1120 -990
  119. package/src/libp2p-node-base.ts +1675 -1651
  120. package/src/libp2p-node-rn.ts +30 -30
  121. package/src/libp2p-node.ts +36 -36
  122. package/src/logger.ts +19 -2
  123. package/src/matchmaking/aggregate-counts.ts +104 -104
  124. package/src/matchmaking/index.ts +20 -20
  125. package/src/matchmaking/module.ts +363 -363
  126. package/src/matchmaking/protocols.ts +51 -51
  127. package/src/matchmaking/provider-manager.ts +95 -95
  128. package/src/matchmaking/query-handler.ts +88 -88
  129. package/src/matchmaking/query-transport.ts +492 -492
  130. package/src/matchmaking/seeker-manager.ts +64 -64
  131. package/src/matchmaking/seeker-walk-client.ts +293 -293
  132. package/src/matchmaking/traffic-validation.ts +195 -195
  133. package/src/optimystic-node.ts +36 -36
  134. package/src/owned-block-seed.ts +53 -40
  135. package/src/peer-address-book.ts +149 -0
  136. package/src/protocol-limits.ts +33 -33
  137. package/src/reactivity/forwarder-host.ts +438 -438
  138. package/src/reactivity/index.ts +19 -19
  139. package/src/reactivity/notify-transport.ts +144 -144
  140. package/src/reactivity/origination-manager.ts +192 -192
  141. package/src/reactivity/protocols.ts +61 -61
  142. package/src/reactivity/push-state-gossip.ts +291 -291
  143. package/src/reactivity/recover-transport.ts +408 -408
  144. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  145. package/src/reactivity/subscriber-registry.ts +96 -96
  146. package/src/reactivity/subscription-manager.ts +450 -450
  147. package/src/reactivity/topic-bytes.ts +37 -37
  148. package/src/repo/client.ts +12 -2
  149. package/src/repo/cluster-coordinator.ts +99 -3
  150. package/src/repo/coordinator-repo.ts +281 -74
  151. package/src/repo/types.ts +7 -7
  152. package/src/rn.ts +39 -36
  153. package/src/rpc-deadline.ts +45 -45
  154. package/src/storage/arachnode-partition.ts +74 -74
  155. package/src/storage/cached-raw-storage.ts +180 -0
  156. package/src/storage/cached-store-driver.ts +859 -0
  157. package/src/storage/i-kv-store.ts +8 -8
  158. package/src/storage/i-raw-storage.ts +12 -5
  159. package/src/storage/kv-raw-storage.ts +135 -135
  160. package/src/storage/memory-kv-store.ts +28 -28
  161. package/src/storage/memory-storage.ts +25 -25
  162. package/src/storage/memory-store-driver.ts +157 -157
  163. package/src/storage/raw-store-codec.ts +42 -42
  164. package/src/storage/raw-store-driver.ts +80 -80
  165. package/src/storage/ring-selector.ts +317 -317
  166. package/src/storage/ring-shift-coordinator.ts +271 -271
  167. package/src/storage/shared-cache-pool.ts +452 -0
  168. package/src/storage/storage-repo.ts +1014 -1014
  169. package/src/testing/cohort-topic-mesh-harness.ts +663 -663
  170. package/src/testing/index.ts +8 -8
  171. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  172. package/src/testing/raw-storage-conformance.ts +453 -417
  173. package/src/testing/reactivity-mesh-harness.ts +922 -922
  174. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  175. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  176. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  177. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,424 +1,424 @@
1
- import type { IRepo, IPeerNetwork } from '@optimystic/db-core';
2
- import { peerIdFromString } from '@libp2p/peer-id';
3
- import type { PartitionDetector } from './partition-detector.js';
4
- import type { RestorationCoordinator } from '../storage/restoration-coordinator.js';
5
- import { BlockTransferClient } from './block-transfer-service.js';
6
- import type { RebalanceEvent } from './rebalance-monitor.js';
7
- import { createLogger } from '../logger.js';
8
-
9
- const log = createLogger('block-transfer');
10
-
11
- export interface BlockTransferConfig {
12
- /** Max concurrent transfers. Default: 4 */
13
- maxConcurrency?: number;
14
- /** Timeout per block transfer (ms). Default: 30000 */
15
- transferTimeoutMs?: number;
16
- /** Retry attempts for failed transfers. Default: 2 */
17
- maxRetries?: number;
18
- /** Whether to push blocks to new owners proactively. Default: true */
19
- enablePush?: boolean;
20
- }
21
-
22
- /**
23
- * Outcome of reacting to a {@link RebalanceEvent}. The `released` list is the gate the caller opens
24
- * before it may stop serving a lost block: a block appears here ONLY after it was confirmed
25
- * replicated to the event's floor of new owners. Everything in `retained` stays tracked and served
26
- * (its push failed, was partition-skipped, or could not reach the floor) and is retried on the next
27
- * rebalance. See `docs/arachnode-ring-handoff.md` § Part 2.
28
- */
29
- export interface RebalanceReactionResult {
30
- /** Gained blocks successfully pulled (now durably held locally). */
31
- pulled: string[];
32
- /** Lost blocks confirmed replicated to ≥ floor new owners — safe to release. */
33
- released: string[];
34
- /** Lost blocks whose replication could not be confirmed — keep serving, retry later. */
35
- retained: string[];
36
- }
37
-
38
- /**
39
- * Coordinates block transfers in response to rebalance events.
40
- *
41
- * For gained blocks: delegates to RestorationCoordinator.restore() which
42
- * already handles ring-based discovery and fetching.
43
- *
44
- * For lost blocks: proactively pushes block data to new responsible peers
45
- * via the BlockTransfer protocol.
46
- */
47
- export class BlockTransferCoordinator {
48
- private readonly maxConcurrency: number;
49
- private readonly transferTimeoutMs: number;
50
- private readonly maxRetries: number;
51
- private readonly enablePush: boolean;
52
- private inFlight = new Set<string>();
53
- private concurrency = 0;
54
- private readonly waitQueue: Array<() => void> = [];
55
-
56
- constructor(
57
- private readonly repo: IRepo,
58
- private readonly peerNetwork: IPeerNetwork,
59
- private readonly restorationCoordinator: RestorationCoordinator,
60
- private readonly partitionDetector: PartitionDetector,
61
- private readonly protocolPrefix: string = '',
62
- config: BlockTransferConfig = {}
63
- ) {
64
- this.maxConcurrency = config.maxConcurrency ?? 4;
65
- this.transferTimeoutMs = config.transferTimeoutMs ?? 30000;
66
- this.maxRetries = config.maxRetries ?? 2;
67
- this.enablePush = config.enablePush ?? true;
68
- }
69
-
70
- /**
71
- * Pull blocks that this node has gained responsibility for.
72
- * Uses RestorationCoordinator to discover holders and fetch block data.
73
- */
74
- async pullBlocks(blockIds: string[]): Promise<{ succeeded: string[]; failed: string[] }> {
75
- if (this.partitionDetector.detectPartition()) {
76
- log('pull:partition-detected, skipping %d blocks', blockIds.length);
77
- return { succeeded: [], failed: blockIds };
78
- }
79
-
80
- const succeeded: string[] = [];
81
- const failed: string[] = [];
82
-
83
- const ids = blockIds.filter(id => !this.inFlight.has(`pull:${id}`));
84
-
85
- await Promise.all(ids.map(id => this.executePull(id, succeeded, failed)));
86
-
87
- return { succeeded, failed };
88
- }
89
-
90
- /**
91
- * Push blocks that this node has lost responsibility for to new owners.
92
- */
93
- async pushBlocks(
94
- blockIds: string[],
95
- newOwners: Map<string, string[]>
96
- ): Promise<{ succeeded: string[]; failed: string[] }> {
97
- if (!this.enablePush) {
98
- return { succeeded: [], failed: [] };
99
- }
100
- if (this.partitionDetector.detectPartition()) {
101
- log('push:partition-detected, skipping %d blocks', blockIds.length);
102
- return { succeeded: [], failed: blockIds };
103
- }
104
-
105
- const succeeded: string[] = [];
106
- const failed: string[] = [];
107
-
108
- const ids = blockIds.filter(id => !this.inFlight.has(`push:${id}`) && newOwners.has(id));
109
-
110
- await Promise.all(ids.map(id => this.executePush(id, newOwners, succeeded, failed)));
111
-
112
- return { succeeded, failed };
113
- }
114
-
115
- /**
116
- * Handle a complete rebalance event — pull gained, and **confirm** lost blocks replicated to the
117
- * floor before reporting them releasable.
118
- *
119
- * The lost path no longer pushes fire-and-forget: it runs {@link confirmReplicated} against the
120
- * event's `newOwners` and `floor`, so `released` contains only blocks that landed on ≥ floor new
121
- * owners. The caller gates its `untrackBlock` (release + GC-eligibility) on `released` and leaves
122
- * `retained` blocks tracked/served for the next rebalance. This closes the release-before-confirm
123
- * hole (`docs/arachnode-ring-handoff.md` § Why the current code violates it #2).
124
- */
125
- async handleRebalanceEvent(event: RebalanceEvent): Promise<RebalanceReactionResult> {
126
- log('rebalance:start gained=%d lost=%d floor=%d', event.gained.length, event.lost.length, event.floor);
127
-
128
- const floor = Math.max(1, event.floor);
129
- const [pullResult, confirmResult] = await Promise.all([
130
- event.gained.length > 0 ? this.pullBlocks(event.gained) : { succeeded: [], failed: [] },
131
- event.lost.length > 0 && event.newOwners.size > 0
132
- ? this.confirmReplicated(event.lost, event.newOwners, floor)
133
- : { confirmed: [], unconfirmed: [...event.lost] }
134
- ]);
135
-
136
- log('rebalance:done pull=%d/%d released=%d/%d',
137
- pullResult.succeeded.length, event.gained.length,
138
- confirmResult.confirmed.length, event.lost.length);
139
-
140
- return {
141
- pulled: pullResult.succeeded,
142
- released: confirmResult.confirmed,
143
- retained: confirmResult.unconfirmed
144
- };
145
- }
146
-
147
- /**
148
- * Confirm each block is replicated to at least `floor` qualifying owners — the gate the ring-shift
149
- * handoff (Phase B) and the rebalance release both open before a block may stop being served.
150
- *
151
- * For each block, this pushes to the candidate owners and counts how many report holding a current
152
- * replica: a holder confirms when the push response does NOT list the block in `missing` (it either
153
- * already had it or accepted the push — `handlePush` reports `accepted` only on a received-AND-persisted
154
- * block). A block is `confirmed` only when that count reaches `floor`; otherwise it is `unconfirmed`
155
- * and the caller keeps serving it. Per-block timeout + retry mirror {@link pushBlocks}. During a
156
- * detected partition every block is left unconfirmed (consistent with the push guard), so a partition
157
- * mid-handoff aborts rather than releases.
158
- *
159
- * @param owners blockId → candidate owner peer ids. The caller MUST have already excluded self and
160
- * any same-range mover (a peer shedding the same sub-range), so every id here is a qualifying holder.
161
- * @param floor required confirming owners per block (the replication floor `N`).
162
- */
163
- async confirmReplicated(
164
- blockIds: string[],
165
- owners: Map<string, string[]>,
166
- floor: number
167
- ): Promise<{ confirmed: string[]; unconfirmed: string[] }> {
168
- // NOTE: unlike pushBlocks, this deliberately does NOT honor `enablePush` — confirmation
169
- // fundamentally requires pushing to verify replication, and skipping it would leave every
170
- // block unconfirmed → never released → the node never sheds. So `enablePush:false` no longer
171
- // suppresses pushes on the rebalance/handoff release path (it only gates the legacy pushBlocks
172
- // fire-and-forget). If a config ever needs "never move data at all", gate the release wiring,
173
- // not this primitive.
174
- if (this.partitionDetector.detectPartition()) {
175
- log('confirm:partition-detected, leaving %d blocks unconfirmed', blockIds.length);
176
- return { confirmed: [], unconfirmed: [...blockIds] };
177
- }
178
- if (floor <= 0) {
179
- // A non-positive floor cannot be safely "met"; refuse to release rather than release for free.
180
- return { confirmed: [], unconfirmed: [...blockIds] };
181
- }
182
-
183
- const confirmed: string[] = [];
184
- const unconfirmed: string[] = [];
185
-
186
- const ids = blockIds.filter(id => !this.inFlight.has(`confirm:${id}`));
187
- await Promise.all(ids.map(id => this.executeConfirm(id, owners, floor, confirmed, unconfirmed)));
188
-
189
- return { confirmed, unconfirmed };
190
- }
191
-
192
- private async executePull(
193
- blockId: string,
194
- succeeded: string[],
195
- failed: string[]
196
- ): Promise<void> {
197
- const key = `pull:${blockId}`;
198
- if (this.inFlight.has(key)) return;
199
- this.inFlight.add(key);
200
-
201
- try {
202
- for (let attempt = 0; ; attempt++) {
203
- await this.acquireSemaphore();
204
- let archive: Awaited<ReturnType<RestorationCoordinator['restore']>>;
205
- try {
206
- archive = await this.withTimeout(
207
- this.restorationCoordinator.restore(blockId),
208
- this.transferTimeoutMs
209
- );
210
- } finally {
211
- this.releaseSemaphore();
212
- }
213
-
214
- if (archive) {
215
- log('pull:ok block=%s', blockId);
216
- succeeded.push(blockId);
217
- return;
218
- }
219
- if (attempt < this.maxRetries) {
220
- log('pull:retry block=%s attempt=%d', blockId, attempt + 1);
221
- await this.delay(this.backoffMs(attempt));
222
- continue;
223
- }
224
- log('pull:failed block=%s', blockId);
225
- failed.push(blockId);
226
- return;
227
- }
228
- } finally {
229
- this.inFlight.delete(key);
230
- }
231
- }
232
-
233
- private async executePush(
234
- blockId: string,
235
- newOwners: Map<string, string[]>,
236
- succeeded: string[],
237
- failed: string[]
238
- ): Promise<void> {
239
- const key = `push:${blockId}`;
240
- if (this.inFlight.has(key)) return;
241
- this.inFlight.add(key);
242
-
243
- try {
244
- for (let attempt = 0; ; attempt++) {
245
- await this.acquireSemaphore();
246
- let pushed = false;
247
- try {
248
- const owners = newOwners.get(blockId);
249
- if (!owners || owners.length === 0) {
250
- failed.push(blockId);
251
- return;
252
- }
253
-
254
- // Read block data from local storage
255
- const result = await this.repo.get({ blockIds: [blockId] });
256
- const blockResult = result[blockId];
257
- if (!blockResult?.block) {
258
- log('push:no-local-data block=%s', blockId);
259
- failed.push(blockId);
260
- return;
261
- }
262
-
263
- const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
264
-
265
- // Push to at least one new owner
266
- for (const ownerPeerIdStr of owners) {
267
- try {
268
- const peerId = peerIdFromString(ownerPeerIdStr);
269
- const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
270
- const response = await this.withTimeout(
271
- client.pushBlocks([blockId], [blockData]),
272
- this.transferTimeoutMs
273
- );
274
-
275
- if (response && !response.missing.includes(blockId)) {
276
- pushed = true;
277
- log('push:ok block=%s peer=%s', blockId, ownerPeerIdStr);
278
- break;
279
- }
280
- } catch (err) {
281
- log('push:peer-error block=%s peer=%s err=%s',
282
- blockId, ownerPeerIdStr, (err as Error).message);
283
- }
284
- }
285
- } finally {
286
- this.releaseSemaphore();
287
- }
288
-
289
- if (pushed) {
290
- succeeded.push(blockId);
291
- return;
292
- }
293
- if (attempt < this.maxRetries) {
294
- log('push:retry block=%s attempt=%d', blockId, attempt + 1);
295
- await this.delay(this.backoffMs(attempt));
296
- continue;
297
- }
298
- log('push:failed block=%s', blockId);
299
- failed.push(blockId);
300
- return;
301
- }
302
- } finally {
303
- this.inFlight.delete(key);
304
- }
305
- }
306
-
307
- /**
308
- * Confirm one block replicated to ≥ `floor` distinct qualifying owners. Reads the local block once,
309
- * pushes to each candidate owner (stopping once the floor is reached), and counts distinct owners
310
- * that report holding it (not `missing`). Retries the whole round up to `maxRetries` before giving
311
- * up. Records the block in `confirmed` iff the floor was met, otherwise in `unconfirmed`.
312
- */
313
- private async executeConfirm(
314
- blockId: string,
315
- owners: Map<string, string[]>,
316
- floor: number,
317
- confirmed: string[],
318
- unconfirmed: string[]
319
- ): Promise<void> {
320
- const key = `confirm:${blockId}`;
321
- if (this.inFlight.has(key)) return;
322
- this.inFlight.add(key);
323
-
324
- try {
325
- const candidateOwners = owners.get(blockId) ?? [];
326
- if (candidateOwners.length === 0) {
327
- // No qualifying holder to confirm against — cannot release; keep serving.
328
- unconfirmed.push(blockId);
329
- return;
330
- }
331
-
332
- for (let attempt = 0; ; attempt++) {
333
- await this.acquireSemaphore();
334
- let confirmCount = 0;
335
- try {
336
- // Read block data from local storage once per attempt.
337
- const result = await this.repo.get({ blockIds: [blockId] });
338
- const blockResult = result[blockId];
339
- if (!blockResult?.block) {
340
- // No local bytes to prove replication with — cannot confirm; keep serving.
341
- log('confirm:no-local-data block=%s', blockId);
342
- unconfirmed.push(blockId);
343
- return;
344
- }
345
-
346
- const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
347
-
348
- // Count DISTINCT owners that hold a current replica; stop once the floor is reached.
349
- const confirmedPeers = new Set<string>();
350
- for (const ownerPeerIdStr of candidateOwners) {
351
- if (confirmedPeers.size >= floor) break;
352
- try {
353
- const peerId = peerIdFromString(ownerPeerIdStr);
354
- const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
355
- const response = await this.withTimeout(
356
- client.pushBlocks([blockId], [blockData]),
357
- this.transferTimeoutMs
358
- );
359
- if (response && !response.missing.includes(blockId)) {
360
- confirmedPeers.add(ownerPeerIdStr);
361
- }
362
- } catch (err) {
363
- log('confirm:peer-error block=%s peer=%s err=%s',
364
- blockId, ownerPeerIdStr, (err as Error).message);
365
- }
366
- }
367
- confirmCount = confirmedPeers.size;
368
- } finally {
369
- this.releaseSemaphore();
370
- }
371
-
372
- if (confirmCount >= floor) {
373
- log('confirm:ok block=%s holders=%d/%d', blockId, confirmCount, floor);
374
- confirmed.push(blockId);
375
- return;
376
- }
377
- if (attempt < this.maxRetries) {
378
- log('confirm:retry block=%s holders=%d/%d attempt=%d', blockId, confirmCount, floor, attempt + 1);
379
- await this.delay(this.backoffMs(attempt));
380
- continue;
381
- }
382
- log('confirm:unmet block=%s holders=%d/%d', blockId, confirmCount, floor);
383
- unconfirmed.push(blockId);
384
- return;
385
- }
386
- } finally {
387
- this.inFlight.delete(key);
388
- }
389
- }
390
-
391
- // --- Semaphore for concurrency limiting ---
392
-
393
- private async acquireSemaphore(): Promise<void> {
394
- if (this.concurrency < this.maxConcurrency) {
395
- this.concurrency++;
396
- return;
397
- }
398
- await new Promise<void>(resolve => this.waitQueue.push(resolve));
399
- this.concurrency++;
400
- }
401
-
402
- private releaseSemaphore(): void {
403
- this.concurrency--;
404
- const next = this.waitQueue.shift();
405
- if (next) next();
406
- }
407
-
408
- // --- Helpers ---
409
-
410
- private backoffMs(attempt: number): number {
411
- return Math.min(1000 * Math.pow(2, attempt), 10000);
412
- }
413
-
414
- private delay(ms: number): Promise<void> {
415
- return new Promise(resolve => setTimeout(resolve, ms));
416
- }
417
-
418
- private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
419
- return Promise.race([
420
- promise,
421
- new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), ms))
422
- ]);
423
- }
424
- }
1
+ import type { IRepo, IPeerNetwork } from '@optimystic/db-core';
2
+ import { peerIdFromString } from '@libp2p/peer-id';
3
+ import type { PartitionDetector } from './partition-detector.js';
4
+ import type { RestorationCoordinator } from '../storage/restoration-coordinator.js';
5
+ import { BlockTransferClient } from './block-transfer-service.js';
6
+ import type { RebalanceEvent } from './rebalance-monitor.js';
7
+ import { createLogger } from '../logger.js';
8
+
9
+ const log = createLogger('block-transfer');
10
+
11
+ export interface BlockTransferConfig {
12
+ /** Max concurrent transfers. Default: 4 */
13
+ maxConcurrency?: number;
14
+ /** Timeout per block transfer (ms). Default: 30000 */
15
+ transferTimeoutMs?: number;
16
+ /** Retry attempts for failed transfers. Default: 2 */
17
+ maxRetries?: number;
18
+ /** Whether to push blocks to new owners proactively. Default: true */
19
+ enablePush?: boolean;
20
+ }
21
+
22
+ /**
23
+ * Outcome of reacting to a {@link RebalanceEvent}. The `released` list is the gate the caller opens
24
+ * before it may stop serving a lost block: a block appears here ONLY after it was confirmed
25
+ * replicated to the event's floor of new owners. Everything in `retained` stays tracked and served
26
+ * (its push failed, was partition-skipped, or could not reach the floor) and is retried on the next
27
+ * rebalance. See `docs/arachnode-ring-handoff.md` § Part 2.
28
+ */
29
+ export interface RebalanceReactionResult {
30
+ /** Gained blocks successfully pulled (now durably held locally). */
31
+ pulled: string[];
32
+ /** Lost blocks confirmed replicated to ≥ floor new owners — safe to release. */
33
+ released: string[];
34
+ /** Lost blocks whose replication could not be confirmed — keep serving, retry later. */
35
+ retained: string[];
36
+ }
37
+
38
+ /**
39
+ * Coordinates block transfers in response to rebalance events.
40
+ *
41
+ * For gained blocks: delegates to RestorationCoordinator.restore() which
42
+ * already handles ring-based discovery and fetching.
43
+ *
44
+ * For lost blocks: proactively pushes block data to new responsible peers
45
+ * via the BlockTransfer protocol.
46
+ */
47
+ export class BlockTransferCoordinator {
48
+ private readonly maxConcurrency: number;
49
+ private readonly transferTimeoutMs: number;
50
+ private readonly maxRetries: number;
51
+ private readonly enablePush: boolean;
52
+ private inFlight = new Set<string>();
53
+ private concurrency = 0;
54
+ private readonly waitQueue: Array<() => void> = [];
55
+
56
+ constructor(
57
+ private readonly repo: IRepo,
58
+ private readonly peerNetwork: IPeerNetwork,
59
+ private readonly restorationCoordinator: RestorationCoordinator,
60
+ private readonly partitionDetector: PartitionDetector,
61
+ private readonly protocolPrefix: string = '',
62
+ config: BlockTransferConfig = {}
63
+ ) {
64
+ this.maxConcurrency = config.maxConcurrency ?? 4;
65
+ this.transferTimeoutMs = config.transferTimeoutMs ?? 30000;
66
+ this.maxRetries = config.maxRetries ?? 2;
67
+ this.enablePush = config.enablePush ?? true;
68
+ }
69
+
70
+ /**
71
+ * Pull blocks that this node has gained responsibility for.
72
+ * Uses RestorationCoordinator to discover holders and fetch block data.
73
+ */
74
+ async pullBlocks(blockIds: string[]): Promise<{ succeeded: string[]; failed: string[] }> {
75
+ if (this.partitionDetector.detectPartition()) {
76
+ log('pull:partition-detected, skipping %d blocks', blockIds.length);
77
+ return { succeeded: [], failed: blockIds };
78
+ }
79
+
80
+ const succeeded: string[] = [];
81
+ const failed: string[] = [];
82
+
83
+ const ids = blockIds.filter(id => !this.inFlight.has(`pull:${id}`));
84
+
85
+ await Promise.all(ids.map(id => this.executePull(id, succeeded, failed)));
86
+
87
+ return { succeeded, failed };
88
+ }
89
+
90
+ /**
91
+ * Push blocks that this node has lost responsibility for to new owners.
92
+ */
93
+ async pushBlocks(
94
+ blockIds: string[],
95
+ newOwners: Map<string, string[]>
96
+ ): Promise<{ succeeded: string[]; failed: string[] }> {
97
+ if (!this.enablePush) {
98
+ return { succeeded: [], failed: [] };
99
+ }
100
+ if (this.partitionDetector.detectPartition()) {
101
+ log('push:partition-detected, skipping %d blocks', blockIds.length);
102
+ return { succeeded: [], failed: blockIds };
103
+ }
104
+
105
+ const succeeded: string[] = [];
106
+ const failed: string[] = [];
107
+
108
+ const ids = blockIds.filter(id => !this.inFlight.has(`push:${id}`) && newOwners.has(id));
109
+
110
+ await Promise.all(ids.map(id => this.executePush(id, newOwners, succeeded, failed)));
111
+
112
+ return { succeeded, failed };
113
+ }
114
+
115
+ /**
116
+ * Handle a complete rebalance event — pull gained, and **confirm** lost blocks replicated to the
117
+ * floor before reporting them releasable.
118
+ *
119
+ * The lost path no longer pushes fire-and-forget: it runs {@link confirmReplicated} against the
120
+ * event's `newOwners` and `floor`, so `released` contains only blocks that landed on ≥ floor new
121
+ * owners. The caller gates its `untrackBlock` (release + GC-eligibility) on `released` and leaves
122
+ * `retained` blocks tracked/served for the next rebalance. This closes the release-before-confirm
123
+ * hole (`docs/arachnode-ring-handoff.md` § Why the current code violates it #2).
124
+ */
125
+ async handleRebalanceEvent(event: RebalanceEvent): Promise<RebalanceReactionResult> {
126
+ log('rebalance:start gained=%d lost=%d floor=%d', event.gained.length, event.lost.length, event.floor);
127
+
128
+ const floor = Math.max(1, event.floor);
129
+ const [pullResult, confirmResult] = await Promise.all([
130
+ event.gained.length > 0 ? this.pullBlocks(event.gained) : { succeeded: [], failed: [] },
131
+ event.lost.length > 0 && event.newOwners.size > 0
132
+ ? this.confirmReplicated(event.lost, event.newOwners, floor)
133
+ : { confirmed: [], unconfirmed: [...event.lost] }
134
+ ]);
135
+
136
+ log('rebalance:done pull=%d/%d released=%d/%d',
137
+ pullResult.succeeded.length, event.gained.length,
138
+ confirmResult.confirmed.length, event.lost.length);
139
+
140
+ return {
141
+ pulled: pullResult.succeeded,
142
+ released: confirmResult.confirmed,
143
+ retained: confirmResult.unconfirmed
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Confirm each block is replicated to at least `floor` qualifying owners — the gate the ring-shift
149
+ * handoff (Phase B) and the rebalance release both open before a block may stop being served.
150
+ *
151
+ * For each block, this pushes to the candidate owners and counts how many report holding a current
152
+ * replica: a holder confirms when the push response does NOT list the block in `missing` (it either
153
+ * already had it or accepted the push — `handlePush` reports `accepted` only on a received-AND-persisted
154
+ * block). A block is `confirmed` only when that count reaches `floor`; otherwise it is `unconfirmed`
155
+ * and the caller keeps serving it. Per-block timeout + retry mirror {@link pushBlocks}. During a
156
+ * detected partition every block is left unconfirmed (consistent with the push guard), so a partition
157
+ * mid-handoff aborts rather than releases.
158
+ *
159
+ * @param owners blockId → candidate owner peer ids. The caller MUST have already excluded self and
160
+ * any same-range mover (a peer shedding the same sub-range), so every id here is a qualifying holder.
161
+ * @param floor required confirming owners per block (the replication floor `N`).
162
+ */
163
+ async confirmReplicated(
164
+ blockIds: string[],
165
+ owners: Map<string, string[]>,
166
+ floor: number
167
+ ): Promise<{ confirmed: string[]; unconfirmed: string[] }> {
168
+ // NOTE: unlike pushBlocks, this deliberately does NOT honor `enablePush` — confirmation
169
+ // fundamentally requires pushing to verify replication, and skipping it would leave every
170
+ // block unconfirmed → never released → the node never sheds. So `enablePush:false` no longer
171
+ // suppresses pushes on the rebalance/handoff release path (it only gates the legacy pushBlocks
172
+ // fire-and-forget). If a config ever needs "never move data at all", gate the release wiring,
173
+ // not this primitive.
174
+ if (this.partitionDetector.detectPartition()) {
175
+ log('confirm:partition-detected, leaving %d blocks unconfirmed', blockIds.length);
176
+ return { confirmed: [], unconfirmed: [...blockIds] };
177
+ }
178
+ if (floor <= 0) {
179
+ // A non-positive floor cannot be safely "met"; refuse to release rather than release for free.
180
+ return { confirmed: [], unconfirmed: [...blockIds] };
181
+ }
182
+
183
+ const confirmed: string[] = [];
184
+ const unconfirmed: string[] = [];
185
+
186
+ const ids = blockIds.filter(id => !this.inFlight.has(`confirm:${id}`));
187
+ await Promise.all(ids.map(id => this.executeConfirm(id, owners, floor, confirmed, unconfirmed)));
188
+
189
+ return { confirmed, unconfirmed };
190
+ }
191
+
192
+ private async executePull(
193
+ blockId: string,
194
+ succeeded: string[],
195
+ failed: string[]
196
+ ): Promise<void> {
197
+ const key = `pull:${blockId}`;
198
+ if (this.inFlight.has(key)) return;
199
+ this.inFlight.add(key);
200
+
201
+ try {
202
+ for (let attempt = 0; ; attempt++) {
203
+ await this.acquireSemaphore();
204
+ let archive: Awaited<ReturnType<RestorationCoordinator['restore']>>;
205
+ try {
206
+ archive = await this.withTimeout(
207
+ this.restorationCoordinator.restore(blockId),
208
+ this.transferTimeoutMs
209
+ );
210
+ } finally {
211
+ this.releaseSemaphore();
212
+ }
213
+
214
+ if (archive) {
215
+ log('pull:ok block=%s', blockId);
216
+ succeeded.push(blockId);
217
+ return;
218
+ }
219
+ if (attempt < this.maxRetries) {
220
+ log('pull:retry block=%s attempt=%d', blockId, attempt + 1);
221
+ await this.delay(this.backoffMs(attempt));
222
+ continue;
223
+ }
224
+ log('pull:failed block=%s', blockId);
225
+ failed.push(blockId);
226
+ return;
227
+ }
228
+ } finally {
229
+ this.inFlight.delete(key);
230
+ }
231
+ }
232
+
233
+ private async executePush(
234
+ blockId: string,
235
+ newOwners: Map<string, string[]>,
236
+ succeeded: string[],
237
+ failed: string[]
238
+ ): Promise<void> {
239
+ const key = `push:${blockId}`;
240
+ if (this.inFlight.has(key)) return;
241
+ this.inFlight.add(key);
242
+
243
+ try {
244
+ for (let attempt = 0; ; attempt++) {
245
+ await this.acquireSemaphore();
246
+ let pushed = false;
247
+ try {
248
+ const owners = newOwners.get(blockId);
249
+ if (!owners || owners.length === 0) {
250
+ failed.push(blockId);
251
+ return;
252
+ }
253
+
254
+ // Read block data from local storage
255
+ const result = await this.repo.get({ blockIds: [blockId] });
256
+ const blockResult = result[blockId];
257
+ if (!blockResult?.block) {
258
+ log('push:no-local-data block=%s', blockId);
259
+ failed.push(blockId);
260
+ return;
261
+ }
262
+
263
+ const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
264
+
265
+ // Push to at least one new owner
266
+ for (const ownerPeerIdStr of owners) {
267
+ try {
268
+ const peerId = peerIdFromString(ownerPeerIdStr);
269
+ const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
270
+ const response = await this.withTimeout(
271
+ client.pushBlocks([blockId], [blockData]),
272
+ this.transferTimeoutMs
273
+ );
274
+
275
+ if (response && !response.missing.includes(blockId)) {
276
+ pushed = true;
277
+ log('push:ok block=%s peer=%s', blockId, ownerPeerIdStr);
278
+ break;
279
+ }
280
+ } catch (err) {
281
+ log('push:peer-error block=%s peer=%s err=%s',
282
+ blockId, ownerPeerIdStr, (err as Error).message);
283
+ }
284
+ }
285
+ } finally {
286
+ this.releaseSemaphore();
287
+ }
288
+
289
+ if (pushed) {
290
+ succeeded.push(blockId);
291
+ return;
292
+ }
293
+ if (attempt < this.maxRetries) {
294
+ log('push:retry block=%s attempt=%d', blockId, attempt + 1);
295
+ await this.delay(this.backoffMs(attempt));
296
+ continue;
297
+ }
298
+ log('push:failed block=%s', blockId);
299
+ failed.push(blockId);
300
+ return;
301
+ }
302
+ } finally {
303
+ this.inFlight.delete(key);
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Confirm one block replicated to ≥ `floor` distinct qualifying owners. Reads the local block once,
309
+ * pushes to each candidate owner (stopping once the floor is reached), and counts distinct owners
310
+ * that report holding it (not `missing`). Retries the whole round up to `maxRetries` before giving
311
+ * up. Records the block in `confirmed` iff the floor was met, otherwise in `unconfirmed`.
312
+ */
313
+ private async executeConfirm(
314
+ blockId: string,
315
+ owners: Map<string, string[]>,
316
+ floor: number,
317
+ confirmed: string[],
318
+ unconfirmed: string[]
319
+ ): Promise<void> {
320
+ const key = `confirm:${blockId}`;
321
+ if (this.inFlight.has(key)) return;
322
+ this.inFlight.add(key);
323
+
324
+ try {
325
+ const candidateOwners = owners.get(blockId) ?? [];
326
+ if (candidateOwners.length === 0) {
327
+ // No qualifying holder to confirm against — cannot release; keep serving.
328
+ unconfirmed.push(blockId);
329
+ return;
330
+ }
331
+
332
+ for (let attempt = 0; ; attempt++) {
333
+ await this.acquireSemaphore();
334
+ let confirmCount = 0;
335
+ try {
336
+ // Read block data from local storage once per attempt.
337
+ const result = await this.repo.get({ blockIds: [blockId] });
338
+ const blockResult = result[blockId];
339
+ if (!blockResult?.block) {
340
+ // No local bytes to prove replication with — cannot confirm; keep serving.
341
+ log('confirm:no-local-data block=%s', blockId);
342
+ unconfirmed.push(blockId);
343
+ return;
344
+ }
345
+
346
+ const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
347
+
348
+ // Count DISTINCT owners that hold a current replica; stop once the floor is reached.
349
+ const confirmedPeers = new Set<string>();
350
+ for (const ownerPeerIdStr of candidateOwners) {
351
+ if (confirmedPeers.size >= floor) break;
352
+ try {
353
+ const peerId = peerIdFromString(ownerPeerIdStr);
354
+ const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
355
+ const response = await this.withTimeout(
356
+ client.pushBlocks([blockId], [blockData]),
357
+ this.transferTimeoutMs
358
+ );
359
+ if (response && !response.missing.includes(blockId)) {
360
+ confirmedPeers.add(ownerPeerIdStr);
361
+ }
362
+ } catch (err) {
363
+ log('confirm:peer-error block=%s peer=%s err=%s',
364
+ blockId, ownerPeerIdStr, (err as Error).message);
365
+ }
366
+ }
367
+ confirmCount = confirmedPeers.size;
368
+ } finally {
369
+ this.releaseSemaphore();
370
+ }
371
+
372
+ if (confirmCount >= floor) {
373
+ log('confirm:ok block=%s holders=%d/%d', blockId, confirmCount, floor);
374
+ confirmed.push(blockId);
375
+ return;
376
+ }
377
+ if (attempt < this.maxRetries) {
378
+ log('confirm:retry block=%s holders=%d/%d attempt=%d', blockId, confirmCount, floor, attempt + 1);
379
+ await this.delay(this.backoffMs(attempt));
380
+ continue;
381
+ }
382
+ log('confirm:unmet block=%s holders=%d/%d', blockId, confirmCount, floor);
383
+ unconfirmed.push(blockId);
384
+ return;
385
+ }
386
+ } finally {
387
+ this.inFlight.delete(key);
388
+ }
389
+ }
390
+
391
+ // --- Semaphore for concurrency limiting ---
392
+
393
+ private async acquireSemaphore(): Promise<void> {
394
+ if (this.concurrency < this.maxConcurrency) {
395
+ this.concurrency++;
396
+ return;
397
+ }
398
+ await new Promise<void>(resolve => this.waitQueue.push(resolve));
399
+ this.concurrency++;
400
+ }
401
+
402
+ private releaseSemaphore(): void {
403
+ this.concurrency--;
404
+ const next = this.waitQueue.shift();
405
+ if (next) next();
406
+ }
407
+
408
+ // --- Helpers ---
409
+
410
+ private backoffMs(attempt: number): number {
411
+ return Math.min(1000 * Math.pow(2, attempt), 10000);
412
+ }
413
+
414
+ private delay(ms: number): Promise<void> {
415
+ return new Promise(resolve => setTimeout(resolve, ms));
416
+ }
417
+
418
+ private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
419
+ return Promise.race([
420
+ promise,
421
+ new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), ms))
422
+ ]);
423
+ }
424
+ }