@optimystic/db-p2p 1.0.0-beta.3 → 1.0.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 (234) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +73 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +70 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +16 -1
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +73 -72
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts +4 -6
  10. package/dist/src/cluster/client.d.ts.map +1 -1
  11. package/dist/src/cluster/client.js +8 -13
  12. package/dist/src/cluster/client.js.map +1 -1
  13. package/dist/src/cluster/cluster-policy.d.ts +2 -1
  14. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  15. package/dist/src/cluster/cluster-policy.js.map +1 -1
  16. package/dist/src/cluster/cluster-repo.d.ts +126 -6
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +378 -50
  19. package/dist/src/cluster/cluster-repo.js.map +1 -1
  20. package/dist/src/cluster/commit-proof.js +2 -2
  21. package/dist/src/cluster/race-resolution.d.ts +51 -7
  22. package/dist/src/cluster/race-resolution.d.ts.map +1 -1
  23. package/dist/src/cluster/race-resolution.js +71 -7
  24. package/dist/src/cluster/race-resolution.js.map +1 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts +67 -5
  26. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  27. package/dist/src/cluster/rebalance-monitor.js +120 -26
  28. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  29. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  30. package/dist/src/cluster/spread-on-churn.js +34 -47
  31. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  32. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  33. package/dist/src/cohort-topic/host.js +8 -6
  34. package/dist/src/cohort-topic/host.js.map +1 -1
  35. package/dist/src/cohort-topic/membership-source.js +1 -1
  36. package/dist/src/cohort-topic/membership-source.js.map +1 -1
  37. package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -1
  38. package/dist/src/cohort-topic/peer-codec.js +5 -2
  39. package/dist/src/cohort-topic/peer-codec.js.map +1 -1
  40. package/dist/src/cohort-topic/stream-util.d.ts +24 -3
  41. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  42. package/dist/src/cohort-topic/stream-util.js +36 -3
  43. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  44. package/dist/src/cohort-topic/topic-router.d.ts +9 -0
  45. package/dist/src/cohort-topic/topic-router.d.ts.map +1 -1
  46. package/dist/src/cohort-topic/topic-router.js +12 -2
  47. package/dist/src/cohort-topic/topic-router.js.map +1 -1
  48. package/dist/src/dispute/client.d.ts.map +1 -1
  49. package/dist/src/dispute/client.js +15 -3
  50. package/dist/src/dispute/client.js.map +1 -1
  51. package/dist/src/index.d.ts +5 -0
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +5 -0
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/libp2p-key-network.d.ts +78 -17
  56. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  57. package/dist/src/libp2p-key-network.js +234 -147
  58. package/dist/src/libp2p-key-network.js.map +1 -1
  59. package/dist/src/libp2p-node-base.d.ts +35 -1
  60. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  61. package/dist/src/libp2p-node-base.js +151 -26
  62. package/dist/src/libp2p-node-base.js.map +1 -1
  63. package/dist/src/libp2p-node-rn.d.ts +2 -2
  64. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  65. package/dist/src/libp2p-node-rn.js.map +1 -1
  66. package/dist/src/libp2p-node.d.ts +2 -2
  67. package/dist/src/libp2p-node.d.ts.map +1 -1
  68. package/dist/src/libp2p-node.js.map +1 -1
  69. package/dist/src/logger.d.ts.map +1 -1
  70. package/dist/src/logger.js +6 -1
  71. package/dist/src/logger.js.map +1 -1
  72. package/dist/src/matchmaking/query-transport.d.ts +3 -4
  73. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  74. package/dist/src/matchmaking/query-transport.js +9 -9
  75. package/dist/src/matchmaking/query-transport.js.map +1 -1
  76. package/dist/src/network/network-manager-service.d.ts +10 -5
  77. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  78. package/dist/src/network/network-manager-service.js +11 -2
  79. package/dist/src/network/network-manager-service.js.map +1 -1
  80. package/dist/src/network/relay-reservation.d.ts +170 -0
  81. package/dist/src/network/relay-reservation.d.ts.map +1 -0
  82. package/dist/src/network/relay-reservation.js +527 -0
  83. package/dist/src/network/relay-reservation.js.map +1 -0
  84. package/dist/src/pend-validation.d.ts +3 -2
  85. package/dist/src/pend-validation.d.ts.map +1 -1
  86. package/dist/src/pend-validation.js +3 -2
  87. package/dist/src/pend-validation.js.map +1 -1
  88. package/dist/src/protocol-client.d.ts +8 -1
  89. package/dist/src/protocol-client.d.ts.map +1 -1
  90. package/dist/src/protocol-client.js +11 -0
  91. package/dist/src/protocol-client.js.map +1 -1
  92. package/dist/src/reactivity/forwarder-host.d.ts +2 -1
  93. package/dist/src/reactivity/forwarder-host.d.ts.map +1 -1
  94. package/dist/src/reactivity/forwarder-host.js +2 -1
  95. package/dist/src/reactivity/forwarder-host.js.map +1 -1
  96. package/dist/src/reactivity/origination-manager.d.ts +3 -3
  97. package/dist/src/reactivity/origination-manager.js +3 -3
  98. package/dist/src/reactivity/recover-transport.d.ts +22 -14
  99. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  100. package/dist/src/reactivity/recover-transport.js +32 -19
  101. package/dist/src/reactivity/recover-transport.js.map +1 -1
  102. package/dist/src/reactivity/subscription-manager.d.ts +2 -2
  103. package/dist/src/reactivity/topic-bytes.d.ts +6 -7
  104. package/dist/src/reactivity/topic-bytes.d.ts.map +1 -1
  105. package/dist/src/reactivity/topic-bytes.js +8 -9
  106. package/dist/src/reactivity/topic-bytes.js.map +1 -1
  107. package/dist/src/repo/client.d.ts +5 -2
  108. package/dist/src/repo/client.d.ts.map +1 -1
  109. package/dist/src/repo/client.js +49 -40
  110. package/dist/src/repo/client.js.map +1 -1
  111. package/dist/src/repo/cluster-coordinator.d.ts +77 -5
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +131 -20
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +238 -122
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +625 -472
  118. package/dist/src/repo/coordinator-repo.js.map +1 -1
  119. package/dist/src/repo/i-under-replication-ledger.d.ts +87 -0
  120. package/dist/src/repo/i-under-replication-ledger.d.ts.map +1 -0
  121. package/dist/src/repo/i-under-replication-ledger.js +2 -0
  122. package/dist/src/repo/i-under-replication-ledger.js.map +1 -0
  123. package/dist/src/repo/kv-under-replication-ledger.d.ts +91 -0
  124. package/dist/src/repo/kv-under-replication-ledger.d.ts.map +1 -0
  125. package/dist/src/repo/kv-under-replication-ledger.js +287 -0
  126. package/dist/src/repo/kv-under-replication-ledger.js.map +1 -0
  127. package/dist/src/repo/responsibility.d.ts +37 -0
  128. package/dist/src/repo/responsibility.d.ts.map +1 -0
  129. package/dist/src/repo/responsibility.js +37 -0
  130. package/dist/src/repo/responsibility.js.map +1 -0
  131. package/dist/src/repo/service.d.ts +44 -15
  132. package/dist/src/repo/service.d.ts.map +1 -1
  133. package/dist/src/repo/service.js +64 -27
  134. package/dist/src/repo/service.js.map +1 -1
  135. package/dist/src/repo/stuck-reservation.d.ts +127 -0
  136. package/dist/src/repo/stuck-reservation.d.ts.map +1 -0
  137. package/dist/src/repo/stuck-reservation.js +179 -0
  138. package/dist/src/repo/stuck-reservation.js.map +1 -0
  139. package/dist/src/repo/under-replication-drain.d.ts +208 -0
  140. package/dist/src/repo/under-replication-drain.d.ts.map +1 -0
  141. package/dist/src/repo/under-replication-drain.js +461 -0
  142. package/dist/src/repo/under-replication-drain.js.map +1 -0
  143. package/dist/src/rn.d.ts +5 -0
  144. package/dist/src/rn.d.ts.map +1 -1
  145. package/dist/src/rn.js +5 -0
  146. package/dist/src/rn.js.map +1 -1
  147. package/dist/src/storage/block-latch.d.ts +3 -2
  148. package/dist/src/storage/block-latch.d.ts.map +1 -1
  149. package/dist/src/storage/block-latch.js +7 -4
  150. package/dist/src/storage/block-latch.js.map +1 -1
  151. package/dist/src/storage/block-storage.d.ts +45 -3
  152. package/dist/src/storage/block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/block-storage.js +156 -9
  154. package/dist/src/storage/block-storage.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +57 -5
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js.map +1 -1
  158. package/dist/src/storage/pending-claim.d.ts +167 -0
  159. package/dist/src/storage/pending-claim.d.ts.map +1 -0
  160. package/dist/src/storage/pending-claim.js +156 -0
  161. package/dist/src/storage/pending-claim.js.map +1 -0
  162. package/dist/src/storage/raw-store-codec.d.ts +13 -0
  163. package/dist/src/storage/raw-store-codec.d.ts.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +24 -3
  165. package/dist/src/storage/raw-store-codec.js.map +1 -1
  166. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -1
  167. package/dist/src/storage/restoration-coordinator.js +2 -1
  168. package/dist/src/storage/restoration-coordinator.js.map +1 -1
  169. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -1
  170. package/dist/src/storage/ring-shift-coordinator.js +3 -3
  171. package/dist/src/storage/ring-shift-coordinator.js.map +1 -1
  172. package/dist/src/storage/storage-repo.d.ts +122 -5
  173. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  174. package/dist/src/storage/storage-repo.js +245 -68
  175. package/dist/src/storage/storage-repo.js.map +1 -1
  176. package/dist/src/storage/struct.d.ts +58 -0
  177. package/dist/src/storage/struct.d.ts.map +1 -1
  178. package/dist/src/testing/mesh-harness.d.ts +77 -11
  179. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  180. package/dist/src/testing/mesh-harness.js +226 -100
  181. package/dist/src/testing/mesh-harness.js.map +1 -1
  182. package/package.json +2 -2
  183. package/readme.md +27 -3
  184. package/src/cluster/block-transfer-service.ts +132 -4
  185. package/src/cluster/block-transfer.ts +79 -84
  186. package/src/cluster/client.ts +115 -119
  187. package/src/cluster/cluster-policy.ts +2 -1
  188. package/src/cluster/cluster-repo.ts +422 -56
  189. package/src/cluster/commit-proof.ts +2 -2
  190. package/src/cluster/race-resolution.ts +76 -7
  191. package/src/cluster/rebalance-monitor.ts +677 -539
  192. package/src/cluster/spread-on-churn.ts +34 -54
  193. package/src/cohort-topic/host.ts +8 -6
  194. package/src/cohort-topic/membership-source.ts +1 -1
  195. package/src/cohort-topic/peer-codec.ts +34 -31
  196. package/src/cohort-topic/stream-util.ts +39 -4
  197. package/src/cohort-topic/topic-router.ts +12 -2
  198. package/src/dispute/client.ts +17 -7
  199. package/src/index.ts +64 -59
  200. package/src/libp2p-key-network.ts +1377 -1260
  201. package/src/libp2p-node-base.ts +186 -27
  202. package/src/libp2p-node-rn.ts +31 -30
  203. package/src/libp2p-node.ts +37 -36
  204. package/src/logger.ts +6 -1
  205. package/src/matchmaking/query-transport.ts +9 -9
  206. package/src/network/network-manager-service.ts +16 -5
  207. package/src/network/relay-reservation.ts +636 -0
  208. package/src/pend-validation.ts +3 -2
  209. package/src/protocol-client.ts +13 -1
  210. package/src/reactivity/forwarder-host.ts +2 -1
  211. package/src/reactivity/origination-manager.ts +3 -3
  212. package/src/reactivity/recover-transport.ts +39 -22
  213. package/src/reactivity/subscription-manager.ts +2 -2
  214. package/src/reactivity/topic-bytes.ts +35 -37
  215. package/src/repo/client.ts +172 -173
  216. package/src/repo/cluster-coordinator.ts +145 -19
  217. package/src/repo/coordinator-repo.ts +652 -505
  218. package/src/repo/i-under-replication-ledger.ts +89 -0
  219. package/src/repo/kv-under-replication-ledger.ts +302 -0
  220. package/src/repo/responsibility.ts +48 -0
  221. package/src/repo/service.ts +76 -39
  222. package/src/repo/stuck-reservation.ts +224 -0
  223. package/src/repo/under-replication-drain.ts +582 -0
  224. package/src/rn.ts +74 -69
  225. package/src/storage/block-latch.ts +7 -4
  226. package/src/storage/block-storage.ts +161 -10
  227. package/src/storage/i-block-storage.ts +60 -5
  228. package/src/storage/pending-claim.ts +200 -0
  229. package/src/storage/raw-store-codec.ts +26 -3
  230. package/src/storage/restoration-coordinator.ts +212 -211
  231. package/src/storage/ring-shift-coordinator.ts +271 -271
  232. package/src/storage/storage-repo.ts +280 -72
  233. package/src/storage/struct.ts +58 -0
  234. package/src/testing/mesh-harness.ts +293 -112
