@peerbit/shared-log 13.2.30 → 13.2.32

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