@peerbit/shared-log 13.2.32 → 13.2.34

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.
@@ -1,5 +1,4 @@
1
- import { serialize } from "@dao-xyz/borsh";
2
- import { type PublicSignKey, randomBytes, sha256Sync } from "@peerbit/crypto";
1
+ import { type PublicSignKey, randomBytes } from "@peerbit/crypto";
3
2
  import { logger as loggerFn } from "@peerbit/logger";
4
3
  import type { RPC } from "@peerbit/rpc";
5
4
  import {
@@ -8,7 +7,7 @@ import {
8
7
  } from "@peerbit/stream-interface";
9
8
  import type { TransportMessage } from "./message.js";
10
9
  import type { ReplicationRangeIndexable } from "./ranges.js";
11
- import { concat, fromString } from "uint8arrays";
10
+ import { deriveReplicationInfoV2ReceiverBinding } from "./replication-info-v2-binding.js";
12
11
  import {
13
12
  AddedReplicationInfoV2Message,
14
13
  AddedReplicationSegmentMessage,
@@ -22,46 +21,11 @@ import {
22
21
  const logger = loggerFn("peerbit:shared-log:replication-info-v2-send");
23
22
 
24
23
  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
- };
24
+ const DEFAULT_SEND_RETRY_MS = 1_000;
25
+ const DEFAULT_MAX_SEND_RETRY_MS = 30_000;
26
+ const MAX_BACKOFF_EXPONENT = 20;
40
27
 
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
- );
28
+ export { deriveReplicationInfoV2ReceiverBinding } from "./replication-info-v2-binding.js";
65
29
 
66
30
  export type LegacyReplicationInfoMessage =
67
31
  | AllReplicatingSegmentsMessage
@@ -78,15 +42,19 @@ export type ReplicationInfoV2SendState = {
78
42
  peerSession: object;
79
43
  receiverTransportSession: bigint;
80
44
  senderTransportSession: bigint;
45
+ capabilityTimestamp: bigint;
81
46
  lastRequestTimestamp: bigint;
82
47
  receiverRequestChallenge: Uint8Array;
83
48
  receiverChallenge: Uint8Array;
84
49
  senderEpoch: Uint8Array;
85
50
  ownershipLifecycleController: AbortController;
51
+ ownershipAbortListener: () => void;
86
52
  nextSequence: bigint;
87
53
  established: boolean;
88
54
  suspended: boolean;
89
55
  inFlightSequence?: bigint;
56
+ retryTimer?: ReturnType<typeof setTimeout>;
57
+ retryAttempts: number;
90
58
  controller: AbortController;
91
59
  pending?: SendRequest;
92
60
  worker?: Promise<void>;
@@ -101,11 +69,14 @@ export type ReplicationInfoV2SendDeps<R extends "u32" | "u64"> = {
101
69
  ranges: readonly { mode: unknown }[],
102
70
  ) => void;
103
71
  isClosed: () => boolean;
72
+ isPeerSessionCurrent: (peerHash: string, peerSession: object) => boolean;
104
73
  isPeerSessionOpen: (peerHash: string, peerSession: object) => boolean;
105
74
  captureReplicationOwnershipLifecycle: () => AbortController;
106
75
  isReplicationOwnershipLifecycleActive: (
107
76
  controller: AbortController,
108
77
  ) => boolean;
78
+ sendRetryMs?: number;
79
+ maxSendRetryMs?: number;
109
80
  };
110
81
 
111
82
  const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => {
@@ -131,7 +102,15 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
131
102
  _spentPeerSessions!: WeakSet<object>;
132
103
  _retiringWorkersByPeer!: Map<string, Promise<void>>;
133
104
 
105
+ private readonly sendRetryMs: number;
106
+ private readonly maxSendRetryMs: number;
107
+
134
108
  constructor(private readonly deps: ReplicationInfoV2SendDeps<R>) {
109
+ this.sendRetryMs = Math.max(1, deps.sendRetryMs ?? DEFAULT_SEND_RETRY_MS);
110
+ this.maxSendRetryMs = Math.max(
111
+ this.sendRetryMs,
112
+ deps.maxSendRetryMs ?? DEFAULT_MAX_SEND_RETRY_MS,
113
+ );
135
114
  this._senderEpoch = randomBytes(32);
136
115
  this._sendStates = new Map();
137
116
  this._spentPeerSessions = new WeakSet();
@@ -169,6 +148,14 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
169
148
 
170
149
  private clearState(state: ReplicationInfoV2SendState): void {
171
150
  this.trackRetiringWorker(state);
151
+ if (state.retryTimer) {
152
+ clearTimeout(state.retryTimer);
153
+ state.retryTimer = undefined;
154
+ }
155
+ state.ownershipLifecycleController.signal.removeEventListener(
156
+ "abort",
157
+ state.ownershipAbortListener,
158
+ );
172
159
  state.controller.abort();
173
160
  if (this._sendStates.get(state.peerHash) === state) {
174
161
  this._sendStates.delete(state.peerHash);
@@ -201,25 +188,72 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
201
188
  !this.deps.isClosed() &&
202
189
  !state.controller.signal.aborted &&
203
190
  this._sendStates.get(state.peerHash) === state &&
204
- this.deps.isPeerSessionOpen(state.peerHash, state.peerSession) &&
191
+ this.deps.isPeerSessionCurrent(state.peerHash, state.peerSession) &&
205
192
  this.deps.getSenderTransportSession() === state.senderTransportSession
206
193
  );
207
194
  }
208
195
 
209
- private isCurrent(state: ReplicationInfoV2SendState): boolean {
196
+ private isDestinationReady(state: ReplicationInfoV2SendState): boolean {
210
197
  return (
211
198
  this.isDestinationCurrent(state) &&
199
+ this.deps.isPeerSessionOpen(state.peerHash, state.peerSession)
200
+ );
201
+ }
202
+
203
+ private isCurrent(state: ReplicationInfoV2SendState): boolean {
204
+ return (
205
+ this.isDestinationReady(state) &&
212
206
  this.deps.isReplicationOwnershipLifecycleActive(
213
207
  state.ownershipLifecycleController,
214
208
  )
215
209
  );
216
210
  }
217
211
 
212
+ private retireSpentState(state: ReplicationInfoV2SendState): void {
213
+ this._spentPeerSessions.add(state.peerSession);
214
+ this.clearState(state);
215
+ }
216
+
217
+ /**
218
+ * Collapse every interrupted normal-send path to one authoritative snapshot.
219
+ * A closed readiness gate is temporary while the exact PeerSession remains
220
+ * current, so it parks instead of destroying the receiver binding. Sequence
221
+ * exhaustion is terminal even when readiness or ownership changed at the same
222
+ * time as an in-flight transport attempt.
223
+ */
224
+ private parkSnapshotForRetry(state: ReplicationInfoV2SendState): void {
225
+ state.inFlightSequence = undefined;
226
+ if (state.nextSequence > MAX_U64) {
227
+ this.retireSpentState(state);
228
+ return;
229
+ }
230
+ if (!this.isDestinationCurrent(state)) {
231
+ this.clearState(state);
232
+ return;
233
+ }
234
+ if (
235
+ !this.deps.isReplicationOwnershipLifecycleActive(
236
+ state.ownershipLifecycleController,
237
+ )
238
+ ) {
239
+ if (state.retryTimer) {
240
+ clearTimeout(state.retryTimer);
241
+ state.retryTimer = undefined;
242
+ }
243
+ state.pending = undefined;
244
+ return;
245
+ }
246
+
247
+ state.suspended = true;
248
+ state.pending = { kind: "snapshot" };
249
+ this.scheduleRetry(state);
250
+ }
251
+
218
252
  /**
219
253
  * Accept a signed receiver request. An exact newer retry asks for another
220
254
  * 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.
255
+ * can replace the binding only after a strictly newer signed capability;
256
+ * retiring work is drained before the replacement can start.
223
257
  */
224
258
  acceptRequest(
225
259
  request: RequestReplicationInfoV2Message,
@@ -227,6 +261,7 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
227
261
  from: PublicSignKey;
228
262
  peerSession: object;
229
263
  receiverTransportSession: bigint;
264
+ capabilityTimestamp: bigint;
230
265
  requestTimestamp: bigint;
231
266
  },
232
267
  ): boolean {
@@ -244,6 +279,7 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
244
279
  if (
245
280
  this._retiringWorkersByPeer.has(peerHash) ||
246
281
  this._spentPeerSessions.has(properties.peerSession) ||
282
+ !this.deps.isPeerSessionCurrent(peerHash, properties.peerSession) ||
247
283
  !this.deps.isPeerSessionOpen(peerHash, properties.peerSession)
248
284
  ) {
249
285
  return false;
@@ -261,6 +297,16 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
261
297
  return false;
262
298
  }
263
299
  }
300
+ if (
301
+ previous &&
302
+ !this.deps.isReplicationOwnershipLifecycleActive(
303
+ previous.ownershipLifecycleController,
304
+ )
305
+ ) {
306
+ // Ownership teardown retains the binding only so close/drop can send its
307
+ // terminal empty Full. A later request must not revive normal delivery.
308
+ return false;
309
+ }
264
310
  if (previous) {
265
311
  const sameBinding =
266
312
  previous.peerSession === properties.peerSession &&
@@ -275,22 +321,43 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
275
321
  return false;
276
322
  }
277
323
  previous.lastRequestTimestamp = properties.requestTimestamp;
324
+ previous.capabilityTimestamp = properties.capabilityTimestamp;
325
+ if (previous.retryTimer) {
326
+ clearTimeout(previous.retryTimer);
327
+ previous.retryTimer = undefined;
328
+ }
278
329
  previous.suspended = false;
330
+ previous.pending = { kind: "snapshot" };
279
331
  this.enqueueState(previous, { kind: "snapshot" });
280
332
  return true;
281
333
  }
282
334
 
283
- return false;
335
+ if (properties.capabilityTimestamp <= previous.capabilityTimestamp) {
336
+ return false;
337
+ }
338
+ this.clearState(previous);
339
+ previous = undefined;
340
+ if (this._retiringWorkersByPeer.has(peerHash)) {
341
+ return false;
342
+ }
284
343
  }
285
344
 
286
345
  const ownershipLifecycleController =
287
346
  this.deps.captureReplicationOwnershipLifecycle();
347
+ if (
348
+ !this.deps.isReplicationOwnershipLifecycleActive(
349
+ ownershipLifecycleController,
350
+ )
351
+ ) {
352
+ return false;
353
+ }
288
354
  const state: ReplicationInfoV2SendState = {
289
355
  peerHash,
290
356
  target: properties.from,
291
357
  peerSession: properties.peerSession,
292
358
  receiverTransportSession: properties.receiverTransportSession,
293
359
  senderTransportSession,
360
+ capabilityTimestamp: properties.capabilityTimestamp,
294
361
  lastRequestTimestamp: properties.requestTimestamp,
295
362
  receiverRequestChallenge: request.receiverChallenge.slice(),
296
363
  receiverChallenge: deriveReplicationInfoV2ReceiverBinding({
@@ -301,12 +368,26 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
301
368
  senderTransportSession,
302
369
  }),
303
370
  senderEpoch: this._senderEpoch.slice(),
371
+ ownershipAbortListener: () => {},
304
372
  nextSequence: 1n,
305
373
  established: false,
306
374
  suspended: false,
375
+ retryAttempts: 0,
307
376
  controller: new AbortController(),
308
377
  ownershipLifecycleController,
309
378
  };
379
+ state.ownershipAbortListener = () => {
380
+ if (state.retryTimer) {
381
+ clearTimeout(state.retryTimer);
382
+ state.retryTimer = undefined;
383
+ }
384
+ state.pending = undefined;
385
+ };
386
+ ownershipLifecycleController.signal.addEventListener(
387
+ "abort",
388
+ state.ownershipAbortListener,
389
+ { once: true },
390
+ );
310
391
  this._sendStates.set(peerHash, state);
311
392
  this.enqueueState(state, { kind: "snapshot" });
312
393
  return true;
@@ -329,20 +410,15 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
329
410
  state: ReplicationInfoV2SendState,
330
411
  request: SendRequest,
331
412
  ): 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