@@ -1,271 +1,271 @@
1
- import { hashKey } from 'p2p-fret';
2
- import type { FretService } from 'p2p-fret';
3
- import type { PartitionDetector } from '../cluster/partition-detector.js';
4
- import type { ArachnodeFretAdapter, ArachnodeInfo } from './arachnode-fret-adapter.js';
5
- import type { RingSelector } from './ring-selector.js';
6
- import { partitionCovers, qualifiesForFloor } from './arachnode-partition.js';
7
- import { createLogger } from '../logger.js';
8
-
9
- const log = createLogger('ring-shift');
10
- const textEncoder = new TextEncoder();
11
-
12
- /**
13
- * The confirm primitive the handoff gates release on. Satisfied by `BlockTransferCoordinator`; kept
14
- * as a narrow structural interface so the coordinator is unit-testable with a stub.
15
- */
16
- export interface ReplicationConfirmer {
17
- confirmReplicated(
18
- blockIds: string[],
19
- owners: Map<string, string[]>,
20
- floor: number
21
- ): Promise<{ confirmed: string[]; unconfirmed: string[] }>;
22
- }
23
-
24
- export interface RingShiftDeps {
25
- fretAdapter: ArachnodeFretAdapter;
26
- ringSelector: RingSelector;
27
- fret: FretService;
28
- partitionDetector: PartitionDetector;
29
- /** Confirms a shed block replicated to ≥ N qualifying holders (Phase B). */
30
- confirmer: ReplicationConfirmer;
31
- /** The blocks this node physically holds — the candidate set the shed range is drawn from. */
32
- ownedBlocks: Set<string>;
33
- /** This node's own peer id string (excluded from the qualifying-holder count). */
34
- selfPeerId: string;
35
- /** The replication floor `N` (e.g. `RebalanceMonitor.getCohortSize`), read fresh per shift. */
36
- getFloor: () => number;
37
- /**
38
- * Called with the shed block ids the instant Phase C releases them. The wiring stops serving +
39
- * spreading them (untrack) and marks their local bytes GC-eligible — nothing may reclaim a shed
40
- * range before this fires.
41
- */
42
- onRelease: (blockIds: string[]) => void;
43
- }
44
-
45
- export interface RingShiftConfig {
46
- /**
47
- * How many extra candidate holders beyond the floor to request from FRET per shed block, giving
48
- * headroom to exclude self and same-range movers while still finding `N` qualifying holders.
49
- * Default 2.
50
- */
51
- candidateMargin?: number;
52
- }
53
-
54
- /** The result of a single `executeShift` — what happened and why, for logging/tests. */
55
- export type ShiftOutcome =
56
- | { status: 'moved-out'; from: number; to: number; released: string[] }
57
- | { status: 'moved-in'; from: number; to: number }
58
- | { status: 'rolled-back'; ring: number; reason: string }
59
- | { status: 'skipped'; 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 class RingShiftCoordinator {
80
- private readonly candidateMargin: number;
81
- /** Guards against a re-entrant shift if the driving interval overlaps a long confirm. */
82
- private inFlight = false;
83
-
84
- constructor(
85
- private readonly deps: RingShiftDeps,
86
- config: RingShiftConfig = {}
87
- ) {
88
- this.candidateMargin = config.candidateMargin ?? 2;
89
- }
90
-
91
- /**
92
- * Execute one single-step ring transition. No-op (`skipped`) if a shift is already in flight or the
93
- * node is already `moving`. `direction`/`newRingDepth` come straight from the damped
94
- * `shouldTransition()` trigger.
95
- */
96
- async executeShift(transition: { direction: 'in' | 'out'; newRingDepth: number }): Promise<ShiftOutcome> {
97
- if (this.inFlight) {
98
- return { status: 'skipped', reason: 'shift-in-flight' };
99
- }
100
- const current = this.deps.fretAdapter.getMyArachnodeInfo();
101
- if (current?.status === 'moving') {
102
- return { status: 'skipped', reason: 'already-moving' };
103
- }
104
-
105
- this.inFlight = true;
106
- try {
107
- return transition.direction === 'out'
108
- ? await this.moveOut(transition.newRingDepth, current)
109
- : await this.moveIn(transition.newRingDepth, current);
110
- } finally {
111
- this.inFlight = false;
112
- }
113
- }
114
-
115
- /**
116
- * Reconcile a stale `status='moving'` advertisement left by a crash between advertise (Phase A) and
117
- * release (Phase C). The crashed node never ran Phase C, so it never released the shed range and is
118
- * still responsible for its OLD range: restore that range and refresh status to `active`. A node
119
- * whose advertised status is not `moving` needs no reconciliation. Called once at startup. See
120
- * `docs/arachnode-ring-handoff.md` § Part 3 (crash mid-handoff).
121
- */
122
- reconcileOnStart(): { reconciled: boolean } {
123
- const info = this.deps.fretAdapter.getMyArachnodeInfo();
124
- if (!info || info.status !== 'moving') {
125
- return { reconciled: false };
126
- }
127
- // Never released → still responsible for the old range. Resume serving it, active at the old ring.
128
- this.deps.fretAdapter.setArachnodeInfo(
129
- info.moveFrom
130
- ? this.clearMove({ ...info, ringDepth: info.moveFrom.ringDepth, partition: info.moveFrom.partition, status: 'active' })
131
- : this.clearMove({ ...info, status: 'active' })
132
- );
133
- log('reconcile:resumed-old-range ring=%d', info.moveFrom?.ringDepth ?? info.ringDepth);
134
- return { reconciled: true };
135
- }
136
-
137
- // --- Move-out: advertise → confirm → release ---
138
-
139
- private async moveOut(newRingDepth: number, oldInfo: ArachnodeInfo | undefined): Promise<ShiftOutcome> {
140
- const oldRing = oldInfo?.ringDepth ?? 0;
141
-
142
- // Phase A — advertise the target ring; KEEP serving the old range (moveFrom retains it).
143
- const target = await this.deps.ringSelector.createArachnodeInfo(this.deps.selfPeerId, newRingDepth);
144
- const movingInfo: ArachnodeInfo = {
145
- ...target,
146
- status: 'moving',
147
- moveFrom: { ringDepth: oldRing, partition: oldInfo?.partition }
148
- };
149
- this.deps.fretAdapter.setArachnodeInfo(movingInfo);
150
- log('phaseA:advertise from=%d to=%d', oldRing, newRingDepth);
151
-
152
- // Phase B — confirm the shed range replicated to ≥ N qualifying holders.
153
- const shed = await this.computeShedBlocks(oldInfo?.partition, target.partition);
154
- const floor = Math.max(1, this.deps.getFloor());
155
- const confirm = await this.confirmShedRange(shed, floor);
156
- if (!confirm.ok) {
157
- this.rollback(oldInfo);
158
- log('phaseB:abort reason=%s → rolled-back to ring=%d', confirm.reason, oldRing);
159
- return { status: 'rolled-back', ring: oldRing, reason: confirm.reason };
160
- }
161
-
162
- // Phase C — release: active at the new ring (moveFrom cleared), stop serving/spreading + GC-eligible.
163
- this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...target, status: 'active' }));
164
- if (shed.length > 0) {
165
- this.deps.onRelease(shed);
166
- }
167
- log('phaseC:release from=%d to=%d shed=%d', oldRing, newRingDepth, shed.length);
168
- return { status: 'moved-out', from: oldRing, to: newRingDepth, released: shed };
169
- }
170
-
171
- /**
172
- * Phase B. Succeeds only when EVERY shed block is confirmed replicated to ≥ `floor` qualifying
173
- * holders. Aborts (no partial release) on a detected partition or any unconfirmed block.
174
- */
175
- private async confirmShedRange(shed: string[], floor: number): Promise<{ ok: true } | { ok: false; reason: string }> {
176
- if (shed.length === 0) {
177
- return { ok: true }; // nothing to shed → trivially safe
178
- }
179
- if (this.deps.partitionDetector.detectPartition()) {
180
- return { ok: false, reason: 'partition' };
181
- }
182
-
183
- const owners = await this.buildOwnersMap(shed, floor);
184
- const { confirmed, unconfirmed } = await this.deps.confirmer.confirmReplicated(shed, owners, floor);
185
- if (unconfirmed.length > 0) {
186
- return { ok: false, reason: `unconfirmed:${unconfirmed.length}` };
187
- }
188
- // Belt-and-suspenders: every shed block must be in `confirmed` before any is released.
189
- return confirmed.length === shed.length ? { ok: true } : { ok: false, reason: 'incomplete' };
190
- }
191
-
192
- /**
193
- * The shed range: blocks this node holds that its OLD partition covers but its NEW (target)
194
- * partition does not — i.e. the half of its slice it stops covering by moving out.
195
- */
196
- private async computeShedBlocks(
197
- oldPartition: ArachnodeInfo['partition'],
198
- newPartition: ArachnodeInfo['partition']
199
- ): Promise<string[]> {
200
- const shed: string[] = [];
201
- for (const blockId of this.deps.ownedBlocks) {
202
- const coord = await hashKey(textEncoder.encode(blockId));
203
- if (partitionCovers(oldPartition, coord) && !partitionCovers(newPartition, coord)) {
204
- shed.push(blockId);
205
- }
206
- }
207
- return shed;
208
- }
209
-
210
- /**
211
- * For each shed block, the qualifying post-move holders to confirm against: the FRET cohort around
212
- * the block minus self and minus any peer that does not still cover the block under its OWN
213
- * advertised (target) partition — which excludes same-range movers ({@link qualifiesForFloor}). A
214
- * candidate with no advertised Arachnode info is excluded (its coverage cannot be verified).
215
- */
216
- private async buildOwnersMap(shed: string[], floor: number): Promise<Map<string, string[]>> {
217
- const owners = new Map<string, string[]>();
218
- const want = floor + 1 + this.candidateMargin; // headroom for self + excluded movers
219
- for (const blockId of shed) {
220
- const coord = await hashKey(textEncoder.encode(blockId));
221
- const cohort = this.deps.fret.assembleCohort(coord, want);
222
- const qualifying = cohort.filter(peerId => {
223
- if (peerId === this.deps.selfPeerId) return false;
224
- const info = this.deps.fretAdapter.getArachnodeInfo(peerId);
225
- return info !== undefined && qualifiesForFloor(info, coord);
226
- });
227
- owners.set(blockId, qualifying);
228
- }
229
- return owners;
230
- }
231
-
232
- // --- Move-in: advertise only (sheds nothing) ---
233
-
234
- private async moveIn(newRingDepth: number, oldInfo: ArachnodeInfo | undefined): Promise<ShiftOutcome> {
235
- const oldRing = oldInfo?.ringDepth ?? 0;
236
- // Sheds nothing: advertise the (broader) inner ring directly at `active`. The gained half is
237
- // pulled by the restoration / rebalance path; the old holders keep serving until THEY confirm
238
- // their own release, so the floor is never at risk from this mover.
239
- const target = await this.deps.ringSelector.createArachnodeInfo(this.deps.selfPeerId, newRingDepth);
240
- this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...target, status: 'active' }));
241
- log('moveIn:advertise from=%d to=%d', oldRing, newRingDepth);
242
- return { status: 'moved-in', from: oldRing, to: newRingDepth };
243
- }
244
-
245
- // --- Shared helpers ---
246
-
247
- /** Restore the pre-move advertisement (old ring/partition, `active`, no `moveFrom`). */
248
- private rollback(oldInfo: ArachnodeInfo | undefined): void {
249
- if (oldInfo) {
250
- this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...oldInfo, status: 'active' }));
251
- return;
252
- }
253
- // No prior advertisement: the node's "old range" is the whole keyspace (ring 0). Phase A has
254
- // already replaced self with the `moving` target, so a bare `setStatus('active')` would leave
255
- // the aborted NARROWER target ring/partition (and a stray `moveFrom`) advertised as active —
256
- // dropping coverage of the shed range with nothing confirmed elsewhere, the exact floor
257
- // violation this handoff exists to prevent. Restore a CLEAN active ring-0 advertisement instead.
258
- const cur = this.deps.fretAdapter.getMyArachnodeInfo();
259
- if (cur) {
260
- this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...cur, ringDepth: 0, partition: undefined, status: 'active' }));
261
- } else {
262
- this.deps.fretAdapter.setStatus('active');
263
- }
264
- }
265
-
266
- /** Strip the transient `moveFrom` field so an `active` advertisement never carries move state. */
267
- private clearMove(info: ArachnodeInfo): ArachnodeInfo {
268
- const { moveFrom: _drop, ...rest } = info;
269
- return rest;
270
- }
271
- }
1
+ import { hashKey } from 'p2p-fret';
2
+ import type { FretService } from 'p2p-fret';
3
+ import { routingKeyForBlock } from '@optimystic/db-core';
4
+ import type { PartitionDetector } from '../cluster/partition-detector.js';
5
+ import type { ArachnodeFretAdapter, ArachnodeInfo } from './arachnode-fret-adapter.js';
6
+ import type { RingSelector } from './ring-selector.js';
7
+ import { partitionCovers, qualifiesForFloor } from './arachnode-partition.js';
8
+ import { createLogger } from '../logger.js';
9
+
10
+ const log = createLogger('ring-shift');
11
+
12
+ /**
13
+ * The confirm primitive the handoff gates release on. Satisfied by `BlockTransferCoordinator`; kept
14
+ * as a narrow structural interface so the coordinator is unit-testable with a stub.
15
+ */
16
+ export interface ReplicationConfirmer {
17
+ confirmReplicated(
18
+ blockIds: string[],
19
+ owners: Map<string, string[]>,
20
+ floor: number
21
+ ): Promise<{ confirmed: string[]; unconfirmed: string[] }>;
22
+ }
23
+
24
+ export interface RingShiftDeps {
25
+ fretAdapter: ArachnodeFretAdapter;
26
+ ringSelector: RingSelector;
27
+ fret: FretService;
28
+ partitionDetector: PartitionDetector;
29
+ /** Confirms a shed block replicated to ≥ N qualifying holders (Phase B). */
30
+ confirmer: ReplicationConfirmer;
31
+ /** The blocks this node physically holds — the candidate set the shed range is drawn from. */
32
+ ownedBlocks: Set<string>;
33
+ /** This node's own peer id string (excluded from the qualifying-holder count). */
34
+ selfPeerId: string;
35
+ /** The replication floor `N` (e.g. `RebalanceMonitor.getCohortSize`), read fresh per shift. */
36
+ getFloor: () => number;
37
+ /**
38
+ * Called with the shed block ids the instant Phase C releases them. The wiring stops serving +
39
+ * spreading them (untrack) and marks their local bytes GC-eligible — nothing may reclaim a shed
40
+ * range before this fires.
41
+ */
42
+ onRelease: (blockIds: string[]) => void;
43
+ }
44
+
45
+ export interface RingShiftConfig {
46
+ /**
47
+ * How many extra candidate holders beyond the floor to request from FRET per shed block, giving
48
+ * headroom to exclude self and same-range movers while still finding `N` qualifying holders.
49
+ * Default 2.
50
+ */
51
+ candidateMargin?: number;
52
+ }
53
+
54
+ /** The result of a single `executeShift` — what happened and why, for logging/tests. */
55
+ export type ShiftOutcome =
56
+ | { status: 'moved-out'; from: number; to: number; released: string[] }
57
+ | { status: 'moved-in'; from: number; to: number }
58
+ | { status: 'rolled-back'; ring: number; reason: string }
59
+ | { status: 'skipped'; 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 class RingShiftCoordinator {
80
+ private readonly candidateMargin: number;
81
+ /** Guards against a re-entrant shift if the driving interval overlaps a long confirm. */
82
+ private inFlight = false;
83
+
84
+ constructor(
85
+ private readonly deps: RingShiftDeps,
86
+ config: RingShiftConfig = {}
87
+ ) {
88
+ this.candidateMargin = config.candidateMargin ?? 2;
89
+ }
90
+
91
+ /**
92
+ * Execute one single-step ring transition. No-op (`skipped`) if a shift is already in flight or the
93
+ * node is already `moving`. `direction`/`newRingDepth` come straight from the damped
94
+ * `shouldTransition()` trigger.
95
+ */
96
+ async executeShift(transition: { direction: 'in' | 'out'; newRingDepth: number }): Promise<ShiftOutcome> {
97
+ if (this.inFlight) {
98
+ return { status: 'skipped', reason: 'shift-in-flight' };
99
+ }
100
+ const current = this.deps.fretAdapter.getMyArachnodeInfo();
101
+ if (current?.status === 'moving') {
102
+ return { status: 'skipped', reason: 'already-moving' };
103
+ }
104
+
105
+ this.inFlight = true;
106
+ try {
107
+ return transition.direction === 'out'
108
+ ? await this.moveOut(transition.newRingDepth, current)
109
+ : await this.moveIn(transition.newRingDepth, current);
110
+ } finally {
111
+ this.inFlight = false;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Reconcile a stale `status='moving'` advertisement left by a crash between advertise (Phase A) and
117
+ * release (Phase C). The crashed node never ran Phase C, so it never released the shed range and is
118
+ * still responsible for its OLD range: restore that range and refresh status to `active`. A node
119
+ * whose advertised status is not `moving` needs no reconciliation. Called once at startup. See
120
+ * `docs/arachnode-ring-handoff.md` § Part 3 (crash mid-handoff).
121
+ */
122
+ reconcileOnStart(): { reconciled: boolean } {
123
+ const info = this.deps.fretAdapter.getMyArachnodeInfo();
124
+ if (!info || info.status !== 'moving') {
125
+ return { reconciled: false };
126
+ }
127
+ // Never released → still responsible for the old range. Resume serving it, active at the old ring.
128
+ this.deps.fretAdapter.setArachnodeInfo(
129
+ info.moveFrom
130
+ ? this.clearMove({ ...info, ringDepth: info.moveFrom.ringDepth, partition: info.moveFrom.partition, status: 'active' })
131
+ : this.clearMove({ ...info, status: 'active' })
132
+ );
133
+ log('reconcile:resumed-old-range ring=%d', info.moveFrom?.ringDepth ?? info.ringDepth);
134
+ return { reconciled: true };
135
+ }
136
+
137
+ // --- Move-out: advertise → confirm → release ---
138
+
139
+ private async moveOut(newRingDepth: number, oldInfo: ArachnodeInfo | undefined): Promise<ShiftOutcome> {
140
+ const oldRing = oldInfo?.ringDepth ?? 0;
141
+
142
+ // Phase A — advertise the target ring; KEEP serving the old range (moveFrom retains it).
143
+ const target = await this.deps.ringSelector.createArachnodeInfo(this.deps.selfPeerId, newRingDepth);
144
+ const movingInfo: ArachnodeInfo = {
145
+ ...target,
146
+ status: 'moving',
147
+ moveFrom: { ringDepth: oldRing, partition: oldInfo?.partition }
148
+ };
149
+ this.deps.fretAdapter.setArachnodeInfo(movingInfo);
150
+ log('phaseA:advertise from=%d to=%d', oldRing, newRingDepth);
151
+
152
+ // Phase B — confirm the shed range replicated to ≥ N qualifying holders.
153
+ const shed = await this.computeShedBlocks(oldInfo?.partition, target.partition);
154
+ const floor = Math.max(1, this.deps.getFloor());
155
+ const confirm = await this.confirmShedRange(shed, floor);
156
+ if (!confirm.ok) {
157
+ this.rollback(oldInfo);
158
+ log('phaseB:abort reason=%s → rolled-back to ring=%d', confirm.reason, oldRing);
159
+ return { status: 'rolled-back', ring: oldRing, reason: confirm.reason };
160
+ }
161
+
162
+ // Phase C — release: active at the new ring (moveFrom cleared), stop serving/spreading + GC-eligible.
163
+ this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...target, status: 'active' }));
164
+ if (shed.length > 0) {
165
+ this.deps.onRelease(shed);
166
+ }
167
+ log('phaseC:release from=%d to=%d shed=%d', oldRing, newRingDepth, shed.length);
168
+ return { status: 'moved-out', from: oldRing, to: newRingDepth, released: shed };
169
+ }
170
+
171
+ /**
172
+ * Phase B. Succeeds only when EVERY shed block is confirmed replicated to ≥ `floor` qualifying
173
+ * holders. Aborts (no partial release) on a detected partition or any unconfirmed block.
174
+ */
175
+ private async confirmShedRange(shed: string[], floor: number): Promise<{ ok: true } | { ok: false; reason: string }> {
176
+ if (shed.length === 0) {
177
+ return { ok: true }; // nothing to shed → trivially safe
178
+ }
179
+ if (this.deps.partitionDetector.detectPartition()) {
180
+ return { ok: false, reason: 'partition' };
181
+ }
182
+
183
+ const owners = await this.buildOwnersMap(shed, floor);
184
+ const { confirmed, unconfirmed } = await this.deps.confirmer.confirmReplicated(shed, owners, floor);
185
+ if (unconfirmed.length > 0) {
186
+ return { ok: false, reason: `unconfirmed:${unconfirmed.length}` };
187
+ }
188
+ // Belt-and-suspenders: every shed block must be in `confirmed` before any is released.
189
+ return confirmed.length === shed.length ? { ok: true } : { ok: false, reason: 'incomplete' };
190
+ }
191
+
192
+ /**
193
+ * The shed range: blocks this node holds that its OLD partition covers but its NEW (target)
194
+ * partition does not — i.e. the half of its slice it stops covering by moving out.
195
+ */
196
+ private async computeShedBlocks(
197
+ oldPartition: ArachnodeInfo['partition'],
198
+ newPartition: ArachnodeInfo['partition']
199
+ ): Promise<string[]> {
200
+ const shed: string[] = [];
201
+ for (const blockId of this.deps.ownedBlocks) {
202
+ const coord = await hashKey(routingKeyForBlock(blockId));
203
+ if (partitionCovers(oldPartition, coord) && !partitionCovers(newPartition, coord)) {
204
+ shed.push(blockId);
205
+ }
206
+ }
207
+ return shed;
208
+ }
209
+
210
+ /**
211
+ * For each shed block, the qualifying post-move holders to confirm against: the FRET cohort around
212
+ * the block minus self and minus any peer that does not still cover the block under its OWN
213
+ * advertised (target) partition — which excludes same-range movers ({@link qualifiesForFloor}). A
214
+ * candidate with no advertised Arachnode info is excluded (its coverage cannot be verified).
215
+ */
216
+ private async buildOwnersMap(shed: string[], floor: number): Promise<Map<string, string[]>> {
217
+ const owners = new Map<string, string[]>();
218
+ const want = floor + 1 + this.candidateMargin; // headroom for self + excluded movers
219
+ for (const blockId of shed) {
220
+ const coord = await hashKey(routingKeyForBlock(blockId));
221
+ const cohort = this.deps.fret.assembleCohort(coord, want);
222
+ const qualifying = cohort.filter(peerId => {
223
+ if (peerId === this.deps.selfPeerId) return false;
224
+ const info = this.deps.fretAdapter.getArachnodeInfo(peerId);
225
+ return info !== undefined && qualifiesForFloor(info, coord);
226
+ });
227
+ owners.set(blockId, qualifying);
228
+ }
229
+ return owners;
230
+ }
231
+
232
+ // --- Move-in: advertise only (sheds nothing) ---
233
+
234
+ private async moveIn(newRingDepth: number, oldInfo: ArachnodeInfo | undefined): Promise<ShiftOutcome> {
235
+ const oldRing = oldInfo?.ringDepth ?? 0;
236
+ // Sheds nothing: advertise the (broader) inner ring directly at `active`. The gained half is
237
+ // pulled by the restoration / rebalance path; the old holders keep serving until THEY confirm
238
+ // their own release, so the floor is never at risk from this mover.
239
+ const target = await this.deps.ringSelector.createArachnodeInfo(this.deps.selfPeerId, newRingDepth);
240
+ this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...target, status: 'active' }));
241
+ log('moveIn:advertise from=%d to=%d', oldRing, newRingDepth);
242
+ return { status: 'moved-in', from: oldRing, to: newRingDepth };
243
+ }
244
+
245
+ // --- Shared helpers ---
246
+
247
+ /** Restore the pre-move advertisement (old ring/partition, `active`, no `moveFrom`). */
248
+ private rollback(oldInfo: ArachnodeInfo | undefined): void {
249
+ if (oldInfo) {
250
+ this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...oldInfo, status: 'active' }));
251
+ return;
252
+ }
253
+ // No prior advertisement: the node's "old range" is the whole keyspace (ring 0). Phase A has
254
+ // already replaced self with the `moving` target, so a bare `setStatus('active')` would leave
255
+ // the aborted NARROWER target ring/partition (and a stray `moveFrom`) advertised as active —
256
+ // dropping coverage of the shed range with nothing confirmed elsewhere, the exact floor
257
+ // violation this handoff exists to prevent. Restore a CLEAN active ring-0 advertisement instead.
258
+ const cur = this.deps.fretAdapter.getMyArachnodeInfo();
259
+ if (cur) {
260
+ this.deps.fretAdapter.setArachnodeInfo(this.clearMove({ ...cur, ringDepth: 0, partition: undefined, status: 'active' }));
261
+ } else {
262
+ this.deps.fretAdapter.setStatus('active');
263
+ }
264
+ }
265
+
266
+ /** Strip the transient `moveFrom` field so an `active` advertisement never carries move state. */
267
+ private clearMove(info: ArachnodeInfo): ArachnodeInfo {
268
+ const { moveFrom: _drop, ...rest } = info;
269
+ return rest;
270
+ }
271
+ }