@optimystic/db-p2p 0.24.2 → 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 (249) 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 +13 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +17 -18
  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 +14 -10
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +28 -58
  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 +148 -63
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  85. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  86. package/dist/src/network/open-protocol-stream.js +60 -0
  87. package/dist/src/network/open-protocol-stream.js.map +1 -0
  88. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  89. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  90. package/dist/src/network/register-protocol-handler.js +51 -0
  91. package/dist/src/network/register-protocol-handler.js.map +1 -0
  92. package/dist/src/peer-address-book.d.ts +60 -2
  93. package/dist/src/peer-address-book.d.ts.map +1 -1
  94. package/dist/src/peer-address-book.js +89 -1
  95. package/dist/src/peer-address-book.js.map +1 -1
  96. package/dist/src/pend-validation.d.ts +65 -0
  97. package/dist/src/pend-validation.d.ts.map +1 -0
  98. package/dist/src/pend-validation.js +69 -0
  99. package/dist/src/pend-validation.js.map +1 -0
  100. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  101. package/dist/src/reactivity/notify-transport.js +2 -1
  102. package/dist/src/reactivity/notify-transport.js.map +1 -1
  103. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  104. package/dist/src/reactivity/push-state-gossip.js +2 -1
  105. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  106. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  107. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.js +44 -10
  109. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  110. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  111. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  112. package/dist/src/repo/coordinator-repo.js +650 -62
  113. package/dist/src/repo/coordinator-repo.js.map +1 -1
  114. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  115. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  116. package/dist/src/repo/served-repo-proxy.js +50 -0
  117. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  118. package/dist/src/repo/service.d.ts +16 -1
  119. package/dist/src/repo/service.d.ts.map +1 -1
  120. package/dist/src/repo/service.js +18 -16
  121. package/dist/src/repo/service.js.map +1 -1
  122. package/dist/src/reputation/types.d.ts +3 -1
  123. package/dist/src/reputation/types.d.ts.map +1 -1
  124. package/dist/src/reputation/types.js +3 -1
  125. package/dist/src/reputation/types.js.map +1 -1
  126. package/dist/src/rn.d.ts +24 -2
  127. package/dist/src/rn.d.ts.map +1 -1
  128. package/dist/src/rn.js +33 -4
  129. package/dist/src/rn.js.map +1 -1
  130. package/dist/src/storage/block-archive.d.ts +153 -0
  131. package/dist/src/storage/block-archive.d.ts.map +1 -0
  132. package/dist/src/storage/block-archive.js +194 -0
  133. package/dist/src/storage/block-archive.js.map +1 -0
  134. package/dist/src/storage/block-latch.d.ts +92 -0
  135. package/dist/src/storage/block-latch.d.ts.map +1 -0
  136. package/dist/src/storage/block-latch.js +132 -0
  137. package/dist/src/storage/block-latch.js.map +1 -0
  138. package/dist/src/storage/block-storage.d.ts +169 -12
  139. package/dist/src/storage/block-storage.d.ts.map +1 -1
  140. package/dist/src/storage/block-storage.js +415 -152
  141. package/dist/src/storage/block-storage.js.map +1 -1
  142. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  143. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  144. package/dist/src/storage/cached-raw-storage.js +16 -1
  145. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  146. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  147. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  148. package/dist/src/storage/cached-store-driver.js +39 -5
  149. package/dist/src/storage/cached-store-driver.js.map +1 -1
  150. package/dist/src/storage/i-block-storage.d.ts +70 -19
  151. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  152. package/dist/src/storage/i-block-storage.js +21 -1
  153. package/dist/src/storage/i-block-storage.js.map +1 -1
  154. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  155. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  156. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  157. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  158. package/dist/src/storage/kv-raw-storage.js +29 -9
  159. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  160. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  161. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  162. package/dist/src/storage/memory-store-driver.js +22 -2
  163. package/dist/src/storage/memory-store-driver.js.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +2 -2
  165. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  166. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  168. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  169. package/dist/src/storage/shared-cache-pool.js +68 -4
  170. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  171. package/dist/src/storage/storage-repo.d.ts +204 -26
  172. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  173. package/dist/src/storage/storage-repo.js +422 -119
  174. package/dist/src/storage/storage-repo.js.map +1 -1
  175. package/dist/src/storage/store-identity.d.ts +21 -0
  176. package/dist/src/storage/store-identity.d.ts.map +1 -0
  177. package/dist/src/storage/store-identity.js +33 -0
  178. package/dist/src/storage/store-identity.js.map +1 -0
  179. package/dist/src/storage/struct.d.ts +15 -0
  180. package/dist/src/storage/struct.d.ts.map +1 -1
  181. package/dist/src/storage/with-read-cache.d.ts +92 -0
  182. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  183. package/dist/src/storage/with-read-cache.js +132 -0
  184. package/dist/src/storage/with-read-cache.js.map +1 -0
  185. package/dist/src/sync/service.d.ts +13 -3
  186. package/dist/src/sync/service.d.ts.map +1 -1
  187. package/dist/src/sync/service.js +7 -30
  188. package/dist/src/sync/service.js.map +1 -1
  189. package/dist/src/testing/mesh-harness.d.ts +75 -2
  190. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  191. package/dist/src/testing/mesh-harness.js +193 -81
  192. package/dist/src/testing/mesh-harness.js.map +1 -1
  193. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  194. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  195. package/dist/src/testing/raw-storage-conformance.js +97 -2
  196. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  197. package/package.json +5 -6
  198. package/src/cluster/block-transfer-service.ts +528 -312
  199. package/src/cluster/block-transfer.ts +547 -424
  200. package/src/cluster/certified-claims.ts +319 -0
  201. package/src/cluster/client-signature-verifier.ts +34 -0
  202. package/src/cluster/cluster-policy.ts +294 -203
  203. package/src/cluster/cluster-repo.ts +2501 -2034
  204. package/src/cluster/commit-proof.ts +344 -0
  205. package/src/cluster/quorum-restore.ts +348 -223
  206. package/src/cluster/rebalance-monitor.ts +539 -254
  207. package/src/cluster/reconcile-block.ts +404 -203
  208. package/src/cluster/service.ts +23 -20
  209. package/src/cluster/spread-on-churn.ts +352 -346
  210. package/src/cohort-topic/host.ts +8 -7
  211. package/src/cohort-topic/stream-util.ts +14 -43
  212. package/src/dispute/cascade.ts +31 -15
  213. package/src/dispute/dispute-service.ts +706 -696
  214. package/src/dispute/invalidation.ts +162 -52
  215. package/src/dispute/service.ts +126 -125
  216. package/src/index.ts +7 -0
  217. package/src/libp2p-key-network.ts +1204 -1235
  218. package/src/libp2p-node-base.ts +185 -70
  219. package/src/network/open-protocol-stream.ts +87 -0
  220. package/src/network/register-protocol-handler.ts +85 -0
  221. package/src/peer-address-book.ts +96 -2
  222. package/src/pend-validation.ts +89 -0
  223. package/src/reactivity/notify-transport.ts +2 -1
  224. package/src/reactivity/push-state-gossip.ts +2 -1
  225. package/src/repo/cluster-coordinator.ts +65 -11
  226. package/src/repo/coordinator-repo.ts +729 -71
  227. package/src/repo/served-repo-proxy.ts +55 -0
  228. package/src/repo/service.ts +25 -16
  229. package/src/reputation/types.ts +3 -1
  230. package/src/rn.ts +34 -4
  231. package/src/storage/block-archive.ts +242 -0
  232. package/src/storage/block-latch.ts +144 -0
  233. package/src/storage/block-storage.ts +787 -498
  234. package/src/storage/cached-raw-storage.ts +21 -1
  235. package/src/storage/cached-store-driver.ts +898 -859
  236. package/src/storage/i-block-storage.ts +172 -116
  237. package/src/storage/i-raw-storage.ts +40 -0
  238. package/src/storage/kv-raw-storage.ts +34 -9
  239. package/src/storage/memory-store-driver.ts +26 -2
  240. package/src/storage/raw-store-codec.ts +42 -42
  241. package/src/storage/raw-store-driver.ts +45 -3
  242. package/src/storage/shared-cache-pool.ts +516 -452
  243. package/src/storage/storage-repo.ts +1421 -1014
  244. package/src/storage/store-identity.ts +41 -0
  245. package/src/storage/struct.ts +52 -34
  246. package/src/storage/with-read-cache.ts +182 -0
  247. package/src/sync/service.ts +178 -198
  248. package/src/testing/mesh-harness.ts +524 -344
  249. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,254 +1,539 @@