- }
413
+ if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
414
+ this.parkSnapshotForRetry(state);
342
415
  return;
343
416
  }
344
417
  if (state.suspended) {
345
- state.pending = undefined;
418
+ // Delivery is ambiguous while the backoff is armed. Never retain a
419
+ // potentially stale delta: one authoritative Full represents every
420
+ // mutation that arrives before the retry fires.
421
+ this.parkSnapshotForRetry(state);
346
422
  return;
347
423
  }
348
424
 
@@ -352,12 +428,14 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
352
428
  worker = Promise.resolve()
353
429
  .then(() => this.runWorker(state))
354
430
  .catch((error) => {
355
- const ownershipActive =
431
+ // Sequence cleanup and exhaustion fencing must happen before any
432
+ // readiness/ownership classification in the recovery path.
433
+ this.parkSnapshotForRetry(state);
434
+ if (
435
+ this._sendStates.get(state.peerHash) === state &&
356
436
  this.deps.isReplicationOwnershipLifecycleActive(
357
437
  state.ownershipLifecycleController,
358
- );
359
- if (
360
- ownershipActive &&
438
+ ) &&
361
439
  !state.controller.signal.aborted &&
362
440
  !this.deps.isClosed()
363
441
  ) {
@@ -367,28 +445,6 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
367
445
  (error as Error)?.message ?? String(error),
368
446
  );
369
447
  }
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
448
  })
