@optimystic/db-p2p 0.14.1 → 0.16.3

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 (275) hide show
  1. package/README.md +616 -609
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +4 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +52 -3
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +129 -6
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts.map +1 -1
  10. package/dist/src/cluster/client.js +3 -1
  11. package/dist/src/cluster/client.js.map +1 -1
  12. package/dist/src/cluster/cluster-repo.d.ts +182 -11
  13. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  14. package/dist/src/cluster/cluster-repo.js +429 -69
  15. package/dist/src/cluster/cluster-repo.js.map +1 -1
  16. package/dist/src/cluster/peer-key-binding.d.ts +25 -0
  17. package/dist/src/cluster/peer-key-binding.d.ts.map +1 -0
  18. package/dist/src/cluster/peer-key-binding.js +39 -0
  19. package/dist/src/cluster/peer-key-binding.js.map +1 -0
  20. package/dist/src/cluster/quorum-restore.d.ts +72 -0
  21. package/dist/src/cluster/quorum-restore.d.ts.map +1 -0
  22. package/dist/src/cluster/quorum-restore.js +0 -0
  23. package/dist/src/cluster/quorum-restore.js.map +1 -0
  24. package/dist/src/cluster/rebalance-monitor.d.ts +23 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  26. package/dist/src/cluster/rebalance-monitor.js +17 -2
  27. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  28. package/dist/src/cluster/service.d.ts.map +1 -1
  29. package/dist/src/cluster/service.js +7 -1
  30. package/dist/src/cluster/service.js.map +1 -1
  31. package/dist/src/cluster/spread-on-churn.d.ts +7 -0
  32. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  33. package/dist/src/cluster/spread-on-churn.js +4 -1
  34. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  35. package/dist/src/cluster/supermajority-coupling.d.ts +22 -0
  36. package/dist/src/cluster/supermajority-coupling.d.ts.map +1 -0
  37. package/dist/src/cluster/supermajority-coupling.js +27 -0
  38. package/dist/src/cluster/supermajority-coupling.js.map +1 -0
  39. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +46 -4
  40. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -1
  41. package/dist/src/cohort-topic/cohort-gossip-driver.js +62 -7
  42. package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -1
  43. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +7 -0
  44. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -1
  45. package/dist/src/cohort-topic/cohort-gossip-transport.js +9 -0
  46. package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -1
  47. package/dist/src/cohort-topic/host.d.ts +197 -27
  48. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  49. package/dist/src/cohort-topic/host.js +643 -87
  50. package/dist/src/cohort-topic/host.js.map +1 -1
  51. package/dist/src/dispute/arbitrator-selection.d.ts +68 -7
  52. package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -1
  53. package/dist/src/dispute/arbitrator-selection.js +105 -16
  54. package/dist/src/dispute/arbitrator-selection.js.map +1 -1
  55. package/dist/src/dispute/cascade.d.ts +7 -0
  56. package/dist/src/dispute/cascade.d.ts.map +1 -1
  57. package/dist/src/dispute/cascade.js +1 -1
  58. package/dist/src/dispute/cascade.js.map +1 -1
  59. package/dist/src/dispute/client.d.ts.map +1 -1
  60. package/dist/src/dispute/client.js +3 -2
  61. package/dist/src/dispute/client.js.map +1 -1
  62. package/dist/src/dispute/dispute-service.d.ts +32 -3
  63. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  64. package/dist/src/dispute/dispute-service.js +82 -9
  65. package/dist/src/dispute/dispute-service.js.map +1 -1
  66. package/dist/src/dispute/index.d.ts +1 -1
  67. package/dist/src/dispute/index.d.ts.map +1 -1
  68. package/dist/src/dispute/index.js +1 -1
  69. package/dist/src/dispute/index.js.map +1 -1
  70. package/dist/src/dispute/invalidation.d.ts +25 -0
  71. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  72. package/dist/src/dispute/invalidation.js +14 -2
  73. package/dist/src/dispute/invalidation.js.map +1 -1
  74. package/dist/src/dispute/service.d.ts.map +1 -1
  75. package/dist/src/dispute/service.js +7 -1
  76. package/dist/src/dispute/service.js.map +1 -1
  77. package/dist/src/index.d.ts +4 -1
  78. package/dist/src/index.d.ts.map +1 -1
  79. package/dist/src/index.js +4 -1
  80. package/dist/src/index.js.map +1 -1
  81. package/dist/src/it-utility.d.ts +1 -1
  82. package/dist/src/it-utility.d.ts.map +1 -1
  83. package/dist/src/it-utility.js +4 -13
  84. package/dist/src/it-utility.js.map +1 -1
  85. package/dist/src/libp2p-key-network.d.ts +98 -1
  86. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  87. package/dist/src/libp2p-key-network.js +272 -17
  88. package/dist/src/libp2p-key-network.js.map +1 -1
  89. package/dist/src/libp2p-node-base.d.ts +12 -0
  90. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  91. package/dist/src/libp2p-node-base.js +441 -89
  92. package/dist/src/libp2p-node-base.js.map +1 -1
  93. package/dist/src/matchmaking/query-transport.d.ts +2 -0
  94. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  95. package/dist/src/matchmaking/query-transport.js +6 -0
  96. package/dist/src/matchmaking/query-transport.js.map +1 -1
  97. package/dist/src/matchmaking/traffic-validation.js +1 -0
  98. package/dist/src/matchmaking/traffic-validation.js.map +1 -1
  99. package/dist/src/network/network-manager-service.d.ts +2 -2
  100. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  101. package/dist/src/network/network-manager-service.js +8 -4
  102. package/dist/src/network/network-manager-service.js.map +1 -1
  103. package/dist/src/owned-block-seed.d.ts +27 -0
  104. package/dist/src/owned-block-seed.d.ts.map +1 -0
  105. package/dist/src/owned-block-seed.js +37 -0
  106. package/dist/src/owned-block-seed.js.map +1 -0
  107. package/dist/src/protocol-client.d.ts +1 -0
  108. package/dist/src/protocol-client.d.ts.map +1 -1
  109. package/dist/src/protocol-client.js +9 -2
  110. package/dist/src/protocol-client.js.map +1 -1
  111. package/dist/src/protocol-limits.d.ts +32 -0
  112. package/dist/src/protocol-limits.d.ts.map +1 -0
  113. package/dist/src/protocol-limits.js +32 -0
  114. package/dist/src/protocol-limits.js.map +1 -0
  115. package/dist/src/repo/client.d.ts.map +1 -1
  116. package/dist/src/repo/client.js +3 -0
  117. package/dist/src/repo/client.js.map +1 -1
  118. package/dist/src/repo/cluster-coordinator.d.ts +41 -4
  119. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  120. package/dist/src/repo/cluster-coordinator.js +128 -80
  121. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  122. package/dist/src/repo/coordinator-repo.d.ts +38 -1
  123. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  124. package/dist/src/repo/coordinator-repo.js +92 -13
  125. package/dist/src/repo/coordinator-repo.js.map +1 -1
  126. package/dist/src/repo/service.d.ts.map +1 -1
  127. package/dist/src/repo/service.js +7 -1
  128. package/dist/src/repo/service.js.map +1 -1
  129. package/dist/src/reputation/peer-reputation.js +1 -1
  130. package/dist/src/reputation/peer-reputation.js.map +1 -1
  131. package/dist/src/reputation/types.d.ts +3 -1
  132. package/dist/src/reputation/types.d.ts.map +1 -1
  133. package/dist/src/reputation/types.js +3 -0
  134. package/dist/src/reputation/types.js.map +1 -1
  135. package/dist/src/rn.d.ts +5 -1
  136. package/dist/src/rn.d.ts.map +1 -1
  137. package/dist/src/rn.js +7 -1
  138. package/dist/src/rn.js.map +1 -1
  139. package/dist/src/storage/arachnode-fret-adapter.d.ts +30 -1
  140. package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
  141. package/dist/src/storage/arachnode-fret-adapter.js +15 -2
  142. package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
  143. package/dist/src/storage/arachnode-partition.d.ts +49 -0
  144. package/dist/src/storage/arachnode-partition.d.ts.map +1 -0
  145. package/dist/src/storage/arachnode-partition.js +61 -0
  146. package/dist/src/storage/arachnode-partition.js.map +1 -0
  147. package/dist/src/storage/block-storage.d.ts +40 -1
  148. package/dist/src/storage/block-storage.d.ts.map +1 -1
  149. package/dist/src/storage/block-storage.js +150 -57
  150. package/dist/src/storage/block-storage.js.map +1 -1
  151. package/dist/src/storage/i-block-storage.d.ts +8 -0
  152. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/i-raw-storage.d.ts +13 -0
  154. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  155. package/dist/src/storage/kv-raw-storage.d.ts +45 -0
  156. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -0
  157. package/dist/src/storage/kv-raw-storage.js +113 -0
  158. package/dist/src/storage/kv-raw-storage.js.map +1 -0
  159. package/dist/src/storage/memory-storage.d.ts +20 -58
  160. package/dist/src/storage/memory-storage.d.ts.map +1 -1
  161. package/dist/src/storage/memory-storage.js +22 -148
  162. package/dist/src/storage/memory-storage.js.map +1 -1
  163. package/dist/src/storage/memory-store-driver.d.ts +39 -0
  164. package/dist/src/storage/memory-store-driver.d.ts.map +1 -0
  165. package/dist/src/storage/memory-store-driver.js +129 -0
  166. package/dist/src/storage/memory-store-driver.js.map +1 -0
  167. package/dist/src/storage/raw-store-codec.d.ts +10 -0
  168. package/dist/src/storage/raw-store-codec.d.ts.map +1 -0
  169. package/dist/src/storage/raw-store-codec.js +36 -0
  170. package/dist/src/storage/raw-store-codec.js.map +1 -0
  171. package/dist/src/storage/raw-store-driver.d.ts +69 -0
  172. package/dist/src/storage/raw-store-driver.d.ts.map +1 -0
  173. package/dist/src/storage/raw-store-driver.js +2 -0
  174. package/dist/src/storage/raw-store-driver.js.map +1 -0
  175. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +1 -1
  176. package/dist/src/storage/restoration-coordinator-v2.js +1 -1
  177. package/dist/src/storage/restoration-coordinator-v2.js.map +1 -1
  178. package/dist/src/storage/restoration-coordinator.d.ts +73 -0
  179. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -0
  180. package/dist/src/storage/restoration-coordinator.js +181 -0
  181. package/dist/src/storage/restoration-coordinator.js.map +1 -0
  182. package/dist/src/storage/ring-selector.d.ts +92 -5
  183. package/dist/src/storage/ring-selector.d.ts.map +1 -1
  184. package/dist/src/storage/ring-selector.js +153 -51
  185. package/dist/src/storage/ring-selector.js.map +1 -1
  186. package/dist/src/storage/ring-shift-coordinator.d.ts +128 -0
  187. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -0
  188. package/dist/src/storage/ring-shift-coordinator.js +196 -0
  189. package/dist/src/storage/ring-shift-coordinator.js.map +1 -0
  190. package/dist/src/storage/storage-monitor.d.ts +28 -0
  191. package/dist/src/storage/storage-monitor.d.ts.map +1 -1
  192. package/dist/src/storage/storage-monitor.js +42 -2
  193. package/dist/src/storage/storage-monitor.js.map +1 -1
  194. package/dist/src/storage/storage-repo.d.ts +19 -0
  195. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  196. package/dist/src/storage/storage-repo.js +172 -22
  197. package/dist/src/storage/storage-repo.js.map +1 -1
  198. package/dist/src/sync/client.d.ts.map +1 -1
  199. package/dist/src/sync/client.js +5 -1
  200. package/dist/src/sync/client.js.map +1 -1
  201. package/dist/src/sync/service.d.ts.map +1 -1
  202. package/dist/src/sync/service.js +2 -1
  203. package/dist/src/sync/service.js.map +1 -1
  204. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +28 -4
  205. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  206. package/dist/src/testing/cohort-topic-mesh-harness.js +51 -10
  207. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  208. package/dist/src/testing/index.d.ts +1 -0
  209. package/dist/src/testing/index.d.ts.map +1 -1
  210. package/dist/src/testing/index.js +1 -0
  211. package/dist/src/testing/index.js.map +1 -1
  212. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  213. package/dist/src/testing/mesh-harness.js +15 -7
  214. package/dist/src/testing/mesh-harness.js.map +1 -1
  215. package/dist/src/testing/raw-storage-conformance.d.ts +24 -0
  216. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -0
  217. package/dist/src/testing/raw-storage-conformance.js +313 -0
  218. package/dist/src/testing/raw-storage-conformance.js.map +1 -0
  219. package/package.json +4 -4
  220. package/src/cluster/block-transfer-service.ts +4 -3
  221. package/src/cluster/block-transfer.ts +424 -265
  222. package/src/cluster/client.ts +3 -1
  223. package/src/cluster/cluster-repo.ts +481 -71
  224. package/src/cluster/peer-key-binding.ts +37 -0
  225. package/src/cluster/quorum-restore.ts +0 -0
  226. package/src/cluster/rebalance-monitor.ts +34 -3
  227. package/src/cluster/service.ts +7 -1
  228. package/src/cluster/spread-on-churn.ts +11 -1
  229. package/src/cluster/supermajority-coupling.ts +37 -0
  230. package/src/cohort-topic/cohort-gossip-driver.ts +96 -9
  231. package/src/cohort-topic/cohort-gossip-transport.ts +10 -0
  232. package/src/cohort-topic/host.ts +831 -105
  233. package/src/dispute/arbitrator-selection.ts +138 -28
  234. package/src/dispute/cascade.ts +8 -1
  235. package/src/dispute/client.ts +3 -2
  236. package/src/dispute/dispute-service.ts +94 -9
  237. package/src/dispute/index.ts +7 -1
  238. package/src/dispute/invalidation.ts +40 -2
  239. package/src/dispute/service.ts +7 -1
  240. package/src/index.ts +46 -43
  241. package/src/it-utility.ts +5 -13
  242. package/src/libp2p-key-network.ts +281 -18
  243. package/src/libp2p-node-base.ts +511 -81
  244. package/src/matchmaking/query-transport.ts +9 -0
  245. package/src/network/network-manager-service.ts +8 -2
  246. package/src/owned-block-seed.ts +40 -0
  247. package/src/protocol-client.ts +10 -3
  248. package/src/protocol-limits.ts +33 -0
  249. package/src/repo/client.ts +3 -0
  250. package/src/repo/cluster-coordinator.ts +145 -82
  251. package/src/repo/coordinator-repo.ts +96 -16
  252. package/src/repo/service.ts +7 -1
  253. package/src/reputation/types.ts +3 -0
  254. package/src/rn.ts +7 -1
  255. package/src/storage/arachnode-fret-adapter.ts +33 -2
  256. package/src/storage/arachnode-partition.ts +74 -0
  257. package/src/storage/block-storage.ts +169 -61
  258. package/src/storage/i-block-storage.ts +9 -0
  259. package/src/storage/i-raw-storage.ts +14 -0
  260. package/src/storage/kv-raw-storage.ts +135 -0
  261. package/src/storage/memory-storage.ts +25 -173
  262. package/src/storage/memory-store-driver.ts +157 -0
  263. package/src/storage/raw-store-codec.ts +42 -0
  264. package/src/storage/raw-store-driver.ts +80 -0
  265. package/src/storage/{restoration-coordinator-v2.ts → restoration-coordinator.ts} +43 -37
  266. package/src/storage/ring-selector.ts +317 -176
  267. package/src/storage/ring-shift-coordinator.ts +271 -0
  268. package/src/storage/storage-monitor.ts +70 -3
  269. package/src/storage/storage-repo.ts +710 -558
  270. package/src/sync/client.ts +5 -1
  271. package/src/sync/service.ts +2 -1
  272. package/src/testing/cohort-topic-mesh-harness.ts +61 -11
  273. package/src/testing/index.ts +1 -0
  274. package/src/testing/mesh-harness.ts +14 -6
  275. package/src/testing/raw-storage-conformance.ts +397 -0
