@optimystic/db-p2p 0.2.2 → 0.3.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 (139) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +66 -0
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -0
  3. package/dist/src/cluster/block-transfer-service.js +163 -0
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -0
  5. package/dist/src/cluster/block-transfer.d.ts +79 -0
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -0
  7. package/dist/src/cluster/block-transfer.js +211 -0
  8. package/dist/src/cluster/block-transfer.js.map +1 -0
  9. package/dist/src/cluster/cluster-repo.d.ts +14 -3
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +80 -35
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/rebalance-monitor.d.ts +64 -0
  14. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -0
  15. package/dist/src/cluster/rebalance-monitor.js +159 -0
  16. package/dist/src/cluster/rebalance-monitor.js.map +1 -0
  17. package/dist/src/cluster/service.js +1 -1
  18. package/dist/src/cluster/service.js.map +1 -1
  19. package/dist/src/dispute/arbitrator-selection.d.ts +10 -0
  20. package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -0
  21. package/dist/src/dispute/arbitrator-selection.js +22 -0
  22. package/dist/src/dispute/arbitrator-selection.js.map +1 -0
  23. package/dist/src/dispute/client.d.ts +17 -0
  24. package/dist/src/dispute/client.d.ts.map +1 -0
  25. package/dist/src/dispute/client.js +28 -0
  26. package/dist/src/dispute/client.js.map +1 -0
  27. package/dist/src/dispute/dispute-service.d.ts +81 -0
  28. package/dist/src/dispute/dispute-service.d.ts.map +1 -0
  29. package/dist/src/dispute/dispute-service.js +365 -0
  30. package/dist/src/dispute/dispute-service.js.map +1 -0
  31. package/dist/src/dispute/engine-health-monitor.d.ts +22 -0
  32. package/dist/src/dispute/engine-health-monitor.d.ts.map +1 -0
  33. package/dist/src/dispute/engine-health-monitor.js +75 -0
  34. package/dist/src/dispute/engine-health-monitor.js.map +1 -0
  35. package/dist/src/dispute/index.d.ts +7 -0
  36. package/dist/src/dispute/index.d.ts.map +1 -0
  37. package/dist/src/dispute/index.js +7 -0
  38. package/dist/src/dispute/index.js.map +1 -0
  39. package/dist/src/dispute/service.d.ts +41 -0
  40. package/dist/src/dispute/service.d.ts.map +1 -0
  41. package/dist/src/dispute/service.js +82 -0
  42. package/dist/src/dispute/service.js.map +1 -0
  43. package/dist/src/dispute/types.d.ts +106 -0
  44. package/dist/src/dispute/types.d.ts.map +1 -0
  45. package/dist/src/dispute/types.js +7 -0
  46. package/dist/src/dispute/types.js.map +1 -0
  47. package/dist/src/index.d.ts +3 -0
  48. package/dist/src/index.d.ts.map +1 -1
  49. package/dist/src/index.js +3 -0
  50. package/dist/src/index.js.map +1 -1
  51. package/dist/src/libp2p-key-network.d.ts +23 -2
  52. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  53. package/dist/src/libp2p-key-network.js +100 -15
  54. package/dist/src/libp2p-key-network.js.map +1 -1
  55. package/dist/src/libp2p-node-base.d.ts +6 -0
  56. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  57. package/dist/src/libp2p-node-base.js +66 -12
  58. package/dist/src/libp2p-node-base.js.map +1 -1
  59. package/dist/src/logger.d.ts +1 -0
  60. package/dist/src/logger.d.ts.map +1 -1
  61. package/dist/src/logger.js +2 -0
  62. package/dist/src/logger.js.map +1 -1
  63. package/dist/src/network/network-manager-service.d.ts +15 -4
  64. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  65. package/dist/src/network/network-manager-service.js +34 -22
  66. package/dist/src/network/network-manager-service.js.map +1 -1
  67. package/dist/src/protocol-client.d.ts +1 -0
  68. package/dist/src/protocol-client.d.ts.map +1 -1
  69. package/dist/src/protocol-client.js +23 -2
  70. package/dist/src/protocol-client.js.map +1 -1
  71. package/dist/src/repo/client.d.ts +1 -0
  72. package/dist/src/repo/client.d.ts.map +1 -1
  73. package/dist/src/repo/client.js +18 -1
  74. package/dist/src/repo/client.js.map +1 -1
  75. package/dist/src/repo/cluster-coordinator.d.ts +3 -1
  76. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  77. package/dist/src/repo/cluster-coordinator.js +42 -2
  78. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  79. package/dist/src/repo/coordinator-repo.d.ts +18 -2
  80. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  81. package/dist/src/repo/coordinator-repo.js +62 -6
  82. package/dist/src/repo/coordinator-repo.js.map +1 -1
  83. package/dist/src/repo/service.d.ts +18 -2
  84. package/dist/src/repo/service.d.ts.map +1 -1
  85. package/dist/src/repo/service.js +88 -91
  86. package/dist/src/repo/service.js.map +1 -1
  87. package/dist/src/reputation/index.d.ts +3 -0
  88. package/dist/src/reputation/index.d.ts.map +1 -0
  89. package/dist/src/reputation/index.js +3 -0
  90. package/dist/src/reputation/index.js.map +1 -0
  91. package/dist/src/reputation/peer-reputation.d.ts +23 -0
  92. package/dist/src/reputation/peer-reputation.d.ts.map +1 -0
  93. package/dist/src/reputation/peer-reputation.js +121 -0
  94. package/dist/src/reputation/peer-reputation.js.map +1 -0
  95. package/dist/src/reputation/types.d.ts +89 -0
  96. package/dist/src/reputation/types.d.ts.map +1 -0
  97. package/dist/src/reputation/types.js +42 -0
  98. package/dist/src/reputation/types.js.map +1 -0
  99. package/dist/src/storage/arachnode-fret-adapter.d.ts +5 -0
  100. package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
  101. package/dist/src/storage/arachnode-fret-adapter.js +10 -0
  102. package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
  103. package/dist/src/storage/block-storage.d.ts.map +1 -1
  104. package/dist/src/storage/block-storage.js +5 -0
  105. package/dist/src/storage/block-storage.js.map +1 -1
  106. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  107. package/dist/src/storage/storage-repo.js +8 -0
  108. package/dist/src/storage/storage-repo.js.map +1 -1
  109. package/package.json +11 -10
  110. package/src/cluster/block-transfer-service.ts +228 -0
  111. package/src/cluster/block-transfer.ts +284 -0
  112. package/src/cluster/cluster-repo.ts +93 -38
  113. package/src/cluster/rebalance-monitor.ts +225 -0
  114. package/src/dispute/arbitrator-selection.ts +28 -0
  115. package/src/dispute/client.ts +41 -0
  116. package/src/dispute/dispute-service.ts +453 -0
  117. package/src/dispute/engine-health-monitor.ts +86 -0
  118. package/src/dispute/index.ts +17 -0
  119. package/src/dispute/service.ts +119 -0
  120. package/src/dispute/types.ts +114 -0
  121. package/src/index.ts +3 -0
  122. package/src/libp2p-key-network.ts +120 -22
  123. package/src/libp2p-node-base.ts +77 -13
  124. package/src/logger.ts +2 -1
  125. package/src/network/network-manager-service.ts +48 -19
  126. package/src/protocol-client.ts +29 -7
  127. package/src/repo/client.ts +20 -6
  128. package/src/repo/cluster-coordinator.ts +43 -2
  129. package/src/repo/coordinator-repo.ts +70 -7
  130. package/src/repo/redirect.ts +0 -2
  131. package/src/repo/service.ts +95 -87
  132. package/src/reputation/index.ts +12 -0
  133. package/src/reputation/peer-reputation.ts +147 -0
  134. package/src/reputation/types.ts +117 -0
  135. package/src/storage/arachnode-fret-adapter.ts +11 -0
  136. package/src/storage/block-storage.ts +6 -0
  137. package/src/storage/storage-repo.ts +9 -0
  138. package/dist/index.min.js +0 -53
  139. package/dist/index.min.js.map +0 -7