393
449
  .finally(() => {
394
450
  if (state.worker === worker) {
@@ -397,7 +453,7 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
397
453
  // before this promise reaction clears `worker`. Re-arm that item
398
454
  // here so the one-slot bound cannot become a stranded queue.
399
455
  const pending = state.pending;
400
- if (pending && this.isCurrent(state)) {
456
+ if (pending) {
401
457
  state.pending = undefined;
402
458
  this.enqueueState(state, pending);
403
459
  }
@@ -417,6 +473,51 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
417
473
  state.pending = { kind: "snapshot" };
418
474
  }
419
475
 
476
+ private retryDelay(state: ReplicationInfoV2SendState): number {
477
+ const exponent = Math.max(0, state.retryAttempts - 1);
478
+ return Math.min(
479
+ this.maxSendRetryMs,
480
+ this.sendRetryMs * 2 ** Math.min(exponent, MAX_BACKOFF_EXPONENT),
481
+ );
482
+ }
483
+
484
+ private scheduleRetry(state: ReplicationInfoV2SendState): void {
485
+ if (state.retryTimer) {
486
+ return;
487
+ }
488
+ if (state.nextSequence > MAX_U64) {
489
+ this.retireSpentState(state);
490
+ return;
491
+ }
492
+ if (!this.isDestinationCurrent(state)) {
493
+ this.clearState(state);
494
+ return;
495
+ }
496
+ if (
497
+ !this.deps.isReplicationOwnershipLifecycleActive(
498
+ state.ownershipLifecycleController,
499
+ )
500
+ ) {
501
+ state.pending = undefined;
502
+ return;
503
+ }
504
+ state.retryAttempts = Math.min(
505
+ state.retryAttempts + 1,
506
+ MAX_BACKOFF_EXPONENT + 1,
507
+ );
508
+ state.retryTimer = setTimeout(() => {
509
+ state.retryTimer = undefined;
510
+ if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
511
+ this.parkSnapshotForRetry(state);
512
+ return;
513
+ }
514
+ state.suspended = false;
515
+ state.pending = { kind: "snapshot" };
516
+ this.enqueueState(state, { kind: "snapshot" });
517
+ }, this.retryDelay(state));
518
+ state.retryTimer.unref?.();
519
+ }
520
+
420
521
  private async createMessage(
421
522
  state: ReplicationInfoV2SendState,
422
523
  request: SendRequest,
@@ -455,19 +556,19 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
455
556
  }
456
557
 
457
558
  private async runWorker(state: ReplicationInfoV2SendState): Promise<void> {
458
- while (this.isCurrent(state)) {
559
+ while (true) {
560
+ if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
561
+ this.parkSnapshotForRetry(state);
562
+ return;
563
+ }
459
564
  const request = state.pending;
460
565
  if (!request) {
461
566
  return;
462
567
  }
463
568
  state.pending = undefined;
464
- if (state.nextSequence > MAX_U64) {
465
- this.clearState(state);
466
- return;
467
- }
468
-
469
569
  const message = await this.createMessage(state, request);
470
- if (!this.isCurrent(state)) {
570
+ if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
571
+ this.parkSnapshotForRetry(state);
471
572
  return;
472
573
  }
473
574
  // Consume the sequence before the transport attempt. From this point on
@@ -488,15 +589,12 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
488
589
  ]),
489
590
  });
