@peerbit/shared-log 13.2.31 → 13.2.33
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/exchange-heads.d.ts +13 -2
- package/dist/src/exchange-heads.d.ts.map +1 -1
- package/dist/src/exchange-heads.js +13 -2
- package/dist/src/exchange-heads.js.map +1 -1
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +727 -85
- package/dist/src/index.js.map +1 -1
- package/dist/src/replication-announcement.d.ts +3 -1
- package/dist/src/replication-announcement.d.ts.map +1 -1
- package/dist/src/replication-announcement.js +1 -0
- package/dist/src/replication-announcement.js.map +1 -1
- package/dist/src/replication-info-v2-binding.d.ts +16 -0
- package/dist/src/replication-info-v2-binding.d.ts.map +1 -0
- package/dist/src/replication-info-v2-binding.js +30 -0
- package/dist/src/replication-info-v2-binding.js.map +1 -0
- package/dist/src/replication-info-v2-receive.d.ts +181 -0
- package/dist/src/replication-info-v2-receive.d.ts.map +1 -0
- package/dist/src/replication-info-v2-receive.js +725 -0
- package/dist/src/replication-info-v2-receive.js.map +1 -0
- package/dist/src/replication-info-v2-send.d.ts +95 -0
- package/dist/src/replication-info-v2-send.d.ts.map +1 -0
- package/dist/src/replication-info-v2-send.js +398 -0
- package/dist/src/replication-info-v2-send.js.map +1 -0
- package/dist/src/replication.d.ts +26 -4
- package/dist/src/replication.d.ts.map +1 -1
- package/dist/src/replication.js +60 -5
- package/dist/src/replication.js.map +1 -1
- package/package.json +11 -11
- package/src/exchange-heads.ts +15 -2
- package/src/index.ts +1040 -201
- package/src/replication-announcement.ts +10 -4
- package/src/replication-info-v2-binding.ts +44 -0
- package/src/replication-info-v2-receive.ts +1045 -0
- package/src/replication-info-v2-send.ts +540 -0
- package/src/replication.ts +39 -5
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { type PublicSignKey, randomBytes } from "@peerbit/crypto";
|
|
2
|
+
import { logger as loggerFn } from "@peerbit/logger";
|
|
3
|
+
import type { RPC } from "@peerbit/rpc";
|
|
4
|
+
import {
|
|
5
|
+
AcknowledgeDelivery,
|
|
6
|
+
CONVERGENCE_MESSAGE_PRIORITY,
|
|
7
|
+
} from "@peerbit/stream-interface";
|
|
8
|
+
import type { TransportMessage } from "./message.js";
|
|
9
|
+
import type { ReplicationRangeIndexable } from "./ranges.js";
|
|
10
|
+
import { deriveReplicationInfoV2ReceiverBinding } from "./replication-info-v2-binding.js";
|
|
11
|
+
import {
|
|
12
|
+
AddedReplicationInfoV2Message,
|
|
13
|
+
AddedReplicationSegmentMessage,
|
|
14
|
+
AllReplicatingSegmentsMessage,
|
|
15
|
+
FullReplicationInfoV2Message,
|
|
16
|
+
RequestReplicationInfoV2Message,
|
|
17
|
+
StoppedReplicating,
|
|
18
|
+
StoppedReplicationInfoV2Message,
|
|
19
|
+
} from "./replication.js";
|
|
20
|
+
|
|
21
|
+
const logger = loggerFn("peerbit:shared-log:replication-info-v2-send");
|
|
22
|
+
|
|
23
|
+
const MAX_U64 = (1n << 64n) - 1n;
|
|
24
|
+
|
|
25
|
+
export { deriveReplicationInfoV2ReceiverBinding } from "./replication-info-v2-binding.js";
|
|
26
|
+
|
|
27
|
+
export type LegacyReplicationInfoMessage =
|
|
28
|
+
| AllReplicatingSegmentsMessage
|
|
29
|
+
| AddedReplicationSegmentMessage
|
|
30
|
+
| StoppedReplicating;
|
|
31
|
+
|
|
32
|
+
type SendRequest =
|
|
33
|
+
| { kind: "snapshot" }
|
|
34
|
+
| { kind: "message"; message: LegacyReplicationInfoMessage };
|
|
35
|
+
|
|
36
|
+
export type ReplicationInfoV2SendState = {
|
|
37
|
+
peerHash: string;
|
|
38
|
+
target: PublicSignKey;
|
|
39
|
+
peerSession: object;
|
|
40
|
+
receiverTransportSession: bigint;
|
|
41
|
+
senderTransportSession: bigint;
|
|
42
|
+
capabilityTimestamp: bigint;
|
|
43
|
+
lastRequestTimestamp: bigint;
|
|
44
|
+
receiverRequestChallenge: Uint8Array;
|
|
45
|
+
receiverChallenge: Uint8Array;
|
|
46
|
+
senderEpoch: Uint8Array;
|
|
47
|
+
ownershipLifecycleController: AbortController;
|
|
48
|
+
nextSequence: bigint;
|
|
49
|
+
established: boolean;
|
|
50
|
+
suspended: boolean;
|
|
51
|
+
inFlightSequence?: bigint;
|
|
52
|
+
controller: AbortController;
|
|
53
|
+
pending?: SendRequest;
|
|
54
|
+
worker?: Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type ReplicationInfoV2SendDeps<R extends "u32" | "u64"> = {
|
|
58
|
+
getRpc: () => RPC<TransportMessage, TransportMessage>;
|
|
59
|
+
getSelfKey: () => PublicSignKey;
|
|
60
|
+
getSenderTransportSession: () => bigint;
|
|
61
|
+
getMyReplicationSegments: () => Promise<ReplicationRangeIndexable<R>[]>;
|
|
62
|
+
validatePersistedReplicationRangeSnapshot: (
|
|
63
|
+
ranges: readonly { mode: unknown }[],
|
|
64
|
+
) => void;
|
|
65
|
+
isClosed: () => boolean;
|
|
66
|
+
isPeerSessionOpen: (peerHash: string, peerSession: object) => boolean;
|
|
67
|
+
captureReplicationOwnershipLifecycle: () => AbortController;
|
|
68
|
+
isReplicationOwnershipLifecycleActive: (
|
|
69
|
+
controller: AbortController,
|
|
70
|
+
) => boolean;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => {
|
|
74
|
+
if (left.byteLength !== right.byteLength) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
for (let index = 0; index < left.byteLength; index++) {
|
|
78
|
+
if (left[index] !== right[index]) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Per-destination V2 sender streams. Each stream retains at most one in-flight
|
|
87
|
+
* operation and one pending operation. A second pending mutation coalesces to
|
|
88
|
+
* a freshly collected authoritative snapshot, bounding memory at O(peers).
|
|
89
|
+
*/
|
|
90
|
+
export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
|
|
91
|
+
_senderEpoch!: Uint8Array;
|
|
92
|
+
_sendStates!: Map<string, ReplicationInfoV2SendState>;
|
|
93
|
+
_spentPeerSessions!: WeakSet<object>;
|
|
94
|
+
_retiringWorkersByPeer!: Map<string, Promise<void>>;
|
|
95
|
+
|
|
96
|
+
constructor(private readonly deps: ReplicationInfoV2SendDeps<R>) {
|
|
97
|
+
this._senderEpoch = randomBytes(32);
|
|
98
|
+
this._sendStates = new Map();
|
|
99
|
+
this._spentPeerSessions = new WeakSet();
|
|
100
|
+
this._retiringWorkersByPeer = new Map();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resetForOpen(): void {
|
|
104
|
+
this.clearForClose();
|
|
105
|
+
this._senderEpoch = randomBytes(32);
|
|
106
|
+
this._sendStates = new Map();
|
|
107
|
+
this._spentPeerSessions = new WeakSet();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
clearForClose(): void {
|
|
111
|
+
for (const state of [...(this._sendStates?.values() ?? [])]) {
|
|
112
|
+
this.clearState(state);
|
|
113
|
+
}
|
|
114
|
+
this._sendStates?.clear();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
clearPeer(peerHash: string, expectedSession?: object): void {
|
|
118
|
+
const state = this._sendStates.get(peerHash);
|
|
119
|
+
if (!state || (expectedSession && state.peerSession !== expectedSession)) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
this.clearState(state);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
advancePeerCapability(peerHash: string): void {
|
|
126
|
+
const state = this._sendStates.get(peerHash);
|
|
127
|
+
if (state) {
|
|
128
|
+
this.clearState(state);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private clearState(state: ReplicationInfoV2SendState): void {
|
|
133
|
+
this.trackRetiringWorker(state);
|
|
134
|
+
state.controller.abort();
|
|
135
|
+
if (this._sendStates.get(state.peerHash) === state) {
|
|
136
|
+
this._sendStates.delete(state.peerHash);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private trackRetiringWorker(state: ReplicationInfoV2SendState): void {
|
|
141
|
+
const worker = state.worker;
|
|
142
|
+
if (!worker) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const previous = this._retiringWorkersByPeer.get(state.peerHash);
|
|
146
|
+
if (previous === worker) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const retirement = previous
|
|
150
|
+
? Promise.allSettled([previous, worker]).then(() => undefined)
|
|
151
|
+
: worker;
|
|
152
|
+
this._retiringWorkersByPeer.set(state.peerHash, retirement);
|
|
153
|
+
const forget = () => {
|
|
154
|
+
if (this._retiringWorkersByPeer.get(state.peerHash) === retirement) {
|
|
155
|
+
this._retiringWorkersByPeer.delete(state.peerHash);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
void retirement.then(forget, forget);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private isDestinationCurrent(state: ReplicationInfoV2SendState): boolean {
|
|
162
|
+
return (
|
|
163
|
+
!this.deps.isClosed() &&
|
|
164
|
+
!state.controller.signal.aborted &&
|
|
165
|
+
this._sendStates.get(state.peerHash) === state &&
|
|
166
|
+
this.deps.isPeerSessionOpen(state.peerHash, state.peerSession) &&
|
|
167
|
+
this.deps.getSenderTransportSession() === state.senderTransportSession
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private isCurrent(state: ReplicationInfoV2SendState): boolean {
|
|
172
|
+
return (
|
|
173
|
+
this.isDestinationCurrent(state) &&
|
|
174
|
+
this.deps.isReplicationOwnershipLifecycleActive(
|
|
175
|
+
state.ownershipLifecycleController,
|
|
176
|
+
)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Accept a signed receiver request. An exact newer retry asks for another
|
|
182
|
+
* full snapshot without resetting the epoch/sequence. A different challenge
|
|
183
|
+
* can replace the binding only after a strictly newer signed capability;
|
|
184
|
+
* retiring work is drained before the replacement can start.
|
|
185
|
+
*/
|
|
186
|
+
acceptRequest(
|
|
187
|
+
request: RequestReplicationInfoV2Message,
|
|
188
|
+
properties: {
|
|
189
|
+
from: PublicSignKey;
|
|
190
|
+
peerSession: object;
|
|
191
|
+
receiverTransportSession: bigint;
|
|
192
|
+
capabilityTimestamp: bigint;
|
|
193
|
+
requestTimestamp: bigint;
|
|
194
|
+
},
|
|
195
|
+
): boolean {
|
|
196
|
+
const self = this.deps.getSelfKey();
|
|
197
|
+
const senderTransportSession = this.deps.getSenderTransportSession();
|
|
198
|
+
if (
|
|
199
|
+
properties.from.equals(self) ||
|
|
200
|
+
!request.intendedSender.equals(self) ||
|
|
201
|
+
request.senderSession !== senderTransportSession
|
|
202
|
+
) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const peerHash = properties.from.hashcode();
|
|
207
|
+
if (
|
|
208
|
+
this._retiringWorkersByPeer.has(peerHash) ||
|
|
209
|
+
this._spentPeerSessions.has(properties.peerSession) ||
|
|
210
|
+
!this.deps.isPeerSessionOpen(peerHash, properties.peerSession)
|
|
211
|
+
) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let previous = this._sendStates.get(peerHash);
|
|
216
|
+
if (
|
|
217
|
+
previous &&
|
|
218
|
+
(previous.peerSession !== properties.peerSession ||
|
|
219
|
+
previous.senderTransportSession !== senderTransportSession)
|
|
220
|
+
) {
|
|
221
|
+
this.clearState(previous);
|
|
222
|
+
previous = undefined;
|
|
223
|
+
if (this._retiringWorkersByPeer.has(peerHash)) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (previous) {
|
|
228
|
+
const sameBinding =
|
|
229
|
+
previous.peerSession === properties.peerSession &&
|
|
230
|
+
previous.receiverTransportSession ===
|
|
231
|
+
properties.receiverTransportSession &&
|
|
232
|
+
bytesEqual(
|
|
233
|
+
previous.receiverRequestChallenge,
|
|
234
|
+
request.receiverChallenge,
|
|
235
|
+
);
|
|
236
|
+
if (sameBinding) {
|
|
237
|
+
if (properties.requestTimestamp <= previous.lastRequestTimestamp) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
previous.lastRequestTimestamp = properties.requestTimestamp;
|
|
241
|
+
previous.capabilityTimestamp = properties.capabilityTimestamp;
|
|
242
|
+
previous.suspended = false;
|
|
243
|
+
this.enqueueState(previous, { kind: "snapshot" });
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (properties.capabilityTimestamp <= previous.capabilityTimestamp) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
this.clearState(previous);
|
|
251
|
+
previous = undefined;
|
|
252
|
+
if (this._retiringWorkersByPeer.has(peerHash)) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const ownershipLifecycleController =
|
|
258
|
+
this.deps.captureReplicationOwnershipLifecycle();
|
|
259
|
+
const state: ReplicationInfoV2SendState = {
|
|
260
|
+
peerHash,
|
|
261
|
+
target: properties.from,
|
|
262
|
+
peerSession: properties.peerSession,
|
|
263
|
+
receiverTransportSession: properties.receiverTransportSession,
|
|
264
|
+
senderTransportSession,
|
|
265
|
+
capabilityTimestamp: properties.capabilityTimestamp,
|
|
266
|
+
lastRequestTimestamp: properties.requestTimestamp,
|
|
267
|
+
receiverRequestChallenge: request.receiverChallenge.slice(),
|
|
268
|
+
receiverChallenge: deriveReplicationInfoV2ReceiverBinding({
|
|
269
|
+
receiverChallenge: request.receiverChallenge,
|
|
270
|
+
receiver: properties.from,
|
|
271
|
+
receiverTransportSession: properties.receiverTransportSession,
|
|
272
|
+
sender: self,
|
|
273
|
+
senderTransportSession,
|
|
274
|
+
}),
|
|
275
|
+
senderEpoch: this._senderEpoch.slice(),
|
|
276
|
+
nextSequence: 1n,
|
|
277
|
+
established: false,
|
|
278
|
+
suspended: false,
|
|
279
|
+
controller: new AbortController(),
|
|
280
|
+
ownershipLifecycleController,
|
|
281
|
+
};
|
|
282
|
+
this._sendStates.set(peerHash, state);
|
|
283
|
+
this.enqueueState(state, { kind: "snapshot" });
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
enqueue(message: LegacyReplicationInfoMessage): void {
|
|
288
|
+
for (const state of [...this._sendStates.values()]) {
|
|
289
|
+
this.enqueueState(state, { kind: "message", message });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
enqueueSnapshotForPeer(peerHash: string): void {
|
|
294
|
+
const state = this._sendStates.get(peerHash);
|
|
295
|
+
if (state) {
|
|
296
|
+
this.enqueueState(state, { kind: "snapshot" });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private enqueueState(
|
|
301
|
+
state: ReplicationInfoV2SendState,
|
|
302
|
+
request: SendRequest,
|
|
303
|
+
): void {
|
|
304
|
+
if (!this.isCurrent(state)) {
|
|
305
|
+
state.pending = undefined;
|
|
306
|
+
if (
|
|
307
|
+
!this.isDestinationCurrent(state) ||
|
|
308
|
+
this.deps.isReplicationOwnershipLifecycleActive(
|
|
309
|
+
state.ownershipLifecycleController,
|
|
310
|
+
)
|
|
311
|
+
) {
|
|
312
|
+
this.clearState(state);
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (state.suspended) {
|
|
317
|
+
state.pending = undefined;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!state.worker) {
|
|
322
|
+
state.pending = request;
|
|
323
|
+
let worker: Promise<void>;
|
|
324
|
+
worker = Promise.resolve()
|
|
325
|
+
.then(() => this.runWorker(state))
|
|
326
|
+
.catch((error) => {
|
|
327
|
+
const ownershipActive =
|
|
328
|
+
this.deps.isReplicationOwnershipLifecycleActive(
|
|
329
|
+
state.ownershipLifecycleController,
|
|
330
|
+
);
|
|
331
|
+
if (
|
|
332
|
+
ownershipActive &&
|
|
333
|
+
!state.controller.signal.aborted &&
|
|
334
|
+
!this.deps.isClosed()
|
|
335
|
+
) {
|
|
336
|
+
logger.trace(
|
|
337
|
+
"Replication-info V2 destination stream failed for %s: %s",
|
|
338
|
+
state.peerHash,
|
|
339
|
+
(error as Error)?.message ?? String(error),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
state.pending = undefined;
|
|
343
|
+
const ordinaryFailure =
|
|
344
|
+
ownershipActive && this.isDestinationCurrent(state);
|
|
345
|
+
if (ordinaryFailure) {
|
|
346
|
+
if (state.inFlightSequence !== undefined) {
|
|
347
|
+
state.inFlightSequence = undefined;
|
|
348
|
+
if (state.nextSequence > MAX_U64) {
|
|
349
|
+
this._spentPeerSessions.add(state.peerSession);
|
|
350
|
+
this.clearState(state);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// A delivery error is ambiguous: the receiver may already have
|
|
355
|
+
// applied this sequence. Retain the exact grant, stop ordinary
|
|
356
|
+
// deltas, and require a newer same-challenge request to resume
|
|
357
|
+
// with an authoritative Full at the next safe sequence.
|
|
358
|
+
state.suspended = true;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!this.isDestinationCurrent(state)) {
|
|
362
|
+
this.clearState(state);
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
.finally(() => {
|
|
366
|
+
if (state.worker === worker) {
|
|
367
|
+
state.worker = undefined;
|
|
368
|
+
// An enqueue can land after runWorker observes an empty slot but
|
|
369
|
+
// before this promise reaction clears `worker`. Re-arm that item
|
|
370
|
+
// here so the one-slot bound cannot become a stranded queue.
|
|
371
|
+
const pending = state.pending;
|
|
372
|
+
if (pending && this.isCurrent(state)) {
|
|
373
|
+
state.pending = undefined;
|
|
374
|
+
this.enqueueState(state, pending);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
state.worker = worker;
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!state.pending) {
|
|
383
|
+
state.pending = request;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// One pending item is the hard bound. Once another mutation arrives,
|
|
388
|
+
// replace the pending delta with a current authoritative snapshot.
|
|
389
|
+
state.pending = { kind: "snapshot" };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private async createMessage(
|
|
393
|
+
state: ReplicationInfoV2SendState,
|
|
394
|
+
request: SendRequest,
|
|
395
|
+
): Promise<
|
|
396
|
+
| FullReplicationInfoV2Message
|
|
397
|
+
| AddedReplicationInfoV2Message
|
|
398
|
+
| StoppedReplicationInfoV2Message
|
|
399
|
+
> {
|
|
400
|
+
const common = {
|
|
401
|
+
receiverChallenge: state.receiverChallenge.slice(),
|
|
402
|
+
senderEpoch: state.senderEpoch.slice(),
|
|
403
|
+
sequence: state.nextSequence,
|
|
404
|
+
};
|
|
405
|
+
if (request.kind === "snapshot") {
|
|
406
|
+
const segments = (await this.deps.getMyReplicationSegments()).map(
|
|
407
|
+
(range) => range.toReplicationRange(),
|
|
408
|
+
);
|
|
409
|
+
this.deps.validatePersistedReplicationRangeSnapshot(segments);
|
|
410
|
+
return new FullReplicationInfoV2Message({ ...common, segments });
|
|
411
|
+
}
|
|
412
|
+
if (request.message instanceof AllReplicatingSegmentsMessage) {
|
|
413
|
+
const segments = request.message.segments;
|
|
414
|
+
this.deps.validatePersistedReplicationRangeSnapshot(segments);
|
|
415
|
+
return new FullReplicationInfoV2Message({ ...common, segments });
|
|
416
|
+
}
|
|
417
|
+
if (request.message instanceof AddedReplicationSegmentMessage) {
|
|
418
|
+
return new AddedReplicationInfoV2Message({
|
|
419
|
+
...common,
|
|
420
|
+
segments: request.message.segments,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
return new StoppedReplicationInfoV2Message({
|
|
424
|
+
...common,
|
|
425
|
+
segmentIds: request.message.segmentIds,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private async runWorker(state: ReplicationInfoV2SendState): Promise<void> {
|
|
430
|
+
while (this.isCurrent(state)) {
|
|
431
|
+
const request = state.pending;
|
|
432
|
+
if (!request) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
state.pending = undefined;
|
|
436
|
+
if (state.nextSequence > MAX_U64) {
|
|
437
|
+
this.clearState(state);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const message = await this.createMessage(state, request);
|
|
442
|
+
if (!this.isCurrent(state)) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
// Consume the sequence before the transport attempt. From this point on
|
|
446
|
+
// delivery is ambiguous even if ownership aborts before the await
|
|
447
|
+
// continuation runs, so `nextSequence` always remains the next value that
|
|
448
|
+
// has never been attempted with different content.
|
|
449
|
+
state.inFlightSequence = state.nextSequence;
|
|
450
|
+
state.nextSequence += 1n;
|
|
451
|
+
await this.deps.getRpc().send(message, {
|
|
452
|
+
mode: new AcknowledgeDelivery({
|
|
453
|
+
to: [state.target],
|
|
454
|
+
redundancy: 1,
|
|
455
|
+
}),
|
|
456
|
+
priority: CONVERGENCE_MESSAGE_PRIORITY,
|
|
457
|
+
signal: AbortSignal.any([
|
|
458
|
+
state.controller.signal,
|
|
459
|
+
state.ownershipLifecycleController.signal,
|
|
460
|
+
]),
|
|
461
|
+
});
|
|
462
|
+
state.inFlightSequence = undefined;
|
|
463
|
+
if (!this.isCurrent(state)) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
state.established = true;
|
|
467
|
+
if (state.nextSequence > MAX_U64) {
|
|
468
|
+
this._spentPeerSessions.add(state.peerSession);
|
|
469
|
+
this.clearState(state);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Terminal close/drop runs after the normal ownership generation is aborted.
|
|
477
|
+
* Send an authoritative empty Full directly from the retained destination
|
|
478
|
+
* bindings. `nextSequence` is consumed before every transport attempt, so it
|
|
479
|
+
* is always safe for different terminal content even if a just-aborted frame
|
|
480
|
+
* was delivered. Receivers admit Full gaps as authoritative resynchronization.
|
|
481
|
+
*/
|
|
482
|
+
async sendTerminalReset(signal: AbortSignal): Promise<void> {
|
|
483
|
+
const sends: Promise<unknown>[] = [];
|
|
484
|
+
for (const state of [...this._sendStates.values()]) {
|
|
485
|
+
if (
|
|
486
|
+
signal.aborted ||
|
|
487
|
+
!this.isDestinationCurrent(state) ||
|
|
488
|
+
state.nextSequence > MAX_U64
|
|
489
|
+
) {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
state.pending = undefined;
|
|
493
|
+
const message = new FullReplicationInfoV2Message({
|
|
494
|
+
receiverChallenge: state.receiverChallenge.slice(),
|
|
495
|
+
senderEpoch: state.senderEpoch.slice(),
|
|
496
|
+
sequence: state.nextSequence,
|
|
497
|
+
segments: [],
|
|
498
|
+
});
|
|
499
|
+
sends.push(
|
|
500
|
+
this.deps.getRpc().send(message, {
|
|
501
|
+
mode: new AcknowledgeDelivery({
|
|
502
|
+
to: [state.target],
|
|
503
|
+
redundancy: 1,
|
|
504
|
+
}),
|
|
505
|
+
priority: CONVERGENCE_MESSAGE_PRIORITY,
|
|
506
|
+
signal,
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
await Promise.allSettled(sends);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async drain(signal?: AbortSignal): Promise<void> {
|
|
514
|
+
while (!signal?.aborted) {
|
|
515
|
+
const workers = [
|
|
516
|
+
...[...this._sendStates.values()]
|
|
517
|
+
.map((state) => state.worker)
|
|
518
|
+
.filter((worker): worker is Promise<void> => worker != null),
|
|
519
|
+
...this._retiringWorkersByPeer.values(),
|
|
520
|
+
];
|
|
521
|
+
const uniqueWorkers = [...new Set(workers)];
|
|
522
|
+
if (uniqueWorkers.length === 0) {
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const settled = Promise.allSettled(uniqueWorkers).then(() => undefined);
|
|
526
|
+
if (!signal) {
|
|
527
|
+
await settled;
|
|
528
|
+
} else {
|
|
529
|
+
await new Promise<void>((resolve) => {
|
|
530
|
+
const onAbort = () => resolve();
|
|
531
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
532
|
+
void settled.then(() => {
|
|
533
|
+
signal.removeEventListener("abort", onAbort);
|
|
534
|
+
resolve();
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
package/src/replication.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
variant,
|
|
8
8
|
vec,
|
|
9
9
|
} from "@dao-xyz/borsh";
|
|
10
|
-
import { randomBytes } from "@peerbit/crypto";
|
|
10
|
+
import { PublicSignKey, randomBytes } from "@peerbit/crypto";
|
|
11
11
|
import { type Index } from "@peerbit/indexer-interface";
|
|
12
12
|
import { TransportMessage } from "./message.js";
|
|
13
13
|
import {
|
|
@@ -126,10 +126,11 @@ export class ReplicationPingMessage extends TransportMessage {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
129
|
+
* Challenge-bound replication-info V2 messages. Their `receiverChallenge`
|
|
130
|
+
* field carries a derived receiver binding, not the request's raw nonce. The
|
|
131
|
+
* binding covers both authenticated identities and signed transport sessions.
|
|
132
|
+
* Sender-ready nodes may emit them only after a signed, session-bound receiver
|
|
133
|
+
* request. Receive/apply activation remains separately capability-gated.
|
|
133
134
|
*/
|
|
134
135
|
@variant([1, 6])
|
|
135
136
|
export class FullReplicationInfoV2Message extends TransportMessage {
|
|
@@ -215,6 +216,39 @@ export class StoppedReplicationInfoV2Message extends TransportMessage {
|
|
|
215
216
|
}
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Signed, receiver-led permission for one sender to start a V2 stream.
|
|
221
|
+
*
|
|
222
|
+
* The transport delivery target is deliberately unsigned because relays may
|
|
223
|
+
* rewrite it. Carry the intended sender in the signed payload so a valid
|
|
224
|
+
* request cannot be retargeted to another peer. `senderSession` is copied from
|
|
225
|
+
* the sender's signed capability envelope and binds the request to that exact
|
|
226
|
+
* transport incarnation.
|
|
227
|
+
*/
|
|
228
|
+
@variant([1, 9])
|
|
229
|
+
export class RequestReplicationInfoV2Message extends TransportMessage {
|
|
230
|
+
/** Random receiver nonce used as input to the V2 receiver-binding hash. */
|
|
231
|
+
@field({ type: fixedArray("u8", 32) })
|
|
232
|
+
receiverChallenge: Uint8Array;
|
|
233
|
+
|
|
234
|
+
@field({ type: PublicSignKey })
|
|
235
|
+
intendedSender: PublicSignKey;
|
|
236
|
+
|
|
237
|
+
@field({ type: "u64" })
|
|
238
|
+
senderSession: bigint;
|
|
239
|
+
|
|
240
|
+
constructor(properties: {
|
|
241
|
+
receiverChallenge: Uint8Array;
|
|
242
|
+
intendedSender: PublicSignKey;
|
|
243
|
+
senderSession: bigint;
|
|
244
|
+
}) {
|
|
245
|
+
super();
|
|
246
|
+
this.receiverChallenge = properties.receiverChallenge;
|
|
247
|
+
this.intendedSender = properties.intendedSender;
|
|
248
|
+
this.senderSession = properties.senderSession;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
218
252
|
export type ReplicationInfoV2Message =
|
|
219
253
|
| FullReplicationInfoV2Message
|
|
220
254
|
| AddedReplicationInfoV2Message
|