@@ -0,0 +1,228 @@
1
+ import type { Startable, Stream } from '@libp2p/interface';
2
+ import type { IRepo, PeerId, IPeerNetwork } from '@optimystic/db-core';
3
+ import { pipe } from 'it-pipe';
4
+ import * as lp from 'it-length-prefixed';
5
+ import { fromString as u8FromString } from 'uint8arrays/from-string';
6
+ import { toString as u8ToString } from 'uint8arrays/to-string';
7
+ import { ProtocolClient } from '../protocol-client.js';
8
+ import { createLogger } from '../logger.js';
9
+
10
+ const log = createLogger('block-transfer-service');
11
+
12
+ /** Protocol path */
13
+ const BLOCK_TRANSFER_PREFIX = '/db-p2p/block-transfer/';
14
+ const BLOCK_TRANSFER_VERSION = '1.0.0';
15
+
16
+ export const buildBlockTransferProtocol = (protocolPrefix: string = ''): string =>
17
+ `${protocolPrefix}${BLOCK_TRANSFER_PREFIX}${BLOCK_TRANSFER_VERSION}`;
18
+
19
+ /** Request to transfer blocks */
20
+ export interface BlockTransferRequest {
21
+ type: 'pull' | 'push';
22
+ /** Block IDs being transferred */
23
+ blockIds: string[];
24
+ /** Reason for transfer */
25
+ reason: 'rebalance' | 'replication' | 'recovery';
26
+ /** For push: base64-encoded block data per block ID */
27
+ blockData?: Record<string, string>;
28
+ }
29
+
30
+ /** Response with block data */
31
+ export interface BlockTransferResponse {
32
+ /** Blocks successfully transferred: blockId → base64-encoded data */
33
+ blocks: Record<string, string>;
34
+ /** Block IDs that couldn't be found/transferred */
35
+ missing: string[];
36
+ }
37
+
38
+ // --- Service (server-side handler) ---
39
+
40
+ export interface BlockTransferServiceInit {
41
+ protocolPrefix?: string;
42
+ }
43
+
44
+ export interface BlockTransferServiceComponents {
45
+ registrar: { handle: (...args: any[]) => Promise<void>; unhandle: (...args: any[]) => Promise<void> };
46
+ repo: IRepo;
47
+ }
48
+
49
+ /**
50
+ * Libp2p service that handles incoming block transfer requests.
51
+ *
52
+ * Responds to pull requests by reading blocks from local storage.
53
+ * Handles push requests by accepting block data and storing it locally.
54
+ */
55
+ export class BlockTransferService implements Startable {
56
+ private running = false;
57
+ private readonly protocol: string;
58
+ private readonly repo: IRepo;
59
+ private readonly registrar: BlockTransferServiceComponents['registrar'];
60
+
61
+ constructor(
62
+ private readonly components: BlockTransferServiceComponents,
63
+ init: BlockTransferServiceInit = {}
64
+ ) {
65
+ this.protocol = buildBlockTransferProtocol(init.protocolPrefix ?? '');
66
+ this.repo = components.repo;
67
+ this.registrar = components.registrar;
68
+ }
69
+
70
+ async start(): Promise<void> {
71
+ if (this.running) return;
72
+ await this.registrar.handle(this.protocol, async (data: any) => {
73
+ await this.handleRequest(data.stream);
74
+ });
75
+ this.running = true;
76
+ log('started on %s', this.protocol);
77
+ }
78
+
79
+ async stop(): Promise<void> {
80
+ if (!this.running) return;
81
+ await this.registrar.unhandle(this.protocol);
82
+ this.running = false;
83
+ log('stopped');
84
+ }
85
+
86
+ private async handleRequest(stream: Stream): Promise<void> {
87
+ try {
88
+ const request = await this.readRequest(stream);
89
+ log('request type=%s blocks=%d reason=%s', request.type, request.blockIds.length, request.reason);
90
+
91
+ let response: BlockTransferResponse;
92
+ if (request.type === 'pull') {
93
+ response = await this.handlePull(request);
94
+ } else {
95
+ response = await this.handlePush(request);
96
+ }
97
+
98
+ await this.sendResponse(stream, response);
99
+ log('response blocks=%d missing=%d', Object.keys(response.blocks).length, response.missing.length);
100
+ } catch (error) {
101
+ log('error: %s', (error as Error).message);
102
+ try {
103
+ await this.sendResponse(stream, { blocks: {}, missing: [] });
104
+ } catch {
105
+ // ignore send errors
106
+ }
107
+ } finally {
108
+ try { await stream.close(); } catch { /* ignore */ }
109
+ }
110
+ }
111
+
112
+ private async handlePull(request: BlockTransferRequest): Promise<BlockTransferResponse> {
113
+ const blocks: Record<string, string> = {};
114
+ const missing: string[] = [];
115
+
116
+ const result = await this.repo.get({ blockIds: request.blockIds });
117
+
118
+ for (const blockId of request.blockIds) {
119
+ const blockResult = result[blockId];
120
+ if (blockResult?.block) {
121
+ blocks[blockId] = Buffer.from(JSON.stringify(blockResult.block)).toString('base64');
122
+ } else {
123
+ missing.push(blockId);
124
+ }
125
+ }
126
+
127
+ return { blocks, missing };
128
+ }
129
+
130
+ private async handlePush(request: BlockTransferRequest): Promise<BlockTransferResponse> {
131
+ const blocks: Record<string, string> = {};
132
+ const missing: string[] = [];
133
+
134
+ if (!request.blockData) {
135
+ return { blocks: {}, missing: request.blockIds };
136
+ }
137
+
138
+ // Accept pushed blocks — for each block, check if we already have it (idempotent)
139
+ for (const blockId of request.blockIds) {
140
+ const data = request.blockData[blockId];
141
+ if (data) {
142
+ // Verify we received valid data
143
+ try {
144
+ JSON.parse(Buffer.from(data, 'base64').toString('utf8'));
145
+ blocks[blockId] = data;
146
+ } catch {
147
+ missing.push(blockId);
148
+ }
149
+ } else {
150
+ missing.push(blockId);
151
+ }
152
+ }
153
+
154
+ return { blocks, missing };
155
+ }
156
+
157
+ private async readRequest(stream: Stream): Promise<BlockTransferRequest> {
158
+ const messages: Uint8Array[] = [];
159
+ await pipe(
160
+ stream,
161
+ lp.decode,
162
+ async (source) => {
163
+ for await (const msg of source) {
164
+ messages.push(msg.subarray());
165
+ }
166
+ }
167
+ );
168
+
169
+ if (messages.length === 0) {
170
+ throw new Error('No request received');
171
+ }
172
+
173
+ return JSON.parse(u8ToString(messages[0]!, 'utf8')) as BlockTransferRequest;
174
+ }
175
+
176
+ private async sendResponse(stream: Stream, response: BlockTransferResponse): Promise<void> {
177
+ const bytes = u8FromString(JSON.stringify(response), 'utf8');
178
+ const encoded = pipe([bytes], lp.encode);
179
+ for await (const chunk of encoded) {
180
+ stream.send(chunk);
181
+ }
182
+ }
183
+ }
184
+
185
+ /** Factory for creating BlockTransferService following the libp2p service pattern. */
186
+ export const blockTransferService = (init: BlockTransferServiceInit = {}) =>
187
+ (components: BlockTransferServiceComponents) => new BlockTransferService(components, init);
188
+
189
+ // --- Client ---
190
+
191
+ /**
192
+ * Client for sending block transfer requests to remote peers.
193
+ */
194
+ export class BlockTransferClient extends ProtocolClient {
195
+ private readonly protocol: string;
196
+
197
+ constructor(
198
+ peerId: PeerId,
199
+ peerNetwork: IPeerNetwork,
200
+ protocolPrefix: string = ''
201
+ ) {
202
+ super(peerId, peerNetwork);
203
+ this.protocol = buildBlockTransferProtocol(protocolPrefix);
204
+ }
205
+
206
+ /** Pull blocks from the remote peer. */
207
+ async pullBlocks(
208
+ blockIds: string[],
209
+ reason: BlockTransferRequest['reason'] = 'rebalance'
210
+ ): Promise<BlockTransferResponse> {
211
+ const request: BlockTransferRequest = { type: 'pull', blockIds, reason };
212
+ return await this.processMessage<BlockTransferResponse>(request, this.protocol);
213
+ }
214
+
215
+ /** Push blocks to the remote peer. */
216
+ async pushBlocks(
217
+ blockIds: string[],
218
+ blockDataBuffers: Uint8Array[],
219
+ reason: BlockTransferRequest['reason'] = 'rebalance'
220
+ ): Promise<BlockTransferResponse> {
221
+ const blockData: Record<string, string> = {};
222
+ for (let i = 0; i < blockIds.length; i++) {
223
+ blockData[blockIds[i]!] = Buffer.from(blockDataBuffers[i]!).toString('base64');
224
+ }
225
+ const request: BlockTransferRequest = { type: 'push', blockIds, reason, blockData };
226
+ return await this.processMessage<BlockTransferResponse>(request, this.protocol);
227
+ }
228
+ }
@@ -0,0 +1,284 @@
1
+ import type { BlockId, IRepo } from '@optimystic/db-core';
2
+ import type { PeerId, IPeerNetwork } from '@optimystic/db-core';
3
+ import { peerIdFromString } from '@libp2p/peer-id';
4
+ import type { PartitionDetector } from './partition-detector.js';
5
+ import type { RestorationCoordinator } from '../storage/restoration-coordinator-v2.js';
6
+ import { BlockTransferClient, type BlockTransferRequest, type BlockTransferResponse } from './block-transfer-service.js';
7
+ import { createLogger } from '../logger.js';
8
+
9
+ const log = createLogger('block-transfer');
10
+
11
+ /**
12
+ * Rebalance event describing gained/lost block responsibilities.
13
+ * Matches the RebalanceMonitor spec from the sibling ticket.
14
+ */
15
+ export interface RebalanceEvent {
16
+ /** Block IDs this node has gained responsibility for */
17
+ gained: string[];
18
+ /** Block IDs this node has lost responsibility for */
19
+ lost: string[];
20
+ /** Peers that are now closer for the lost blocks: blockId → peerId[] */
21
+ newOwners: Map<string, string[]>;
22
+ /** Timestamp of the topology change that triggered this */
23
+ triggeredAt: number;
24
+ }
25
+
26
+ export interface BlockTransferConfig {
27
+ /** Max concurrent transfers. Default: 4 */
28
+ maxConcurrency?: number;
29
+ /** Timeout per block transfer (ms). Default: 30000 */
30
+ transferTimeoutMs?: number;
31
+ /** Retry attempts for failed transfers. Default: 2 */
32
+ maxRetries?: number;
33
+ /** Whether to push blocks to new owners proactively. Default: true */
34
+ enablePush?: boolean;
35
+ }
36
+
37
+ interface TransferTask {
38
+ blockId: string;
39
+ attempt: number;
40
+ }
41
+
42
+ /**
43
+ * Coordinates block transfers in response to rebalance events.
44
+ *
45
+ * For gained blocks: delegates to RestorationCoordinator.restore() which
46
+ * already handles ring-based discovery and fetching.
47
+ *
48
+ * For lost blocks: proactively pushes block data to new responsible peers
49
+ * via the BlockTransfer protocol.
50
+ */
51
+ export class BlockTransferCoordinator {
52
+ private readonly maxConcurrency: number;
53
+ private readonly transferTimeoutMs: number;
54
+ private readonly maxRetries: number;
55
+ private readonly enablePush: boolean;
56
+ private inFlight = new Set<string>();
57
+ private concurrency = 0;
58
+ private readonly waitQueue: Array<() => void> = [];
59
+
60
+ constructor(
61
+ private readonly repo: IRepo,
62
+ private readonly peerNetwork: IPeerNetwork,
63
+ private readonly restorationCoordinator: RestorationCoordinator,
64
+ private readonly partitionDetector: PartitionDetector,
65
+ private readonly protocolPrefix: string = '',
66
+ config: BlockTransferConfig = {}
67
+ ) {
68
+ this.maxConcurrency = config.maxConcurrency ?? 4;
69
+ this.transferTimeoutMs = config.transferTimeoutMs ?? 30000;
70
+ this.maxRetries = config.maxRetries ?? 2;
71
+ this.enablePush = config.enablePush ?? true;
72
+ }
73
+
74
+ /**
75
+ * Pull blocks that this node has gained responsibility for.
76
+ * Uses RestorationCoordinator to discover holders and fetch block data.
77
+ */
78
+ async pullBlocks(blockIds: string[]): Promise<{ succeeded: string[]; failed: string[] }> {
79
+ if (this.partitionDetector.detectPartition()) {
80
+ log('pull:partition-detected, skipping %d blocks', blockIds.length);
81
+ return { succeeded: [], failed: blockIds };
82
+ }
83
+
84
+ const succeeded: string[] = [];
85
+ const failed: string[] = [];
86
+
87
+ const tasks = blockIds
88
+ .filter(id => !this.inFlight.has(`pull:${id}`))
89
+ .map(id => ({ blockId: id, attempt: 0 }));
90
+
91
+ await Promise.all(tasks.map(task => this.executePull(task, succeeded, failed)));
92
+
93
+ return { succeeded, failed };
94
+ }
95
+
96
+ /**
97
+ * Push blocks that this node has lost responsibility for to new owners.
98
+ */
99
+ async pushBlocks(
100
+ blockIds: string[],
101
+ newOwners: Map<string, string[]>
102
+ ): Promise<{ succeeded: string[]; failed: string[] }> {
103
+ if (!this.enablePush) {
104
+ return { succeeded: [], failed: [] };
105
+ }
106
+ if (this.partitionDetector.detectPartition()) {
107
+ log('push:partition-detected, skipping %d blocks', blockIds.length);
108
+ return { succeeded: [], failed: blockIds };
109
+ }
110
+
111
+ const succeeded: string[] = [];
112
+ const failed: string[] = [];
113
+
114
+ const tasks = blockIds
115
+ .filter(id => !this.inFlight.has(`push:${id}`) && newOwners.has(id))
116
+ .map(id => ({ blockId: id, attempt: 0 }));
117
+
118
+ await Promise.all(tasks.map(task => this.executePush(task, newOwners, succeeded, failed)));
119
+
120
+ return { succeeded, failed };
121
+ }
122
+
123
+ /**
124
+ * Handle a complete rebalance event — pull gained, push lost.
125
+ */
126
+ async handleRebalanceEvent(event: RebalanceEvent): Promise<void> {
127
+ log('rebalance:start gained=%d lost=%d', event.gained.length, event.lost.length);
128
+
129
+ const [pullResult, pushResult] = 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.pushBlocks(event.lost, event.newOwners)
133
+ : { succeeded: [], failed: [] }
134
+ ]);
135
+
136
+ log('rebalance:done pull=%d/%d push=%d/%d',
137
+ pullResult.succeeded.length, event.gained.length,
138
+ pushResult.succeeded.length, event.lost.length);
139
+ }
140
+
141
+ private async executePull(
142
+ task: TransferTask,
143
+ succeeded: string[],
144
+ failed: string[]
145
+ ): Promise<void> {
146
+ const key = `pull:${task.blockId}`;
147
+ if (this.inFlight.has(key)) return;
148
+ this.inFlight.add(key);
149
+
150
+ try {
151
+ await this.acquireSemaphore();
152
+ try {
153
+ const archive = await this.withTimeout(
154
+ this.restorationCoordinator.restore(task.blockId),
155
+ this.transferTimeoutMs
156
+ );
157
+
158
+ if (archive) {
159
+ log('pull:ok block=%s', task.blockId);
160
+ succeeded.push(task.blockId);
161
+ } else if (task.attempt < this.maxRetries) {
162
+ log('pull:retry block=%s attempt=%d', task.blockId, task.attempt + 1);
163
+ this.inFlight.delete(key);
164
+ await this.delay(this.backoffMs(task.attempt));
165
+ await this.executePull({ ...task, attempt: task.attempt + 1 }, succeeded, failed);
166
+ return;
167
+ } else {
168
+ log('pull:failed block=%s', task.blockId);
169
+ failed.push(task.blockId);
170
+ }
171
+ } finally {
172
+ this.releaseSemaphore();
173
+ }
174
+ } finally {
175
+ this.inFlight.delete(key);
176
+ }
177
+ }
178
+
179
+ private async executePush(
180
+ task: TransferTask,
181
+ newOwners: Map<string, string[]>,
182
+ succeeded: string[],
183
+ failed: string[]
184
+ ): Promise<void> {
185
+ const key = `push:${task.blockId}`;
186
+ if (this.inFlight.has(key)) return;
187
+ this.inFlight.add(key);
188
+
189
+ try {
190
+ await this.acquireSemaphore();
191
+ try {
192
+ const owners = newOwners.get(task.blockId);
193
+ if (!owners || owners.length === 0) {
194
+ failed.push(task.blockId);
195
+ return;
196
+ }
197
+
198
+ // Read block data from local storage
199
+ const result = await this.repo.get({ blockIds: [task.blockId] });
200
+ const blockResult = result[task.blockId];
201
+ if (!blockResult?.block) {
202
+ log('push:no-local-data block=%s', task.blockId);
203
+ failed.push(task.blockId);
204
+ return;
205
+ }
206
+
207
+ const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
208
+
209
+ // Push to at least one new owner
210
+ let pushed = false;
211
+ for (const ownerPeerIdStr of owners) {
212
+ try {
213
+ const peerId = peerIdFromString(ownerPeerIdStr);
214
+ const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
215
+ const response = await this.withTimeout(
216
+ client.pushBlocks([task.blockId], [blockData]),
217
+ this.transferTimeoutMs
218
+ );
219
+
220
+ if (response && !response.missing.includes(task.blockId)) {
221
+ pushed = true;
222
+ log('push:ok block=%s peer=%s', task.blockId, ownerPeerIdStr);
223
+ break;
224
+ }
225
+ } catch (err) {
226
+ log('push:peer-error block=%s peer=%s err=%s',
227
+ task.blockId, ownerPeerIdStr, (err as Error).message);
228
+ }
229
+ }
230
+
231
+ if (pushed) {
232
+ succeeded.push(task.blockId);
233
+ } else if (task.attempt < this.maxRetries) {
234
+ log('push:retry block=%s attempt=%d', task.blockId, task.attempt + 1);
235
+ this.inFlight.delete(key);
236
+ await this.delay(this.backoffMs(task.attempt));
237
+ await this.executePush({ ...task, attempt: task.attempt + 1 }, newOwners, succeeded, failed);
238
+ return;
239
+ } else {
240
+ log('push:failed block=%s', task.blockId);
241
+ failed.push(task.blockId);
242
+ }
243
+ } finally {
244
+ this.releaseSemaphore();
245
+ }
246
+ } finally {
247
+ this.inFlight.delete(key);
248
+ }
249
+ }
250
+
251
+ // --- Semaphore for concurrency limiting ---
252
+
253
+ private async acquireSemaphore(): Promise<void> {
254
+ if (this.concurrency < this.maxConcurrency) {
255
+ this.concurrency++;
256
+ return;
257
+ }
258
+ await new Promise<void>(resolve => this.waitQueue.push(resolve));
259
+ this.concurrency++;
260
+ }
261
+
262
+ private releaseSemaphore(): void {
263
+ this.concurrency--;
264
+ const next = this.waitQueue.shift();
265
+ if (next) next();
266
+ }
267
+
268
+ // --- Helpers ---
269
+
270
+ private backoffMs(attempt: number): number {
271
+ return Math.min(1000 * Math.pow(2, attempt), 10000);
272
+ }
273
+
274
+ private delay(ms: number): Promise<void> {
275
+ return new Promise(resolve => setTimeout(resolve, ms));
276
+ }
277
+
278
+ private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
279
+ return Promise.race([
280
+ promise,
281
+ new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), ms))
282
+ ]);
283
+ }
284
+ }