@peerbit/shared-log 13.2.33 → 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.
@@ -21,6 +21,9 @@ import {
21
21
  const logger = loggerFn("peerbit:shared-log:replication-info-v2-send");
22
22
 
23
23
  const MAX_U64 = (1n << 64n) - 1n;
24
+ const DEFAULT_SEND_RETRY_MS = 1_000;
25
+ const DEFAULT_MAX_SEND_RETRY_MS = 30_000;
26
+ const MAX_BACKOFF_EXPONENT = 20;
24
27
 
25
28
  export { deriveReplicationInfoV2ReceiverBinding } from "./replication-info-v2-binding.js";
26
29
 
@@ -45,10 +48,13 @@ export type ReplicationInfoV2SendState = {
45
48
  receiverChallenge: Uint8Array;
46
49
  senderEpoch: Uint8Array;
47
50
  ownershipLifecycleController: AbortController;
51
+ ownershipAbortListener: () => void;
48
52
  nextSequence: bigint;
49
53
  established: boolean;
50
54
  suspended: boolean;
51
55
  inFlightSequence?: bigint;
56
+ retryTimer?: ReturnType<typeof setTimeout>;
57
+ retryAttempts: number;
52
58
  controller: AbortController;
53
59
  pending?: SendRequest;
54
60
  worker?: Promise<void>;
@@ -63,11 +69,14 @@ export type ReplicationInfoV2SendDeps<R extends "u32" | "u64"> = {
63
69
  ranges: readonly { mode: unknown }[],
64
70
  ) => void;
65
71
  isClosed: () => boolean;
72
+ isPeerSessionCurrent: (peerHash: string, peerSession: object) => boolean;
66
73
  isPeerSessionOpen: (peerHash: string, peerSession: object) => boolean;
67
74
  captureReplicationOwnershipLifecycle: () => AbortController;
68
75
  isReplicationOwnershipLifecycleActive: (
69
76
  controller: AbortController,
70
77
  ) => boolean;
78
+ sendRetryMs?: number;
79
+ maxSendRetryMs?: number;
71
80
  };
72
81
 
73
82
  const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => {
@@ -93,7 +102,15 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
93
102
  _spentPeerSessions!: WeakSet<object>;
94
103
  _retiringWorkersByPeer!: Map<string, Promise<void>>;
95
104
 
105
+ private readonly sendRetryMs: number;
106
+ private readonly maxSendRetryMs: number;
107
+
96
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
+ );
97
114
  this._senderEpoch = randomBytes(32);
98
115
  this._sendStates = new Map();
99
116
  this._spentPeerSessions = new WeakSet();
@@ -131,6 +148,14 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
131
148
 
132
149
  private clearState(state: ReplicationInfoV2SendState): void {
133
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
+ );
134
159
  state.controller.abort();
135
160
  if (this._sendStates.get(state.peerHash) === state) {
136
161
  this._sendStates.delete(state.peerHash);
@@ -163,20 +188,67 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
163
188
  !this.deps.isClosed() &&
164
189
  !state.controller.signal.aborted &&
165
190
  this._sendStates.get(state.peerHash) === state &&
166
- this.deps.isPeerSessionOpen(state.peerHash, state.peerSession) &&
191
+ this.deps.isPeerSessionCurrent(state.peerHash, state.peerSession) &&
167
192
  this.deps.getSenderTransportSession() === state.senderTransportSession
168
193
  );
169
194
  }
170
195
 
171
- private isCurrent(state: ReplicationInfoV2SendState): boolean {
196
+ private isDestinationReady(state: ReplicationInfoV2SendState): boolean {
172
197
  return (
173
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) &&
174
206
  this.deps.isReplicationOwnershipLifecycleActive(
175
207
  state.ownershipLifecycleController,
176
208
  )
177
209
  );
178
210
  }
