@optimystic/db-p2p 0.24.1 → 0.25.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 (257) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +21 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +28 -17
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +78 -6
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +136 -57
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +150 -62
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/logger.d.ts +2 -2
  85. package/dist/src/logger.js +2 -2
  86. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  87. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  88. package/dist/src/network/open-protocol-stream.js +60 -0
  89. package/dist/src/network/open-protocol-stream.js.map +1 -0
  90. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  91. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  92. package/dist/src/network/register-protocol-handler.js +51 -0
  93. package/dist/src/network/register-protocol-handler.js.map +1 -0
  94. package/dist/src/peer-address-book.d.ts +128 -1
  95. package/dist/src/peer-address-book.d.ts.map +1 -1
  96. package/dist/src/peer-address-book.js +198 -15
  97. package/dist/src/peer-address-book.js.map +1 -1
  98. package/dist/src/pend-validation.d.ts +65 -0
  99. package/dist/src/pend-validation.d.ts.map +1 -0
  100. package/dist/src/pend-validation.js +69 -0
  101. package/dist/src/pend-validation.js.map +1 -0
  102. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  103. package/dist/src/reactivity/notify-transport.js +2 -1
  104. package/dist/src/reactivity/notify-transport.js.map +1 -1
  105. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  106. package/dist/src/reactivity/push-state-gossip.js +2 -1
  107. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  109. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  110. package/dist/src/repo/cluster-coordinator.js +44 -10
  111. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  112. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  113. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  114. package/dist/src/repo/coordinator-repo.js +650 -62
  115. package/dist/src/repo/coordinator-repo.js.map +1 -1
  116. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  117. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  118. package/dist/src/repo/served-repo-proxy.js +50 -0
  119. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  120. package/dist/src/repo/service.d.ts +22 -1
  121. package/dist/src/repo/service.d.ts.map +1 -1
  122. package/dist/src/repo/service.js +25 -13
  123. package/dist/src/repo/service.js.map +1 -1
  124. package/dist/src/reputation/types.d.ts +3 -1
  125. package/dist/src/reputation/types.d.ts.map +1 -1
  126. package/dist/src/reputation/types.js +3 -1
  127. package/dist/src/reputation/types.js.map +1 -1
  128. package/dist/src/rn.d.ts +24 -2
  129. package/dist/src/rn.d.ts.map +1 -1
  130. package/dist/src/rn.js +33 -4
  131. package/dist/src/rn.js.map +1 -1
  132. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
  133. package/dist/src/routing/libp2p-known-peers.js +5 -0
  134. package/dist/src/routing/libp2p-known-peers.js.map +1 -1
  135. package/dist/src/storage/block-archive.d.ts +153 -0
  136. package/dist/src/storage/block-archive.d.ts.map +1 -0
  137. package/dist/src/storage/block-archive.js +194 -0
  138. package/dist/src/storage/block-archive.js.map +1 -0
  139. package/dist/src/storage/block-latch.d.ts +92 -0
  140. package/dist/src/storage/block-latch.d.ts.map +1 -0
  141. package/dist/src/storage/block-latch.js +132 -0
  142. package/dist/src/storage/block-latch.js.map +1 -0
  143. package/dist/src/storage/block-storage.d.ts +169 -12
  144. package/dist/src/storage/block-storage.d.ts.map +1 -1
  145. package/dist/src/storage/block-storage.js +415 -152
  146. package/dist/src/storage/block-storage.js.map +1 -1
  147. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  148. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  149. package/dist/src/storage/cached-raw-storage.js +16 -1
  150. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  151. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  152. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  153. package/dist/src/storage/cached-store-driver.js +39 -5
  154. package/dist/src/storage/cached-store-driver.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +70 -19
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js +21 -1
  158. package/dist/src/storage/i-block-storage.js.map +1 -1
  159. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  160. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  161. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  162. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  163. package/dist/src/storage/kv-raw-storage.js +29 -9
  164. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  165. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  166. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/memory-store-driver.js +22 -2
  168. package/dist/src/storage/memory-store-driver.js.map +1 -1
  169. package/dist/src/storage/raw-store-codec.js +2 -2
  170. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  171. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  172. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  173. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  174. package/dist/src/storage/shared-cache-pool.js +68 -4
  175. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  176. package/dist/src/storage/storage-repo.d.ts +204 -26
  177. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  178. package/dist/src/storage/storage-repo.js +422 -119
  179. package/dist/src/storage/storage-repo.js.map +1 -1
  180. package/dist/src/storage/store-identity.d.ts +21 -0
  181. package/dist/src/storage/store-identity.d.ts.map +1 -0
  182. package/dist/src/storage/store-identity.js +33 -0
  183. package/dist/src/storage/store-identity.js.map +1 -0
  184. package/dist/src/storage/struct.d.ts +15 -0
  185. package/dist/src/storage/struct.d.ts.map +1 -1
  186. package/dist/src/storage/with-read-cache.d.ts +92 -0
  187. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  188. package/dist/src/storage/with-read-cache.js +132 -0
  189. package/dist/src/storage/with-read-cache.js.map +1 -0
  190. package/dist/src/sync/service.d.ts +13 -3
  191. package/dist/src/sync/service.d.ts.map +1 -1
  192. package/dist/src/sync/service.js +7 -30
  193. package/dist/src/sync/service.js.map +1 -1
  194. package/dist/src/testing/mesh-harness.d.ts +75 -2
  195. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  196. package/dist/src/testing/mesh-harness.js +193 -81
  197. package/dist/src/testing/mesh-harness.js.map +1 -1
  198. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  199. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  200. package/dist/src/testing/raw-storage-conformance.js +97 -2
  201. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  202. package/package.json +5 -6
  203. package/src/cluster/block-transfer-service.ts +528 -312
  204. package/src/cluster/block-transfer.ts +547 -424
  205. package/src/cluster/certified-claims.ts +319 -0
  206. package/src/cluster/client-signature-verifier.ts +34 -0
  207. package/src/cluster/cluster-policy.ts +294 -203
  208. package/src/cluster/cluster-repo.ts +2501 -2034
  209. package/src/cluster/commit-proof.ts +344 -0
  210. package/src/cluster/quorum-restore.ts +348 -223
  211. package/src/cluster/rebalance-monitor.ts +539 -254
  212. package/src/cluster/reconcile-block.ts +404 -203
  213. package/src/cluster/service.ts +308 -293
  214. package/src/cluster/spread-on-churn.ts +352 -346
  215. package/src/cohort-topic/host.ts +8 -7
  216. package/src/cohort-topic/stream-util.ts +14 -43
  217. package/src/dispute/cascade.ts +31 -15
  218. package/src/dispute/dispute-service.ts +706 -696
  219. package/src/dispute/invalidation.ts +162 -52
  220. package/src/dispute/service.ts +126 -125
  221. package/src/index.ts +7 -0
  222. package/src/libp2p-key-network.ts +142 -58
  223. package/src/libp2p-node-base.ts +1793 -1675
  224. package/src/logger.ts +27 -27
  225. package/src/network/open-protocol-stream.ts +87 -0
  226. package/src/network/register-protocol-handler.ts +85 -0
  227. package/src/peer-address-book.ts +360 -149
  228. package/src/pend-validation.ts +89 -0
  229. package/src/reactivity/notify-transport.ts +145 -144
  230. package/src/reactivity/push-state-gossip.ts +292 -291
  231. package/src/repo/cluster-coordinator.ts +65 -11
  232. package/src/repo/coordinator-repo.ts +729 -71
  233. package/src/repo/served-repo-proxy.ts +55 -0
  234. package/src/repo/service.ts +332 -313
  235. package/src/reputation/types.ts +3 -1
  236. package/src/rn.ts +34 -4
  237. package/src/routing/libp2p-known-peers.ts +31 -26
  238. package/src/storage/block-archive.ts +242 -0
  239. package/src/storage/block-latch.ts +144 -0
  240. package/src/storage/block-storage.ts +787 -498
  241. package/src/storage/cached-raw-storage.ts +21 -1
  242. package/src/storage/cached-store-driver.ts +898 -859
  243. package/src/storage/i-block-storage.ts +172 -116
  244. package/src/storage/i-raw-storage.ts +40 -0
  245. package/src/storage/kv-raw-storage.ts +34 -9
  246. package/src/storage/memory-store-driver.ts +26 -2
  247. package/src/storage/raw-store-codec.ts +42 -42
  248. package/src/storage/raw-store-driver.ts +45 -3
  249. package/src/storage/shared-cache-pool.ts +516 -452
  250. package/src/storage/storage-repo.ts +1421 -1014
  251. package/src/storage/store-identity.ts +41 -0
  252. package/src/storage/struct.ts +52 -34
  253. package/src/storage/with-read-cache.ts +182 -0
  254. package/src/sync/service.ts +178 -198
  255. package/src/testing/cohort-topic-mesh-harness.ts +673 -673
  256. package/src/testing/mesh-harness.ts +524 -344
  257. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,424 +1,547 @@
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 { 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 type { ProofRetainingRepo } from '../storage/block-archive.js';
6
+ import { BlockTransferClient, sourceBlockCertification } from './block-transfer-service.js';
7
+ import type { GrowthOutcome, RebalanceEvent } from './rebalance-monitor.js';
8
+ import { createLogger } from '../logger.js';
9
+
10
+ const log = createLogger('block-transfer');
11
+
12
+ export interface BlockTransferConfig {
13
+ /** Max concurrent transfers. Default: 4 */
14
+ maxConcurrency?: number;
15
+ /** Timeout per block transfer (ms). Default: 30000 */
16
+ transferTimeoutMs?: number;
17
+ /** Retry attempts for failed transfers. Default: 2 */
18
+ maxRetries?: number;
19
+ /** Whether to push blocks to new owners proactively. Default: true */
20
+ enablePush?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Outcome of reacting to a {@link RebalanceEvent}. The `released` list is the gate the caller opens
25
+ * before it may stop serving a lost block: a block appears here ONLY after it was confirmed
26
+ * replicated to the event's floor of new owners. Everything in `retained` stays tracked and served
27
+ * (its push failed, was partition-skipped, or could not reach the floor) and is retried on the next
28
+ * rebalance. See `docs/arachnode-ring-handoff.md` § Part 2.
29
+ */
30
+ export interface RebalanceReactionResult {
31
+ /** Gained blocks successfully pulled (now durably held locally). */
32
+ pulled: string[];
33
+ /** Lost blocks confirmed replicated to ≥ floor new owners — safe to release. */
34
+ released: string[];
35
+ /** Lost blocks whose replication could not be confirmed — keep serving, retry later. */
36
+ retained: string[];
37
+ /** Grown blocks confirmed pushed to every newly co-responsible peer (capped by the floor). */
38
+ replicated: string[];
39
+ /**
40
+ * Grown blocks that could not be confirmed on the new peers this pass. Nothing is released off
41
+ * this list the node keeps the block either way. The retry lives in the monitor: the caller
42
+ * feeds each block's {@link GrowthOutcome} (in `growth`) back via
43
+ * `RebalanceMonitor.recordGrowthOutcome`, so an unconfirmed peer stays out of the seen set and
44
+ * the next check re-detects it.
45
+ */
46
+ underReplicated: string[];
47
+ /**
48
+ * Per-block feedback for the growth arm, keyed by block id. A block reported `grown` that the
49
+ * reaction had NO information about (its confirm was deduped against one already in flight) has
50
+ * no entry — the monitor must leave that block's state untouched.
51
+ */
52
+ growth: Map<string, GrowthOutcome>;
53
+ }
54
+
55
+ /**
56
+ * Coordinates block transfers in response to rebalance events.
57
+ *
58
+ * For gained blocks: delegates to RestorationCoordinator.restore() which
59
+ * already handles ring-based discovery and fetching.
60
+ *
61
+ * For lost blocks: proactively pushes block data to new responsible peers
62
+ * via the BlockTransfer protocol.
63
+ */
64
+ export class BlockTransferCoordinator {
65
+ private readonly maxConcurrency: number;
66
+ private readonly transferTimeoutMs: number;
67
+ private readonly maxRetries: number;
68
+ private readonly enablePush: boolean;
69
+ private inFlight = new Set<string>();
70
+ private concurrency = 0;
71
+ private readonly waitQueue: Array<() => void> = [];
72
+
73
+ constructor(
74
+ /**
75
+ * The node's OWN store, proof accessor REQUIRED (not the optional `ArchiveServingRepo`
76
+ * shape): every push this class makes is certified out of it, and a repo without the
77
+ * accessor would make `sourceBlockCertification` return meta-only for every block so
78
+ * every push would be refused by a receiver running the default `requirePushCertificate`,
79
+ * silently and with no type error. Same reasoning as `createServedRepoProxy`'s.
80
+ */
81
+ private readonly repo: ProofRetainingRepo,
82
+ private readonly peerNetwork: IPeerNetwork,
83
+ private readonly restorationCoordinator: RestorationCoordinator,
84
+ private readonly partitionDetector: PartitionDetector,
85
+ private readonly protocolPrefix: string = '',
86
+ config: BlockTransferConfig = {}
87
+ ) {
88
+ this.maxConcurrency = config.maxConcurrency ?? 4;
89
+ this.transferTimeoutMs = config.transferTimeoutMs ?? 30000;
90
+ this.maxRetries = config.maxRetries ?? 2;
91
+ this.enablePush = config.enablePush ?? true;
92
+ }
93
+
94
+ /**
95
+ * Pull blocks that this node has gained responsibility for.
96
+ * Uses RestorationCoordinator to discover holders and fetch block data.
97
+ */
98
+ async pullBlocks(blockIds: string[]): Promise<{ succeeded: string[]; failed: string[] }> {
99
+ if (this.partitionDetector.detectPartition()) {
100
+ log('pull:partition-detected, skipping %d blocks', blockIds.length);
101
+ return { succeeded: [], failed: blockIds };
102
+ }
103
+
104
+ const succeeded: string[] = [];
105
+ const failed: string[] = [];
106
+
107
+ const ids = blockIds.filter(id => !this.inFlight.has(`pull:${id}`));
108
+
109
+ await Promise.all(ids.map(id => this.executePull(id, succeeded, failed)));
110
+
111
+ return { succeeded, failed };
112
+ }
113
+
114
+ /**
115
+ * Push blocks that this node has lost responsibility for to new owners.
116
+ */
117
+ async pushBlocks(
118
+ blockIds: string[],
119
+ newOwners: Map<string, string[]>
120
+ ): Promise<{ succeeded: string[]; failed: string[] }> {
121
+ if (!this.enablePush) {
122
+ return { succeeded: [], failed: [] };
123
+ }
124
+ if (this.partitionDetector.detectPartition()) {
125
+ log('push:partition-detected, skipping %d blocks', blockIds.length);
126
+ return { succeeded: [], failed: blockIds };
127
+ }
128
+
129
+ const succeeded: string[] = [];
130
+ const failed: string[] = [];
131
+
132
+ const ids = blockIds.filter(id => !this.inFlight.has(`push:${id}`) && newOwners.has(id));
133
+
134
+ await Promise.all(ids.map(id => this.executePush(id, newOwners, succeeded, failed)));
135
+
136
+ return { succeeded, failed };
137
+ }
138
+
139
+ /**
140
+ * Handle a complete rebalance event — pull gained, and **confirm** lost blocks replicated to the
141
+ * floor before reporting them releasable.
142
+ *
143
+ * The lost path no longer pushes fire-and-forget: it runs {@link confirmReplicated} against the
144
+ * event's `newOwners` and `floor`, so `released` contains only blocks that landed on ≥ floor new
145
+ * owners. The caller gates its `untrackBlock` (release + GC-eligibility) on `released` and leaves
146
+ * `retained` blocks tracked/served for the next rebalance. This closes the release-before-confirm
147
+ * hole (`docs/arachnode-ring-handoff.md` § Why the current code violates it #2).
148
+ */
149
+ async handleRebalanceEvent(event: RebalanceEvent): Promise<RebalanceReactionResult> {
150
+ log('rebalance:start gained=%d lost=%d grown=%d floor=%d',
151
+ event.gained.length, event.lost.length, event.grown.size, event.floor);
152
+
153
+ const floor = Math.max(1, event.floor);
154
+ const [pullResult, confirmResult, growResult] = await Promise.all([
155
+ event.gained.length > 0 ? this.pullBlocks(event.gained) : { succeeded: [], failed: [] },
156
+ event.lost.length > 0 && event.newOwners.size > 0
157
+ ? this.confirmReplicated(event.lost, event.newOwners, floor)
158
+ : { confirmed: [], unconfirmed: [...event.lost] },
159
+ this.replicateGrown(event.grown, floor)
160
+ ]);
161
+
162
+ log('rebalance:done pull=%d/%d released=%d/%d replicated=%d/%d',
163
+ pullResult.succeeded.length, event.gained.length,
164
+ confirmResult.confirmed.length, event.lost.length,
165
+ growResult.confirmed.length, event.grown.size);
166
+
167
+ return {
168
+ pulled: pullResult.succeeded,
169
+ released: confirmResult.confirmed,
170
+ retained: confirmResult.unconfirmed,
171
+ replicated: growResult.confirmed,
172
+ underReplicated: growResult.unconfirmed,
173
+ growth: growResult.growth
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Push each GROWN block (still owned; new peers became co-responsible) to its newly
179
+ * co-responsible peers, reusing {@link executeConfirm} per block. The per-block floor is
180
+ * `min(event floor, new-peer count)`: passing the raw event floor would be wrong when fewer new
181
+ * peers exist than the floor — `executeConfirm` could then never reach the floor and would burn
182
+ * `maxRetries` re-pushing peers that already accepted. Nothing is released off this path (the
183
+ * node KEEPS the block either way); the confirmed/unconfirmed split is reporting only. The
184
+ * in-flight `confirm:<id>` key dedups against a concurrent lost-confirm for the same block —
185
+ * such a block gets NO `growth` entry (no information), so the monitor leaves it untouched.
186
+ * `enablePush` deliberately does not gate this (same rationale as the NOTE in
187
+ * {@link confirmReplicated}); the growth arm as a whole is gated by the `rebalance.enabled`
188
+ * wiring in `libp2p-node-base`.
189
+ *
190
+ * Per-block {@link GrowthOutcome} rules:
191
+ * - floor met → all reported peers satisfied, complete. Deliberately includes peers
192
+ * `executeConfirm` skipped once the floor was reached — the block is adequately replicated,
193
+ * and re-pushing the remainder on every check forever would be a live loop.
194
+ * - no local data → all reported peers satisfied, complete (nothing to replicate; see NOTE).
195
+ * - otherwise → only the peers that actually confirmed, incomplete (retried by the monitor).
196
+ */
197
+ private async replicateGrown(
198
+ grown: Map<string, string[]>,
199
+ floor: number
200
+ ): Promise<{ confirmed: string[]; unconfirmed: string[]; growth: Map<string, GrowthOutcome> }> {
201
+ const confirmed: string[] = [];
202
+ const unconfirmed: string[] = [];
203
+ const growth = new Map<string, GrowthOutcome>();
204
+ if (grown.size === 0) {
205
+ return { confirmed, unconfirmed, growth };
206
+ }
207
+
208
+ if (this.partitionDetector.detectPartition()) {
209
+ // Mirrors confirmReplicated's guard (replicateGrown drives executeConfirm directly). An
210
+ // incomplete outcome with nothing satisfied keeps every reported peer un-seen AND counts an
211
+ // attempt, so a partition mid-reaction is retried like any other failed push.
212
+ log('grow:partition-detected, leaving %d blocks unconfirmed', grown.size);
213
+ for (const [blockId, newPeers] of grown) {
214
+ if (newPeers.length === 0) continue;
215
+ unconfirmed.push(blockId);
216
+ growth.set(blockId, { satisfiedPeers: [], complete: false });
217
+ }
218
+ return { confirmed, unconfirmed, growth };
219
+ }
220
+
221
+ await Promise.all([...grown.entries()].map(async ([blockId, newPeers]) => {
222
+ if (newPeers.length === 0) return;
223
+ const result = await this.executeConfirm(
224
+ blockId,
225
+ new Map([[blockId, newPeers]]),
226
+ Math.min(floor, newPeers.length) // both ≥ 1 here: the caller clamps floor, empty newPeers returned above
227
+ );
228
+ if (result === null) return; // confirm already in flight — no information, no entry
229
+ if (result.confirmed) {
230
+ confirmed.push(blockId);
231
+ growth.set(blockId, { satisfiedPeers: [...newPeers], complete: true });
232
+ } else if (result.noLocalData) {
233
+ // NOTE: nothing local to replicate (the gained∩grown first-observation case) — the
234
+ // reported peers are recorded satisfied so this does not become a permanent retry loop
235
+ // (those cohort peers are the pull's own source). If the node later obtains the block by
236
+ // another route (a fresh local commit, a spread push), these peers stay recorded and are
237
+ // never pushed — benign today, since a gained block's data comes from these very peers.
238
+ unconfirmed.push(blockId);
239
+ growth.set(blockId, { satisfiedPeers: [...newPeers], complete: true });
240
+ } else {
241
+ unconfirmed.push(blockId);
242
+ growth.set(blockId, { satisfiedPeers: [...result.confirmedPeers], complete: false });
243
+ }
244
+ }));
245
+
246
+ return { confirmed, unconfirmed, growth };
247
+ }
248
+
249
+ /**
250
+ * Confirm each block is replicated to at least `floor` qualifying owners — the gate the ring-shift
251
+ * handoff (Phase B) and the rebalance release both open before a block may stop being served.
252
+ *
253
+ * For each block, this pushes to the candidate owners and counts how many report holding a current
254
+ * replica: a holder confirms when the push response does NOT list the block in `missing` (it either
255
+ * already had it or accepted the push — `handlePush` reports `accepted` only on a received-AND-persisted
256
+ * block). A block is `confirmed` only when that count reaches `floor`; otherwise it is `unconfirmed`
257
+ * and the caller keeps serving it. Per-block timeout + retry mirror {@link pushBlocks}. During a
258
+ * detected partition every block is left unconfirmed (consistent with the push guard), so a partition
259
+ * mid-handoff aborts rather than releases.
260
+ *
261
+ * @param owners blockId → candidate owner peer ids. The caller MUST have already excluded self and
262
+ * any same-range mover (a peer shedding the same sub-range), so every id here is a qualifying holder.
263
+ * @param floor required confirming owners per block (the replication floor `N`).
264
+ */
265
+ async confirmReplicated(
266
+ blockIds: string[],
267
+ owners: Map<string, string[]>,
268
+ floor: number
269
+ ): Promise<{ confirmed: string[]; unconfirmed: string[] }> {
270
+ // NOTE: unlike pushBlocks, this deliberately does NOT honor `enablePush` — confirmation
271
+ // fundamentally requires pushing to verify replication, and skipping it would leave every
272
+ // block unconfirmed → never released → the node never sheds. So `enablePush:false` no longer
273
+ // suppresses pushes on the rebalance/handoff release path (it only gates the legacy pushBlocks
274
+ // fire-and-forget). If a config ever needs "never move data at all", gate the release wiring,
275
+ // not this primitive.
276
+ if (this.partitionDetector.detectPartition()) {
277
+ log('confirm:partition-detected, leaving %d blocks unconfirmed', blockIds.length);
278
+ return { confirmed: [], unconfirmed: [...blockIds] };
279
+ }
280
+ if (floor <= 0) {
281
+ // A non-positive floor cannot be safely "met"; refuse to release rather than release for free.
282
+ return { confirmed: [], unconfirmed: [...blockIds] };
283
+ }
284
+
285
+ const confirmed: string[] = [];
286
+ const unconfirmed: string[] = [];
287
+
288
+ const ids = blockIds.filter(id => !this.inFlight.has(`confirm:${id}`));
289
+ await Promise.all(ids.map(async id => {
290
+ const result = await this.executeConfirm(id, owners, floor);
291
+ if (result === null) return; // raced into flight after the filter — no information
292
+ (result.confirmed ? confirmed : unconfirmed).push(id);
293
+ }));
294
+
295
+ return { confirmed, unconfirmed };
296
+ }
297
+
298
+ private async executePull(
299
+ blockId: string,
300
+ succeeded: string[],
301
+ failed: string[]
302
+ ): Promise<void> {
303
+ const key = `pull:${blockId}`;
304
+ if (this.inFlight.has(key)) return;
305
+ this.inFlight.add(key);
306
+
307
+ try {
308
+ for (let attempt = 0; ; attempt++) {
309
+ await this.acquireSemaphore();
310
+ let archive: Awaited<ReturnType<RestorationCoordinator['restore']>>;
311
+ try {
312
+ archive = await this.withTimeout(
313
+ this.restorationCoordinator.restore(blockId),
314
+ this.transferTimeoutMs
315
+ );
316
+ } finally {
317
+ this.releaseSemaphore();
318
+ }
319
+
320
+ if (archive) {
321
+ log('pull:ok block=%s', blockId);
322
+ succeeded.push(blockId);
323
+ return;
324
+ }
325
+ if (attempt < this.maxRetries) {
326
+ log('pull:retry block=%s attempt=%d', blockId, attempt + 1);
327
+ await this.delay(this.backoffMs(attempt));
328
+ continue;
329
+ }
330
+ log('pull:failed block=%s', blockId);
331
+ failed.push(blockId);
332
+ return;
333
+ }
334
+ } finally {
335
+ this.inFlight.delete(key);
336
+ }
337
+ }
338
+
339
+ private async executePush(
340
+ blockId: string,
341
+ newOwners: Map<string, string[]>,
342
+ succeeded: string[],
343
+ failed: string[]
344
+ ): Promise<void> {
345
+ const key = `push:${blockId}`;
346
+ if (this.inFlight.has(key)) return;
347
+ this.inFlight.add(key);
348
+
349
+ try {
350
+ for (let attempt = 0; ; attempt++) {
351
+ await this.acquireSemaphore();
352
+ let pushed = false;
353
+ try {
354
+ const owners = newOwners.get(blockId);
355
+ if (!owners || owners.length === 0) {
356
+ failed.push(blockId);
357
+ return;
358
+ }
359
+
360
+ // Read block data from local storage
361
+ const result = await this.repo.get({ blockIds: [blockId] });
362
+ const blockResult = result[blockId];
363
+ if (!blockResult?.block) {
364
+ log('push:no-local-data block=%s', blockId);
365
+ failed.push(blockId);
366
+ return;
367
+ }
368
+
369
+ const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
370
+ // Revision metadata AND the cohort proof for that revision, from this one unpinned
371
+ // read. A receiver running the default `requirePushCertificate` rejects a push with
372
+ // no proof, so a block whose proof this node never retained (pre-proof history, a
373
+ // diverged commit) simply fails to place here and is retried/kept as today.
374
+ const certification = await sourceBlockCertification(this.repo, blockId, blockResult);
375
+
376
+ // Push to at least one new owner
377
+ for (const ownerPeerIdStr of owners) {
378
+ try {
379
+ const peerId = peerIdFromString(ownerPeerIdStr);
380
+ const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
381
+ const response = await this.withTimeout(
382
+ client.pushBlocks([blockId], [blockData], 'rebalance', certification),
383
+ this.transferTimeoutMs
384
+ );
385
+
386
+ if (response && !response.missing.includes(blockId)) {
387
+ pushed = true;
388
+ log('push:ok block=%s peer=%s', blockId, ownerPeerIdStr);
389
+ break;
390
+ }
391
+ } catch (err) {
392
+ log('push:peer-error block=%s peer=%s err=%s',
393
+ blockId, ownerPeerIdStr, (err as Error).message);
394
+ }
395
+ }
396
+ } finally {
397
+ this.releaseSemaphore();
398
+ }
399
+
400
+ if (pushed) {
401
+ succeeded.push(blockId);
402
+ return;
403
+ }
404
+ if (attempt < this.maxRetries) {
405
+ log('push:retry block=%s attempt=%d', blockId, attempt + 1);
406
+ await this.delay(this.backoffMs(attempt));
407
+ continue;
408
+ }
409
+ log('push:failed block=%s', blockId);
410
+ failed.push(blockId);
411
+ return;
412
+ }
413
+ } finally {
414
+ this.inFlight.delete(key);
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Confirm one block replicated to ≥ `floor` distinct qualifying owners. Reads the local block once
420
+ * per attempt, pushes to each candidate owner (stopping once the floor is reached), and counts
421
+ * distinct owners that report holding it (not `missing`). Retries the whole round up to
422
+ * `maxRetries` before giving up.
423
+ *
424
+ * Returns `null` when a confirm for this block is already in flight (no information — the caller
425
+ * must not record anything for it). Otherwise: `confirmed` iff the floor was met; `confirmedPeers`
426
+ * is the union of owners that confirmed across every attempt (a peer that accepted a push holds a
427
+ * replica even if a later round missed it — the floor decision itself stays per-round, unchanged);
428
+ * `noLocalData` marks the nothing-local-to-push case. The lost-block release path uses only
429
+ * `confirmed`; the growth arm consumes the other two.
430
+ */
431
+ private async executeConfirm(
432
+ blockId: string,
433
+ owners: Map<string, string[]>,
434
+ floor: number
435
+ ): Promise<{ confirmed: boolean; confirmedPeers: Set<string>; noLocalData: boolean } | null> {
436
+ const key = `confirm:${blockId}`;
437
+ if (this.inFlight.has(key)) return null;
438
+ this.inFlight.add(key);
439
+
440
+ try {
441
+ const allConfirmedPeers = new Set<string>();
442
+ const candidateOwners = owners.get(blockId) ?? [];
443
+ if (candidateOwners.length === 0) {
444
+ // No qualifying holder to confirm against — cannot release; keep serving.
445
+ return { confirmed: false, confirmedPeers: allConfirmedPeers, noLocalData: false };
446
+ }
447
+
448
+ for (let attempt = 0; ; attempt++) {
449
+ await this.acquireSemaphore();
450
+ let confirmCount = 0;
451
+ try {
452
+ // Read block data from local storage once per attempt.
453
+ const result = await this.repo.get({ blockIds: [blockId] });
454
+ const blockResult = result[blockId];
455
+ if (!blockResult?.block) {
456
+ // No local bytes to prove replication with — cannot confirm; keep serving.
457
+ log('confirm:no-local-data block=%s', blockId);
458
+ return { confirmed: false, confirmedPeers: allConfirmedPeers, noLocalData: true };
459
+ }
460
+
461
+ const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
462
+ // See executePush: meta + proof are built together from this one unpinned read, so a
463
+ // confirming holder either takes a certified replica or reports the block missing.
464
+ const certification = await sourceBlockCertification(this.repo, blockId, blockResult);
465
+
466
+ // Count DISTINCT owners that hold a current replica; stop once the floor is reached.
467
+ const confirmedPeers = new Set<string>();
468
+ for (const ownerPeerIdStr of candidateOwners) {
469
+ if (confirmedPeers.size >= floor) break;
470
+ try {
471
+ const peerId = peerIdFromString(ownerPeerIdStr);
472
+ const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
473
+ const response = await this.withTimeout(
474
+ client.pushBlocks([blockId], [blockData], 'rebalance', certification),
475
+ this.transferTimeoutMs
476
+ );
477
+ if (response && !response.missing.includes(blockId)) {
478
+ confirmedPeers.add(ownerPeerIdStr);
479
+ // NOTE: allConfirmedPeers never un-records a peer. A holder that confirms in one
480
+ // round and reports `missing` in a later one stays recorded, on the reasoning that
481
+ // `handlePush` answers non-missing only after persisting. If the receiver ever
482
+ // gains a path that drops a just-persisted block (an eviction sweep, a rejected
483
+ // revision), the growth arm would record a peer that no longer holds a replica —
484
+ // intersect against the LAST round's confirmedPeers instead of unioning.
485
+ allConfirmedPeers.add(ownerPeerIdStr);
486
+ }
487
+ } catch (err) {
488
+ log('confirm:peer-error block=%s peer=%s err=%s',
489
+ blockId, ownerPeerIdStr, (err as Error).message);
490
+ }
491
+ }
492
+ confirmCount = confirmedPeers.size;
493
+ } finally {
494
+ this.releaseSemaphore();
495
+ }
496
+
497
+ if (confirmCount >= floor) {
498
+ log('confirm:ok block=%s holders=%d/%d', blockId, confirmCount, floor);
499
+ return { confirmed: true, confirmedPeers: allConfirmedPeers, noLocalData: false };
500
+ }
501
+ if (attempt < this.maxRetries) {
502
+ log('confirm:retry block=%s holders=%d/%d attempt=%d', blockId, confirmCount, floor, attempt + 1);
503
+ await this.delay(this.backoffMs(attempt));
504
+ continue;
505
+ }
506
+ log('confirm:unmet block=%s holders=%d/%d', blockId, confirmCount, floor);
507
+ return { confirmed: false, confirmedPeers: allConfirmedPeers, noLocalData: false };
508
+ }
509
+ } finally {
510
+ this.inFlight.delete(key);
511
+ }
512
+ }
513
+
514
+ // --- Semaphore for concurrency limiting ---
515
+
516
+ private async acquireSemaphore(): Promise<void> {
517
+ if (this.concurrency < this.maxConcurrency) {
518
+ this.concurrency++;
519
+ return;
520
+ }
521
+ await new Promise<void>(resolve => this.waitQueue.push(resolve));
522
+ this.concurrency++;
523
+ }
524
+
525
+ private releaseSemaphore(): void {
526
+ this.concurrency--;
527
+ const next = this.waitQueue.shift();
528
+ if (next) next();
529
+ }
530
+
531
+ // --- Helpers ---
532
+
533
+ private backoffMs(attempt: number): number {
534
+ return Math.min(1000 * Math.pow(2, attempt), 10000);
535
+ }
536
+
537
+ private delay(ms: number): Promise<void> {
538
+ return new Promise(resolve => setTimeout(resolve, ms));
539
+ }
540
+
541
+ private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
542
+ return Promise.race([
543
+ promise,
544
+ new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), ms))
545
+ ]);
546
+ }
547
+ }