1
- import type { Startable, Libp2p } from '@libp2p/interface'
2
- import { hashKey } from 'p2p-fret'
3
- import type { FretService } from 'p2p-fret'
4
- import type { PartitionDetector } from './partition-detector.js'
5
- import type { ArachnodeFretAdapter, ArachnodeInfo } from '../storage/arachnode-fret-adapter.js'
6
- import { createLogger } from '../logger.js'
7
-
8
- const log = createLogger('rebalance-monitor')
9
- const textEncoder = new TextEncoder()
10
-
11
- export interface RebalanceEvent {
12
- /** Block IDs this node has gained responsibility for */
13
- gained: string[]
14
- /** Block IDs this node has lost responsibility for */
15
- lost: string[]
16
- /** Peers that are now closer for the lost blocks: blockId → peerId[] */
17
- newOwners: Map<string, string[]>
18
- /**
19
- * Replication floor `N` for this event the cohort size FRET assembled at check time
20
- * ({@link RebalanceMonitor.getCohortSize}). The reaction gates release of a `lost` block on
21
- * confirming it replicated to this many new owners, so a lost block is never released below the
22
- * floor. See `docs/arachnode-ring-handoff.md` § Part 2.
23
- */
24
- floor: number
25
- /** Timestamp of the topology change that triggered this */
26
- triggeredAt: number
27
- }
28
-
29
- export interface RebalanceMonitorConfig {
30
- /** Debounce window for topology changes (ms). Default: 5000 */
31
- debounceMs?: number
32
- /** Maximum frequency of full rebalance scans (ms). Default: 60000 */
33
- minRebalanceIntervalMs?: number
34
- /** Whether to suppress rebalancing during detected partitions. Default: true */
35
- suppressDuringPartition?: boolean
36
- }
37
-
38
- export interface RebalanceMonitorDeps {
39
- libp2p: Libp2p
40
- fret: FretService
41
- partitionDetector: PartitionDetector
42
- fretAdapter: ArachnodeFretAdapter
43
- /**
44
- * The owned-block tracked set. When provided (e.g. the shared `ownedBlocks` set wired in
45
- * `libp2p-node-base`), the monitor references this exact `Set` instead of constructing its own,
46
- * so it stays in lock-step with the `SpreadOnChurnMonitor` that shares it. Omit for standalone
47
- * construction (unit tests) — a fresh private `Set` preserves all existing behavior. Note: only
48
- * `trackedBlocks` is shared; `responsibilitySnapshot` stays per-monitor (it is rebalance's own
49
- * was-responsible memory, not owned-block tracking).
50
- */
51
- trackedBlocks?: Set<string>
52
- }
53
-
54
- type RebalanceHandler = (event: RebalanceEvent) => void
55
-
56
- export class RebalanceMonitor implements Startable {
57
- private running = false
58
- private readonly trackedBlocks: Set<string>
59
- // Per-monitor was-responsible memory (NOT shared, unlike trackedBlocks). When the shared
60
- // trackedBlocks set is mutated externally spread's no-local-data self-prune, or the node's
61
- // responsibility-loss eviction going through untrackBlock — a snapshot entry for a since-removed
62
- // block may linger here. That is acceptable: performRebalanceCheck only iterates trackedBlocks, so
63
- // a lingering entry is inert; if the block is later re-fed, its responsibility is simply re-derived.
64
- private readonly responsibilitySnapshot = new Map<string, boolean>()
65
- private readonly handlers: RebalanceHandler[] = []
66
- private debounceTimer: ReturnType<typeof setTimeout> | null = null
67
- private lastRebalanceAt = 0
68
- private pendingTopologyChange = false
69
- private topologyChangeTimestamp = 0
70
-
71
- private readonly debounceMs: number
72
- private readonly minRebalanceIntervalMs: number
73
- private readonly suppressDuringPartition: boolean
74
-
75
- private readonly onConnectionOpen: () => void
76
- private readonly onConnectionClose: () => void
77
-
78
- constructor(
79
- private readonly deps: RebalanceMonitorDeps,
80
- config: RebalanceMonitorConfig = {}
81
- ) {
82
- // Share the injected owned-block set when present (so spread + rebalance never drift);
83
- // otherwise own a private set (standalone construction / unit tests). Only trackedBlocks is
84
- // shared responsibilitySnapshot stays per-monitor.
85
- this.trackedBlocks = deps.trackedBlocks ?? new Set<string>()
86
- this.debounceMs = config.debounceMs ?? 5000
87
- this.minRebalanceIntervalMs = config.minRebalanceIntervalMs ?? 60000
88
- this.suppressDuringPartition = config.suppressDuringPartition ?? true
89
-
90
- this.onConnectionOpen = () => this.handleTopologyChange()
91
- this.onConnectionClose = () => this.handleTopologyChange()
92
- }
93
-
94
- async start(): Promise<void> {
95
- if (this.running) return
96
- this.running = true
97
-
98
- this.deps.libp2p.addEventListener('connection:open', this.onConnectionOpen)
99
- this.deps.libp2p.addEventListener('connection:close', this.onConnectionClose)
100
-
101
- log('started, tracking %d blocks', this.trackedBlocks.size)
102
- }
103
-
104
- async stop(): Promise<void> {
105
- if (!this.running) return
106
- this.running = false
107
-
108
- this.deps.libp2p.removeEventListener('connection:open', this.onConnectionOpen)
109
- this.deps.libp2p.removeEventListener('connection:close', this.onConnectionClose)
110
-
111
- if (this.debounceTimer) {
112
- clearTimeout(this.debounceTimer)
113
- this.debounceTimer = null
114
- }
115
-
116
- this.pendingTopologyChange = false
117
- log('stopped')
118
- }
119
-
120
- onRebalance(handler: RebalanceHandler): void {
121
- this.handlers.push(handler)
122
- }
123
-
124
- trackBlock(blockId: string): void {
125
- this.trackedBlocks.add(blockId)
126
- }
127
-
128
- untrackBlock(blockId: string): void {
129
- this.trackedBlocks.delete(blockId)
130
- this.responsibilitySnapshot.delete(blockId)
131
- }
132
-
133
- getTrackedBlockCount(): number {
134
- return this.trackedBlocks.size
135
- }
136
-
137
- async checkNow(): Promise<RebalanceEvent | null> {
138
- return this.performRebalanceCheck(Date.now())
139
- }
140
-
141
- private handleTopologyChange(): void {
142
- if (!this.running) return
143
-
144
- if (!this.pendingTopologyChange) {
145
- this.topologyChangeTimestamp = Date.now()
146
- }
147
- this.pendingTopologyChange = true
148
-
149
- if (this.debounceTimer) {
150
- clearTimeout(this.debounceTimer)
151
- }
152
-
153
- this.debounceTimer = setTimeout(() => {
154
- this.debounceTimer = null
155
- this.pendingTopologyChange = false
156
- this.maybeRebalance()
157
- }, this.debounceMs)
158
- }
159
-
160
- private async maybeRebalance(): Promise<void> {
161
- if (!this.running) return
162
-
163
- const now = Date.now()
164
- const elapsed = now - this.lastRebalanceAt
165
- if (elapsed < this.minRebalanceIntervalMs) {
166
- log('throttled, %dms since last rebalance', elapsed)
167
- return
168
- }
169
-
170
- const event = await this.performRebalanceCheck(this.topologyChangeTimestamp || now)
171
- if (event) {
172
- this.emitEvent(event)
173
- }
174
- }
175
-
176
- private async performRebalanceCheck(triggeredAt: number): Promise<RebalanceEvent | null> {
177
- if (this.suppressDuringPartition && this.deps.partitionDetector.detectPartition()) {
178
- log('partition detected, suppressing rebalance')
179
- return null
180
- }
181
-
182
- if (this.trackedBlocks.size === 0) {
183
- this.lastRebalanceAt = Date.now()
184
- return null
185
- }
186
-
187
- const selfId = this.deps.libp2p.peerId.toString()
188
- const gained: string[] = []
189
- const lost: string[] = []
190
- const newOwners = new Map<string, string[]>()
191
-
192
- for (const blockId of this.trackedBlocks) {
193
- const key = textEncoder.encode(blockId)
194
- const coord = await hashKey(key)
195
-
196
- // Get the current cohort — assembleCohort returns peer IDs sorted by distance
197
- const cohort = this.deps.fret.assembleCohort(coord, this.getCohortSize())
198
- const isResponsible = cohort.includes(selfId)
199
- const wasResponsible = this.responsibilitySnapshot.get(blockId) ?? false
200
-
201
- if (isResponsible && !wasResponsible) {
202
- gained.push(blockId)
203
- } else if (!isResponsible && wasResponsible) {
204
- lost.push(blockId)
205
- // The cohort members are the new owners
206
- newOwners.set(blockId, cohort.filter(id => id !== selfId))
207
- }
208
-
209
- this.responsibilitySnapshot.set(blockId, isResponsible)
210
- }
211
-
212
- this.lastRebalanceAt = Date.now()
213
-
214
- if (gained.length === 0 && lost.length === 0) {
215
- return null
216
- }
217
-
218
- log('rebalance check: gained=%d lost=%d', gained.length, lost.length)
219
-
220
- return { gained, lost, newOwners, floor: this.getCohortSize(), triggeredAt }
221
- }
222
-
223
- /**
224
- * The replication floor `N` — the cohort size FRET assembles for a block. Public so the ring-shift
225
- * handoff and the rebalance reaction can gate release on confirming replication to this many
226
- * holders (`docs/arachnode-ring-handoff.md` § Replication floor). Derives from FRET's network-size
227
- * estimate: `clamp(ceil(sqrt(n_est)), 1, 3)`, defaulting to 3 when no confident estimate exists.
228
- */
229
- getCohortSize(): number {
230
- const diag: any = (this.deps.fret as any).getDiagnostics?.()
231
- const estimate = diag?.estimate ?? diag?.n
232
- if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) {
233
- return Math.max(1, Math.min(3, Math.ceil(Math.sqrt(estimate))))
234
- }
235
- return 3
236
- }
237
-
238
- private emitEvent(event: RebalanceEvent): void {
239
- for (const handler of this.handlers) {
240
- try {
241
- handler(event)
242
- } catch (err) {
243
- log('handler error: %O', err)
244
- }
245
- }
246
- }
247
-
248
- /**
249
- * Update ArachnodeInfo status through the fret adapter.
250
- */
251
- setStatus(status: ArachnodeInfo['status']): void {
252
- this.deps.fretAdapter.setStatus(status)
253
- }
254
- }
1
+ import type { Startable, Libp2p } from '@libp2p/interface'
2
+ import { hashKey } from 'p2p-fret'
3
+ import type { FretService } from 'p2p-fret'
4
+ import type { PartitionDetector } from './partition-detector.js'
5
+ import type { ArachnodeFretAdapter, ArachnodeInfo } from '../storage/arachnode-fret-adapter.js'
6
+ import { createLogger } from '../logger.js'
7
+
8
+ const log = createLogger('rebalance-monitor')
9
+ const textEncoder = new TextEncoder()
10
+
11
+ export interface RebalanceEvent {
12
+ /** Block IDs this node has gained responsibility for */
13
+ gained: string[]
14
+ /** Block IDs this node has lost responsibility for */
15
+ lost: string[]
16
+ /** Peers that are now closer for the lost blocks: blockId → peerId[] */
17
+ newOwners: Map<string, string[]>
18
+ /**
19
+ * Blocks this node KEEPS responsibility for whose cohort now contains peers it has not seen
20
+ * co-responsible before: blockId the newly co-responsible peer ids (never self). This is the
21
+ * cohort-GROWTH arm: the founder case (a block committed while the deployment was one node) never
22
+ * appears in `lost` the holder keeps the block — so without this arm nothing ever pushes the
23
+ * second copy and the block stays readable only by its sole holder. The reaction pushes each
24
+ * block to these peers (capped by the replication floor). A block can appear in both `gained`
25
+ * and `grown` (first observation after a restart/regain — the push then finds no local data and
26
+ * is a benign no-op); it can never appear in both `lost` and `grown` (lost ⇒ not responsible).
27
+ */
28
+ grown: Map<string, string[]>
29
+ /**
30
+ * Replication floor `N` for this event the cohort size FRET assembled at check time
31
+ * ({@link RebalanceMonitor.getCohortSize}). The reaction gates release of a `lost` block on
32
+ * confirming it replicated to this many new owners, so a lost block is never released below the
33
+ * floor. See `docs/arachnode-ring-handoff.md` § Part 2.
34
+ */
35
+ floor: number
36
+ /** Timestamp of the topology change that triggered this */
37
+ triggeredAt: number
38
+ }
39
+
40
+ /**
41
+ * What the growth reaction learned about ONE block reported `grown`. Fed back to
42
+ * {@link RebalanceMonitor.recordGrowthOutcome} so the seen set is confirmation-driven: a peer
43
+ * enters a block's seen set only once a replica is confirmed on it, or once the block has
44
+ * otherwise reached its floor. A block the reaction had NO information about (its confirm was
45
+ * deduped against one already in flight) gets no outcome at all the monitor's state stays
46
+ * untouched and the next check re-detects.
47
+ */
48
+ export interface GrowthOutcome {
49
+ /** Newly co-responsible peers that may now be recorded as seen for this block. */
50
+ satisfiedPeers: string[]
51
+ /** True when nothing about this block is still owed a push. */
52
+ complete: boolean
53
+ }
54
+
55
+ export interface RebalanceMonitorConfig {
56
+ /** Debounce window for topology changes (ms). Default: 5000 */
57
+ debounceMs?: number
58
+ /** Maximum frequency of full rebalance scans (ms). Default: 60000 */
59
+ minRebalanceIntervalMs?: number
60
+ /** Whether to suppress rebalancing during detected partitions. Default: true */
61
+ suppressDuringPartition?: boolean
62
+ /**
63
+ * Maximum blocks reported `grown` per check. Bounds the work a single peer join can trigger on a
64
+ * node with a large owned-block set — the primary bound is already the floor (a cohort is at most
65
+ * floor-sized, so each grown block pushes to ≤ floor−1 peers, and the reaction stops per block
66
+ * once the floor is met); this cap bounds the block COUNT per pass. A block dropped by the cap is
67
+ * NOT recorded as seen, so the next check re-detects the same growth — deferred, never lost.
68
+ * Default: 64
69
+ */
70
+ growthBlockBudget?: number
71
+ /**
72
+ * How many incomplete growth outcomes ({@link GrowthOutcome} with `complete: false`) a block
73
+ * absorbs before its still-unsatisfied peers are moved to a per-block abandoned set and no longer
74
+ * pushed to. Without this bound a peer that permanently refuses would be re-pushed on every check
75
+ * forever, and its block would re-consume `growthBlockBudget` slots and starve genuinely-new
76
+ * growth. An abandoned peer that leaves the cohort and later rejoins is retried from scratch.
77
+ * Default: 5
78
+ */
79
+ growthMaxAttempts?: number
80
+ /**
81
+ * Self-arming re-check timer for outstanding growth work (reported-but-unconfirmed peers, or
82
+ * blocks deferred by `growthBlockBudget`). Checks otherwise fire only on libp2p connection
83
+ * events, so a failed push on a then-quiet network would never be retried. Armed at the end of a
84
+ * check only while work is outstanding; fires `maybeRebalance()` so the existing
85
+ * `minRebalanceIntervalMs` throttle still bounds the push rate. `0` disables.
86
+ * Default: `minRebalanceIntervalMs`
87
+ */
88
+ growthRecheckIntervalMs?: number
89
+ }
90
+
91
+ export interface RebalanceMonitorDeps {
92
+ libp2p: Libp2p
93
+ fret: FretService
94
+ partitionDetector: PartitionDetector
95
+ fretAdapter: ArachnodeFretAdapter
96
+ /**
97
+ * The owned-block tracked set. When provided (e.g. the shared `ownedBlocks` set wired in
98
+ * `libp2p-node-base`), the monitor references this exact `Set` instead of constructing its own,
99
+ * so it stays in lock-step with the `SpreadOnChurnMonitor` that shares it. Omit for standalone
100
+ * construction (unit tests) — a fresh private `Set` preserves all existing behavior. Note: only
101
+ * `trackedBlocks` is shared; `responsibilitySnapshot` stays per-monitor (it is rebalance's own
102
+ * was-responsible memory, not owned-block tracking).
103
+ */
104
+ trackedBlocks?: Set<string>
105
+ }
106
+
107
+ type RebalanceHandler = (event: RebalanceEvent) => void
108
+
109
+ /** Per-block rebalance/growth state (the `responsibilitySnapshot` entry). */
110
+ interface BlockGrowthState {
111
+ responsible: boolean
112
+ /**
113
+ * The growth arm's seen set: peers CONFIRMED to hold a replica of this block (or satisfied
114
+ * another way — floor met, or nothing local to push). Peers enter ONLY via
115
+ * {@link RebalanceMonitor.recordGrowthOutcome}, never at report time.
116
+ */
117
+ cohortPeers: Set<string>
118
+ /** Peers reported grown at the last emitting check whose confirmation is still outstanding. */
119
+ pendingPeers: Set<string>
120
+ /** Consecutive incomplete growth outcomes — the give-up counter against `growthMaxAttempts`. */
121
+ growthAttempts: number
122
+ /**
123
+ * Peers given up on after `growthMaxAttempts` incomplete outcomes — excluded from growth reports
124
+ * until they leave the cohort and rejoin (each check intersects this with the current cohort).
125
+ */
126
+ abandonedPeers: Set<string>
127
+ }
128
+
129
+ const emptyGrowthState = (responsible: boolean): BlockGrowthState => ({
130
+ responsible,
131
+ cohortPeers: new Set<string>(),
132
+ pendingPeers: new Set<string>(),
133
+ growthAttempts: 0,
134
+ abandonedPeers: new Set<string>()
135
+ })
136
+
137
+ const intersect = (remembered: Set<string> | undefined, current: Set<string>): Set<string> =>
138
+ new Set([...(remembered ?? [])].filter(id => current.has(id)))
139
+
140
+ /**
141
+ * Carry a still-responsible block's growth state into the next check, intersecting both remembered
142
+ * peer sets against the CURRENT cohort: a peer that left drops out of `cohortPeers` (so its return
143
+ * is re-detected — the departure self-heal) and out of `abandonedPeers` (so a rejoin is retried from
144
+ * scratch). `pendingPeers` is always rebuilt from this check's own report, never carried.
145
+ */
146
+ const carryGrowthState = (prior: BlockGrowthState | undefined, currentPeers: Set<string>): BlockGrowthState => ({
147
+ responsible: true,
148
+ cohortPeers: intersect(prior?.cohortPeers, currentPeers),
149
+ pendingPeers: new Set<string>(),
150
+ growthAttempts: prior?.growthAttempts ?? 0,
151
+ abandonedPeers: intersect(prior?.abandonedPeers, currentPeers)
152
+ })
153
+
154
+ export class RebalanceMonitor implements Startable {
155
+ private running = false
156
+ private readonly trackedBlocks: Set<string>
157
+ // Per-monitor was-responsible memory (NOT shared, unlike trackedBlocks). When the shared
158
+ // trackedBlocks set is mutated externally — spread's no-local-data self-prune, or the node's
159
+ // responsibility-loss eviction going through untrackBlock — a snapshot entry for a since-removed
160
+ // block may linger here. That is acceptable: performRebalanceCheck only iterates trackedBlocks, so
161
+ // a lingering entry is inert; if the block is later re-fed, its responsibility is simply re-derived.
162
+ //
163
+ // `cohortPeers` is the growth arm's CONFIRMED-co-responsible memory. A MISSING entry is
164
+ // deliberately treated as "prior cohort = empty", so the first check after a topology event
165
+ // reports the whole non-self cohort as grown — that is what heals the founder case (A alone
166
+ // commits; B joins first check pushes to B) and the restarted-holder case (snapshot memory is
167
+ // process-local, so a restarted holder re-pushes to everyone once). Peers enter the set only
168
+ // through recordGrowthOutcome — a reported-but-unconfirmed peer stays out, so a failed push is
169
+ // re-detected on the next check instead of being recorded as done.
170
+ private readonly responsibilitySnapshot = new Map<string, BlockGrowthState>()
171
+ private readonly handlers: RebalanceHandler[] = []
172
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null
173
+ private recheckTimer: ReturnType<typeof setTimeout> | null = null
174
+ private lastRebalanceAt = 0
175
+ private pendingTopologyChange = false
176
+ private topologyChangeTimestamp = 0
177
+ private lastGrowthDeferred = 0
178
+
179
+ private readonly debounceMs: number
180
+ private readonly minRebalanceIntervalMs: number
181
+ private readonly suppressDuringPartition: boolean
182
+ private readonly growthBlockBudget: number
183
+ private readonly growthMaxAttempts: number
184
+ private readonly growthRecheckIntervalMs: number
185
+
186
+ private readonly onConnectionOpen: () => void
187
+ private readonly onConnectionClose: () => void
188
+
189
+ constructor(
190
+ private readonly deps: RebalanceMonitorDeps,
191
+ config: RebalanceMonitorConfig = {}
192
+ ) {
193
+ // Share the injected owned-block set when present (so spread + rebalance never drift);
194
+ // otherwise own a private set (standalone construction / unit tests). Only trackedBlocks is
195
+ // shared — responsibilitySnapshot stays per-monitor.
196
+ this.trackedBlocks = deps.trackedBlocks ?? new Set<string>()
197
+ this.debounceMs = config.debounceMs ?? 5000
198
+ this.minRebalanceIntervalMs = config.minRebalanceIntervalMs ?? 60000
199
+ this.suppressDuringPartition = config.suppressDuringPartition ?? true
200
+ this.growthBlockBudget = config.growthBlockBudget ?? 64
201
+ this.growthMaxAttempts = config.growthMaxAttempts ?? 5
202
+ this.growthRecheckIntervalMs = config.growthRecheckIntervalMs ?? this.minRebalanceIntervalMs
203
+
204
+ this.onConnectionOpen = () => this.handleTopologyChange()
205
+ this.onConnectionClose = () => this.handleTopologyChange()
206
+ }
207
+
208
+ async start(): Promise<void> {
209
+ if (this.running) return
210
+ this.running = true
211
+
212
+ this.deps.libp2p.addEventListener('connection:open', this.onConnectionOpen)
213
+ this.deps.libp2p.addEventListener('connection:close', this.onConnectionClose)
214
+
215
+ log('started, tracking %d blocks', this.trackedBlocks.size)
216
+ }
217
+
218
+ async stop(): Promise<void> {
219
+ if (!this.running) return
220
+ this.running = false
221
+
222
+ this.deps.libp2p.removeEventListener('connection:open', this.onConnectionOpen)
223
+ this.deps.libp2p.removeEventListener('connection:close', this.onConnectionClose)
224
+
225
+ if (this.debounceTimer) {
226
+ clearTimeout(this.debounceTimer)
227
+ this.debounceTimer = null
228
+ }
229
+ if (this.recheckTimer) {
230
+ clearTimeout(this.recheckTimer)
231
+ this.recheckTimer = null
232
+ }
233
+
234
+ this.pendingTopologyChange = false
235
+ log('stopped')
236
+ }
237
+
238
+ onRebalance(handler: RebalanceHandler): void {
239
+ this.handlers.push(handler)
240
+ }
241
+
242
+ trackBlock(blockId: string): void {
243
+ this.trackedBlocks.add(blockId)
244
+ }
245
+
246
+ untrackBlock(blockId: string): void {
247
+ this.trackedBlocks.delete(blockId)
248
+ this.responsibilitySnapshot.delete(blockId)
249
+ }
250
+
251
+ getTrackedBlockCount(): number {
252
+ return this.trackedBlocks.size
253
+ }
254
+
255
+ async checkNow(): Promise<RebalanceEvent | null> {
256
+ return this.performRebalanceCheck(Date.now())
257
+ }
258
+
259
+ /**
260
+ * Feedback from the growth reaction for one block reported `grown`. `satisfiedPeers` enter the
261
+ * block's seen set; an incomplete outcome counts an attempt against `growthMaxAttempts`, and on
262
+ * reaching the bound the block's still-unsatisfied reported peers are abandoned (no longer
263
+ * pushed to until they leave the cohort and rejoin). Never called for a block the reaction had
264
+ * no information about (a confirm deduped against one already in flight) — a missing outcome
265
+ * leaves the block's state untouched so the next check retries.
266
+ */
267
+ recordGrowthOutcome(blockId: string, outcome: GrowthOutcome): void {
268
+ const state = this.responsibilitySnapshot.get(blockId)
269
+ // No state (untracked since) or responsibility lost since the report: the growth state was
270
+ // cleared, and recording into it would survive the clear and suppress the regain re-push.
271
+ if (!state || !state.responsible) return
272
+
273
+ for (const peerId of outcome.satisfiedPeers) {
274
+ state.cohortPeers.add(peerId)
275
+ state.pendingPeers.delete(peerId)
276
+ }
277
+
278
+ if (outcome.complete) {
279
+ state.growthAttempts = 0
280
+ state.pendingPeers.clear()
281
+ } else {
282
+ // NOTE: growthMaxAttempts is a floor on the retry count, not an exact one. `pendingPeers` is
283
+ // rebuilt from each check's own report, so two cases blunt the bound: a check that defers
284
+ // this block on growthBlockBudget clears pendingPeers, and a give-up landing right then
285
+ // abandons nobody while still resetting the counter; and two checks racing (the second
286
+ // re-reporting the same peer after the first's confirm left `inFlight` but before its outcome
287
+ // landed) double-count one attempt. Both are rare, both only change how many pushes a doomed
288
+ // peer absorbs. If a deployment ever tracks far more blocks than growthBlockBudget, deferral
289
+ // stops being rare — carry the report's peer list on GrowthOutcome and match it against
290
+ // pendingPeers instead of trusting the latest report.
291
+ state.growthAttempts++
292
+ if (state.growthAttempts >= this.growthMaxAttempts) {
293
+ for (const peerId of state.pendingPeers) {
294
+ state.abandonedPeers.add(peerId)
295
+ }
296
+ log('growth give-up: block=%s abandoning %d unsatisfied peer(s) after %d attempts',
297
+ blockId, state.pendingPeers.size, state.growthAttempts)
298
+ state.pendingPeers.clear()
299
+ state.growthAttempts = 0
300
+ }
301
+ }
302
+
303
+ this.updateRecheckTimer()
304
+ }
305
+
306
+ /**
307
+ * Growth-arm observability: how many tracked blocks still await confirmation on reported peers,
308
+ * how many (block, peer) pairs have been given up on, and whether the re-check timer is armed.
309
+ */
310
+ getGrowthDiagnostics(): { blocksAwaitingConfirmation: number; abandonedPairs: number; recheckArmed: boolean } {
311
+ let blocksAwaitingConfirmation = 0
312
+ let abandonedPairs = 0
313
+ for (const [blockId, state] of this.responsibilitySnapshot) {
314
+ if (!state.responsible || !this.trackedBlocks.has(blockId)) continue
315
+ if (state.pendingPeers.size > 0) blocksAwaitingConfirmation++
316
+ abandonedPairs += state.abandonedPeers.size
317
+ }
318
+ return { blocksAwaitingConfirmation, abandonedPairs, recheckArmed: this.recheckTimer !== null }
319
+ }
320
+
321
+ /** Growth work is outstanding while any reported peer is unconfirmed or blocks were budget-deferred. */
322
+ private hasOutstandingGrowthWork(): boolean {
323
+ if (this.lastGrowthDeferred > 0) return true
324
+ for (const [blockId, state] of this.responsibilitySnapshot) {
325
+ if (state.responsible && state.pendingPeers.size > 0 && this.trackedBlocks.has(blockId)) {
326
+ return true
327
+ }
328
+ }
329
+ return false
330
+ }
331
+
332
+ /**
333
+ * Arm the growth re-check timer while work is outstanding; disarm it when there is none. The
334
+ * timer fires maybeRebalance(), so minRebalanceIntervalMs still bounds the push rate, and it
335
+ * re-arms itself after firing for as long as work remains. unref'd so it never holds the
336
+ * process open; stop() clears it.
337
+ */
338
+ private updateRecheckTimer(): void {
339
+ if (this.growthRecheckIntervalMs <= 0) return
340
+
341
+ if (!this.running || !this.hasOutstandingGrowthWork()) {
342
+ if (this.recheckTimer) {
343
+ clearTimeout(this.recheckTimer)
344
+ this.recheckTimer = null
345
+ }
346
+ return
347
+ }
348
+
349
+ if (this.recheckTimer) return // already armed
350
+
351
+ this.recheckTimer = setTimeout(() => {
352
+ this.recheckTimer = null
353
+ void this.maybeRebalance()
354
+ .catch(err => { log('recheck error: %O', err) })
355
+ .finally(() => this.updateRecheckTimer())
356
+ }, this.growthRecheckIntervalMs)
357
+ ;(this.recheckTimer as unknown as { unref?: () => void }).unref?.()
358
+ }
359
+
360
+ private handleTopologyChange(): void {
361
+ if (!this.running) return
362
+
363
+ if (!this.pendingTopologyChange) {
364
+ this.topologyChangeTimestamp = Date.now()
365
+ }
366
+ this.pendingTopologyChange = true
367
+
368
+ if (this.debounceTimer) {
369
+ clearTimeout(this.debounceTimer)
370
+ }
371
+
372
+ this.debounceTimer = setTimeout(() => {
373
+ this.debounceTimer = null
374
+ this.pendingTopologyChange = false
375
+ this.maybeRebalance()
376
+ }, this.debounceMs)
377
+ }
378
+
379
+ private async maybeRebalance(): Promise<void> {
380
+ if (!this.running) return
381
+
382
+ const now = Date.now()
383
+ const elapsed = now - this.lastRebalanceAt
384
+ if (elapsed < this.minRebalanceIntervalMs) {
385
+ log('throttled, %dms since last rebalance', elapsed)
386
+ return
387
+ }
388
+
389
+ const event = await this.performRebalanceCheck(this.topologyChangeTimestamp || now)
390
+ if (event) {
391
+ this.emitEvent(event)
392
+ }
393
+ }
394
+
395
+ private async performRebalanceCheck(triggeredAt: number): Promise<RebalanceEvent | null> {
396
+ if (this.suppressDuringPartition && this.deps.partitionDetector.detectPartition()) {
397
+ log('partition detected, suppressing rebalance')
398
+ return null
399
+ }
400
+
401
+ if (this.trackedBlocks.size === 0) {
402
+ this.lastRebalanceAt = Date.now()
403
+ // Nothing left to grow, so any prior deferral is moot — drop it and let the re-check timer
404
+ // disarm, rather than re-arming forever against blocks that were untracked out from under it.
405
+ this.lastGrowthDeferred = 0
406
+ this.updateRecheckTimer()
407
+ return null
408
+ }
409
+
410
+ const selfId = this.deps.libp2p.peerId.toString()
411
+ const gained: string[] = []
412
+ const lost: string[] = []
413
+ const newOwners = new Map<string, string[]>()
414
+ const grown = new Map<string, string[]>()
415
+ let growthDeferred = 0
416
+ const growthCandidates: Array<{ blockId: string; newPeers: string[]; state: BlockGrowthState }> = []
417
+
418
+ for (const blockId of this.trackedBlocks) {
419
+ const key = textEncoder.encode(blockId)
420
+ const coord = await hashKey(key)
421
+
422
+ // Get the current cohort — assembleCohort returns peer IDs sorted by distance
423
+ const cohort = this.deps.fret.assembleCohort(coord, this.getCohortSize())
424
+ const isResponsible = cohort.includes(selfId)
425
+ const prior = this.responsibilitySnapshot.get(blockId)
426
+ const wasResponsible = prior?.responsible ?? false
427
+
428
+ if (isResponsible && !wasResponsible) {
429
+ gained.push(blockId)
430
+ } else if (!isResponsible && wasResponsible) {
431
+ lost.push(blockId)
432
+ // The cohort members are the new owners
433
+ newOwners.set(blockId, cohort.filter(id => id !== selfId))
434
+ }
435
+
436
+ // Growth arm: while this node STAYS responsible, any cohort peer not yet CONFIRMED to hold
437
+ // the block (and not abandoned) gets it pushed (up to the floor). Runs on every responsible
438
+ // check — NOT gated on wasResponsible — so a first observation (no snapshot entry) treats
439
+ // the whole non-self cohort as new; see the responsibilitySnapshot comment for why that is
440
+ // load-bearing. Not responsible ⇒ arm skipped, so `lost` ∩ `grown` is impossible.
441
+ //
442
+ // Reporting a peer does NOT record it as seen — only recordGrowthOutcome does — so a push
443
+ // that fails (dial timeout, receiver refused to persist, partition mid-reaction, reaction
444
+ // threw) leaves the peer un-seen and the next check re-detects it.
445
+ let state: BlockGrowthState
446
+ if (isResponsible) {
447
+ const currentSet = new Set(cohort.filter(id => id !== selfId))
448
+ state = carryGrowthState(prior, currentSet)
449
+ const newPeers = [...currentSet].filter(id => !state.cohortPeers.has(id) && !state.abandonedPeers.has(id))
450
+ if (newPeers.length > 0) {
451
+ growthCandidates.push({ blockId, newPeers, state })
452
+ } else {
453
+ // Nothing owed for this block. growthAttempts counts CONSECUTIVE incomplete outcomes
454
+ // against outstanding growth, so it must not carry across a quiet stretch — otherwise a
455
+ // block that failed a few times, then had that peer leave, would spend the leftovers on
456
+ // whichever peer joins next and abandon it early.
457
+ state.growthAttempts = 0
458
+ }
459
+ } else {
460
+ // Not responsible: clear ALL growth state (seen set, attempts, abandoned peers), so a
461
+ // later regain re-pushes to the whole cohort (benign when the local data is gone — the
462
+ // push finds nothing and no-ops).
463
+ state = emptyGrowthState(false)
464
+ }
465
+
466
+ this.responsibilitySnapshot.set(blockId, state)
467
+ }
468
+
469
+ // Fill the growth budget in two passes: fresh growth (no failed attempts yet) first, retrying
470
+ // blocks with what remains — otherwise a stuck retry set at the front of the tracked-block
471
+ // insertion order would starve peers that just joined.
472
+ for (const candidate of [
473
+ ...growthCandidates.filter(c => c.state.growthAttempts === 0),
474
+ ...growthCandidates.filter(c => c.state.growthAttempts > 0)
475
+ ]) {
476
+ if (grown.size < this.growthBlockBudget) {
477
+ grown.set(candidate.blockId, candidate.newPeers)
478
+ candidate.state.pendingPeers = new Set(candidate.newPeers)
479
+ } else {
480
+ // Budget-dropped: the seen set was not touched, so the next check re-detects the same
481
+ // growth — a deferral, not a loss.
482
+ growthDeferred++
483
+ }
484
+ }
485
+ this.lastGrowthDeferred = growthDeferred
486
+
487
+ this.lastRebalanceAt = Date.now()
488
+
489
+ if (growthDeferred > 0) {
490
+ // Deferred blocks drain one budget-full per check. Checks fire on libp2p connection events
491
+ // AND — while growth work is outstanding — on the growthRecheckIntervalMs timer armed
492
+ // below, so a backlog drains even on a quiet network.
493
+ log('growth budget reached: %d blocks deferred to the next check (budget=%d)',
494
+ growthDeferred, this.growthBlockBudget)
495
+ }
496
+
497
+ this.updateRecheckTimer()
498
+
499
+ if (gained.length === 0 && lost.length === 0 && grown.size === 0) {
500
+ return null
501
+ }
502
+
503
+ log('rebalance check: gained=%d lost=%d grown=%d', gained.length, lost.length, grown.size)
504
+
505
+ return { gained, lost, newOwners, grown, floor: this.getCohortSize(), triggeredAt }
506
+ }
507
+
508
+ /**
509
+ * The replication floor `N` — the cohort size FRET assembles for a block. Public so the ring-shift
510
+ * handoff and the rebalance reaction can gate release on confirming replication to this many
511
+ * holders (`docs/arachnode-ring-handoff.md` § Replication floor). Derives from FRET's network-size
512
+ * estimate: `clamp(ceil(sqrt(n_est)), 1, 3)`, defaulting to 3 when no confident estimate exists.
513
+ */
514
+ getCohortSize(): number {
515
+ const diag: any = (this.deps.fret as any).getDiagnostics?.()
516
+ const estimate = diag?.estimate ?? diag?.n
517
+ if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) {
518
+ return Math.max(1, Math.min(3, Math.ceil(Math.sqrt(estimate))))
519
+ }
520
+ return 3
521
+ }
522
+
523
+ private emitEvent(event: RebalanceEvent): void {
524
+ for (const handler of this.handlers) {
525
+ try {
526
+ handler(event)
527
+ } catch (err) {
528
+ log('handler error: %O', err)
529
+ }
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Update ArachnodeInfo status through the fret adapter.
535
+ */
536
+ setStatus(status: ArachnodeInfo['status']): void {
537
+ this.deps.fretAdapter.setStatus(status)
538
+ }
539
+ }