@@ -1,4 +1,14 @@
1
1
  import { hashPeerId } from 'p2p-fret';
2
+ import { peerIdFromString } from '@libp2p/peer-id';
3
+ import { extractPrefix } from './arachnode-partition.js';
4
+ /** EWMA weight for the demand inputs. Higher = more reactive, lower = smoother. */
5
+ const DEFAULT_SMOOTHING_ALPHA = 0.2;
6
+ /** Hysteresis half-width in rings. 0.5 puts a full ring between the move-out and move-in triggers. */
7
+ const DEFAULT_DEADBAND = 0.5;
8
+ /** Minimum time (ms) at a ring after a move is triggered before another may start. */
9
+ const DEFAULT_MIN_DWELL_MS = 10 * 60 * 1000;
10
+ /** Floor for smoothed coverage so `-log2` never sees 0; the [0,16] clamp bounds the result anyway. */
11
+ const DEPTH_EPSILON = 1e-12;
2
12
  /**
3
13
  * Determines appropriate ring depth based on storage capacity and network demand.
4
14
  *
@@ -7,18 +17,36 @@ import { hashPeerId } from 'p2p-fret';
7
17
  * - Ring N: 2^N partitions
8
18
  *
9
19
  * A node selects its ring based on: available_capacity / estimated_neighborhood_demand
20
+ *
21
+ * The transition decision ({@link shouldTransition}) is damped so it does not thrash near a ring
22
+ * boundary: the demand signal is smoothed with an EWMA, a dead-band keeps a hovering ratio from
23
+ * moving, and a minimum-dwell timer + single-step rule bound how often and how far it moves. See
24
+ * `docs/arachnode-ring-handoff.md` § Part 1.
10
25
  */
