@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.
- package/dist/src/cluster/block-transfer-service.d.ts +73 -1
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +70 -3
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/block-transfer.d.ts +16 -1
- package/dist/src/cluster/block-transfer.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer.js +73 -72
- package/dist/src/cluster/block-transfer.js.map +1 -1
- package/dist/src/cluster/client.d.ts +4 -6
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +8 -13
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-policy.d.ts +2 -1
- package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
- package/dist/src/cluster/cluster-policy.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +126 -6
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +378 -50
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/commit-proof.js +2 -2
- package/dist/src/cluster/race-resolution.d.ts +51 -7
- package/dist/src/cluster/race-resolution.d.ts.map +1 -1
- package/dist/src/cluster/race-resolution.js +71 -7
- package/dist/src/cluster/race-resolution.js.map +1 -1
- package/dist/src/cluster/rebalance-monitor.d.ts +67 -5
- package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
- package/dist/src/cluster/rebalance-monitor.js +120 -26
- package/dist/src/cluster/rebalance-monitor.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +34 -47
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +8 -6
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/cohort-topic/membership-source.js +1 -1
- package/dist/src/cohort-topic/membership-source.js.map +1 -1
- package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -1
- package/dist/src/cohort-topic/peer-codec.js +5 -2
- package/dist/src/cohort-topic/peer-codec.js.map +1 -1
- package/dist/src/cohort-topic/stream-util.d.ts +24 -3
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +36 -3
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/cohort-topic/topic-router.d.ts +9 -0
- package/dist/src/cohort-topic/topic-router.d.ts.map +1 -1
- package/dist/src/cohort-topic/topic-router.js +12 -2
- package/dist/src/cohort-topic/topic-router.js.map +1 -1
- package/dist/src/dispute/client.d.ts.map +1 -1
- package/dist/src/dispute/client.js +15 -3
- package/dist/src/dispute/client.js.map +1 -1
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +78 -17
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +234 -147
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +35 -1
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +151 -26
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/libp2p-node-rn.d.ts +2 -2
- package/dist/src/libp2p-node-rn.d.ts.map +1 -1
- package/dist/src/libp2p-node-rn.js.map +1 -1
- package/dist/src/libp2p-node.d.ts +2 -2
- package/dist/src/libp2p-node.d.ts.map +1 -1
- package/dist/src/libp2p-node.js.map +1 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +6 -1
- package/dist/src/logger.js.map +1 -1
- package/dist/src/matchmaking/query-transport.d.ts +3 -4
- package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
- package/dist/src/matchmaking/query-transport.js +9 -9
- package/dist/src/matchmaking/query-transport.js.map +1 -1
- package/dist/src/network/network-manager-service.d.ts +10 -5
- package/dist/src/network/network-manager-service.d.ts.map +1 -1
- package/dist/src/network/network-manager-service.js +11 -2
- package/dist/src/network/network-manager-service.js.map +1 -1
- package/dist/src/network/relay-reservation.d.ts +170 -0
- package/dist/src/network/relay-reservation.d.ts.map +1 -0
- package/dist/src/network/relay-reservation.js +527 -0
- package/dist/src/network/relay-reservation.js.map +1 -0
- package/dist/src/pend-validation.d.ts +3 -2
- package/dist/src/pend-validation.d.ts.map +1 -1
- package/dist/src/pend-validation.js +3 -2
- package/dist/src/pend-validation.js.map +1 -1
- package/dist/src/protocol-client.d.ts +8 -1
- package/dist/src/protocol-client.d.ts.map +1 -1
- package/dist/src/protocol-client.js +11 -0
- package/dist/src/protocol-client.js.map +1 -1
- package/dist/src/reactivity/forwarder-host.d.ts +2 -1
- package/dist/src/reactivity/forwarder-host.d.ts.map +1 -1
- package/dist/src/reactivity/forwarder-host.js +2 -1
- package/dist/src/reactivity/forwarder-host.js.map +1 -1
- package/dist/src/reactivity/origination-manager.d.ts +3 -3
- package/dist/src/reactivity/origination-manager.js +3 -3
- package/dist/src/reactivity/recover-transport.d.ts +22 -14
- package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
- package/dist/src/reactivity/recover-transport.js +32 -19
- package/dist/src/reactivity/recover-transport.js.map +1 -1
- package/dist/src/reactivity/subscription-manager.d.ts +2 -2
- package/dist/src/reactivity/topic-bytes.d.ts +6 -7
- package/dist/src/reactivity/topic-bytes.d.ts.map +1 -1
- package/dist/src/reactivity/topic-bytes.js +8 -9
- package/dist/src/reactivity/topic-bytes.js.map +1 -1
- package/dist/src/repo/client.d.ts +5 -2
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +49 -40
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +77 -5
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +131 -20
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +238 -122
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +625 -472
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/i-under-replication-ledger.d.ts +87 -0
- package/dist/src/repo/i-under-replication-ledger.d.ts.map +1 -0
- package/dist/src/repo/i-under-replication-ledger.js +2 -0
- package/dist/src/repo/i-under-replication-ledger.js.map +1 -0
- package/dist/src/repo/kv-under-replication-ledger.d.ts +91 -0
- package/dist/src/repo/kv-under-replication-ledger.d.ts.map +1 -0
- package/dist/src/repo/kv-under-replication-ledger.js +287 -0
- package/dist/src/repo/kv-under-replication-ledger.js.map +1 -0
- package/dist/src/repo/responsibility.d.ts +37 -0
- package/dist/src/repo/responsibility.d.ts.map +1 -0
- package/dist/src/repo/responsibility.js +37 -0
- package/dist/src/repo/responsibility.js.map +1 -0
- package/dist/src/repo/service.d.ts +44 -15
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +64 -27
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/repo/stuck-reservation.d.ts +127 -0
- package/dist/src/repo/stuck-reservation.d.ts.map +1 -0
- package/dist/src/repo/stuck-reservation.js +179 -0
- package/dist/src/repo/stuck-reservation.js.map +1 -0
- package/dist/src/repo/under-replication-drain.d.ts +208 -0
- package/dist/src/repo/under-replication-drain.d.ts.map +1 -0
- package/dist/src/repo/under-replication-drain.js +461 -0
- package/dist/src/repo/under-replication-drain.js.map +1 -0
- package/dist/src/rn.d.ts +5 -0
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +5 -0
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/block-latch.d.ts +3 -2
- package/dist/src/storage/block-latch.d.ts.map +1 -1
- package/dist/src/storage/block-latch.js +7 -4
- package/dist/src/storage/block-latch.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts +45 -3
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +156 -9
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +57 -5
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-block-storage.js.map +1 -1
- package/dist/src/storage/pending-claim.d.ts +167 -0
- package/dist/src/storage/pending-claim.d.ts.map +1 -0
- package/dist/src/storage/pending-claim.js +156 -0
- package/dist/src/storage/pending-claim.js.map +1 -0
- package/dist/src/storage/raw-store-codec.d.ts +13 -0
- package/dist/src/storage/raw-store-codec.d.ts.map +1 -1
- package/dist/src/storage/raw-store-codec.js +24 -3
- package/dist/src/storage/raw-store-codec.js.map +1 -1
- package/dist/src/storage/restoration-coordinator.d.ts.map +1 -1
- package/dist/src/storage/restoration-coordinator.js +2 -1
- package/dist/src/storage/restoration-coordinator.js.map +1 -1
- package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -1
- package/dist/src/storage/ring-shift-coordinator.js +3 -3
- package/dist/src/storage/ring-shift-coordinator.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +122 -5
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +245 -68
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/storage/struct.d.ts +58 -0
- package/dist/src/storage/struct.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts +77 -11
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +226 -100
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/package.json +2 -2
- package/readme.md +27 -3
- package/src/cluster/block-transfer-service.ts +132 -4
- package/src/cluster/block-transfer.ts +79 -84
- package/src/cluster/client.ts +115 -119
- package/src/cluster/cluster-policy.ts +2 -1
- package/src/cluster/cluster-repo.ts +422 -56
- package/src/cluster/commit-proof.ts +2 -2
- package/src/cluster/race-resolution.ts +76 -7
- package/src/cluster/rebalance-monitor.ts +677 -539
- package/src/cluster/spread-on-churn.ts +34 -54
- package/src/cohort-topic/host.ts +8 -6
- package/src/cohort-topic/membership-source.ts +1 -1
- package/src/cohort-topic/peer-codec.ts +34 -31
- package/src/cohort-topic/stream-util.ts +39 -4
- package/src/cohort-topic/topic-router.ts +12 -2
- package/src/dispute/client.ts +17 -7
- package/src/index.ts +64 -59
- package/src/libp2p-key-network.ts +1377 -1260
- package/src/libp2p-node-base.ts +186 -27
- package/src/libp2p-node-rn.ts +31 -30
- package/src/libp2p-node.ts +37 -36
- package/src/logger.ts +6 -1
- package/src/matchmaking/query-transport.ts +9 -9
- package/src/network/network-manager-service.ts +16 -5
- package/src/network/relay-reservation.ts +636 -0
- package/src/pend-validation.ts +3 -2
- package/src/protocol-client.ts +13 -1
- package/src/reactivity/forwarder-host.ts +2 -1
- package/src/reactivity/origination-manager.ts +3 -3
- package/src/reactivity/recover-transport.ts +39 -22
- package/src/reactivity/subscription-manager.ts +2 -2
- package/src/reactivity/topic-bytes.ts +35 -37
- package/src/repo/client.ts +172 -173
- package/src/repo/cluster-coordinator.ts +145 -19
- package/src/repo/coordinator-repo.ts +652 -505
- package/src/repo/i-under-replication-ledger.ts +89 -0
- package/src/repo/kv-under-replication-ledger.ts +302 -0
- package/src/repo/responsibility.ts +48 -0
- package/src/repo/service.ts +76 -39
- package/src/repo/stuck-reservation.ts +224 -0
- package/src/repo/under-replication-drain.ts +582 -0
- package/src/rn.ts +74 -69
- package/src/storage/block-latch.ts +7 -4
- package/src/storage/block-storage.ts +161 -10
- package/src/storage/i-block-storage.ts +60 -5
- package/src/storage/pending-claim.ts +200 -0
- package/src/storage/raw-store-codec.ts +26 -3
- package/src/storage/restoration-coordinator.ts +212 -211
- package/src/storage/ring-shift-coordinator.ts +271 -271
- package/src/storage/storage-repo.ts +280 -72
- package/src/storage/struct.ts +58 -0
- 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
|
|
4
|
-
import type {
|
|
5
|
-
import type {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
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(
|
|
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(
|
|
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
|
+
}
|