@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,346 +1,352 @@
1
- import type { Startable, Libp2p } from '@libp2p/interface'
2
- import type { IRepo, IPeerNetwork } from '@optimystic/db-core'
3
- import { hashKey } from 'p2p-fret'
4
- import type { FretService } from 'p2p-fret'
5
- import { peerIdFromString } from '@libp2p/peer-id'
6
- import type { PartitionDetector } from './partition-detector.js'
7
- import { BlockTransferClient } from './block-transfer-service.js'
8
- import { createLogger } from '../logger.js'
9
-
10
- const log = createLogger('spread-on-churn')
11
- const textEncoder = new TextEncoder()
12
-
13
- // ── Types ────────────────────────────────────────────────────────────
14
-
15
- export interface SpreadOnChurnConfig {
16
- /** Enable the churn-resilient spread protocol. Default: true */
17
- enabled: boolean
18
- /** Number of middle-closest peers eligible to spread (d). Default: 3 */
19
- spreadDistance: number
20
- /** Enable dynamic d scaling based on cluster health. Default: true */
21
- dynamicSpreadDistance: boolean
22
- /** Cluster size ratio below which spread becomes more aggressive. Default: 0.6 */
23
- healthThreshold: number
24
- /** Debounce window for departure detection (ms). Default: 5000 */
25
- departureDebounceMs: number
26
- /** Number of peers beyond cluster boundary to target. Default: 4 */
27
- expansionStep: number
28
- /**
29
- * Per-target dial deadline (ms) for churn pushes. A target that never accepts
30
- * the connection fails fast instead of stalling the sequential spread pass.
31
- * Default: 3000 (matches the codebase's other dial caps).
32
- */
33
- pushDialTimeoutMs: number
34
- /**
35
- * Per-target response deadline (ms) for churn pushes. A target that dials OK but
36
- * never replies (connects, then goes silent) is given up on the push throws
37
- * `ResponseTimeoutError`, is recorded as failed, and the loop continues to the
38
- * next target/block rather than hanging the whole pass. Default: 10000.
39
- */
40
- pushResponseTimeoutMs: number
41
- }
42
-
43
- export interface SpreadOnChurnDeps {
44
- libp2p: Libp2p
45
- fret: FretService
46
- partitionDetector: PartitionDetector
47
- repo: IRepo
48
- peerNetwork: IPeerNetwork
49
- clusterSize: number
50
- protocolPrefix?: string
51
- /**
52
- * The owned-block tracked set. When provided (e.g. the shared `ownedBlocks` set wired in
53
- * `libp2p-node-base`), the monitor references this exact `Set` instead of constructing its own,
54
- * so it stays in lock-step with the `RebalanceMonitor` that shares it. Omit for standalone
55
- * construction (unit tests) — a fresh private `Set` preserves all existing behavior.
56
- */
57
- trackedBlocks?: Set<string>
58
- }
59
-
60
- export interface SpreadEvent {
61
- /** Blocks that were spread */
62
- spread: Array<{
63
- blockId: string
64
- targets: string[]
65
- succeeded: string[]
66
- failed: string[]
67
- }>
68
- /** Current effective d */
69
- effectiveD: number
70
- /** Timestamp of the departure that triggered this */
71
- triggeredAt: number
72
- }
73
-
74
- type SpreadHandler = (event: SpreadEvent) => void
75
-
76
- // ── Defaults ─────────────────────────────────────────────────────────
77
-
78
- const DEFAULT_CONFIG: SpreadOnChurnConfig = {
79
- enabled: true,
80
- spreadDistance: 3,
81
- dynamicSpreadDistance: true,
82
- healthThreshold: 0.6,
83
- departureDebounceMs: 5000,
84
- expansionStep: 4,
85
- pushDialTimeoutMs: 3000,
86
- pushResponseTimeoutMs: 10000,
87
- }
88
-
89
- // ── Monitor ──────────────────────────────────────────────────────────
90
-
91
- export class SpreadOnChurnMonitor implements Startable {
92
- private running = false
93
- private readonly trackedBlocks: Set<string>
94
- private readonly handlers: SpreadHandler[] = []
95
- private debounceTimer: ReturnType<typeof setTimeout> | null = null
96
- private departureTimestamps: number[] = []
97
- private departureTimestamp = 0
98
-
99
- private readonly config: SpreadOnChurnConfig
100
- private readonly onConnectionClose: () => void
101
-
102
- constructor(
103
- private readonly deps: SpreadOnChurnDeps,
104
- config: Partial<SpreadOnChurnConfig> = {}
105
- ) {
106
- // Share the injected owned-block set when present (so spread + rebalance never drift);
107
- // otherwise own a private set (standalone construction / unit tests).
108
- this.trackedBlocks = deps.trackedBlocks ?? new Set<string>()
109
- this.config = { ...DEFAULT_CONFIG, ...config }
110
- this.onConnectionClose = () => this.handleDeparture()
111
- }
112
-
113
- // ── Startable ────────────────────────────────────────────────────
114
-
115
- async start(): Promise<void> {
116
- if (this.running) return
117
- this.running = true
118
-
119
- this.deps.libp2p.addEventListener('connection:close', this.onConnectionClose)
120
-
121
- log('started, tracking %d blocks', this.trackedBlocks.size)
122
- }
123
-
124
- async stop(): Promise<void> {
125
- if (!this.running) return
126
- this.running = false
127
-
128
- this.deps.libp2p.removeEventListener('connection:close', this.onConnectionClose)
129
-
130
- if (this.debounceTimer) {
131
- clearTimeout(this.debounceTimer)
132
- this.debounceTimer = null
133
- }
134
-
135
- log('stopped')
136
- }
137
-
138
- // ── Public API ───────────────────────────────────────────────────
139
-
140
- onSpread(handler: SpreadHandler): void {
141
- this.handlers.push(handler)
142
- }
143
-
144
- trackBlock(blockId: string): void {
145
- this.trackedBlocks.add(blockId)
146
- }
147
-
148
- untrackBlock(blockId: string): void {
149
- this.trackedBlocks.delete(blockId)
150
- }
151
-
152
- getTrackedBlockCount(): number {
153
- return this.trackedBlocks.size
154
- }
155
-
156
- /** Force an immediate spread check (useful for testing). */
157
- async checkNow(): Promise<SpreadEvent | null> {
158
- return this.performSpread(Date.now())
159
- }
160
-
161
- // ── Internal ─────────────────────────────────────────────────────
162
-
163
- private handleDeparture(): void {
164
- if (!this.running) return
165
- if (!this.config.enabled) return
166
-
167
- if (!this.departureTimestamp) {
168
- this.departureTimestamp = Date.now()
169
- }
170
-
171
- // Record for dynamic-d sliding window
172
- this.departureTimestamps.push(Date.now())
173
-
174
- if (this.debounceTimer) {
175
- clearTimeout(this.debounceTimer)
176
- }
177
-
178
- this.debounceTimer = setTimeout(() => {
179
- this.debounceTimer = null
180
- const ts = this.departureTimestamp
181
- this.departureTimestamp = 0
182
- if (this.running) {
183
- this.performSpread(ts).catch(err => {
184
- log('spread error: %O', err)
185
- })
186
- }
187
- }, this.config.departureDebounceMs)
188
- }
189
-
190
- private async performSpread(triggeredAt: number): Promise<SpreadEvent | null> {
191
- if (!this.config.enabled) return null
192
-
193
- if (this.deps.partitionDetector.detectPartition()) {
194
- log('partition detected, suppressing spread')
195
- return null
196
- }
197
-
198
- if (this.trackedBlocks.size === 0) return null
199
-
200
- const selfId = this.deps.libp2p.peerId.toString()
201
- const effectiveD = this.computeEffectiveD()
202
- const spreadResults: SpreadEvent['spread'] = []
203
-
204
- for (const blockId of this.trackedBlocks) {
205
- const key = textEncoder.encode(blockId)
206
- const coord = await hashKey(key)
207
-
208
- // Check eligibility: only middle peers spread
209
- const rank = this.deps.fret.neighborDistance(selfId, coord, this.deps.clusterSize)
210
- if (rank >= effectiveD) continue
211
-
212
- // Get current cohort and expansion targets
213
- const cohort = this.deps.fret.assembleCohort(coord, this.deps.clusterSize)
214
- const cohortSet = new Set(cohort)
215
- const expanded = this.deps.fret.expandCohort(
216
- cohort, coord, this.config.expansionStep
217
- )
218
- const targets = expanded.filter(id => !cohortSet.has(id) && id !== selfId)
219
- if (targets.length === 0) continue
220
-
221
- // Read block data from local storage
222
- const result = await this.deps.repo.get({ blockIds: [blockId] })
223
- const blockResult = result[blockId]
224
- if (blockResult?.unavailable !== undefined) {
225
- // The repo could not work out whether it still holds this block (unmaterializable
226
- // history / failed restore). Untracking on that answer would silently drop the block
227
- // from the spread set on a guess, and only a later re-commit would put it back — so
228
- // keep it tracked and let the next sweep (or a heal) settle it.
229
- log('unavailable block=%s reason=%s (keeping tracked)', blockId, blockResult.unavailable)
230
- continue
231
- }
232
- if (!blockResult?.block) {
233
- // The block has left local storage. No deletion event exists today to evict it
234
- // from the tracked set, so prune here. Deleting the current element of a Set mid
235
- // for...of is safe - it does not disturb the rest of the iteration. A later
236
- // re-commit re-adds the block via the owned-block feed.
237
- log('no-local-data block=%s (untracking)', blockId)
238
- this.trackedBlocks.delete(blockId)
239
- continue
240
- }
241
-
242
- const blockData = textEncoder.encode(JSON.stringify(blockResult.block))
243
-
244
- // Carry the source's revision metadata so the replica's `latest` matches the
245
- // source instead of being fabricated as rev 1 on the receiver.
246
- const latest = blockResult.state?.latest
247
- const blockMeta = latest
248
- ? { [blockId]: { rev: latest.rev, actionId: latest.actionId } }
249
- : undefined
250
-
251
- // Push to each target
252
- const succeeded: string[] = []
253
- const failed: string[] = []
254
-
255
- for (const targetId of targets) {
256
- try {
257
- const peerId = peerIdFromString(targetId)
258
- const client = new BlockTransferClient(
259
- peerId,
260
- this.deps.peerNetwork,
261
- this.deps.protocolPrefix
262
- )
263
- // Bound both the dial and the response read. A target that connects but
264
- // never replies would otherwise hang this sequential loop forever during
265
- // churn (exactly when slow/dying peers are common); the deadline makes the
266
- // push throw, which the catch below records as `failed` so the pass advances.
267
- const response = await client.pushBlocks([blockId], [blockData], 'replication', blockMeta, {
268
- dialTimeoutMs: this.config.pushDialTimeoutMs,
269
- responseTimeoutMs: this.config.pushResponseTimeoutMs,
270
- })
271
- // A round-trip that does not throw still does not mean the replica
272
- // landed: handlePush reports a block in `missing` when it could not
273
- // parse or persist it. Treat that as a failed push so the resilience
274
- // mechanism does not falsely count the new owner as holding the block.
275
- if (response.missing.includes(blockId)) {
276
- failed.push(targetId)
277
- log('push:rejected block=%s target=%s (receiver did not persist)', blockId, targetId)
278
- } else {
279
- succeeded.push(targetId)
280
- log('push:ok block=%s target=%s', blockId, targetId)
281
- }
282
- } catch (err) {
283
- failed.push(targetId)
284
- log('push:fail block=%s target=%s err=%s',
285
- blockId, targetId, (err as Error).message)
286
- }
287
- }
288
-
289
- spreadResults.push({ blockId, targets, succeeded, failed })
290
- }
291
-
292
- if (spreadResults.length === 0) return null
293
-
294
- const event: SpreadEvent = {
295
- spread: spreadResults,
296
- effectiveD,
297
- triggeredAt,
298
- }
299
-
300
- this.emitEvent(event)
301
- return event
302
- }
303
-
304
- private computeEffectiveD(): number {
305
- const d = this.config.spreadDistance
306
- if (!this.config.dynamicSpreadDistance) return d
307
-
308
- const maxD = Math.max(d, Math.floor(this.deps.clusterSize / 2))
309
- const windowMs = this.config.departureDebounceMs * 4
310
- const now = Date.now()
311
-
312
- // Prune old departure timestamps
313
- this.departureTimestamps = this.departureTimestamps.filter(
314
- ts => now - ts < windowMs
315
- )
316
-
317
- // Rapid churn: 3+ departures in window → increase d by 1
318
- if (this.departureTimestamps.length >= 3) {
319
- return Math.min(d + 1, maxD)
320
- }
321
-
322
- // Low cluster health: observed cohort shrunk relative to expected
323
- // We approximate observed cohort size from FRET diagnostics
324
- const diag: any = (this.deps.fret as any).getDiagnostics?.()
325
- const estimate = diag?.estimate ?? diag?.n
326
- if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) {
327
- const ratio = estimate / this.deps.clusterSize
328
- if (ratio < this.config.healthThreshold) {
329
- const scaled = Math.ceil(d * (this.deps.clusterSize / estimate))
330
- return Math.min(scaled, maxD)
331
- }
332
- }
333
-
334
- return d
335
- }
336
-
337
- private emitEvent(event: SpreadEvent): void {
338
- for (const handler of this.handlers) {
339
- try {
340
- handler(event)
341
- } catch (err) {
342
- log('handler error: %O', err)
343
- }
344
- }
345
- }
346
- }
1
+ import type { Startable, Libp2p } from '@libp2p/interface'
2
+ import type { IPeerNetwork } from '@optimystic/db-core'
3
+ import { hashKey } from 'p2p-fret'
4
+ import type { FretService } from 'p2p-fret'
5
+ import { peerIdFromString } from '@libp2p/peer-id'
6
+ import type { PartitionDetector } from './partition-detector.js'
7
+ import type { ProofRetainingRepo } from '../storage/block-archive.js'
8
+ import { BlockTransferClient, sourceBlockCertification } from './block-transfer-service.js'
9
+ import { createLogger } from '../logger.js'
10
+
11
+ const log = createLogger('spread-on-churn')
12
+ const textEncoder = new TextEncoder()
13
+
14
+ // ── Types ────────────────────────────────────────────────────────────
15
+
16
+ export interface SpreadOnChurnConfig {
17
+ /** Enable the churn-resilient spread protocol. Default: true */
18
+ enabled: boolean
19
+ /** Number of middle-closest peers eligible to spread (d). Default: 3 */
20
+ spreadDistance: number
21
+ /** Enable dynamic d scaling based on cluster health. Default: true */
22
+ dynamicSpreadDistance: boolean
23
+ /** Cluster size ratio below which spread becomes more aggressive. Default: 0.6 */
24
+ healthThreshold: number
25
+ /** Debounce window for departure detection (ms). Default: 5000 */
26
+ departureDebounceMs: number
27
+ /** Number of peers beyond cluster boundary to target. Default: 4 */
28
+ expansionStep: number
29
+ /**
30
+ * Per-target dial deadline (ms) for churn pushes. A target that never accepts
31
+ * the connection fails fast instead of stalling the sequential spread pass.
32
+ * Default: 3000 (matches the codebase's other dial caps).
33
+ */
34
+ pushDialTimeoutMs: number
35
+ /**
36
+ * Per-target response deadline (ms) for churn pushes. A target that dials OK but
37
+ * never replies (connects, then goes silent) is given up on the push throws
38
+ * `ResponseTimeoutError`, is recorded as failed, and the loop continues to the
39
+ * next target/block rather than hanging the whole pass. Default: 10000.
40
+ */
41
+ pushResponseTimeoutMs: number
42
+ }
43
+
44
+ export interface SpreadOnChurnDeps {
45
+ libp2p: Libp2p
46
+ fret: FretService
47
+ partitionDetector: PartitionDetector
48
+ /**
49
+ * The node's OWN store, proof accessor REQUIRED — see the same requirement on
50
+ * `BlockTransferCoordinator`: a repo without it spreads meta-only pushes that every
51
+ * default-configured receiver refuses, with no type error to say so.
52
+ */
53
+ repo: ProofRetainingRepo
54
+ peerNetwork: IPeerNetwork
55
+ clusterSize: number
56
+ protocolPrefix?: string
57
+ /**
58
+ * The owned-block tracked set. When provided (e.g. the shared `ownedBlocks` set wired in
59
+ * `libp2p-node-base`), the monitor references this exact `Set` instead of constructing its own,
60
+ * so it stays in lock-step with the `RebalanceMonitor` that shares it. Omit for standalone
61
+ * construction (unit tests) a fresh private `Set` preserves all existing behavior.
62
+ */
63
+ trackedBlocks?: Set<string>
64
+ }
65
+
66
+ export interface SpreadEvent {
67
+ /** Blocks that were spread */
68
+ spread: Array<{
69
+ blockId: string
70
+ targets: string[]
71
+ succeeded: string[]
72
+ failed: string[]
73
+ }>
74
+ /** Current effective d */
75
+ effectiveD: number
76
+ /** Timestamp of the departure that triggered this */
77
+ triggeredAt: number
78
+ }
79
+
80
+ type SpreadHandler = (event: SpreadEvent) => void
81
+
82
+ // ── Defaults ─────────────────────────────────────────────────────────
83
+
84
+ const DEFAULT_CONFIG: SpreadOnChurnConfig = {
85
+ enabled: true,
86
+ spreadDistance: 3,
87
+ dynamicSpreadDistance: true,
88
+ healthThreshold: 0.6,
89
+ departureDebounceMs: 5000,
90
+ expansionStep: 4,
91
+ pushDialTimeoutMs: 3000,
92
+ pushResponseTimeoutMs: 10000,
93
+ }
94
+
95
+ // ── Monitor ──────────────────────────────────────────────────────────
96
+
97
+ export class SpreadOnChurnMonitor implements Startable {
98
+ private running = false
99
+ private readonly trackedBlocks: Set<string>
100
+ private readonly handlers: SpreadHandler[] = []
101
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null
102
+ private departureTimestamps: number[] = []
103
+ private departureTimestamp = 0
104
+
105
+ private readonly config: SpreadOnChurnConfig
106
+ private readonly onConnectionClose: () => void
107
+
108
+ constructor(
109
+ private readonly deps: SpreadOnChurnDeps,
110
+ config: Partial<SpreadOnChurnConfig> = {}
111
+ ) {
112
+ // Share the injected owned-block set when present (so spread + rebalance never drift);
113
+ // otherwise own a private set (standalone construction / unit tests).
114
+ this.trackedBlocks = deps.trackedBlocks ?? new Set<string>()
115
+ this.config = { ...DEFAULT_CONFIG, ...config }
116
+ this.onConnectionClose = () => this.handleDeparture()
117
+ }
118
+
119
+ // ── Startable ────────────────────────────────────────────────────
120
+
121
+ async start(): Promise<void> {
122
+ if (this.running) return
123
+ this.running = true
124
+
125
+ this.deps.libp2p.addEventListener('connection:close', this.onConnectionClose)
126
+
127
+ log('started, tracking %d blocks', this.trackedBlocks.size)
128
+ }
129
+
130
+ async stop(): Promise<void> {
131
+ if (!this.running) return
132
+ this.running = false
133
+
134
+ this.deps.libp2p.removeEventListener('connection:close', this.onConnectionClose)
135
+
136
+ if (this.debounceTimer) {
137
+ clearTimeout(this.debounceTimer)
138
+ this.debounceTimer = null
139
+ }
140
+
141
+ log('stopped')
142
+ }
143
+
144
+ // ── Public API ───────────────────────────────────────────────────
145
+
146
+ onSpread(handler: SpreadHandler): void {
147
+ this.handlers.push(handler)
148
+ }
149
+
150
+ trackBlock(blockId: string): void {
151
+ this.trackedBlocks.add(blockId)
152
+ }
153
+
154
+ untrackBlock(blockId: string): void {
155
+ this.trackedBlocks.delete(blockId)
156
+ }
157
+
158
+ getTrackedBlockCount(): number {
159
+ return this.trackedBlocks.size
160
+ }
161
+
162
+ /** Force an immediate spread check (useful for testing). */
163
+ async checkNow(): Promise<SpreadEvent | null> {
164
+ return this.performSpread(Date.now())
165
+ }
166
+
167
+ // ── Internal ─────────────────────────────────────────────────────
168
+
169
+ private handleDeparture(): void {
170
+ if (!this.running) return
171
+ if (!this.config.enabled) return
172
+
173
+ if (!this.departureTimestamp) {
174
+ this.departureTimestamp = Date.now()
175
+ }
176
+
177
+ // Record for dynamic-d sliding window
178
+ this.departureTimestamps.push(Date.now())
179
+
180
+ if (this.debounceTimer) {
181
+ clearTimeout(this.debounceTimer)
182
+ }
183
+
184
+ this.debounceTimer = setTimeout(() => {
185
+ this.debounceTimer = null
186
+ const ts = this.departureTimestamp
187
+ this.departureTimestamp = 0
188
+ if (this.running) {
189
+ this.performSpread(ts).catch(err => {
190
+ log('spread error: %O', err)
191
+ })
192
+ }
193
+ }, this.config.departureDebounceMs)
194
+ }
195
+
196
+ private async performSpread(triggeredAt: number): Promise<SpreadEvent | null> {
197
+ if (!this.config.enabled) return null
198
+
199
+ if (this.deps.partitionDetector.detectPartition()) {
200
+ log('partition detected, suppressing spread')
201
+ return null
202
+ }
203
+
204
+ if (this.trackedBlocks.size === 0) return null
205
+
206
+ const selfId = this.deps.libp2p.peerId.toString()
207
+ const effectiveD = this.computeEffectiveD()
208
+ const spreadResults: SpreadEvent['spread'] = []
209
+
210
+ for (const blockId of this.trackedBlocks) {
211
+ const key = textEncoder.encode(blockId)
212
+ const coord = await hashKey(key)
213
+
214
+ // Check eligibility: only middle peers spread
215
+ const rank = this.deps.fret.neighborDistance(selfId, coord, this.deps.clusterSize)
216
+ if (rank >= effectiveD) continue
217
+
218
+ // Get current cohort and expansion targets
219
+ const cohort = this.deps.fret.assembleCohort(coord, this.deps.clusterSize)
220
+ const cohortSet = new Set(cohort)
221
+ const expanded = this.deps.fret.expandCohort(
222
+ cohort, coord, this.config.expansionStep
223
+ )
224
+ const targets = expanded.filter(id => !cohortSet.has(id) && id !== selfId)
225
+ if (targets.length === 0) continue
226
+
227
+ // Read block data from local storage
228
+ const result = await this.deps.repo.get({ blockIds: [blockId] })
229
+ const blockResult = result[blockId]
230
+ if (blockResult?.unavailable !== undefined) {
231
+ // The repo could not work out whether it still holds this block (unmaterializable
232
+ // history / failed restore). Untracking on that answer would silently drop the block
233
+ // from the spread set on a guess, and only a later re-commit would put it back — so
234
+ // keep it tracked and let the next sweep (or a heal) settle it.
235
+ log('unavailable block=%s reason=%s (keeping tracked)', blockId, blockResult.unavailable)
236
+ continue
237
+ }
238
+ if (!blockResult?.block) {
239
+ // The block has left local storage. No deletion event exists today to evict it
240
+ // from the tracked set, so prune here. Deleting the current element of a Set mid
241
+ // for...of is safe - it does not disturb the rest of the iteration. A later
242
+ // re-commit re-adds the block via the owned-block feed.
243
+ log('no-local-data block=%s (untracking)', blockId)
244
+ this.trackedBlocks.delete(blockId)
245
+ continue
246
+ }
247
+
248
+ const blockData = textEncoder.encode(JSON.stringify(blockResult.block))
249
+
250
+ // Carry the source's revision metadata so the replica's `latest` matches the
251
+ // source instead of being fabricated as rev 1 on the receiver, plus the cohort commit
252
+ // proof for that same revision — a receiver running the default `requirePushCertificate`
253
+ // rejects an uncertified push, so a block this node holds no proof for simply fails to
254
+ // spread (logged by the receiver as `push:reject-uncertified`) rather than being planted.
255
+ const certification = await sourceBlockCertification(this.deps.repo, blockId, blockResult)
256
+
257
+ // Push to each target
258
+ const succeeded: string[] = []
259
+ const failed: string[] = []
260
+
261
+ for (const targetId of targets) {
262
+ try {
263
+ const peerId = peerIdFromString(targetId)
264
+ const client = new BlockTransferClient(
265
+ peerId,
266
+ this.deps.peerNetwork,
267
+ this.deps.protocolPrefix
268
+ )
269
+ // Bound both the dial and the response read. A target that connects but
270
+ // never replies would otherwise hang this sequential loop forever during
271
+ // churn (exactly when slow/dying peers are common); the deadline makes the
272
+ // push throw, which the catch below records as `failed` so the pass advances.
273
+ const response = await client.pushBlocks([blockId], [blockData], 'replication', certification, {
274
+ dialTimeoutMs: this.config.pushDialTimeoutMs,
275
+ responseTimeoutMs: this.config.pushResponseTimeoutMs,
276
+ })
277
+ // A round-trip that does not throw still does not mean the replica
278
+ // landed: handlePush reports a block in `missing` when it could not
279
+ // parse or persist it. Treat that as a failed push so the resilience
280
+ // mechanism does not falsely count the new owner as holding the block.
281
+ if (response.missing.includes(blockId)) {
282
+ failed.push(targetId)
283
+ log('push:rejected block=%s target=%s (receiver did not persist)', blockId, targetId)
284
+ } else {
285
+ succeeded.push(targetId)
286
+ log('push:ok block=%s target=%s', blockId, targetId)
287
+ }
288
+ } catch (err) {
289
+ failed.push(targetId)
290
+ log('push:fail block=%s target=%s err=%s',
291
+ blockId, targetId, (err as Error).message)
292
+ }
293
+ }
294
+
295
+ spreadResults.push({ blockId, targets, succeeded, failed })
296
+ }
297
+
298
+ if (spreadResults.length === 0) return null
299
+
300
+ const event: SpreadEvent = {
301
+ spread: spreadResults,
302
+ effectiveD,
303
+ triggeredAt,
304
+ }
305
+
306
+ this.emitEvent(event)
307
+ return event
308
+ }
309
+
310
+ private computeEffectiveD(): number {
311
+ const d = this.config.spreadDistance
312
+ if (!this.config.dynamicSpreadDistance) return d
313
+
314
+ const maxD = Math.max(d, Math.floor(this.deps.clusterSize / 2))
315
+ const windowMs = this.config.departureDebounceMs * 4
316
+ const now = Date.now()
317
+
318
+ // Prune old departure timestamps
319
+ this.departureTimestamps = this.departureTimestamps.filter(
320
+ ts => now - ts < windowMs
321
+ )
322
+
323
+ // Rapid churn: 3+ departures in window increase d by 1
324
+ if (this.departureTimestamps.length >= 3) {
325
+ return Math.min(d + 1, maxD)
326
+ }
327
+
328
+ // Low cluster health: observed cohort shrunk relative to expected
329
+ // We approximate observed cohort size from FRET diagnostics
330
+ const diag: any = (this.deps.fret as any).getDiagnostics?.()
331
+ const estimate = diag?.estimate ?? diag?.n
332
+ if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) {
333
+ const ratio = estimate / this.deps.clusterSize
334
+ if (ratio < this.config.healthThreshold) {
335
+ const scaled = Math.ceil(d * (this.deps.clusterSize / estimate))
336
+ return Math.min(scaled, maxD)
337
+ }
338
+ }
339
+
340
+ return d
341
+ }
342
+
343
+ private emitEvent(event: SpreadEvent): void {
344
+ for (const handler of this.handlers) {
345
+ try {
346
+ handler(event)
347
+ } catch (err) {
348
+ log('handler error: %O', err)
349
+ }
350
+ }
351
+ }
352
+ }