@optimystic/db-p2p 0.21.0 → 0.24.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/client.d.ts +10 -0
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +30 -1
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-policy.d.ts +13 -2
- package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
- package/dist/src/cluster/cluster-policy.js +51 -4
- package/dist/src/cluster/cluster-policy.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +42 -17
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +229 -122
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/cluster-size-coupling.d.ts +28 -0
- package/dist/src/cluster/cluster-size-coupling.d.ts.map +1 -0
- package/dist/src/cluster/cluster-size-coupling.js +35 -0
- package/dist/src/cluster/cluster-size-coupling.js.map +1 -0
- package/dist/src/cluster/quorum-restore.d.ts +6 -0
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +1 -1
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
- package/dist/src/cluster/reconcile-block.js +15 -3
- package/dist/src/cluster/reconcile-block.js.map +1 -1
- package/dist/src/cluster/service.d.ts +32 -1
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +43 -2
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cohort-topic/stream-util.d.ts +22 -6
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +56 -10
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +9 -3
- package/dist/src/dispute/dispute-service.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 +134 -7
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +174 -37
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +3 -2
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +859 -778
- 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 +17 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +19 -2
- package/dist/src/logger.js.map +1 -1
- package/dist/src/network/network-manager-service.d.ts +2 -0
- package/dist/src/network/network-manager-service.d.ts.map +1 -1
- package/dist/src/network/network-manager-service.js +4 -0
- package/dist/src/network/network-manager-service.js.map +1 -1
- package/dist/src/optimystic-node.d.ts +35 -0
- package/dist/src/optimystic-node.d.ts.map +1 -0
- package/dist/src/optimystic-node.js +2 -0
- package/dist/src/optimystic-node.js.map +1 -0
- package/dist/src/owned-block-seed.d.ts +6 -3
- package/dist/src/owned-block-seed.d.ts.map +1 -1
- package/dist/src/owned-block-seed.js +16 -3
- package/dist/src/owned-block-seed.js.map +1 -1
- package/dist/src/peer-address-book.d.ts +72 -0
- package/dist/src/peer-address-book.d.ts.map +1 -0
- package/dist/src/peer-address-book.js +123 -0
- package/dist/src/peer-address-book.js.map +1 -0
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +11 -2
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +30 -0
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +95 -3
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +78 -14
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +266 -81
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- 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-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +57 -5
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +83 -0
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
- package/dist/src/storage/cached-raw-storage.js +152 -0
- package/dist/src/storage/cached-raw-storage.js.map +1 -0
- package/dist/src/storage/cached-store-driver.d.ts +186 -0
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
- package/dist/src/storage/cached-store-driver.js +775 -0
- package/dist/src/storage/cached-store-driver.js.map +1 -0
- package/dist/src/storage/i-block-storage.d.ts +20 -1
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +12 -5
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +234 -0
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
- package/dist/src/storage/shared-cache-pool.js +354 -0
- package/dist/src/storage/shared-cache-pool.js.map +1 -0
- package/dist/src/storage/storage-repo.d.ts +56 -3
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +124 -18
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
- package/dist/src/testing/raw-storage-conformance.js +52 -2
- package/dist/src/testing/raw-storage-conformance.js.map +1 -1
- package/package.json +3 -3
- package/readme.md +668 -653
- package/src/cluster/block-transfer.ts +424 -424
- package/src/cluster/client.ts +119 -88
- package/src/cluster/cluster-error.ts +64 -64
- package/src/cluster/cluster-policy.ts +203 -152
- package/src/cluster/cluster-repo.ts +245 -125
- package/src/cluster/cluster-size-coupling.ts +45 -0
- package/src/cluster/commit-cert.ts +139 -139
- package/src/cluster/i-transaction-state-store.ts +43 -43
- package/src/cluster/memory-transaction-state-store.ts +56 -56
- package/src/cluster/peer-key-binding.ts +37 -37
- package/src/cluster/persistent-transaction-state-store.ts +92 -92
- package/src/cluster/quorum-restore.ts +223 -223
- package/src/cluster/reconcile-block.ts +203 -191
- package/src/cluster/service.ts +293 -241
- package/src/cluster/supermajority-coupling.ts +37 -37
- package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
- package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
- package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
- package/src/cohort-topic/change-bridge.ts +109 -109
- package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
- package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
- package/src/cohort-topic/fret-trust-anchor.ts +153 -153
- package/src/cohort-topic/host.ts +2901 -2901
- package/src/cohort-topic/index.ts +13 -13
- package/src/cohort-topic/membership-publish-sink.ts +20 -20
- package/src/cohort-topic/membership-source.ts +68 -68
- package/src/cohort-topic/peer-codec.ts +31 -31
- package/src/cohort-topic/peer-sig.ts +86 -86
- package/src/cohort-topic/protocols.ts +71 -71
- package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
- package/src/cohort-topic/size-estimator.ts +16 -16
- package/src/cohort-topic/stream-util.ts +135 -87
- package/src/cohort-topic/threshold-crypto.ts +239 -239
- package/src/cohort-topic/topic-router.ts +77 -77
- package/src/dispute/arbitrator-selection.ts +138 -138
- package/src/dispute/cascade.ts +524 -524
- package/src/dispute/dispute-service.ts +11 -5
- package/src/dispute/invalidation.ts +625 -625
- package/src/inbound-authorization.ts +190 -190
- package/src/index.ts +52 -47
- package/src/libp2p-key-network.ts +1120 -958
- package/src/libp2p-node-base.ts +1675 -1591
- package/src/libp2p-node-rn.ts +30 -30
- package/src/libp2p-node.ts +36 -36
- package/src/logger.ts +19 -2
- package/src/matchmaking/aggregate-counts.ts +104 -104
- package/src/matchmaking/index.ts +20 -20
- package/src/matchmaking/module.ts +363 -363
- package/src/matchmaking/protocols.ts +51 -51
- package/src/matchmaking/provider-manager.ts +95 -95
- package/src/matchmaking/query-handler.ts +88 -88
- package/src/matchmaking/query-transport.ts +492 -492
- package/src/matchmaking/seeker-manager.ts +64 -64
- package/src/matchmaking/seeker-walk-client.ts +293 -293
- package/src/matchmaking/traffic-validation.ts +195 -195
- package/src/network/network-manager-service.ts +5 -0
- package/src/optimystic-node.ts +36 -0
- package/src/owned-block-seed.ts +53 -40
- package/src/peer-address-book.ts +149 -0
- package/src/protocol-limits.ts +33 -33
- package/src/reactivity/forwarder-host.ts +438 -438
- package/src/reactivity/index.ts +19 -19
- package/src/reactivity/notify-transport.ts +144 -144
- package/src/reactivity/origination-manager.ts +192 -192
- package/src/reactivity/protocols.ts +61 -61
- package/src/reactivity/push-state-gossip.ts +291 -291
- package/src/reactivity/recover-transport.ts +408 -408
- package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
- package/src/reactivity/subscriber-registry.ts +96 -96
- package/src/reactivity/subscription-manager.ts +450 -450
- package/src/reactivity/topic-bytes.ts +37 -37
- package/src/repo/client.ts +12 -2
- package/src/repo/cluster-coordinator.ts +99 -3
- package/src/repo/coordinator-repo.ts +305 -82
- package/src/repo/types.ts +7 -7
- package/src/rn.ts +39 -34
- package/src/rpc-deadline.ts +45 -45
- package/src/storage/arachnode-partition.ts +74 -74
- package/src/storage/block-storage.ts +59 -6
- package/src/storage/cached-raw-storage.ts +180 -0
- package/src/storage/cached-store-driver.ts +859 -0
- package/src/storage/i-block-storage.ts +20 -1
- package/src/storage/i-kv-store.ts +8 -8
- package/src/storage/i-raw-storage.ts +12 -5
- package/src/storage/kv-raw-storage.ts +135 -135
- package/src/storage/memory-kv-store.ts +28 -28
- package/src/storage/memory-storage.ts +25 -25
- package/src/storage/memory-store-driver.ts +157 -157
- package/src/storage/raw-store-codec.ts +42 -42
- package/src/storage/raw-store-driver.ts +80 -80
- package/src/storage/ring-selector.ts +317 -317
- package/src/storage/ring-shift-coordinator.ts +271 -271
- package/src/storage/shared-cache-pool.ts +452 -0
- package/src/storage/storage-repo.ts +1014 -903
- package/src/testing/cohort-topic-mesh-harness.ts +663 -663
- package/src/testing/index.ts +8 -8
- package/src/testing/matchmaking-mesh-harness.ts +475 -475
- package/src/testing/raw-storage-conformance.ts +453 -397
- package/src/testing/reactivity-mesh-harness.ts +922 -922
- package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
- package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
- package/dist/src/storage/restoration-coordinator-v2.js +0 -172
- package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
|
@@ -1,424 +1,424 @@
|
|
|
1
|
-
import type { IRepo, IPeerNetwork } from '@optimystic/db-core';
|
|
2
|
-
import { peerIdFromString } from '@libp2p/peer-id';
|
|
3
|
-
import type { PartitionDetector } from './partition-detector.js';
|
|
4
|
-
import type { RestorationCoordinator } from '../storage/restoration-coordinator.js';
|
|
5
|
-
import { BlockTransferClient } from './block-transfer-service.js';
|
|
6
|
-
import type { RebalanceEvent } from './rebalance-monitor.js';
|
|
7
|
-
import { createLogger } from '../logger.js';
|
|
8
|
-
|
|
9
|
-
const log = createLogger('block-transfer');
|
|
10
|
-
|
|
11
|
-
export interface BlockTransferConfig {
|
|
12
|
-
/** Max concurrent transfers. Default: 4 */
|
|
13
|
-
maxConcurrency?: number;
|
|
14
|
-
/** Timeout per block transfer (ms). Default: 30000 */
|
|
15
|
-
transferTimeoutMs?: number;
|
|
16
|
-
/** Retry attempts for failed transfers. Default: 2 */
|
|
17
|
-
maxRetries?: number;
|
|
18
|
-
/** Whether to push blocks to new owners proactively. Default: true */
|
|
19
|
-
enablePush?: boolean;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Outcome of reacting to a {@link RebalanceEvent}. The `released` list is the gate the caller opens
|
|
24
|
-
* before it may stop serving a lost block: a block appears here ONLY after it was confirmed
|
|
25
|
-
* replicated to the event's floor of new owners. Everything in `retained` stays tracked and served
|
|
26
|
-
* (its push failed, was partition-skipped, or could not reach the floor) and is retried on the next
|
|
27
|
-
* rebalance. See `docs/arachnode-ring-handoff.md` § Part 2.
|
|
28
|
-
*/
|
|
29
|
-
export interface RebalanceReactionResult {
|
|
30
|
-
/** Gained blocks successfully pulled (now durably held locally). */
|
|
31
|
-
pulled: string[];
|
|
32
|
-
/** Lost blocks confirmed replicated to ≥ floor new owners — safe to release. */
|
|
33
|
-
released: string[];
|
|
34
|
-
/** Lost blocks whose replication could not be confirmed — keep serving, retry later. */
|
|
35
|
-
retained: string[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Coordinates block transfers in response to rebalance events.
|
|
40
|
-
*
|
|
41
|
-
* For gained blocks: delegates to RestorationCoordinator.restore() which
|
|
42
|
-
* already handles ring-based discovery and fetching.
|
|
43
|
-
*
|
|
44
|
-
* For lost blocks: proactively pushes block data to new responsible peers
|
|
45
|
-
* via the BlockTransfer protocol.
|
|
46
|
-
*/
|
|
47
|
-
export class BlockTransferCoordinator {
|
|
48
|
-
private readonly maxConcurrency: number;
|
|
49
|
-
private readonly transferTimeoutMs: number;
|
|
50
|
-
private readonly maxRetries: number;
|
|
51
|
-
private readonly enablePush: boolean;
|
|
52
|
-
private inFlight = new Set<string>();
|
|
53
|
-
private concurrency = 0;
|
|
54
|
-
private readonly waitQueue: Array<() => void> = [];
|
|
55
|
-
|
|
56
|
-
constructor(
|
|
57
|
-
private readonly repo: IRepo,
|
|
58
|
-
private readonly peerNetwork: IPeerNetwork,
|
|
59
|
-
private readonly restorationCoordinator: RestorationCoordinator,
|
|
60
|
-
private readonly partitionDetector: PartitionDetector,
|
|
61
|
-
private readonly protocolPrefix: string = '',
|
|
62
|
-
config: BlockTransferConfig = {}
|
|
63
|
-
) {
|
|
64
|
-
this.maxConcurrency = config.maxConcurrency ?? 4;
|
|
65
|
-
this.transferTimeoutMs = config.transferTimeoutMs ?? 30000;
|
|
66
|
-
this.maxRetries = config.maxRetries ?? 2;
|
|
67
|
-
this.enablePush = config.enablePush ?? true;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Pull blocks that this node has gained responsibility for.
|
|
72
|
-
* Uses RestorationCoordinator to discover holders and fetch block data.
|
|
73
|
-
*/
|
|
74
|
-
async pullBlocks(blockIds: string[]): Promise<{ succeeded: string[]; failed: string[] }> {
|
|
75
|
-
if (this.partitionDetector.detectPartition()) {
|
|
76
|
-
log('pull:partition-detected, skipping %d blocks', blockIds.length);
|
|
77
|
-
return { succeeded: [], failed: blockIds };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const succeeded: string[] = [];
|
|
81
|
-
const failed: string[] = [];
|
|
82
|
-
|
|
83
|
-
const ids = blockIds.filter(id => !this.inFlight.has(`pull:${id}`));
|
|
84
|
-
|
|
85
|
-
await Promise.all(ids.map(id => this.executePull(id, succeeded, failed)));
|
|
86
|
-
|
|
87
|
-
return { succeeded, failed };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Push blocks that this node has lost responsibility for to new owners.
|
|
92
|
-
*/
|
|
93
|
-
async pushBlocks(
|
|
94
|
-
blockIds: string[],
|
|
95
|
-
newOwners: Map<string, string[]>
|
|
96
|
-
): Promise<{ succeeded: string[]; failed: string[] }> {
|
|
97
|
-
if (!this.enablePush) {
|
|
98
|
-
return { succeeded: [], failed: [] };
|
|
99
|
-
}
|
|
100
|
-
if (this.partitionDetector.detectPartition()) {
|
|
101
|
-
log('push:partition-detected, skipping %d blocks', blockIds.length);
|
|
102
|
-
return { succeeded: [], failed: blockIds };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const succeeded: string[] = [];
|
|
106
|
-
const failed: string[] = [];
|
|
107
|
-
|
|
108
|
-
const ids = blockIds.filter(id => !this.inFlight.has(`push:${id}`) && newOwners.has(id));
|
|
109
|
-
|
|
110
|
-
await Promise.all(ids.map(id => this.executePush(id, newOwners, succeeded, failed)));
|
|
111
|
-
|
|
112
|
-
return { succeeded, failed };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Handle a complete rebalance event — pull gained, and **confirm** lost blocks replicated to the
|
|
117
|
-
* floor before reporting them releasable.
|
|
118
|
-
*
|
|
119
|
-
* The lost path no longer pushes fire-and-forget: it runs {@link confirmReplicated} against the
|
|
120
|
-
* event's `newOwners` and `floor`, so `released` contains only blocks that landed on ≥ floor new
|
|
121
|
-
* owners. The caller gates its `untrackBlock` (release + GC-eligibility) on `released` and leaves
|
|
122
|
-
* `retained` blocks tracked/served for the next rebalance. This closes the release-before-confirm
|
|
123
|
-
* hole (`docs/arachnode-ring-handoff.md` § Why the current code violates it #2).
|
|
124
|
-
*/
|
|
125
|
-
async handleRebalanceEvent(event: RebalanceEvent): Promise<RebalanceReactionResult> {
|
|
126
|
-
log('rebalance:start gained=%d lost=%d floor=%d', event.gained.length, event.lost.length, event.floor);
|
|
127
|
-
|
|
128
|
-
const floor = Math.max(1, event.floor);
|
|
129
|
-
const [pullResult, confirmResult] = await Promise.all([
|
|
130
|
-
event.gained.length > 0 ? this.pullBlocks(event.gained) : { succeeded: [], failed: [] },
|
|
131
|
-
event.lost.length > 0 && event.newOwners.size > 0
|
|
132
|
-
? this.confirmReplicated(event.lost, event.newOwners, floor)
|
|
133
|
-
: { confirmed: [], unconfirmed: [...event.lost] }
|
|
134
|
-
]);
|
|
135
|
-
|
|
136
|
-
log('rebalance:done pull=%d/%d released=%d/%d',
|
|
137
|
-
pullResult.succeeded.length, event.gained.length,
|
|
138
|
-
confirmResult.confirmed.length, event.lost.length);
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
pulled: pullResult.succeeded,
|
|
142
|
-
released: confirmResult.confirmed,
|
|
143
|
-
retained: confirmResult.unconfirmed
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Confirm each block is replicated to at least `floor` qualifying owners — the gate the ring-shift
|
|
149
|
-
* handoff (Phase B) and the rebalance release both open before a block may stop being served.
|
|
150
|
-
*
|
|
151
|
-
* For each block, this pushes to the candidate owners and counts how many report holding a current
|
|
152
|
-
* replica: a holder confirms when the push response does NOT list the block in `missing` (it either
|
|
153
|
-
* already had it or accepted the push — `handlePush` reports `accepted` only on a received-AND-persisted
|
|
154
|
-
* block). A block is `confirmed` only when that count reaches `floor`; otherwise it is `unconfirmed`
|
|
155
|
-
* and the caller keeps serving it. Per-block timeout + retry mirror {@link pushBlocks}. During a
|
|
156
|
-
* detected partition every block is left unconfirmed (consistent with the push guard), so a partition
|
|
157
|
-
* mid-handoff aborts rather than releases.
|
|
158
|
-
*
|
|
159
|
-
* @param owners blockId → candidate owner peer ids. The caller MUST have already excluded self and
|
|
160
|
-
* any same-range mover (a peer shedding the same sub-range), so every id here is a qualifying holder.
|
|
161
|
-
* @param floor required confirming owners per block (the replication floor `N`).
|
|
162
|
-
*/
|
|
163
|
-
async confirmReplicated(
|
|
164
|
-
blockIds: string[],
|
|
165
|
-
owners: Map<string, string[]>,
|
|
166
|
-
floor: number
|
|
167
|
-
): Promise<{ confirmed: string[]; unconfirmed: string[] }> {
|
|
168
|
-
// NOTE: unlike pushBlocks, this deliberately does NOT honor `enablePush` — confirmation
|
|
169
|
-
// fundamentally requires pushing to verify replication, and skipping it would leave every
|
|
170
|
-
// block unconfirmed → never released → the node never sheds. So `enablePush:false` no longer
|
|
171
|
-
// suppresses pushes on the rebalance/handoff release path (it only gates the legacy pushBlocks
|
|
172
|
-
// fire-and-forget). If a config ever needs "never move data at all", gate the release wiring,
|
|
173
|
-
// not this primitive.
|
|
174
|
-
if (this.partitionDetector.detectPartition()) {
|
|
175
|
-
log('confirm:partition-detected, leaving %d blocks unconfirmed', blockIds.length);
|
|
176
|
-
return { confirmed: [], unconfirmed: [...blockIds] };
|
|
177
|
-
}
|
|
178
|
-
if (floor <= 0) {
|
|
179
|
-
// A non-positive floor cannot be safely "met"; refuse to release rather than release for free.
|
|
180
|
-
return { confirmed: [], unconfirmed: [...blockIds] };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const confirmed: string[] = [];
|
|
184
|
-
const unconfirmed: string[] = [];
|
|
185
|
-
|
|
186
|
-
const ids = blockIds.filter(id => !this.inFlight.has(`confirm:${id}`));
|
|
187
|
-
await Promise.all(ids.map(id => this.executeConfirm(id, owners, floor, confirmed, unconfirmed)));
|
|
188
|
-
|
|
189
|
-
return { confirmed, unconfirmed };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
private async executePull(
|
|
193
|
-
blockId: string,
|
|
194
|
-
succeeded: string[],
|
|
195
|
-
failed: string[]
|
|
196
|
-
): Promise<void> {
|
|
197
|
-
const key = `pull:${blockId}`;
|
|
198
|
-
if (this.inFlight.has(key)) return;
|
|
199
|
-
this.inFlight.add(key);
|
|
200
|
-
|
|
201
|
-
try {
|
|
202
|
-
for (let attempt = 0; ; attempt++) {
|
|
203
|
-
await this.acquireSemaphore();
|
|
204
|
-
let archive: Awaited<ReturnType<RestorationCoordinator['restore']>>;
|
|
205
|
-
try {
|
|
206
|
-
archive = await this.withTimeout(
|
|
207
|
-
this.restorationCoordinator.restore(blockId),
|
|
208
|
-
this.transferTimeoutMs
|
|
209
|
-
);
|
|
210
|
-
} finally {
|
|
211
|
-
this.releaseSemaphore();
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (archive) {
|
|
215
|
-
log('pull:ok block=%s', blockId);
|
|
216
|
-
succeeded.push(blockId);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
if (attempt < this.maxRetries) {
|
|
220
|
-
log('pull:retry block=%s attempt=%d', blockId, attempt + 1);
|
|
221
|
-
await this.delay(this.backoffMs(attempt));
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
log('pull:failed block=%s', blockId);
|
|
225
|
-
failed.push(blockId);
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
} finally {
|
|
229
|
-
this.inFlight.delete(key);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
private async executePush(
|
|
234
|
-
blockId: string,
|
|
235
|
-
newOwners: Map<string, string[]>,
|
|
236
|
-
succeeded: string[],
|
|
237
|
-
failed: string[]
|
|
238
|
-
): Promise<void> {
|
|
239
|
-
const key = `push:${blockId}`;
|
|
240
|
-
if (this.inFlight.has(key)) return;
|
|
241
|
-
this.inFlight.add(key);
|
|
242
|
-
|
|
243
|
-
try {
|
|
244
|
-
for (let attempt = 0; ; attempt++) {
|
|
245
|
-
await this.acquireSemaphore();
|
|
246
|
-
let pushed = false;
|
|
247
|
-
try {
|
|
248
|
-
const owners = newOwners.get(blockId);
|
|
249
|
-
if (!owners || owners.length === 0) {
|
|
250
|
-
failed.push(blockId);
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Read block data from local storage
|
|
255
|
-
const result = await this.repo.get({ blockIds: [blockId] });
|
|
256
|
-
const blockResult = result[blockId];
|
|
257
|
-
if (!blockResult?.block) {
|
|
258
|
-
log('push:no-local-data block=%s', blockId);
|
|
259
|
-
failed.push(blockId);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
|
|
264
|
-
|
|
265
|
-
// Push to at least one new owner
|
|
266
|
-
for (const ownerPeerIdStr of owners) {
|
|
267
|
-
try {
|
|
268
|
-
const peerId = peerIdFromString(ownerPeerIdStr);
|
|
269
|
-
const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
|
|
270
|
-
const response = await this.withTimeout(
|
|
271
|
-
client.pushBlocks([blockId], [blockData]),
|
|
272
|
-
this.transferTimeoutMs
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
if (response && !response.missing.includes(blockId)) {
|
|
276
|
-
pushed = true;
|
|
277
|
-
log('push:ok block=%s peer=%s', blockId, ownerPeerIdStr);
|
|
278
|
-
break;
|
|
279
|
-
}
|
|
280
|
-
} catch (err) {
|
|
281
|
-
log('push:peer-error block=%s peer=%s err=%s',
|
|
282
|
-
blockId, ownerPeerIdStr, (err as Error).message);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
} finally {
|
|
286
|
-
this.releaseSemaphore();
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (pushed) {
|
|
290
|
-
succeeded.push(blockId);
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
if (attempt < this.maxRetries) {
|
|
294
|
-
log('push:retry block=%s attempt=%d', blockId, attempt + 1);
|
|
295
|
-
await this.delay(this.backoffMs(attempt));
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
298
|
-
log('push:failed block=%s', blockId);
|
|
299
|
-
failed.push(blockId);
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
} finally {
|
|
303
|
-
this.inFlight.delete(key);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Confirm one block replicated to ≥ `floor` distinct qualifying owners. Reads the local block once,
|
|
309
|
-
* pushes to each candidate owner (stopping once the floor is reached), and counts distinct owners
|
|
310
|
-
* that report holding it (not `missing`). Retries the whole round up to `maxRetries` before giving
|
|
311
|
-
* up. Records the block in `confirmed` iff the floor was met, otherwise in `unconfirmed`.
|
|
312
|
-
*/
|
|
313
|
-
private async executeConfirm(
|
|
314
|
-
blockId: string,
|
|
315
|
-
owners: Map<string, string[]>,
|
|
316
|
-
floor: number,
|
|
317
|
-
confirmed: string[],
|
|
318
|
-
unconfirmed: string[]
|
|
319
|
-
): Promise<void> {
|
|
320
|
-
const key = `confirm:${blockId}`;
|
|
321
|
-
if (this.inFlight.has(key)) return;
|
|
322
|
-
this.inFlight.add(key);
|
|
323
|
-
|
|
324
|
-
try {
|
|
325
|
-
const candidateOwners = owners.get(blockId) ?? [];
|
|
326
|
-
if (candidateOwners.length === 0) {
|
|
327
|
-
// No qualifying holder to confirm against — cannot release; keep serving.
|
|
328
|
-
unconfirmed.push(blockId);
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
for (let attempt = 0; ; attempt++) {
|
|
333
|
-
await this.acquireSemaphore();
|
|
334
|
-
let confirmCount = 0;
|
|
335
|
-
try {
|
|
336
|
-
// Read block data from local storage once per attempt.
|
|
337
|
-
const result = await this.repo.get({ blockIds: [blockId] });
|
|
338
|
-
const blockResult = result[blockId];
|
|
339
|
-
if (!blockResult?.block) {
|
|
340
|
-
// No local bytes to prove replication with — cannot confirm; keep serving.
|
|
341
|
-
log('confirm:no-local-data block=%s', blockId);
|
|
342
|
-
unconfirmed.push(blockId);
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
|
|
347
|
-
|
|
348
|
-
// Count DISTINCT owners that hold a current replica; stop once the floor is reached.
|
|
349
|
-
const confirmedPeers = new Set<string>();
|
|
350
|
-
for (const ownerPeerIdStr of candidateOwners) {
|
|
351
|
-
if (confirmedPeers.size >= floor) break;
|
|
352
|
-
try {
|
|
353
|
-
const peerId = peerIdFromString(ownerPeerIdStr);
|
|
354
|
-
const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
|
|
355
|
-
const response = await this.withTimeout(
|
|
356
|
-
client.pushBlocks([blockId], [blockData]),
|
|
357
|
-
this.transferTimeoutMs
|
|
358
|
-
);
|
|
359
|
-
if (response && !response.missing.includes(blockId)) {
|
|
360
|
-
confirmedPeers.add(ownerPeerIdStr);
|
|
361
|
-
}
|
|
362
|
-
} catch (err) {
|
|
363
|
-
log('confirm:peer-error block=%s peer=%s err=%s',
|
|
364
|
-
blockId, ownerPeerIdStr, (err as Error).message);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
confirmCount = confirmedPeers.size;
|
|
368
|
-
} finally {
|
|
369
|
-
this.releaseSemaphore();
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (confirmCount >= floor) {
|
|
373
|
-
log('confirm:ok block=%s holders=%d/%d', blockId, confirmCount, floor);
|
|
374
|
-
confirmed.push(blockId);
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
if (attempt < this.maxRetries) {
|
|
378
|
-
log('confirm:retry block=%s holders=%d/%d attempt=%d', blockId, confirmCount, floor, attempt + 1);
|
|
379
|
-
await this.delay(this.backoffMs(attempt));
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
log('confirm:unmet block=%s holders=%d/%d', blockId, confirmCount, floor);
|
|
383
|
-
unconfirmed.push(blockId);
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
} finally {
|
|
387
|
-
this.inFlight.delete(key);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// --- Semaphore for concurrency limiting ---
|
|
392
|
-
|
|
393
|
-
private async acquireSemaphore(): Promise<void> {
|
|
394
|
-
if (this.concurrency < this.maxConcurrency) {
|
|
395
|
-
this.concurrency++;
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
await new Promise<void>(resolve => this.waitQueue.push(resolve));
|
|
399
|
-
this.concurrency++;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
private releaseSemaphore(): void {
|
|
403
|
-
this.concurrency--;
|
|
404
|
-
const next = this.waitQueue.shift();
|
|
405
|
-
if (next) next();
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// --- Helpers ---
|
|
409
|
-
|
|
410
|
-
private backoffMs(attempt: number): number {
|
|
411
|
-
return Math.min(1000 * Math.pow(2, attempt), 10000);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
private delay(ms: number): Promise<void> {
|
|
415
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
|
|
419
|
-
return Promise.race([
|
|
420
|
-
promise,
|
|
421
|
-
new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), ms))
|
|
422
|
-
]);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
1
|
+
import type { IRepo, IPeerNetwork } from '@optimystic/db-core';
|
|
2
|
+
import { peerIdFromString } from '@libp2p/peer-id';
|
|
3
|
+
import type { PartitionDetector } from './partition-detector.js';
|
|
4
|
+
import type { RestorationCoordinator } from '../storage/restoration-coordinator.js';
|
|
5
|
+
import { BlockTransferClient } from './block-transfer-service.js';
|
|
6
|
+
import type { RebalanceEvent } from './rebalance-monitor.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
|
|
9
|
+
const log = createLogger('block-transfer');
|
|
10
|
+
|
|
11
|
+
export interface BlockTransferConfig {
|
|
12
|
+
/** Max concurrent transfers. Default: 4 */
|
|
13
|
+
maxConcurrency?: number;
|
|
14
|
+
/** Timeout per block transfer (ms). Default: 30000 */
|
|
15
|
+
transferTimeoutMs?: number;
|
|
16
|
+
/** Retry attempts for failed transfers. Default: 2 */
|
|
17
|
+
maxRetries?: number;
|
|
18
|
+
/** Whether to push blocks to new owners proactively. Default: true */
|
|
19
|
+
enablePush?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Outcome of reacting to a {@link RebalanceEvent}. The `released` list is the gate the caller opens
|
|
24
|
+
* before it may stop serving a lost block: a block appears here ONLY after it was confirmed
|
|
25
|
+
* replicated to the event's floor of new owners. Everything in `retained` stays tracked and served
|
|
26
|
+
* (its push failed, was partition-skipped, or could not reach the floor) and is retried on the next
|
|
27
|
+
* rebalance. See `docs/arachnode-ring-handoff.md` § Part 2.
|
|
28
|
+
*/
|
|
29
|
+
export interface RebalanceReactionResult {
|
|
30
|
+
/** Gained blocks successfully pulled (now durably held locally). */
|
|
31
|
+
pulled: string[];
|
|
32
|
+
/** Lost blocks confirmed replicated to ≥ floor new owners — safe to release. */
|
|
33
|
+
released: string[];
|
|
34
|
+
/** Lost blocks whose replication could not be confirmed — keep serving, retry later. */
|
|
35
|
+
retained: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Coordinates block transfers in response to rebalance events.
|
|
40
|
+
*
|
|
41
|
+
* For gained blocks: delegates to RestorationCoordinator.restore() which
|
|
42
|
+
* already handles ring-based discovery and fetching.
|
|
43
|
+
*
|
|
44
|
+
* For lost blocks: proactively pushes block data to new responsible peers
|
|
45
|
+
* via the BlockTransfer protocol.
|
|
46
|
+
*/
|
|
47
|
+
export class BlockTransferCoordinator {
|
|
48
|
+
private readonly maxConcurrency: number;
|
|
49
|
+
private readonly transferTimeoutMs: number;
|
|
50
|
+
private readonly maxRetries: number;
|
|
51
|
+
private readonly enablePush: boolean;
|
|
52
|
+
private inFlight = new Set<string>();
|
|
53
|
+
private concurrency = 0;
|
|
54
|
+
private readonly waitQueue: Array<() => void> = [];
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
private readonly repo: IRepo,
|
|
58
|
+
private readonly peerNetwork: IPeerNetwork,
|
|
59
|
+
private readonly restorationCoordinator: RestorationCoordinator,
|
|
60
|
+
private readonly partitionDetector: PartitionDetector,
|
|
61
|
+
private readonly protocolPrefix: string = '',
|
|
62
|
+
config: BlockTransferConfig = {}
|
|
63
|
+
) {
|
|
64
|
+
this.maxConcurrency = config.maxConcurrency ?? 4;
|
|
65
|
+
this.transferTimeoutMs = config.transferTimeoutMs ?? 30000;
|
|
66
|
+
this.maxRetries = config.maxRetries ?? 2;
|
|
67
|
+
this.enablePush = config.enablePush ?? true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pull blocks that this node has gained responsibility for.
|
|
72
|
+
* Uses RestorationCoordinator to discover holders and fetch block data.
|
|
73
|
+
*/
|
|
74
|
+
async pullBlocks(blockIds: string[]): Promise<{ succeeded: string[]; failed: string[] }> {
|
|
75
|
+
if (this.partitionDetector.detectPartition()) {
|
|
76
|
+
log('pull:partition-detected, skipping %d blocks', blockIds.length);
|
|
77
|
+
return { succeeded: [], failed: blockIds };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const succeeded: string[] = [];
|
|
81
|
+
const failed: string[] = [];
|
|
82
|
+
|
|
83
|
+
const ids = blockIds.filter(id => !this.inFlight.has(`pull:${id}`));
|
|
84
|
+
|
|
85
|
+
await Promise.all(ids.map(id => this.executePull(id, succeeded, failed)));
|
|
86
|
+
|
|
87
|
+
return { succeeded, failed };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Push blocks that this node has lost responsibility for to new owners.
|
|
92
|
+
*/
|
|
93
|
+
async pushBlocks(
|
|
94
|
+
blockIds: string[],
|
|
95
|
+
newOwners: Map<string, string[]>
|
|
96
|
+
): Promise<{ succeeded: string[]; failed: string[] }> {
|
|
97
|
+
if (!this.enablePush) {
|
|
98
|
+
return { succeeded: [], failed: [] };
|
|
99
|
+
}
|
|
100
|
+
if (this.partitionDetector.detectPartition()) {
|
|
101
|
+
log('push:partition-detected, skipping %d blocks', blockIds.length);
|
|
102
|
+
return { succeeded: [], failed: blockIds };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const succeeded: string[] = [];
|
|
106
|
+
const failed: string[] = [];
|
|
107
|
+
|
|
108
|
+
const ids = blockIds.filter(id => !this.inFlight.has(`push:${id}`) && newOwners.has(id));
|
|
109
|
+
|
|
110
|
+
await Promise.all(ids.map(id => this.executePush(id, newOwners, succeeded, failed)));
|
|
111
|
+
|
|
112
|
+
return { succeeded, failed };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Handle a complete rebalance event — pull gained, and **confirm** lost blocks replicated to the
|
|
117
|
+
* floor before reporting them releasable.
|
|
118
|
+
*
|
|
119
|
+
* The lost path no longer pushes fire-and-forget: it runs {@link confirmReplicated} against the
|
|
120
|
+
* event's `newOwners` and `floor`, so `released` contains only blocks that landed on ≥ floor new
|
|
121
|
+
* owners. The caller gates its `untrackBlock` (release + GC-eligibility) on `released` and leaves
|
|
122
|
+
* `retained` blocks tracked/served for the next rebalance. This closes the release-before-confirm
|
|
123
|
+
* hole (`docs/arachnode-ring-handoff.md` § Why the current code violates it #2).
|
|
124
|
+
*/
|
|
125
|
+
async handleRebalanceEvent(event: RebalanceEvent): Promise<RebalanceReactionResult> {
|
|
126
|
+
log('rebalance:start gained=%d lost=%d floor=%d', event.gained.length, event.lost.length, event.floor);
|
|
127
|
+
|
|
128
|
+
const floor = Math.max(1, event.floor);
|
|
129
|
+
const [pullResult, confirmResult] = await Promise.all([
|
|
130
|
+
event.gained.length > 0 ? this.pullBlocks(event.gained) : { succeeded: [], failed: [] },
|
|
131
|
+
event.lost.length > 0 && event.newOwners.size > 0
|
|
132
|
+
? this.confirmReplicated(event.lost, event.newOwners, floor)
|
|
133
|
+
: { confirmed: [], unconfirmed: [...event.lost] }
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
log('rebalance:done pull=%d/%d released=%d/%d',
|
|
137
|
+
pullResult.succeeded.length, event.gained.length,
|
|
138
|
+
confirmResult.confirmed.length, event.lost.length);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
pulled: pullResult.succeeded,
|
|
142
|
+
released: confirmResult.confirmed,
|
|
143
|
+
retained: confirmResult.unconfirmed
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Confirm each block is replicated to at least `floor` qualifying owners — the gate the ring-shift
|
|
149
|
+
* handoff (Phase B) and the rebalance release both open before a block may stop being served.
|
|
150
|
+
*
|
|
151
|
+
* For each block, this pushes to the candidate owners and counts how many report holding a current
|
|
152
|
+
* replica: a holder confirms when the push response does NOT list the block in `missing` (it either
|
|
153
|
+
* already had it or accepted the push — `handlePush` reports `accepted` only on a received-AND-persisted
|
|
154
|
+
* block). A block is `confirmed` only when that count reaches `floor`; otherwise it is `unconfirmed`
|
|
155
|
+
* and the caller keeps serving it. Per-block timeout + retry mirror {@link pushBlocks}. During a
|
|
156
|
+
* detected partition every block is left unconfirmed (consistent with the push guard), so a partition
|
|
157
|
+
* mid-handoff aborts rather than releases.
|
|
158
|
+
*
|
|
159
|
+
* @param owners blockId → candidate owner peer ids. The caller MUST have already excluded self and
|
|
160
|
+
* any same-range mover (a peer shedding the same sub-range), so every id here is a qualifying holder.
|
|
161
|
+
* @param floor required confirming owners per block (the replication floor `N`).
|
|
162
|
+
*/
|
|
163
|
+
async confirmReplicated(
|
|
164
|
+
blockIds: string[],
|
|
165
|
+
owners: Map<string, string[]>,
|
|
166
|
+
floor: number
|
|
167
|
+
): Promise<{ confirmed: string[]; unconfirmed: string[] }> {
|
|
168
|
+
// NOTE: unlike pushBlocks, this deliberately does NOT honor `enablePush` — confirmation
|
|
169
|
+
// fundamentally requires pushing to verify replication, and skipping it would leave every
|
|
170
|
+
// block unconfirmed → never released → the node never sheds. So `enablePush:false` no longer
|
|
171
|
+
// suppresses pushes on the rebalance/handoff release path (it only gates the legacy pushBlocks
|
|
172
|
+
// fire-and-forget). If a config ever needs "never move data at all", gate the release wiring,
|
|
173
|
+
// not this primitive.
|
|
174
|
+
if (this.partitionDetector.detectPartition()) {
|
|
175
|
+
log('confirm:partition-detected, leaving %d blocks unconfirmed', blockIds.length);
|
|
176
|
+
return { confirmed: [], unconfirmed: [...blockIds] };
|
|
177
|
+
}
|
|
178
|
+
if (floor <= 0) {
|
|
179
|
+
// A non-positive floor cannot be safely "met"; refuse to release rather than release for free.
|
|
180
|
+
return { confirmed: [], unconfirmed: [...blockIds] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const confirmed: string[] = [];
|
|
184
|
+
const unconfirmed: string[] = [];
|
|
185
|
+
|
|
186
|
+
const ids = blockIds.filter(id => !this.inFlight.has(`confirm:${id}`));
|
|
187
|
+
await Promise.all(ids.map(id => this.executeConfirm(id, owners, floor, confirmed, unconfirmed)));
|
|
188
|
+
|
|
189
|
+
return { confirmed, unconfirmed };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async executePull(
|
|
193
|
+
blockId: string,
|
|
194
|
+
succeeded: string[],
|
|
195
|
+
failed: string[]
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
const key = `pull:${blockId}`;
|
|
198
|
+
if (this.inFlight.has(key)) return;
|
|
199
|
+
this.inFlight.add(key);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
for (let attempt = 0; ; attempt++) {
|
|
203
|
+
await this.acquireSemaphore();
|
|
204
|
+
let archive: Awaited<ReturnType<RestorationCoordinator['restore']>>;
|
|
205
|
+
try {
|
|
206
|
+
archive = await this.withTimeout(
|
|
207
|
+
this.restorationCoordinator.restore(blockId),
|
|
208
|
+
this.transferTimeoutMs
|
|
209
|
+
);
|
|
210
|
+
} finally {
|
|
211
|
+
this.releaseSemaphore();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (archive) {
|
|
215
|
+
log('pull:ok block=%s', blockId);
|
|
216
|
+
succeeded.push(blockId);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (attempt < this.maxRetries) {
|
|
220
|
+
log('pull:retry block=%s attempt=%d', blockId, attempt + 1);
|
|
221
|
+
await this.delay(this.backoffMs(attempt));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
log('pull:failed block=%s', blockId);
|
|
225
|
+
failed.push(blockId);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
} finally {
|
|
229
|
+
this.inFlight.delete(key);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async executePush(
|
|
234
|
+
blockId: string,
|
|
235
|
+
newOwners: Map<string, string[]>,
|
|
236
|
+
succeeded: string[],
|
|
237
|
+
failed: string[]
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
const key = `push:${blockId}`;
|
|
240
|
+
if (this.inFlight.has(key)) return;
|
|
241
|
+
this.inFlight.add(key);
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
for (let attempt = 0; ; attempt++) {
|
|
245
|
+
await this.acquireSemaphore();
|
|
246
|
+
let pushed = false;
|
|
247
|
+
try {
|
|
248
|
+
const owners = newOwners.get(blockId);
|
|
249
|
+
if (!owners || owners.length === 0) {
|
|
250
|
+
failed.push(blockId);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Read block data from local storage
|
|
255
|
+
const result = await this.repo.get({ blockIds: [blockId] });
|
|
256
|
+
const blockResult = result[blockId];
|
|
257
|
+
if (!blockResult?.block) {
|
|
258
|
+
log('push:no-local-data block=%s', blockId);
|
|
259
|
+
failed.push(blockId);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
|
|
264
|
+
|
|
265
|
+
// Push to at least one new owner
|
|
266
|
+
for (const ownerPeerIdStr of owners) {
|
|
267
|
+
try {
|
|
268
|
+
const peerId = peerIdFromString(ownerPeerIdStr);
|
|
269
|
+
const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
|
|
270
|
+
const response = await this.withTimeout(
|
|
271
|
+
client.pushBlocks([blockId], [blockData]),
|
|
272
|
+
this.transferTimeoutMs
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
if (response && !response.missing.includes(blockId)) {
|
|
276
|
+
pushed = true;
|
|
277
|
+
log('push:ok block=%s peer=%s', blockId, ownerPeerIdStr);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
} catch (err) {
|
|
281
|
+
log('push:peer-error block=%s peer=%s err=%s',
|
|
282
|
+
blockId, ownerPeerIdStr, (err as Error).message);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} finally {
|
|
286
|
+
this.releaseSemaphore();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (pushed) {
|
|
290
|
+
succeeded.push(blockId);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (attempt < this.maxRetries) {
|
|
294
|
+
log('push:retry block=%s attempt=%d', blockId, attempt + 1);
|
|
295
|
+
await this.delay(this.backoffMs(attempt));
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
log('push:failed block=%s', blockId);
|
|
299
|
+
failed.push(blockId);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
} finally {
|
|
303
|
+
this.inFlight.delete(key);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Confirm one block replicated to ≥ `floor` distinct qualifying owners. Reads the local block once,
|
|
309
|
+
* pushes to each candidate owner (stopping once the floor is reached), and counts distinct owners
|
|
310
|
+
* that report holding it (not `missing`). Retries the whole round up to `maxRetries` before giving
|
|
311
|
+
* up. Records the block in `confirmed` iff the floor was met, otherwise in `unconfirmed`.
|
|
312
|
+
*/
|
|
313
|
+
private async executeConfirm(
|
|
314
|
+
blockId: string,
|
|
315
|
+
owners: Map<string, string[]>,
|
|
316
|
+
floor: number,
|
|
317
|
+
confirmed: string[],
|
|
318
|
+
unconfirmed: string[]
|
|
319
|
+
): Promise<void> {
|
|
320
|
+
const key = `confirm:${blockId}`;
|
|
321
|
+
if (this.inFlight.has(key)) return;
|
|
322
|
+
this.inFlight.add(key);
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const candidateOwners = owners.get(blockId) ?? [];
|
|
326
|
+
if (candidateOwners.length === 0) {
|
|
327
|
+
// No qualifying holder to confirm against — cannot release; keep serving.
|
|
328
|
+
unconfirmed.push(blockId);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (let attempt = 0; ; attempt++) {
|
|
333
|
+
await this.acquireSemaphore();
|
|
334
|
+
let confirmCount = 0;
|
|
335
|
+
try {
|
|
336
|
+
// Read block data from local storage once per attempt.
|
|
337
|
+
const result = await this.repo.get({ blockIds: [blockId] });
|
|
338
|
+
const blockResult = result[blockId];
|
|
339
|
+
if (!blockResult?.block) {
|
|
340
|
+
// No local bytes to prove replication with — cannot confirm; keep serving.
|
|
341
|
+
log('confirm:no-local-data block=%s', blockId);
|
|
342
|
+
unconfirmed.push(blockId);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
|
|
347
|
+
|
|
348
|
+
// Count DISTINCT owners that hold a current replica; stop once the floor is reached.
|
|
349
|
+
const confirmedPeers = new Set<string>();
|
|
350
|
+
for (const ownerPeerIdStr of candidateOwners) {
|
|
351
|
+
if (confirmedPeers.size >= floor) break;
|
|
352
|
+
try {
|
|
353
|
+
const peerId = peerIdFromString(ownerPeerIdStr);
|
|
354
|
+
const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
|
|
355
|
+
const response = await this.withTimeout(
|
|
356
|
+
client.pushBlocks([blockId], [blockData]),
|
|
357
|
+
this.transferTimeoutMs
|
|
358
|
+
);
|
|
359
|
+
if (response && !response.missing.includes(blockId)) {
|
|
360
|
+
confirmedPeers.add(ownerPeerIdStr);
|
|
361
|
+
}
|
|
362
|
+
} catch (err) {
|
|
363
|
+
log('confirm:peer-error block=%s peer=%s err=%s',
|
|
364
|
+
blockId, ownerPeerIdStr, (err as Error).message);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
confirmCount = confirmedPeers.size;
|
|
368
|
+
} finally {
|
|
369
|
+
this.releaseSemaphore();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (confirmCount >= floor) {
|
|
373
|
+
log('confirm:ok block=%s holders=%d/%d', blockId, confirmCount, floor);
|
|
374
|
+
confirmed.push(blockId);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (attempt < this.maxRetries) {
|
|
378
|
+
log('confirm:retry block=%s holders=%d/%d attempt=%d', blockId, confirmCount, floor, attempt + 1);
|
|
379
|
+
await this.delay(this.backoffMs(attempt));
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
log('confirm:unmet block=%s holders=%d/%d', blockId, confirmCount, floor);
|
|
383
|
+
unconfirmed.push(blockId);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
} finally {
|
|
387
|
+
this.inFlight.delete(key);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- Semaphore for concurrency limiting ---
|
|
392
|
+
|
|
393
|
+
private async acquireSemaphore(): Promise<void> {
|
|
394
|
+
if (this.concurrency < this.maxConcurrency) {
|
|
395
|
+
this.concurrency++;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
await new Promise<void>(resolve => this.waitQueue.push(resolve));
|
|
399
|
+
this.concurrency++;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private releaseSemaphore(): void {
|
|
403
|
+
this.concurrency--;
|
|
404
|
+
const next = this.waitQueue.shift();
|
|
405
|
+
if (next) next();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// --- Helpers ---
|
|
409
|
+
|
|
410
|
+
private backoffMs(attempt: number): number {
|
|
411
|
+
return Math.min(1000 * Math.pow(2, attempt), 10000);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private delay(ms: number): Promise<void> {
|
|
415
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
|
|
419
|
+
return Promise.race([
|
|
420
|
+
promise,
|
|
421
|
+
new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), ms))
|
|
422
|
+
]);
|
|
423
|
+
}
|
|
424
|
+
}
|