@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.
@@ -25,6 +25,7 @@ const DEFAULT_MAX_REQUEST_RETRY_MS = 30_000;
25
25
  const DEFAULT_REQUEST_MAX_ATTEMPTS = 7;
26
26
  const DEFAULT_LEGACY_FALLBACK_DELAY_MS = 5_000;
27
27
  const MAX_U64 = (1n << 64n) - 1n;
28
+ const MAX_BACKOFF_EXPONENT = 20;
28
29
 
29
30
  const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => {
30
31
  if (left.byteLength !== right.byteLength) {
@@ -64,6 +65,7 @@ export type ReplicationInfoV2ReceivePhase =
64
65
 
65
66
  type LocalCapabilityReady = {
66
67
  peerHash: string;
68
+ receiveEpoch: object | null;
67
69
  receiverTransportSession: bigint;
68
70
  /**
69
71
  * The local capability envelope and the later RequestV2 envelope use the
@@ -71,6 +73,45 @@ type LocalCapabilityReady = {
71
73
  * be strictly newer, so never construct it in this captured millisecond.
72
74
  */
73
75
  requestNotBeforeMs: number;
76
+ advertisement?: ReplicationInfoV2LocalCapabilityAdvertisement;
77
+ };
78
+
79
+ type LocalCapabilityReadyProperties = {
80
+ peerHash: string;
81
+ peerSession: object;
82
+ receiveEpoch: object | null;
83
+ receiverTransportSession: bigint;
84
+ requestNotBeforeMs: number;
85
+ };
86
+
87
+ export type ReplicationInfoV2LocalCapabilityAdvertisementHandle = {
88
+ firstAttempt: Promise<void>;
89
+ releaseLegacyBarrier(): void;
90
+ };
91
+
92
+ export type ReplicationInfoV2LocalCapabilityContext = {
93
+ peerHash: string;
94
+ target: PublicSignKey;
95
+ lifecycleSignal: AbortSignal;
96
+ legacyBarrierReleased: boolean;
97
+ };
98
+
99
+ export type ReplicationInfoV2LocalCapabilityAdvertisement = {
100
+ peerHash: string;
101
+ target: PublicSignKey;
102
+ peerSession: object;
103
+ receiveEpoch: object | null;
104
+ lifecycleSignal: AbortSignal;
105
+ onLifecycleAbort: () => void;
106
+ controller: AbortController;
107
+ context: ReplicationInfoV2LocalCapabilityContext;
108
+ attempts: number;
109
+ ready: boolean;
110
+ acknowledgedReady?: LocalCapabilityReady;
111
+ receiverTransportSession?: bigint;
112
+ timer?: ReturnType<typeof setTimeout>;
113
+ inFlight?: Promise<void>;
114
+ firstAttempt?: Promise<void>;
74
115
  };
75
116
 
76
117
  export type ReplicationInfoV2LocalCapabilityRefresh = {
@@ -131,6 +172,11 @@ export type ReplicationInfoV2ReceiveDeps = {
131
172
  getSelfKey: () => PublicSignKey;
132
173
  getReceiverTransportSession: () => bigint;
133
174
  isClosed: () => boolean;
175
+ isPeerSessionCurrent: (peerHash: string, peerSession: object) => boolean;
176
+ isReceiveEpochCurrent: (
177
+ peerHash: string,
178
+ receiveEpoch: object | null,
179
+ ) => boolean;
134
180
  isPeerStateCurrent: (
135
181
  peerHash: string,
136
182
  peerSession: object,
@@ -153,6 +199,7 @@ export type ReplicationInfoV2ReceiveDeps = {
153
199
  signal: AbortSignal;
154
200
  }) => Promise<ReplicationInfoV2LocalCapabilityRefresh | undefined>;
155
201
  onRequestError?: (error: unknown) => void;
202
+ onLocalCapabilityError?: (error: unknown) => void;
156
203
  now?: () => number;
157
204
  requestRetryMs?: number;
158
205
  maxRequestRetryMs?: number;
@@ -169,6 +216,14 @@ export class ReplicationInfoV2ReceiveCoordinator {
169
216
  _receiveStates!: Map<string, ReplicationInfoV2ReceiveState>;
170
217
  _cutoverPeerSessions!: WeakSet<object>;
171
218
  _localCapabilityReadyBySession!: WeakMap<object, LocalCapabilityReady>;
219
+ _localCapabilityContextBySession!: WeakMap<
220
+ object,
221
+ ReplicationInfoV2LocalCapabilityContext
222
+ >;
223
+ _localCapabilityAdvertisementsByPeer!: Map<
224
+ string,
225
+ ReplicationInfoV2LocalCapabilityAdvertisement
226
+ >;
172
227
  _reservedAdmissionsByPeer!: Map<string, ReplicationInfoV2ReceiveAdmission>;
173
228
 
174
229
  private readonly now: () => number;
@@ -198,6 +253,8 @@ export class ReplicationInfoV2ReceiveCoordinator {
198
253
  this._receiveStates = new Map();
199
254
  this._cutoverPeerSessions = new WeakSet();
200
255
  this._localCapabilityReadyBySession = new WeakMap();
256
+ this._localCapabilityContextBySession = new WeakMap();
257
+ this._localCapabilityAdvertisementsByPeer = new Map();
201
258
  this._reservedAdmissionsByPeer = new Map();
202
259
  }
203
260
 
@@ -206,27 +263,46 @@ export class ReplicationInfoV2ReceiveCoordinator {
206
263
  this._receiveStates = new Map();
207
264
  this._cutoverPeerSessions = new WeakSet();
208
265
  this._localCapabilityReadyBySession = new WeakMap();
266
+ this._localCapabilityContextBySession = new WeakMap();
267
+ this._localCapabilityAdvertisementsByPeer = new Map();
209
268
  this._reservedAdmissionsByPeer = new Map();
210
269
  }
211
270
 
212
271
  clearForClose(): void {
272
+ for (const advertisement of [
273
+ ...(this._localCapabilityAdvertisementsByPeer?.values() ?? []),
274
+ ]) {
275
+ this.clearLocalCapabilityAdvertisement(advertisement);
276
+ }
277
+ this._localCapabilityAdvertisementsByPeer?.clear();
213
278
  for (const state of this._receiveStates?.values() ?? []) {
214
279
  this.clearState(state);
215
280
  }
216
281
  this._receiveStates?.clear();
217
282
  this._cutoverPeerSessions = new WeakSet();
218
283
  this._localCapabilityReadyBySession = new WeakMap();
284
+ this._localCapabilityContextBySession = new WeakMap();
219
285
  }
220
286
 
221
287
  clearPeer(peerHash: string, expectedSession?: object): void {
288
+ const advertisement =
289
+ this._localCapabilityAdvertisementsByPeer.get(peerHash);
290
+ if (
291
+ advertisement &&
292
+ (!expectedSession || advertisement.peerSession === expectedSession)
293
+ ) {
294
+ this.clearLocalCapabilityAdvertisement(advertisement);
295
+ }
222
296
  const state = this._receiveStates.get(peerHash);
223
297
  if (state && (!expectedSession || state.peerSession === expectedSession)) {
224
298
  this.clearState(state);
225
299
  this._localCapabilityReadyBySession.delete(state.peerSession);
300
+ this._localCapabilityContextBySession.delete(state.peerSession);
226
301
  this._cutoverPeerSessions.delete(state.peerSession);
227
302
  }
228
303
  if (expectedSession) {
229
304
  this._localCapabilityReadyBySession.delete(expectedSession);
305
+ this._localCapabilityContextBySession.delete(expectedSession);
230
306
  this._cutoverPeerSessions.delete(expectedSession);
231
307
  }
232
308
  }
@@ -259,14 +335,462 @@ export class ReplicationInfoV2ReceiveCoordinator {
259
335
  }
260
336
  }
261
337
 
262
- /** Record success of this session's ACKed local APPLY advertisement. */
263
- markLocalCapabilityReady(properties: {
338
+ private clearLocalCapabilityAdvertisement(
339
+ state: ReplicationInfoV2LocalCapabilityAdvertisement,
340
+ options?: { preserveContext?: boolean },
341
+ ): void {
342
+ if (state.timer) {
343
+ clearTimeout(state.timer);
344
+ state.timer = undefined;
345
+ }
346
+ state.lifecycleSignal.removeEventListener("abort", state.onLifecycleAbort);
347
+ state.controller.abort();
348
+ const wasMapped =
349
+ this._localCapabilityAdvertisementsByPeer.get(state.peerHash) === state;
350
+ if (wasMapped) {
351
+ this._localCapabilityAdvertisementsByPeer.delete(state.peerHash);
352
+ }
353
+ const ready = this._localCapabilityReadyBySession.get(state.peerSession);
354
+ if (ready?.advertisement === state) {
355
+ this._localCapabilityReadyBySession.delete(state.peerSession);
356
+ }
357
+ if (
358
+ wasMapped &&
359
+ !options?.preserveContext &&
360
+ this._localCapabilityContextBySession.get(state.peerSession) ===
361
+ state.context
362
+ ) {
363
+ this._localCapabilityContextBySession.delete(state.peerSession);
364
+ }
365
+ }
366
+
367
+ private isLocalCapabilityAdvertisementOwnerCurrent(
368
+ state: ReplicationInfoV2LocalCapabilityAdvertisement,
369
+ ): boolean {
370
+ return (
371
+ this._localCapabilityAdvertisementsByPeer.get(state.peerHash) === state &&
372
+ this._localCapabilityContextBySession.get(state.peerSession) ===
373
+ state.context &&
374
+ state.context.peerHash === state.peerHash &&
375
+ state.context.lifecycleSignal === state.lifecycleSignal &&
376
+ state.context.target.equals(state.target) &&
377
+ !state.controller.signal.aborted &&
378
+ !state.lifecycleSignal.aborted &&
379
+ !this.deps.isClosed() &&
380
+ this.deps.isPeerSessionCurrent(state.peerHash, state.peerSession) &&
381
+ (state.receiverTransportSession === undefined ||
382
+ this.deps.getReceiverTransportSession() ===
383
+ state.receiverTransportSession)
384
+ );
385
+ }
386
+
387
+ private isLocalCapabilityAdvertisementGenerationCurrent(
388
+ state: ReplicationInfoV2LocalCapabilityAdvertisement,
389
+ ): boolean {
390
+ return (
391
+ this.isLocalCapabilityAdvertisementOwnerCurrent(state) &&
392
+ this.deps.isReceiveEpochCurrent(state.peerHash, state.receiveEpoch)
393
+ );
394
+ }
395
+
396
+ private isLocalCapabilityAdvertisementReadyOpen(
397
+ state: ReplicationInfoV2LocalCapabilityAdvertisement,
398
+ ): boolean {
399
+ return (
400
+ this.isLocalCapabilityAdvertisementGenerationCurrent(state) &&
401
+ this.deps.isPeerStateCurrent(
402
+ state.peerHash,
403
+ state.peerSession,
404
+ state.receiveEpoch,
405
+ )
406
+ );
407
+ }
408
+
409
+ private localCapabilityRetryDelay(
410
+ state: ReplicationInfoV2LocalCapabilityAdvertisement,
411
+ ): number {
412
+ const exponent = Math.max(0, state.attempts - 1);
413
+ return Math.min(
414
+ this.maxRequestRetryMs,
415
+ this.requestRetryMs * 2 ** Math.min(exponent, MAX_BACKOFF_EXPONENT),
416
+ );
417
+ }
418
+
419
+ private armLocalCapabilityAdvertisement(
420
+ state: ReplicationInfoV2LocalCapabilityAdvertisement,
421
+ ): void {
422
+ if (state.timer || state.inFlight || state.ready) {
423
+ return;
424
+ }
425
+ if (state.acknowledgedReady && !state.context.legacyBarrierReleased) {
426
+ return;
427
+ }
428
+ if (!this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
429
+ this.clearLocalCapabilityAdvertisement(state);
430
+ return;
431
+ }
432
+ if (!this.isLocalCapabilityAdvertisementGenerationCurrent(state)) {
433
+ return;
434
+ }
435
+ state.timer = setTimeout(() => {
436
+ state.timer = undefined;
437
+ if (!this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
438
+ this.clearLocalCapabilityAdvertisement(state);
439
+ return;
440
+ }
441
+ if (!this.isLocalCapabilityAdvertisementGenerationCurrent(state)) {
442
+ return;
443
+ }
444
+ if (!this.isLocalCapabilityAdvertisementReadyOpen(state)) {
445
+ this.armLocalCapabilityAdvertisement(state);
446
+ return;
447
+ }
448
+ void this.runLocalCapabilityAdvertisement(state);
449
+ }, this.localCapabilityRetryDelay(state));
450
+ state.timer.unref?.();
451
+ }
452
+
453
+ private async runLocalCapabilityAdvertisement(
454
+ state: ReplicationInfoV2LocalCapabilityAdvertisement,
455
+ ): Promise<void> {
456
+ if (state.inFlight) {
457
+ await state.inFlight;
458
+ return;
459
+ }
460
+ if (state.ready) {
461
+ return;
462
+ }
463
+ if (!this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
464
+ this.clearLocalCapabilityAdvertisement(state);
465
+ return;
466
+ }
467
+ if (!this.isLocalCapabilityAdvertisementGenerationCurrent(state)) {
468
+ return;
469
+ }
470
+ if (state.acknowledgedReady) {
471
+ if (state.context.legacyBarrierReleased) {
472
+ this.promoteLocalCapabilityAdvertisement(state);
473
+ }
474
+ return;
475
+ }
476
+ if (!this.isLocalCapabilityAdvertisementReadyOpen(state)) {
477
+ this.armLocalCapabilityAdvertisement(state);
478
+ return;
479
+ }
480
+ state.attempts = Math.min(state.attempts + 1, MAX_BACKOFF_EXPONENT + 1);
481
+ let operation: Promise<void>;
482
+ operation = (async () => {
483
+ const refreshed = await this.deps.refreshLocalCapability({
484
+ peerHash: state.peerHash,
485
+ target: state.target,
486
+ peerSession: state.peerSession,
487
+ receiveEpoch: state.receiveEpoch,
488
+ signal: AbortSignal.any([
489
+ state.controller.signal,
490
+ state.lifecycleSignal,
491
+ ]),
492
+ });
493
+ if (
494
+ !refreshed ||
495
+ !this.isLocalCapabilityAdvertisementGenerationCurrent(state) ||
496
+ this.deps.getReceiverTransportSession() !==
497
+ refreshed.receiverTransportSession
498
+ ) {
499
+ return;
500
+ }
501
+ state.receiverTransportSession = refreshed.receiverTransportSession;
502
+ state.acknowledgedReady = {
503
+ peerHash: state.peerHash,
504
+ receiveEpoch: state.receiveEpoch,
505
+ receiverTransportSession: refreshed.receiverTransportSession,
506
+ requestNotBeforeMs: refreshed.requestNotBeforeMs,
507
+ advertisement: state,
508
+ };
509
+ state.attempts = 0;
510
+ this.promoteLocalCapabilityAdvertisement(state);
511
+ })()
512
+ .catch((error) => {
513
+ if (
514
+ !state.controller.signal.aborted &&
515
+ !state.lifecycleSignal.aborted &&
516
+ !this.deps.isClosed()
517
+ ) {
518
+ this.deps.onLocalCapabilityError?.(error);
519
+ }
520
+ })
521
+ .finally(() => {
522
+ if (state.inFlight === operation) {
523
+ state.inFlight = undefined;
524
+ }
525
+ if (!this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
526
+ this.clearLocalCapabilityAdvertisement(state);
527
+ } else if (
528
+ this.isLocalCapabilityAdvertisementGenerationCurrent(state) &&
529
+ !state.ready
530
+ ) {
531
+ if (!state.acknowledgedReady || state.context.legacyBarrierReleased) {
532
+ this.armLocalCapabilityAdvertisement(state);
533
+ }
534
+ }
535
+ });
536
+ state.inFlight = operation;
537
+ await operation;
538
+ }
539
+
540
+ /**
541
+ * Start local authenticated-apply advertisement independently of the legacy
542
+ * join path. ACK and legacy publication are a two-phase barrier: the first
543
+ * attempt always settles independently, while readiness is promoted only
544
+ * after the host releases the legacy barrier. Failed attempts leave one
545
+ * exact-session worker retrying with capped exponential backoff.
546
+ */
547
+ advertiseLocalCapability(properties: {
548
+ target: PublicSignKey;
549
+ peerSession: object;
550
+ receiveEpoch: object | null;
551
+ signal: AbortSignal;
552
+ }): ReplicationInfoV2LocalCapabilityAdvertisementHandle {
553
+ const peerHash = properties.target.hashcode();
554
+ if (
555
+ properties.signal.aborted ||
556
+ this.deps.isClosed() ||
557
+ !this.deps.isPeerSessionCurrent(peerHash, properties.peerSession) ||
558
+ !this.deps.isReceiveEpochCurrent(peerHash, properties.receiveEpoch)
559
+ ) {
560
+ return {
561
+ firstAttempt: Promise.resolve(),
562
+ releaseLegacyBarrier: () => {},
563
+ };
564
+ }
565
+ let context = this._localCapabilityContextBySession.get(
566
+ properties.peerSession,
567
+ );
568
+ if (
569
+ context &&
570
+ (context.peerHash !== peerHash ||
571
+ !context.target.equals(properties.target) ||
572
+ context.lifecycleSignal !== properties.signal)
573
+ ) {
574
+ return {
575
+ firstAttempt: Promise.resolve(),
576
+ releaseLegacyBarrier: () => {},
577
+ };
578
+ }
579
+ if (!context) {
580
+ context = {
581
+ peerHash,
582
+ target: properties.target,
583
+ lifecycleSignal: properties.signal,
584
+ legacyBarrierReleased: false,
585
+ };
586
+ this._localCapabilityContextBySession.set(
587
+ properties.peerSession,
588
+ context,
589
+ );
590
+ }
591
+ let state = this._localCapabilityAdvertisementsByPeer.get(peerHash);
592
+ if (
593
+ state &&
594
+ (state.peerSession !== properties.peerSession ||
595
+ state.context !== context ||
596
+ state.lifecycleSignal !== properties.signal ||
597
+ !state.target.equals(properties.target))
598
+ ) {
599
+ this.clearLocalCapabilityAdvertisement(state);
600
+ state = undefined;
601
+ }
602
+ if (state && state.receiveEpoch !== properties.receiveEpoch) {
603
+ this.clearLocalCapabilityAdvertisement(state, { preserveContext: true });
604
+ state = undefined;
605
+ }
606
+ if (state && !this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
607
+ this.clearLocalCapabilityAdvertisement(state);
608
+ state = undefined;
609
+ }
610
+ if (
611
+ this._localCapabilityContextBySession.get(properties.peerSession) !==
612
+ context
613
+ ) {
614
+ return {
615
+ firstAttempt: Promise.resolve(),
616
+ releaseLegacyBarrier: () => {},
617
+ };
618
+ }
619
+ if (!state) {
620
+ const controller = new AbortController();
621
+ const advertisement: ReplicationInfoV2LocalCapabilityAdvertisement = {
622
+ peerHash,
623
+ target: properties.target,
624
+ peerSession: properties.peerSession,
625
+ receiveEpoch: properties.receiveEpoch,
626
+ lifecycleSignal: properties.signal,
627
+ onLifecycleAbort: () => {},
628
+ controller,
629
+ context,
630
+ attempts: 0,
631
+ ready: false,
632
+ receiverTransportSession: this.deps.getReceiverTransportSession(),
633
+ };
634
+ advertisement.onLifecycleAbort = () =>
635
+ this.clearLocalCapabilityAdvertisement(advertisement);
636
+ properties.signal.addEventListener(
637
+ "abort",
638
+ advertisement.onLifecycleAbort,
639
+ {
640
+ once: true,
641
+ },
642
+ );
643
+ this._localCapabilityAdvertisementsByPeer.set(peerHash, advertisement);
644
+ state = advertisement;
645
+ }
646
+ const firstAttempt =
647
+ state.firstAttempt ??
648
+ (state.firstAttempt = this.runLocalCapabilityAdvertisement(state));
649
+ const advertisement = state;
650
+ return {
651
+ firstAttempt,
652
+ releaseLegacyBarrier: () =>
653
+ this.releaseLocalCapabilityLegacyBarrier(
654
+ advertisement.peerSession,
655
+ context,
656
+ ),
657
+ };
658
+ }
659
+
660
+ private releaseLocalCapabilityLegacyBarrier(
661
+ peerSession: object,
662
+ context: ReplicationInfoV2LocalCapabilityContext,
663
+ ): void {
664
+ if (context.legacyBarrierReleased) {
665
+ return;
666
+ }
667
+ if (
668
+ this._localCapabilityContextBySession.get(peerSession) !== context ||
669
+ context.lifecycleSignal.aborted ||
670
+ this.deps.isClosed() ||
671
+ !this.deps.isPeerSessionCurrent(context.peerHash, peerSession)
672
+ ) {
673
+ return;
674
+ }
675
+ context.legacyBarrierReleased = true;
676
+ const state = this._localCapabilityAdvertisementsByPeer.get(
677
+ context.peerHash,
678
+ );
679
+ if (state?.peerSession === peerSession && state.context === context) {
680
+ if (!this.isLocalCapabilityAdvertisementOwnerCurrent(state)) {
681
+ this.clearLocalCapabilityAdvertisement(state);
682
+ return;
683
+ }
684
+ this.promoteLocalCapabilityAdvertisement(state);
685
+ }
686
+ }
687
+
688
+ private promoteLocalCapabilityAdvertisement(
689
+ state: ReplicationInfoV2LocalCapabilityAdvertisement,
690
+ ): boolean {
691
+ const ready = state.acknowledgedReady;
692
+ if (
693
+ state.ready ||
694
+ !state.context.legacyBarrierReleased ||
695
+ !ready ||
696
+ ready.receiveEpoch !== state.receiveEpoch ||
697
+ ready.advertisement !== state ||
698
+ !this.isLocalCapabilityAdvertisementOwnerCurrent(state) ||
699
+ !this.isLocalCapabilityAdvertisementGenerationCurrent(state) ||
700
+ this.deps.getReceiverTransportSession() !== ready.receiverTransportSession
701
+ ) {
702
+ return state.ready;
703
+ }
704
+ if (!this.isLocalCapabilityAdvertisementReadyOpen(state)) {
705
+ this.armLocalCapabilityAdvertisement(state);
706
+ return false;
707
+ }
708
+ if (
709
+ !this.recordLocalCapabilityReady(
710
+ {
711
+ peerHash: state.peerHash,
712
+ peerSession: state.peerSession,
713
+ receiveEpoch: state.receiveEpoch,
714
+ receiverTransportSession: ready.receiverTransportSession,
715
+ requestNotBeforeMs: ready.requestNotBeforeMs,
716
+ },
717
+ state,
718
+ )
719
+ ) {
720
+ return false;
721
+ }
722
+ state.ready = true;
723
+ return true;
724
+ }
725
+
726
+ /**
727
+ * Re-advertise one exact current recovery epoch from the stable membership
728
+ * context captured during opening. The opening handle owns barrier release;
729
+ * recovery never bypasses a legacy snapshot or role publication still in
730
+ * progress.
731
+ */
732
+ reAdvertiseLocalCapabilityForRecovery(properties: {
264
733
  peerHash: string;
265
734
  peerSession: object;
266
735
  receiveEpoch: object | null;
267
- receiverTransportSession: bigint;
268
- requestNotBeforeMs: number;
269
736
  }): boolean {
737
+ const context = this._localCapabilityContextBySession.get(
738
+ properties.peerSession,
739
+ );
740
+ if (
741
+ !context ||
742
+ context.peerHash !== properties.peerHash ||
743
+ context.lifecycleSignal.aborted ||
744
+ this.deps.isClosed() ||
745
+ !this.deps.isPeerSessionCurrent(
746
+ properties.peerHash,
747
+ properties.peerSession,
748
+ ) ||
749
+ !this.deps.isReceiveEpochCurrent(
750
+ properties.peerHash,
751
+ properties.receiveEpoch,
752
+ )
753
+ ) {
754
+ return false;
755
+ }
756
+ const state = this._receiveStates.get(properties.peerHash);
757
+ if (
758
+ state?.peerSession === properties.peerSession &&
759
+ state.receiveEpoch === properties.receiveEpoch &&
760
+ state.receiverBinding !== undefined
761
+ ) {
762
+ return false;
763
+ }
764
+ const ready = this._localCapabilityReadyBySession.get(
765
+ properties.peerSession,
766
+ );
767
+ if (
768
+ ready?.peerHash === properties.peerHash &&
769
+ ready.receiveEpoch === properties.receiveEpoch &&
770
+ ready.receiverTransportSession === this.deps.getReceiverTransportSession()
771
+ ) {
772
+ return false;
773
+ }
774
+ this.advertiseLocalCapability({
775
+ target: context.target,
776
+ peerSession: properties.peerSession,
777
+ receiveEpoch: properties.receiveEpoch,
778
+ signal: context.lifecycleSignal,
779
+ });
780
+ return true;
781
+ }
782
+
783
+ /** Record success of this session's ACKed local APPLY advertisement. */
784
+ markLocalCapabilityReady(
785
+ properties: LocalCapabilityReadyProperties,
786
+ ): boolean {
787
+ return this.recordLocalCapabilityReady(properties);
788
+ }
789
+
790
+ private recordLocalCapabilityReady(
791
+ properties: LocalCapabilityReadyProperties,
792
+ advertisement?: ReplicationInfoV2LocalCapabilityAdvertisement,
793
+ ): boolean {
270
794
  const { peerHash, peerSession, receiverTransportSession } = properties;
271
795
  const state = this._receiveStates.get(peerHash);
272
796
  if (
@@ -283,8 +807,10 @@ export class ReplicationInfoV2ReceiveCoordinator {
283
807
 
284
808
  const ready: LocalCapabilityReady = {
285
809
  peerHash,
810
+ receiveEpoch: properties.receiveEpoch,
286
811
  receiverTransportSession,
287
812
  requestNotBeforeMs: properties.requestNotBeforeMs,
813
+ advertisement,
288
814
  };
289
815
  this._localCapabilityReadyBySession.set(peerSession, ready);
290
816
  if (
@@ -388,7 +914,11 @@ export class ReplicationInfoV2ReceiveCoordinator {
388
914
  });
389
915
  }
390
916
  const ready = this._localCapabilityReadyBySession.get(peerSession);
391
- if (ready?.peerHash === peerHash && state.receiverBinding === undefined) {
917
+ if (
918
+ ready?.peerHash === peerHash &&
919
+ ready.receiveEpoch === receiveEpoch &&
920
+ state.receiverBinding === undefined
921
+ ) {
392
922
  this.bindLocalCapability(state, ready);
393
923
  }
394
924
  if (state.phase !== "active") {
@@ -422,7 +952,7 @@ export class ReplicationInfoV2ReceiveCoordinator {
422
952
  };
423
953
  this._receiveStates.set(peerHash, state);
424
954
  const ready = this._localCapabilityReadyBySession.get(peerSession);
425
- if (ready?.peerHash === peerHash) {
955
+ if (ready?.peerHash === peerHash && ready.receiveEpoch === receiveEpoch) {
426
956
  this.bindLocalCapability(state, ready);
427
957
  this.armRequest(state, 0);
428
958
  }
@@ -937,6 +1467,7 @@ export class ReplicationInfoV2ReceiveCoordinator {
937
1467
 
938
1468
  const ready: LocalCapabilityReady = {
939
1469
  peerHash: state.peerHash,
1470
+ receiveEpoch: state.receiveEpoch,
940
1471
  receiverTransportSession: refreshed.receiverTransportSession,
941
1472
  requestNotBeforeMs: refreshed.requestNotBeforeMs,
942
1473
  };
@@ -988,6 +1519,7 @@ export class ReplicationInfoV2ReceiveCoordinator {
988
1519
  if (
989
1520
  !ready ||
990
1521
  ready.peerHash !== state.peerHash ||
1522
+ ready.receiveEpoch !== state.receiveEpoch ||
991
1523
  ready.receiverTransportSession !== state.receiverTransportSession
992
1524
  ) {
993
1525
  return;