179
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
+
180
252
  /**
181
253
  * Accept a signed receiver request. An exact newer retry asks for another
182
254
  * full snapshot without resetting the epoch/sequence. A different challenge
@@ -207,6 +279,7 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
207
279
  if (
208
280
  this._retiringWorkersByPeer.has(peerHash) ||
209
281
  this._spentPeerSessions.has(properties.peerSession) ||
282
+ !this.deps.isPeerSessionCurrent(peerHash, properties.peerSession) ||
210
283
  !this.deps.isPeerSessionOpen(peerHash, properties.peerSession)
211
284
  ) {
212
285
  return false;
@@ -224,6 +297,16 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
224
297
  return false;
225
298
  }
226
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
+ }
227
310
  if (previous) {
228
311
  const sameBinding =
229
312
  previous.peerSession === properties.peerSession &&
@@ -239,7 +322,12 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
239
322
  }
240
323
  previous.lastRequestTimestamp = properties.requestTimestamp;
241
324
  previous.capabilityTimestamp = properties.capabilityTimestamp;
325
+ if (previous.retryTimer) {
326
+ clearTimeout(previous.retryTimer);
327
+ previous.retryTimer = undefined;
328
+ }
242
329
  previous.suspended = false;
330
+ previous.pending = { kind: "snapshot" };
243
331
  this.enqueueState(previous, { kind: "snapshot" });
244
332
  return true;
245
333
  }
@@ -256,6 +344,13 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
256
344
 
257
345
  const ownershipLifecycleController =
258
346
  this.deps.captureReplicationOwnershipLifecycle();
347
+ if (
348
+ !this.deps.isReplicationOwnershipLifecycleActive(
349
+ ownershipLifecycleController,
350
+ )
351
+ ) {
352
+ return false;
353
+ }
259
354
  const state: ReplicationInfoV2SendState = {
260
355
  peerHash,
261
356
  target: properties.from,
@@ -273,12 +368,26 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
273
368
  senderTransportSession,
274
369
  }),
275
370
  senderEpoch: this._senderEpoch.slice(),
371
+ ownershipAbortListener: () => {},
276
372
  nextSequence: 1n,
277
373
  established: false,
278
374
  suspended: false,
375
+ retryAttempts: 0,
279
376
  controller: new AbortController(),
280
377
  ownershipLifecycleController,
281
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
+ );
282
391
  this._sendStates.set(peerHash, state);
283
392
  this.enqueueState(state, { kind: "snapshot" });
284
393
  return true;
@@ -301,20 +410,15 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
301
410
  state: ReplicationInfoV2SendState,
302
411
  request: SendRequest,
303
412
  ): 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
- }
413
+ if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
414
+ this.parkSnapshotForRetry(state);
314
415
  return;
315
416
  }
316
417
  if (state.suspended) {
317
- 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);
318
422
  return;
319
423
  }
320
424
 
@@ -324,12 +428,14 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
324
428
  worker = Promise.resolve()
325
429
  .then(() => this.runWorker(state))
326
430
  .catch((error) => {
327
- 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 &&
328
436
  this.deps.isReplicationOwnershipLifecycleActive(
329
437
  state.ownershipLifecycleController,
330
- );
331
- if (
332
- ownershipActive &&
438
+ ) &&
333
439
  !state.controller.signal.aborted &&
334
440
  !this.deps.isClosed()
335
441
  ) {
@@ -339,28 +445,6 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
339
445
  (error as Error)?.message ?? String(error),
340
446
  );
341
447
  }
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
448
  })
365
449
  .finally(() => {
366
450
  if (state.worker === worker) {
@@ -369,7 +453,7 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
369
453
  // before this promise reaction clears `worker`. Re-arm that item
370
454
  // here so the one-slot bound cannot become a stranded queue.
371
455
  const pending = state.pending;
372
- if (pending && this.isCurrent(state)) {
456
+ if (pending) {
373
457
  state.pending = undefined;
374
458
  this.enqueueState(state, pending);
375
459
  }
@@ -389,6 +473,51 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
389
473
  state.pending = { kind: "snapshot" };
390
474
  }
391
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
+
392
521
  private async createMessage(
393
522
  state: ReplicationInfoV2SendState,
394
523
  request: SendRequest,
@@ -427,19 +556,19 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
427
556
  }
428
557
 
429
558
  private async runWorker(state: ReplicationInfoV2SendState): Promise<void> {
430
- while (this.isCurrent(state)) {
559
+ while (true) {
560
+ if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
561
+ this.parkSnapshotForRetry(state);
562
+ return;
563
+ }
431
564
  const request = state.pending;
432
565
  if (!request) {
433
566
  return;
434
567
  }
435
568
  state.pending = undefined;
436
- if (state.nextSequence > MAX_U64) {
437
- this.clearState(state);
438
- return;
439
- }
440
-
441
569
  const message = await this.createMessage(state, request);
442
- if (!this.isCurrent(state)) {
570
+ if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
571
+ this.parkSnapshotForRetry(state);
443
572
  return;
444
573
  }
445
574
  // Consume the sequence before the transport attempt. From this point on
@@ -460,15 +589,12 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
460
589
  ]),
461
590
  });
462
591
  state.inFlightSequence = undefined;
463
- if (!this.isCurrent(state)) {
592
+ if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
593
+ this.parkSnapshotForRetry(state);
464
594
  return;
465
595
  }
596
+ state.retryAttempts = 0;
466
597
  state.established = true;
467
- if (state.nextSequence > MAX_U64) {
468
- this._spentPeerSessions.add(state.peerSession);
469
- this.clearState(state);
470
- return;
471
- }
472
598
  }
473
599
  }
474
600
 
@@ -490,22 +616,38 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
490
616
  continue;
491
617
  }
492
618
  state.pending = undefined;
619
+ const sequence = state.nextSequence;
620
+ state.nextSequence += 1n;
493
621
  const message = new FullReplicationInfoV2Message({
494
622
  receiverChallenge: state.receiverChallenge.slice(),
495
623
  senderEpoch: state.senderEpoch.slice(),
496
- sequence: state.nextSequence,
624
+ sequence,
497
625
  segments: [],
498
626
  });
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
- );
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
+ }
509
651
  }
510
652
  await Promise.allSettled(sends);
511
653
  }