490
591
  state.inFlightSequence = undefined;
491
- if (!this.isCurrent(state)) {
592
+ if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
593
+ this.parkSnapshotForRetry(state);
492
594
  return;
493
595
  }
596
+ state.retryAttempts = 0;
494
597
  state.established = true;
495
- if (state.nextSequence > MAX_U64) {
496
- this._spentPeerSessions.add(state.peerSession);
497
- this.clearState(state);
498
- return;
499
- }
500
598
  }
501
599
  }
502
600
 
@@ -518,22 +616,38 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
518
616
  continue;
519
617
  }
520
618
  state.pending = undefined;
619
+ const sequence = state.nextSequence;
620
+ state.nextSequence += 1n;
521
621
  const message = new FullReplicationInfoV2Message({
522
622
  receiverChallenge: state.receiverChallenge.slice(),
523
623
  senderEpoch: state.senderEpoch.slice(),
524
- sequence: state.nextSequence,
624
+ sequence,
525
625
  segments: [],
526
626
  });
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
- );
627
+ if (sequence === MAX_U64) {
628
+ // Retire the exhausted normal stream before transport invocation. The
629
+ // terminal attempt remains valid because it carries its caller-owned
630
+ // signal rather than the state controller that clearState aborts.
631
+ this.retireSpentState(state);
632
+ }
633
+ try {
634
+ sends.push(
635
+ Promise.resolve(
636
+ this.deps.getRpc().send(message, {
637
+ mode: new AcknowledgeDelivery({
638
+ to: [state.target],
639
+ redundancy: 1,
640
+ }),
641
+ priority: CONVERGENCE_MESSAGE_PRIORITY,
642
+ signal,
643
+ }),
644
+ ),
645
+ );
646
+ } catch (error) {
647
+ // Keep one synchronous transport failure isolated to its destination;
648
+ // the sequence was already consumed and later peers must still reset.
649
+ sends.push(Promise.reject(error));
650
+ }
537
651
  }
538
652
  await Promise.allSettled(sends);
539
653
  }