11
26
  export class RingSelector {
12
27
  fretAdapter;
13
28
  storageMonitor;
14
29
  config;
30
+ /** EWMA of available capacity (bytes). Undefined until the first sample seeds it. */
31
+ smoothedAvailable;
32
+ /** EWMA of estimated total network data (bytes). Undefined until the first sample seeds it. */
33
+ smoothedTotalData;
34
+ /** Clock time (ms) a move was last triggered; undefined = no move yet, so dwell does not block. */
35
+ lastMoveAt;
36
+ now;
15
37
  constructor(fretAdapter, storageMonitor, config) {
16
38
  this.fretAdapter = fretAdapter;
17
39
  this.storageMonitor = storageMonitor;
18
40
  this.config = config;
41
+ this.now = config.now ?? (() => Date.now());
19
42
  }
20
43
  /**
21
- * Determine appropriate ring depth based on capacity and demand.
44
+ * Determine appropriate ring depth from an instantaneous capacity + demand snapshot.
45
+ *
46
+ * This is the *undamped* reading used for the deterministic first-boot ring and anywhere a
47
+ * one-shot estimate is wanted. The damped transition logic lives in {@link shouldTransition};
48
+ * this method intentionally does not touch the EWMA state so its result is a pure function of
49
+ * the current snapshot.
22
50
  */
23
51
  async determineRing() {
24
52
  const capacity = await this.storageMonitor.getCapacity();
@@ -26,14 +54,31 @@ export class RingSelector {
26
54
  // Not enough capacity for any ring
27
55
  return -1;
28
56
  }
29
- // Estimate total network spare capacity by aggregating observed per-ring stats.
30
- // See docs/arachnode.md §"Capacity Management and Ring Adjustment": ring selection
31
- // should reflect observed network demand, not a hard-coded constant. We treat
32
- // Σ (peerCount × avgCapacity) over known rings as a proxy for total network spare
33
- // capacity, so `coverage` becomes our share of it. More peers (or richer peers) →
34
- // smaller share higher ring depth (more specialization). On bootstrap, when no
35
- // ring stats are observed yet, we fall back to a conservative fixed estimate so
36
- // first-boot behavior is deterministic.
57
+ const estimatedTotalData = this.estimateNetworkData(capacity.available);
58
+ // Calculate what fraction of keyspace we can cover
59
+ const coverage = capacity.available / estimatedTotalData;
60
+ // Ring depth: 0 = full keyspace, N = 2^N partitions
61
+ // If coverage = 0.01 (1%), we need ~100 partitions Ring 7
62
+ // If coverage = 1.0 (100%), we can handle full keyspace = Ring 0
63
+ const ringDepth = Math.max(0, Math.ceil(-Math.log2(Math.max(0.001, coverage))));
64
+ return Math.min(ringDepth, 16); // Cap at Ring 16 (65536 partitions)
65
+ }
66
+ // NOTE: prefix extraction lives in `arachnode-partition.ts` (single source of truth shared with
67
+ // the block-side responsibility derivation in RestorationCoordinator / RingShiftCoordinator).
68
+ /**
69
+ * Estimate total network spare capacity (bytes) by aggregating observed per-ring stats.
70
+ *
71
+ * See docs/arachnode.md §"Capacity Management and Ring Adjustment": ring selection should
72
+ * reflect observed network demand, not a hard-coded constant. We treat Σ (peerCount ×
73
+ * avgCapacity) over known rings as a proxy for total network spare capacity, so `coverage`
74
+ * becomes our share of it. More peers (or richer peers) → smaller share → higher ring depth
75
+ * (more specialization). On bootstrap, when no ring stats are observed yet, we fall back to a
76
+ * conservative fixed estimate so first-boot behavior is deterministic.
77
+ *
78
+ * @param availableForFallback local available bytes, used only for the pathological
79
+ * zero-aggregate case (e.g. all peers report 0 available) so coverage collapses to 1.0 (ring 0).
80
+ */
81
+ estimateNetworkData(availableForFallback) {
37
82
  const ringStats = this.fretAdapter.getRingStats();
38
83
  let estimatedTotalData;
39
84
  if (ringStats.length > 0) {
@@ -44,18 +89,34 @@ export class RingSelector {
44
89
  const estimatedTotalBlocks = 1000; // Conservative estimate
45
90
  estimatedTotalData = estimatedTotalBlocks * avgBlockSize;
46
91
  }
47
- // Guard against pathological zero-aggregate (e.g. all peers report 0 available);
48
- // fall back to local capacity so coverage = 1.0 (ring 0).
49
92
  if (estimatedTotalData <= 0) {
50
- estimatedTotalData = capacity.available;
93
+ estimatedTotalData = availableForFallback;
51
94
  }
52
- // Calculate what fraction of keyspace we can cover
53
- const coverage = capacity.available / estimatedTotalData;
54
- // Ring depth: 0 = full keyspace, N = 2^N partitions
55
- // If coverage = 0.01 (1%), we need ~100 partitions Ring 7
56
- // If coverage = 1.0 (100%), we can handle full keyspace = Ring 0
57
- const ringDepth = Math.max(0, Math.ceil(-Math.log2(Math.max(0.001, coverage))));
58
- return Math.min(ringDepth, 16); // Cap at Ring 16 (65536 partitions)
95
+ return estimatedTotalData;
96
+ }
97
+ /**
98
+ * Fold one sample into the EWMA of each demand input. Seeds from the first real sample rather
99
+ * than from 0 — a 0 seed would drag the smoothed depth for many ticks.
100
+ */
101
+ updateSmoothing(available, totalData) {
102
+ const alpha = this.config.smoothingAlpha ?? DEFAULT_SMOOTHING_ALPHA;
103
+ this.smoothedAvailable = this.smoothedAvailable === undefined
104
+ ? available
105
+ : alpha * available + (1 - alpha) * this.smoothedAvailable;
106
+ this.smoothedTotalData = this.smoothedTotalData === undefined
107
+ ? totalData
108
+ : alpha * totalData + (1 - alpha) * this.smoothedTotalData;
109
+ }
110
+ /**
111
+ * Continuous smoothed ring depth `d = clamp(-log2(smoothedCoverage), 0, 16)` from the current
112
+ * EWMA state. Returns 0 (ring 0) if no sample has been folded in yet.
113
+ */
114
+ smoothedDepth() {
115
+ const available = this.smoothedAvailable ?? 0;
116
+ const totalData = this.smoothedTotalData ?? 0;
117
+ const coverage = totalData > 0 ? available / totalData : 1;
118
+ const depth = -Math.log2(Math.max(DEPTH_EPSILON, coverage));
119
+ return Math.min(16, Math.max(0, depth));
59
120
  }
60
121
  /**
61
122
  * Calculate partition for a given ring depth and peer ID.
@@ -64,19 +125,27 @@ export class RingSelector {
64
125
  if (ringDepth === 0) {
65
126
  return undefined; // Full keyspace, no partition
66
127
  }
67
- // Hash peer ID to get coordinate
68
- const coord = await hashPeerId({ toString: () => peerId });
69
- // Extract prefix bits from coordinate
128
+ // Hash peer ID to get coordinate. hashPeerId reads `peerId.toMultihash().bytes`, so it
129
+ // needs a real PeerId reconstruct one from the string. Peers must occupy the SAME ring
130
+ // coordinate FRET uses to place them (hashPeerId(peerId)), or restoration's block-prefix
131
+ // vs peer-prefix comparison stops meaning "this peer owns this block's slice".
132
+ const coord = await hashPeerId(peerIdFromString(peerId));
133
+ // Extract prefix bits from coordinate (shared with block-side responsibility derivation).
70
134
  const prefixBits = ringDepth;
71
- const prefixValue = this.extractPrefix(coord, prefixBits);
135
+ const prefixValue = extractPrefix(coord, prefixBits);
72
136
  return { prefixBits, prefixValue };
73
137
  }
74
138
  /**
75
139
  * Create Arachnode info for this node.
140
+ *
141
+ * @param ringDepthOverride when supplied (e.g. the single-step target from
142
+ * {@link shouldTransition}), the node advertises exactly this ring instead of a freshly
143
+ * recomputed {@link determineRing}. The transition site passes it so the advertised ring can
144
+ * never disagree with the ring the transition decision just chose.
76
145
  */
77
- async createArachnodeInfo(peerId) {
146
+ async createArachnodeInfo(peerId, ringDepthOverride) {
78
147
  const capacity = await this.storageMonitor.getCapacity();
79
- const ringDepth = await this.determineRing();
148
+ const ringDepth = ringDepthOverride ?? await this.determineRing();
80
149
  const partition = ringDepth >= 0
81
150
  ? await this.calculatePartition(ringDepth, peerId)
82
151
  : undefined;
@@ -92,45 +161,78 @@ export class RingSelector {
92
161
  };
93
162
  }
94
163
  /**
95
- * Monitor capacity and determine if ring transition is needed.
164
+ * Decide whether a damped ring transition should start.
165
+ *
166
+ * Damping (see `docs/arachnode-ring-handoff.md` § Part 1):
167
+ * - **Smoothing** — every call folds the current sample into an EWMA of the demand inputs and
168
+ * works off the smoothed continuous depth `d`, so a single noisy sample cannot trigger a move.
169
+ * - **Hysteresis** — anchored on the node's *currently-advertised* ring `R` (not a freshly
170
+ * recomputed one). A move only fires when `d` is past the boundary by the dead-band `h`, so a
171
+ * ratio hovering around an integer produces no move.
172
+ * - **Dwell + single-step** — no new move within `minDwellMs` of the last, no move while a shift
173
+ * is already in flight (`status === 'moving'`), and each move steps by exactly ±1 ring even
174
+ * when `d` implies a larger jump.
175
+ *
176
+ * Return shape (`{ shouldMove, direction, newRingDepth }`) is the *trigger* the handoff protocol
177
+ * consumes; it is kept stable so that consumer is unaffected.
96
178
  */
97
179
  async shouldTransition() {
98
180
  const capacity = await this.storageMonitor.getCapacity();
99
- const usedPercent = capacity.used / capacity.total;
100
- if (usedPercent > this.config.thresholds.moveOut) {
101
- // Move to outer ring (more granular partition)
102
- const currentRingDepth = await this.determineRing();
181
+ const usedPercent = capacity.total > 0 ? capacity.used / capacity.total : 0;
182
+ // Smoothing updates every tick, regardless of whether we act, so the EWMA keeps tracking
183
+ // demand even while a candidate move is dwell-blocked or a shift is already in flight.
184
+ const rawTotalData = this.estimateNetworkData(capacity.available);
185
+ this.updateSmoothing(capacity.available, rawTotalData);
186
+ const d = this.smoothedDepth();
187
+ const info = this.fretAdapter.getMyArachnodeInfo();
188
+ // Hysteresis anchor: the node's currently-advertised ring. Fall back to the smoothed depth
189
+ // only in the pathological case where nothing has been advertised yet (production always
190
+ // advertises at bootstrap before the first tick).
191
+ const advertisedRing = info?.ringDepth ?? Math.min(16, Math.max(0, Math.round(d)));
192
+ // No re-entrant shift: a move already in flight (advertise→confirm→release) owns the ring.
193
+ if (info?.status === 'moving') {
194
+ return { shouldMove: false };
195
+ }
196
+ // Minimum dwell: rate-limit so signal noise cannot drive rapid flips. `lastMoveAt` undefined
197
+ // means no prior move, so the first genuine move is never blocked. The stamp here (at *trigger*)
198
+ // keeps a move from immediately re-triggering before `status` flips to `moving`; the driver then
199
+ // calls {@link recordShiftSettled} once the advertise→confirm→release handoff SETTLES, so the
200
+ // effective dwell is measured from the *completed* shift (spec §1.3), not merely the trigger.
201
+ const minDwellMs = this.config.minDwellMs ?? DEFAULT_MIN_DWELL_MS;
202
+ if (this.lastMoveAt !== undefined && (this.now() - this.lastMoveAt) < minDwellMs) {
203
+ return { shouldMove: false };
204
+ }
205
+ const h = this.config.deadband ?? DEFAULT_DEADBAND;
206
+ // Move OUT (R → R+1): smoothed depth solidly past the outer boundary AND storage pressure high.
207
+ if (d >= advertisedRing + 1 - h && usedPercent > this.config.thresholds.moveOut) {
208
+ this.lastMoveAt = this.now();
103
209
  return {
104
210
  shouldMove: true,
105
211
  direction: 'out',
106
- newRingDepth: currentRingDepth + 1
212
+ newRingDepth: advertisedRing + 1 // single step, even if d implies a larger jump
107
213
  };
108
214
  }
109
- if (usedPercent < this.config.thresholds.moveIn) {
110
- // Move to inner ring (broader coverage)
111
- const currentRingDepth = await this.determineRing();
112
- if (currentRingDepth > 0) {
113
- return {
114
- shouldMove: true,
115
- direction: 'in',
116
- newRingDepth: currentRingDepth - 1
117
- };
118
- }
215
+ // Move IN (R R-1): smoothed depth solidly past the inner boundary AND storage slack AND R>0.
216
+ if (advertisedRing > 0 && d <= advertisedRing - 1 + h && usedPercent < this.config.thresholds.moveIn) {
217
+ this.lastMoveAt = this.now();
218
+ return {
219
+ shouldMove: true,
220
+ direction: 'in',
221
+ newRingDepth: advertisedRing - 1 // single step
222
+ };
119
223
  }
120
224
  return { shouldMove: false };
121
225
  }
122
226
  /**
123
- * Extract first N bits from byte array as a number.
227
+ * Re-stamp the dwell timer at the moment a ring shift *settles* — the driver
228
+ * (`RingShiftCoordinator` via `libp2p-node-base`) calls this once `executeShift` resolves, for a
229
+ * completed OR a rolled-back move. So the minimum dwell is measured from the settled shift rather
230
+ * than only from the trigger (`shouldTransition`): a completed shift dwells `minDwellMs` before the
231
+ * next move (spec §1.3, "after a *completed* shift"), and a shift that keeps failing is rate-limited
232
+ * rather than retried every tick. No-op-safe to call when no shift ran.
124
233
  */
125
- extractPrefix(bytes, bits) {
126
- let value = 0;
127
- for (let i = 0; i < bits; i++) {
128
- const byteIndex = Math.floor(i / 8);
129
- const bitIndex = 7 - (i % 8);
130
- const bit = (bytes[byteIndex] >> bitIndex) & 1;
131
- value = (value << 1) | bit;
132
- }
133
- return value;
234
+ recordShiftSettled() {
235
+ this.lastMoveAt = this.now();
134
236
  }
135
237
  }
136
238
  //# sourceMappingURL=ring-selector.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ring-selector.js","sourceRoot":"","sources":["../../../src/storage/ring-selector.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ring-selector.js","sourceRoot":"","sources":["../../../src/storage/ring-selector.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,128 @@
1
+ import type { FretService } from 'p2p-fret';
2
+ import type { PartitionDetector } from '../cluster/partition-detector.js';
3
+ import type { ArachnodeFretAdapter } from './arachnode-fret-adapter.js';
4
+ import type { RingSelector } from './ring-selector.js';
5
+ /**
6
+ * The confirm primitive the handoff gates release on. Satisfied by `BlockTransferCoordinator`; kept
7
+ * as a narrow structural interface so the coordinator is unit-testable with a stub.
8
+ */
9
+ export interface ReplicationConfirmer {
10
+ confirmReplicated(blockIds: string[], owners: Map<string, string[]>, floor: number): Promise<{
11
+ confirmed: string[];
12
+ unconfirmed: string[];
13
+ }>;
14
+ }
15
+ export interface RingShiftDeps {
16
+ fretAdapter: ArachnodeFretAdapter;
17
+ ringSelector: RingSelector;
18
+ fret: FretService;
19
+ partitionDetector: PartitionDetector;
20
+ /** Confirms a shed block replicated to ≥ N qualifying holders (Phase B). */
21
+ confirmer: ReplicationConfirmer;
22
+ /** The blocks this node physically holds — the candidate set the shed range is drawn from. */
23
+ ownedBlocks: Set<string>;
24
+ /** This node's own peer id string (excluded from the qualifying-holder count). */
25
+ selfPeerId: string;
26
+ /** The replication floor `N` (e.g. `RebalanceMonitor.getCohortSize`), read fresh per shift. */
27
+ getFloor: () => number;
28
+ /**
29
+ * Called with the shed block ids the instant Phase C releases them. The wiring stops serving +
30
+ * spreading them (untrack) and marks their local bytes GC-eligible — nothing may reclaim a shed
31
+ * range before this fires.
32
+ */
33
+ onRelease: (blockIds: string[]) => void;
34
+ }
35
+ export interface RingShiftConfig {
36
+ /**
37
+ * How many extra candidate holders beyond the floor to request from FRET per shed block, giving
38
+ * headroom to exclude self and same-range movers while still finding `N` qualifying holders.
39
+ * Default 2.
40
+ */
41
+ candidateMargin?: number;
42
+ }
43
+ /** The result of a single `executeShift` — what happened and why, for logging/tests. */
44
+ export type ShiftOutcome = {
45
+ status: 'moved-out';
46
+ from: number;
47
+ to: number;
48
+ released: string[];
49
+ } | {
50
+ status: 'moved-in';
51
+ from: number;
52
+ to: number;
53
+ } | {
54
+ status: 'rolled-back';
55
+ ring: number;
56
+ reason: string;
57
+ } | {
58
+ status: 'skipped';
59
+ reason: string;
60
+ };
61
+ /**
62
+ * Drives a single damped ring transition through the **advertise → confirm-replication → release**
63
+ * handoff (`docs/arachnode-ring-handoff.md` § Part 2), so a ring shift never drops a key below its
64
+ * replication floor `N`.
65
+ *
66
+ * - **Move-out** (`R → R+1`, sheds half its keyspace) runs all three phases: advertise the target
67
+ * ring while still serving the old range (Phase A), confirm every shed block is replicated to ≥ N
68
+ * qualifying post-move holders (Phase B), then release the shed range (Phase C). Any Phase-B
69
+ * failure — partition, unreachable holders, floor unmet — rolls back to `active` at the old ring,
70
+ * keeping the range. No shed block is released unless EVERY shed block confirmed.
71
+ * - **Move-in** (`R → R-1`, gains keyspace, sheds nothing) is Phase A only: it advertises the inner
72
+ * ring so peers observe the membership change, then pulls the gained half via the restoration /
73
+ * rebalance path. The floor is never at risk from a mover that only gains, so there is no
74
+ * confirm/release.
75
+ *
76
+ * The trigger is the (damped) `RingSelector.shouldTransition()` decision; this class is the state
77
+ * machine that decision drives.
78
+ */
79
+ export declare class RingShiftCoordinator {
80
+ private readonly deps;
81
+ private readonly candidateMargin;
82
+ /** Guards against a re-entrant shift if the driving interval overlaps a long confirm. */
83
+ private inFlight;
84
+ constructor(deps: RingShiftDeps, config?: RingShiftConfig);
85
+ /**
86
+ * Execute one single-step ring transition. No-op (`skipped`) if a shift is already in flight or the
87
+ * node is already `moving`. `direction`/`newRingDepth` come straight from the damped
88
+ * `shouldTransition()` trigger.
89
+ */
90
+ executeShift(transition: {
91
+ direction: 'in' | 'out';
92
+ newRingDepth: number;
93
+ }): Promise<ShiftOutcome>;
94
+ /**
95
+ * Reconcile a stale `status='moving'` advertisement left by a crash between advertise (Phase A) and
96
+ * release (Phase C). The crashed node never ran Phase C, so it never released the shed range and is
97
+ * still responsible for its OLD range: restore that range and refresh status to `active`. A node
98
+ * whose advertised status is not `moving` needs no reconciliation. Called once at startup. See
99
+ * `docs/arachnode-ring-handoff.md` § Part 3 (crash mid-handoff).
100
+ */
101
+ reconcileOnStart(): {
102
+ reconciled: boolean;
103
+ };
104
+ private moveOut;
105
+ /**
106
+ * Phase B. Succeeds only when EVERY shed block is confirmed replicated to ≥ `floor` qualifying
107
+ * holders. Aborts (no partial release) on a detected partition or any unconfirmed block.
108
+ */
109
+ private confirmShedRange;
110
+ /**
111
+ * The shed range: blocks this node holds that its OLD partition covers but its NEW (target)
112
+ * partition does not — i.e. the half of its slice it stops covering by moving out.
113
+ */
114
+ private computeShedBlocks;
115
+ /**
116
+ * For each shed block, the qualifying post-move holders to confirm against: the FRET cohort around
117
+ * the block minus self and minus any peer that does not still cover the block under its OWN
118
+ * advertised (target) partition — which excludes same-range movers ({@link qualifiesForFloor}). A
119
+ * candidate with no advertised Arachnode info is excluded (its coverage cannot be verified).
120
+ */
121
+ private buildOwnersMap;
122
+ private moveIn;
123
+ /** Restore the pre-move advertisement (old ring/partition, `active`, no `moveFrom`). */
124
+ private rollback;
125
+ /** Strip the transient `moveFrom` field so an `active` advertisement never carries move state. */
126
+ private clearMove;
127
+ }
128
+ //# sourceMappingURL=ring-shift-coordinator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ring-shift-coordinator.d.ts","sourceRoot":"","sources":["../../../src/storage/ring-shift-coordinator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AAC1E,OAAO,KAAK,EAAE,oBAAoB,EAAiB,MAAM,6BAA6B,CAAC;AACvF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAOvD;;;GAGG;AACH,MAAM,WAAW,oBAAoB;IACpC,iBAAiB,CAChB,QAAQ,EAAE,MAAM,EAAE,EAClB,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,EAC7B,KAAK,EAAE,MAAM,GACX,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,EAAE,CAAC;QAAC,WAAW,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;CAC3D;AAED,MAAM,WAAW,aAAa;IAC7B,WAAW,EAAE,oBAAoB,CAAC;IAClC,YAAY,EAAE,YAAY,CAAC;IAC3B,IAAI,EAAE,WAAW,CAAC;IAClB,iBAAiB,EAAE,iBAAiB,CAAC;IACrC,4EAA4E;IAC5E,SAAS,EAAE,oBAAoB,CAAC;IAChC,8FAA8F;IAC9F,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACzB,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAC;IACnB,+FAA+F;IAC/F,QAAQ,EAAE,MAAM,MAAM,CAAC;IACvB;;;;OAIG;IACH,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,eAAe;IAC/B;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,wFAAwF;AACxF,MAAM,MAAM,YAAY,GACrB;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,GACrE;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAChD;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACvD;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzC;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,oBAAoB;IAM/B,OAAO,CAAC,QAAQ,CAAC,IAAI;IALtB,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,yFAAyF;IACzF,OAAO,CAAC,QAAQ,CAAS;gBAGP,IAAI,EAAE,aAAa,EACpC,MAAM,GAAE,eAAoB;IAK7B;;;;OAIG;IACG,YAAY,CAAC,UAAU,EAAE;QAAE,SAAS,EAAE,IAAI,GAAG,KAAK,CAAC;QAAC,YAAY,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,YAAY,CAAC;IAmBxG;;;;;;OAMG;IACH,gBAAgB,IAAI;QAAE,UAAU,EAAE,OAAO,CAAA;KAAE;YAiB7B,OAAO;IAgCrB;;;OAGG;YACW,gBAAgB;IAiB9B;;;OAGG;YACW,iBAAiB;IAc/B;;;;;OAKG;YACW,cAAc;YAkBd,MAAM;IAapB,wFAAwF;IACxF,OAAO,CAAC,QAAQ;IAkBhB,kGAAkG;IAClG,OAAO,CAAC,SAAS;CAIjB"}
@@ -0,0 +1,196 @@
1
+ import { hashKey } from 'p2p-fret';
2
+ import { partitionCovers, qualifiesForFloor } from './arachnode-partition.js';
3
+ import { createLogger } from '../logger.js';
4
+ const log = createLogger('ring-shift');
5
+ const textEncoder = new TextEncoder();
6
+ /**
7
+ * Drives a single damped ring transition through the **advertise → confirm-replication → release**
8
+ * handoff (`docs/arachnode-ring-handoff.md` § Part 2), so a ring shift never drops a key below its
9
+ * replication floor `N`.
10
+ *
11
+ * - **Move-out** (`R → R+1`, sheds half its keyspace) runs all three phases: advertise the target
12
+ * ring while still serving the old range (Phase A), confirm every shed block is replicated to ≥ N
13
+ * qualifying post-move holders (Phase B), then release the shed range (Phase C). Any Phase-B
14
+ * failure — partition, unreachable holders, floor unmet — rolls back to `active` at the old ring,
15
+ * keeping the range. No shed block is released unless EVERY shed block confirmed.
16
+ * - **Move-in** (`R → R-1`, gains keyspace, sheds nothing) is Phase A only: it advertises the inner
17
+ * ring so peers observe the membership change, then pulls the gained half via the restoration /
18
+ * rebalance path. The floor is never at risk from a mover that only gains, so there is no
19
+ * confirm/release.
20
+ *
21
+ * The trigger is the (damped) `RingSelector.shouldTransition()` decision; this class is the state
22
+ * machine that decision drives.
23
+ */
24
+ export class RingShiftCoordinator {
25
+ deps;
26
+ candidateMargin;
27
+ /** Guards against a re-entrant shift if the driving interval overlaps a long confirm. */
28
+ inFlight = false;
29
+ constructor(deps, config = {}) {
30
+ this.deps = deps;
31
+ this.candidateMargin = config.candidateMargin ?? 2;
32
+ }
33
+ /**
34
+ * Execute one single-step ring transition. No-op (`skipped`) if a shift is already in flight or the
35
+ * node is already `moving`. `direction`/`newRingDepth` come straight from the damped
36
+ * `shouldTransition()` trigger.
37
+ */
38
+ async executeShift(transition) {
39
+ if (this.inFlight) {
40
+ return { status: 'skipped', reason: 'shift-in-flight' };
41
+ }
42
+ const current = this.deps.fretAdapter.getMyArachnodeInfo();
43
+ if (current?.status === 'moving') {
44
+ return { status: 'skipped', reason: 'already-moving' };
45
+ }
46
+ this.inFlight = true;
47
+ try {
48
+ return transition.direction === 'out'
49
+ ? await this.moveOut(transition.newRingDepth, current)
50
+ : await this.moveIn(transition.newRingDepth, current);
51
+ }
52
+ finally {
53
+ this.inFlight = false;
54
+ }
55
+ }
56
+ /**
57
+ * Reconcile a stale `status='moving'` advertisement left by a crash between advertise (Phase A) and
58
+ * release (Phase C). The crashed node never ran Phase C, so it never released the shed range and is
59
+ * still responsible for its OLD range: restore that range and refresh status to `active`. A node
60
+ * whose advertised status is not `moving` needs no reconciliation. Called once at startup. See
61
+ * `docs/arachnode-ring-handoff.md` § Part 3 (crash mid-handoff).
62
+ */
63
+ reconcileOnStart() {
64
+ const info = this.deps.fretAdapter.getMyArachnodeInfo();
65
+ if (!info || info.status !== 'moving') {
66
+ return { reconciled: false };
67
+ }
68
+ // Never released → still responsible for the old range. Resume serving it, active at the old ring.
69
+ this.deps.fretAdapter.setArachnodeInfo(info.moveFrom
70
+ ? this.clearMove({ ...info, ringDepth: info.moveFrom.ringDepth, partition: info.moveFrom.partition, status: 'active' })
71
+ : this.clearMove({ ...info, status: 'active' }));
72
+ log('reconcile:resumed-old-range ring=%d', info.moveFrom?.ringDepth ?? info.ringDepth);
73
+ return { reconciled: true };
74
+ }
75
+ // --- Move-out: advertise → confirm → release ---
76
+ async moveOut(newRingDepth, oldInfo) {
77
+ const oldRing = oldInfo?.ringDepth ?? 0;
78
+ // Phase A — advertise the target ring; KEEP serving the old range (moveFrom retains it).
79
+ const target = await this.deps.ringSelector.createArachnodeInfo(this.deps.selfPeerId, newRingDepth);
80
+ const movingInfo = {
81
+ ...target,
82
+ status: 'moving',
83
+ moveFrom: { ringDepth: oldRing, partition: oldInfo?.partition }
84
+ };
85
+ this.deps.fretAdapter.setArachnodeInfo(movingInfo);
86
+ log('phaseA:advertise from=%d to=%d', oldRing, newRingDepth);
87
+ // Phase B — confirm the shed range replicated to ≥ N qualifying holders.
88
+ const shed = await this.computeShedBlocks(oldInfo?.partition, target.partition);
89
+ const floor = Math.max(1, this.deps.getFloor());
90
+ const confirm = await this.confirmShedRange(shed, floor);
91
+ if (!confirm.ok) {
92
+ this.rollback(oldInfo);
93
+ log('phaseB:abort reason=%s → rolled-back to ring=%d', confirm.reason, oldRing);
94
+ return { status: 'rolled-back', ring: oldRing, reason: confirm.reason };
95
+ }
96
+ // Phase C — release: active at the new ring (moveFrom cleared), stop serving/spreading + GC-eligible.
97
+ this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...target, status: 'active' }));
98
+ if (shed.length > 0) {
99
+ this.deps.onRelease(shed);
100
+ }
101
+ log('phaseC:release from=%d to=%d shed=%d', oldRing, newRingDepth, shed.length);
102
+ return { status: 'moved-out', from: oldRing, to: newRingDepth, released: shed };
103
+ }
104
+ /**
105
+ * Phase B. Succeeds only when EVERY shed block is confirmed replicated to ≥ `floor` qualifying
106
+ * holders. Aborts (no partial release) on a detected partition or any unconfirmed block.
107
+ */
108
+ async confirmShedRange(shed, floor) {
109
+ if (shed.length === 0) {
110
+ return { ok: true }; // nothing to shed → trivially safe
111
+ }
112
+ if (this.deps.partitionDetector.detectPartition()) {
113
+ return { ok: false, reason: 'partition' };
114
+ }
115
+ const owners = await this.buildOwnersMap(shed, floor);
116
+ const { confirmed, unconfirmed } = await this.deps.confirmer.confirmReplicated(shed, owners, floor);
117
+ if (unconfirmed.length > 0) {
118
+ return { ok: false, reason: `unconfirmed:${unconfirmed.length}` };
119
+ }
120
+ // Belt-and-suspenders: every shed block must be in `confirmed` before any is released.
121
+ return confirmed.length === shed.length ? { ok: true } : { ok: false, reason: 'incomplete' };
122
+ }
123
+ /**
124
+ * The shed range: blocks this node holds that its OLD partition covers but its NEW (target)
125
+ * partition does not — i.e. the half of its slice it stops covering by moving out.
126
+ */
127
+ async computeShedBlocks(oldPartition, newPartition) {
128
+ const shed = [];
129
+ for (const blockId of this.deps.ownedBlocks) {
130
+ const coord = await hashKey(textEncoder.encode(blockId));
131
+ if (partitionCovers(oldPartition, coord) && !partitionCovers(newPartition, coord)) {
132
+ shed.push(blockId);
133
+ }
134
+ }
135
+ return shed;
136
+ }
137
+ /**
138
+ * For each shed block, the qualifying post-move holders to confirm against: the FRET cohort around
139
+ * the block minus self and minus any peer that does not still cover the block under its OWN
140
+ * advertised (target) partition — which excludes same-range movers ({@link qualifiesForFloor}). A
141
+ * candidate with no advertised Arachnode info is excluded (its coverage cannot be verified).
142
+ */
143
+ async buildOwnersMap(shed, floor) {
144
+ const owners = new Map();
145
+ const want = floor + 1 + this.candidateMargin; // headroom for self + excluded movers
146
+ for (const blockId of shed) {
147
+ const coord = await hashKey(textEncoder.encode(blockId));
148
+ const cohort = this.deps.fret.assembleCohort(coord, want);
149
+ const qualifying = cohort.filter(peerId => {
150
+ if (peerId === this.deps.selfPeerId)
151
+ return false;
152
+ const info = this.deps.fretAdapter.getArachnodeInfo(peerId);
153
+ return info !== undefined && qualifiesForFloor(info, coord);
154
+ });
155
+ owners.set(blockId, qualifying);
156
+ }
157
+ return owners;
158
+ }
159
+ // --- Move-in: advertise only (sheds nothing) ---
160
+ async moveIn(newRingDepth, oldInfo) {
161
+ const oldRing = oldInfo?.ringDepth ?? 0;
162
+ // Sheds nothing: advertise the (broader) inner ring directly at `active`. The gained half is
163
+ // pulled by the restoration / rebalance path; the old holders keep serving until THEY confirm
164
+ // their own release, so the floor is never at risk from this mover.
165
+ const target = await this.deps.ringSelector.createArachnodeInfo(this.deps.selfPeerId, newRingDepth);
166
+ this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...target, status: 'active' }));
167
+ log('moveIn:advertise from=%d to=%d', oldRing, newRingDepth);
168
+ return { status: 'moved-in', from: oldRing, to: newRingDepth };
169
+ }
170
+ // --- Shared helpers ---
171
+ /** Restore the pre-move advertisement (old ring/partition, `active`, no `moveFrom`). */
172
+ rollback(oldInfo) {
173
+ if (oldInfo) {
174
+ this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...oldInfo, status: 'active' }));
175
+ return;
176
+ }
177
+ // No prior advertisement: the node's "old range" is the whole keyspace (ring 0). Phase A has
178
+ // already replaced self with the `moving` target, so a bare `setStatus('active')` would leave
179
+ // the aborted NARROWER target ring/partition (and a stray `moveFrom`) advertised as active —
180
+ // dropping coverage of the shed range with nothing confirmed elsewhere, the exact floor
181
+ // violation this handoff exists to prevent. Restore a CLEAN active ring-0 advertisement instead.
182
+ const cur = this.deps.fretAdapter.getMyArachnodeInfo();
183
+ if (cur) {
184
+ this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...cur, ringDepth: 0, partition: undefined, status: 'active' }));
185
+ }
186
+ else {
187
+ this.deps.fretAdapter.setStatus('active');
188
+ }
189
+ }
190
+ /** Strip the transient `moveFrom` field so an `active` advertisement never carries move state. */
191
+ clearMove(info) {
192
+ const { moveFrom: _drop, ...rest } = info;
193
+ return rest;
194
+ }
195
+ }
196
+ //# sourceMappingURL=ring-shift-coordinator.js.map