@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.
package/src/index.ts CHANGED
@@ -154,6 +154,9 @@ import {
154
154
  ResponseIPrune,
155
155
  ResponseIPruneV2,
156
156
  SYNC_CAPABILITY_RAW_EXCHANGE_HEADS,
157
+ SYNC_CAPABILITY_REPLICATION_INFO_V2_APPLY,
158
+ SYNC_CAPABILITY_REPLICATION_INFO_V2_DECODE,
159
+ SYNC_CAPABILITY_REPLICATION_INFO_V2_SEND,
157
160
  StashBackedRawExchangeHeadsMessage,
158
161
  SyncCapabilitiesMessage,
159
162
  collectRawExchangeHeadSendPlan,
@@ -234,6 +237,7 @@ import {
234
237
  ReplicationAnnouncementCoordinator,
235
238
  isTransientReplicationAnnouncementError,
236
239
  } from "./replication-announcement.js";
240
+ import { ReplicationInfoV2SendCoordinator } from "./replication-info-v2-send.js";
237
241
  import {
238
242
  type ReplicationDomainHash,
239
243
  createReplicationDomainHash,
@@ -257,10 +261,12 @@ import {
257
261
  type ReplicationLimits,
258
262
  ReplicationPingMessage,
259
263
  RequestReplicationInfoMessage,
264
+ RequestReplicationInfoV2Message,
260
265
  ResponseRoleMessage,
261
266
  StoppedReplicating,
262
267
  decodeReplicas,
263
268
  encodeReplicas,
269
+ isReplicationInfoV2Message,
264
270
  maxReplicas,
265
271
  } from "./replication.js";
266
272
  import { ReplicatorLivenessMonitor } from "./replicator-liveness.js";
@@ -2022,7 +2028,12 @@ export class SharedLog<
2022
2028
  // PeerSession the advert was staged under; promote/delete key off it.
2023
2029
  private _openingSyncCapabilitiesByPeer!: Map<
2024
2030
  string,
2025
- { epoch: object; capabilities: number }
2031
+ {
2032
+ epoch: object;
2033
+ capabilities: number;
2034
+ transportSession?: bigint;
2035
+ timestamp?: bigint;
2036
+ }
2026
2037
  >;
2027
2038
  private _onFanoutDataFn?: (arg: any) => void;
2028
2039
  private _onFanoutUnicastFn?: (arg: any) => void;
@@ -3223,6 +3234,7 @@ export class SharedLog<
3223
3234
  | ReturnType<typeof debounceFixedInterval>
3224
3235
  | undefined;
3225
3236
  private _announcements!: ReplicationAnnouncementCoordinator<R>;
3237
+ private _v2Send!: ReplicationInfoV2SendCoordinator<R>;
3226
3238
 
3227
3239
  // A fn for debouncing the calls for pruning
3228
3240
  pruneDebouncedFn!: DebouncedAccumulatorMap<{
@@ -3284,6 +3296,10 @@ export class SharedLog<
3284
3296
  // Sync capability bits advertised by peers (SyncCapabilitiesMessage), keyed
3285
3297
  // by public key hash. Entries are dropped on unsubscribe/disconnect.
3286
3298
  private _peerSyncCapabilities!: Map<string, number>;
3299
+ // Signed transport session carried by the capability envelope. Kept in a
3300
+ // parallel map so existing capability-number consumers remain unchanged.
3301
+ private _peerSyncCapabilitySessions!: Map<string, bigint>;
3302
+ private _peerSyncCapabilityTimestamps!: Map<string, bigint>;
3287
3303
  // Pending live raw exchange-head gossip, coalesced per recipient set and
3288
3304
  // flushed at the end of the current event-loop turn (or when a batch cap
3289
3305
  // is hit). Only used when every recipient advertised raw capability.
@@ -3353,6 +3369,7 @@ export class SharedLog<
3353
3369
  this._announcements.queueCurrentReplicationStateAnnouncementRepair(),
3354
3370
  queueCurrentReplicationStateAnnouncementRetry: (error: unknown) =>
3355
3371
  this._announcements.queueCurrentReplicationStateAnnouncementRetry(error),
3372
+ enqueueReplicationInfoV2: (message) => this._v2Send.enqueue(message),
3356
3373
  isClosed: () => this.closed,
3357
3374
  getCloseSignal: () => this._closeController.signal,
3358
3375
  getMyReplicationSegments: () => this.getMyReplicationSegments(),
@@ -3372,6 +3389,30 @@ export class SharedLog<
3372
3389
  });
3373
3390
  }
3374
3391
 
3392
+ private createReplicationInfoV2SendCoordinator(): ReplicationInfoV2SendCoordinator<R> {
3393
+ return new ReplicationInfoV2SendCoordinator<R>({
3394
+ getRpc: () => this.rpc,
3395
+ getSelfKey: () => this.node.identity.publicKey,
3396
+ getSenderTransportSession: () =>
3397
+ BigInt(
3398
+ (this.node.services.pubsub as unknown as { session: number }).session,
3399
+ ),
3400
+ getMyReplicationSegments: () => this.getMyReplicationSegments(),
3401
+ validatePersistedReplicationRangeSnapshot: (ranges) =>
3402
+ this.validatePersistedReplicationRangeSnapshot(ranges),
3403
+ isClosed: () => this.closed,
3404
+ isPeerSessionOpen: (peerHash, peerSession) =>
3405
+ this._peerSessions.isCurrent(peerHash, peerSession) &&
3406
+ (peerSession as PeerSession).phase === "open" &&
3407
+ !this._peerSessions.isReplicationInfoBlocked(peerHash) &&
3408
+ this._peerSessions.isReceiveCleanupGateOpen(peerHash),
3409
+ captureReplicationOwnershipLifecycle: () =>
3410
+ this.captureReplicationOwnershipLifecycle(),
3411
+ isReplicationOwnershipLifecycleActive: (controller) =>
3412
+ this.isRepairLifecycleActive(controller),
3413
+ });
3414
+ }
3415
+
3375
3416
  private createReplicatorLivenessMonitor(): ReplicatorLivenessMonitor {
3376
3417
  return new ReplicatorLivenessMonitor({
3377
3418
  // Sweep-driven probes and activity marks dispatch through the owner so
@@ -3486,6 +3527,8 @@ export class SharedLog<
3486
3527
  this._appendBackfillPendingByTarget = new Map();
3487
3528
  this._topicSubscribersCache = new Map();
3488
3529
  this._peerSyncCapabilities = new Map();
3530
+ this._peerSyncCapabilitySessions = new Map();
3531
+ this._peerSyncCapabilityTimestamps = new Map();
3489
3532
  this._liveRawGossipBatches = new Map();
3490
3533
  this._liveRawGossipFlushScheduled = false;
3491
3534
  this.coordinateToHash = new Cache<string>({ max: 1e6, ttl: 1e4 });
@@ -3494,6 +3537,7 @@ export class SharedLog<
3494
3537
  this._replicatorJoinEmitted = new Set();
3495
3538
  this._replicatorsReconciled = false;
3496
3539
  this._liveness = this.createReplicatorLivenessMonitor();
3540
+ this._v2Send = this.createReplicationInfoV2SendCoordinator();
3497
3541
  this._announcements = this.createReplicationAnnouncementCoordinator();
3498
3542
  this.pendingMaturity = new Map();
3499
3543
  this._closeController = new AbortController();
@@ -3989,6 +4033,97 @@ export class SharedLog<
3989
4033
  );
3990
4034
  }
3991
4035
 
4036
+ private observePeerSyncCapabilities(properties: {
4037
+ peerHash: string;
4038
+ capabilities: number;
4039
+ transportSession?: bigint;
4040
+ timestamp?: bigint;
4041
+ openingSession?: PeerSession;
4042
+ }): boolean {
4043
+ const {
4044
+ peerHash,
4045
+ capabilities,
4046
+ transportSession,
4047
+ timestamp,
4048
+ openingSession,
4049
+ } = properties;
4050
+ if (openingSession) {
4051
+ const previous = this._openingSyncCapabilitiesByPeer.get(peerHash);
4052
+ if (
4053
+ transportSession !== undefined &&
4054
+ timestamp !== undefined &&
4055
+ previous?.epoch === openingSession &&
4056
+ previous.transportSession === transportSession
4057
+ ) {
4058
+ if (previous.timestamp !== undefined && timestamp < previous.timestamp) {
4059
+ return false;
4060
+ }
4061
+ this._openingSyncCapabilitiesByPeer.set(peerHash, {
4062
+ epoch: openingSession,
4063
+ capabilities: previous.capabilities | capabilities,
4064
+ transportSession,
4065
+ timestamp:
4066
+ previous.timestamp === undefined || timestamp > previous.timestamp
4067
+ ? timestamp
4068
+ : previous.timestamp,
4069
+ });
4070
+ return true;
4071
+ }
4072
+ this._openingSyncCapabilitiesByPeer.set(peerHash, {
4073
+ epoch: openingSession,
4074
+ capabilities,
4075
+ transportSession,
4076
+ timestamp,
4077
+ });
4078
+ return true;
4079
+ }
4080
+
4081
+ if (transportSession === undefined || timestamp === undefined) {
4082
+ // Test/in-process synthetic contexts predate signed envelope captures.
4083
+ // They may exercise capability-number behavior, but can never authorize V2.
4084
+ this._peerSyncCapabilities.set(peerHash, capabilities);
4085
+ this._peerSyncCapabilitySessions.delete(peerHash);
4086
+ this._peerSyncCapabilityTimestamps.delete(peerHash);
4087
+ this._v2Send.advancePeerCapability(peerHash);
4088
+ return true;
4089
+ }
4090
+
4091
+ const previousSession = this._peerSyncCapabilitySessions.get(peerHash);
4092
+ const previousTimestamp = this._peerSyncCapabilityTimestamps.get(peerHash);
4093
+ const sameTransportSession = previousSession === transportSession;
4094
+ if (
4095
+ sameTransportSession &&
4096
+ previousTimestamp !== undefined &&
4097
+ timestamp < previousTimestamp
4098
+ ) {
4099
+ return false;
4100
+ }
4101
+ const previousCapabilities =
4102
+ this._peerSyncCapabilities.get(peerHash) ?? 0;
4103
+ const nextCapabilities = sameTransportSession
4104
+ ? previousCapabilities | capabilities
4105
+ : capabilities;
4106
+ const generationAdvanced =
4107
+ !sameTransportSession ||
4108
+ previousTimestamp === undefined ||
4109
+ timestamp > previousTimestamp ||
4110
+ nextCapabilities !== previousCapabilities;
4111
+ this._peerSyncCapabilities.set(peerHash, nextCapabilities);
4112
+ this._peerSyncCapabilitySessions.set(peerHash, transportSession);
4113
+ this._peerSyncCapabilityTimestamps.set(
4114
+ peerHash,
4115
+ previousTimestamp === undefined ||
4116
+ !sameTransportSession ||
4117
+ timestamp > previousTimestamp
4118
+ ? timestamp
4119
+ : previousTimestamp,
4120
+ );
4121
+ if (generationAdvanced) {
4122
+ this._v2Send.advancePeerCapability(peerHash);
4123
+ }
4124
+ return true;
4125
+ }
4126
+
3992
4127
  /**
3993
4128
  * Live append gossip may use the raw exchange-heads path only when we
3994
4129
  * opted into raw sync and every remote recipient advertised raw capability
@@ -13829,6 +13964,8 @@ export class SharedLog<
13829
13964
  ttl: LEADER_PLAN_CACHE_TTL_MS,
13830
13965
  });
13831
13966
  this._peerSyncCapabilities = new Map();
13967
+ this._peerSyncCapabilitySessions = new Map();
13968
+ this._peerSyncCapabilityTimestamps = new Map();
13832
13969
  this._liveRawGossipBatches = new Map();
13833
13970
  this._liveRawGossipFlushScheduled = false;
13834
13971
  this.coordinateToHash = new Cache<string>({ max: 1e6, ttl: 1e4 });
@@ -13845,6 +13982,8 @@ export class SharedLog<
13845
13982
  this._lastLocalAppendAt = 0;
13846
13983
  this._announcements ??= this.createReplicationAnnouncementCoordinator();
13847
13984
  this._announcements.resetForOpen();
13985
+ this._v2Send ??= this.createReplicationInfoV2SendCoordinator();
13986
+ this._v2Send.resetForOpen();
13848
13987
  const adaptiveReplicateOptions =
13849
13988
  options?.replicate && isAdaptiveReplicatorOption(options.replicate)
13850
13989
  ? options.replicate
@@ -15337,6 +15476,9 @@ export class SharedLog<
15337
15476
  this._liveness._replicatorLivenessFailures.delete(peerHash);
15338
15477
  this._liveness._replicatorLastActivityAt.delete(peerHash);
15339
15478
  this._peerSyncCapabilities.delete(peerHash);
15479
+ this._peerSyncCapabilitySessions.delete(peerHash);
15480
+ this._peerSyncCapabilityTimestamps.delete(peerHash);
15481
+ this._v2Send.clearPeer(peerHash);
15340
15482
  this.cleanupPendingIHavePeer(peerHash);
15341
15483
  this.cleanupCheckedPrunePeer(
15342
15484
  peerHash,
@@ -16346,6 +16488,9 @@ export class SharedLog<
16346
16488
  this._openingSyncCapabilitiesByPeer?.clear();
16347
16489
  this._gidPeersHistory?.clear();
16348
16490
  this._peerSyncCapabilities?.clear();
16491
+ this._peerSyncCapabilitySessions?.clear();
16492
+ this._peerSyncCapabilityTimestamps?.clear();
16493
+ this._v2Send?.clearForClose();
16349
16494
  this._liveRawGossipBatches?.clear();
16350
16495
  this._nativeSharedLogState?.clearGidPeers();
16351
16496
  this._nativeBackbone?.clearGidPeers();
@@ -16478,12 +16623,14 @@ export class SharedLog<
16478
16623
  }
16479
16624
  }, 2_000);
16480
16625
  try {
16481
- await this.rpc
16482
- .send(new AllReplicatingSegmentsMessage({ segments: [] }), {
16626
+ const reset = new AllReplicatingSegmentsMessage({ segments: [] });
16627
+ await Promise.all([
16628
+ this.rpc.send(reset, {
16483
16629
  priority: CONVERGENCE_MESSAGE_PRIORITY,
16484
16630
  signal: abort.signal,
16485
- })
16486
- .catch(() => {});
16631
+ }).catch(() => {}),
16632
+ this._v2Send.sendTerminalReset(abort.signal),
16633
+ ]);
16487
16634
  } finally {
16488
16635
  clearTimeout(abortTimer);
16489
16636
  }
@@ -16602,12 +16749,14 @@ export class SharedLog<
16602
16749
  }
16603
16750
  }, 2_000);
16604
16751
  try {
16605
- await this.rpc
16606
- .send(new AllReplicatingSegmentsMessage({ segments: [] }), {
16752
+ const reset = new AllReplicatingSegmentsMessage({ segments: [] });
16753
+ await Promise.all([
16754
+ this.rpc.send(reset, {
16607
16755
  priority: CONVERGENCE_MESSAGE_PRIORITY,
16608
16756
  signal: abort.signal,
16609
- })
16610
- .catch(() => {});
16757
+ }).catch(() => {}),
16758
+ this._v2Send.sendTerminalReset(abort.signal),
16759
+ ]);
16611
16760
  } finally {
16612
16761
  clearTimeout(abortTimer);
16613
16762
  }
@@ -17124,6 +17273,13 @@ export class SharedLog<
17124
17273
  msg: TransportMessage,
17125
17274
  context: RequestContext,
17126
17275
  ): Promise<void> {
17276
+ // Decode-first only: capability advertisement is not permission to use
17277
+ // V2. Drop every unsolicited V2 envelope before even entering the shared
17278
+ // receive envelope, so durable poison checks, leases, liveness, ordering
17279
+ // watermarks, replication ranges and cleanup side effects cannot observe it.
17280
+ if (isReplicationInfoV2Message(msg)) {
17281
+ return;
17282
+ }
17127
17283
  const stashBackedRawMessage = isStashBackedRawExchangeHeadsMessage(msg)
17128
17284
  ? msg
17129
17285
  : undefined;
@@ -17186,7 +17342,10 @@ export class SharedLog<
17186
17342
  // allocation cost, and before liveness or apply-queue side effects.
17187
17343
  this.validateStoppedReplicationAnnouncement(msg.segmentIds);
17188
17344
  }
17189
- if (!context.from.equals(this.node.identity.publicKey)) {
17345
+ if (
17346
+ !context.from.equals(this.node.identity.publicKey) &&
17347
+ !(msg instanceof RequestReplicationInfoV2Message)
17348
+ ) {
17190
17349
  this._liveness.markReplicatorActivity(receiveFromHash);
17191
17350
  }
17192
17351
 
@@ -18815,6 +18974,8 @@ export class SharedLog<
18815
18974
  return;
18816
18975
  } else if (msg instanceof SyncCapabilitiesMessage) {
18817
18976
  if (!context.from.equals(this.node.identity.publicKey)) {
18977
+ const capabilityTransportSession = context.message?.header?.session;
18978
+ const capabilityTimestamp = context.message?.header?.timestamp;
18818
18979
  // No await separates this from the capture above, so the captured
18819
18980
  // window state is exact: the legacy re-read of the opening map here
18820
18981
  // could never observe a different value.
@@ -18825,15 +18986,47 @@ export class SharedLog<
18825
18986
  // A prior unsubscribe cleanup may still be ahead of this reconnect
18826
18987
  // barrier. Stage the new generation's one-shot advertisement so that
18827
18988
  // cleanup cannot erase it before the opening transition commits.
18828
- this._openingSyncCapabilitiesByPeer.set(receiveFromHash, {
18829
- epoch: receiveSession!,
18989
+ this.observePeerSyncCapabilities({
18990
+ peerHash: receiveFromHash,
18830
18991
  capabilities: msg.capabilities,
18992
+ transportSession: capabilityTransportSession,
18993
+ timestamp: capabilityTimestamp,
18994
+ openingSession: receiveSession!,
18831
18995
  });
18832
18996
  } else {
18833
- this._peerSyncCapabilities.set(receiveFromHash, msg.capabilities);
18997
+ this.observePeerSyncCapabilities({
18998
+ peerHash: receiveFromHash,
18999
+ capabilities: msg.capabilities,
19000
+ transportSession: capabilityTransportSession,
19001
+ timestamp: capabilityTimestamp,
19002
+ });
18834
19003
  }
18835
19004
  }
18836
19005
  return;
19006
+ } else if (msg instanceof RequestReplicationInfoV2Message) {
19007
+ const requiredCapabilities =
19008
+ SYNC_CAPABILITY_REPLICATION_INFO_V2_DECODE |
19009
+ SYNC_CAPABILITY_REPLICATION_INFO_V2_APPLY;
19010
+ if (
19011
+ receiveSession &&
19012
+ ((this._peerSyncCapabilities.get(receiveFromHash) ?? 0) &
19013
+ requiredCapabilities) ===
19014
+ requiredCapabilities &&
19015
+ this._peerSyncCapabilitySessions.get(receiveFromHash) ===
19016
+ context.message.header.session &&
19017
+ this._peerSyncCapabilityTimestamps.has(receiveFromHash) &&
19018
+ context.message.header.timestamp >
19019
+ this._peerSyncCapabilityTimestamps.get(receiveFromHash)! &&
19020
+ this._v2Send.acceptRequest(msg, {
19021
+ from: context.from,
19022
+ peerSession: receiveSession,
19023
+ receiverTransportSession: context.message.header.session,
19024
+ requestTimestamp: context.message.header.timestamp,
19025
+ })
19026
+ ) {
19027
+ this._liveness.markReplicatorActivity(receiveFromHash);
19028
+ }
19029
+ return;
18837
19030
  } else if (await this.syncronizer.onMessage(msg, context)) {
18838
19031
  return; // the syncronizer has handled the message
18839
19032
  } else if (msg instanceof BlocksMessage) {
@@ -19148,6 +19341,7 @@ export class SharedLog<
19148
19341
  }
19149
19342
  const segments = replicationSegments.map((x) => x.toReplicationRange());
19150
19343
  this.validatePersistedReplicationRangeSnapshot(segments);
19344
+ this._v2Send.enqueueSnapshotForPeer(receiveFromHash);
19151
19345
 
19152
19346
  await this.rpc
19153
19347
  .send(new AllReplicatingSegmentsMessage({ segments }), {
@@ -22590,6 +22784,12 @@ export class SharedLog<
22590
22784
  if (!ownsSubscriptionEpoch()) {
22591
22785
  return;
22592
22786
  }
22787
+ // A destination stream is scoped to exactly one topic-subscription
22788
+ // session. Abort the predecessor synchronously before either barrier can
22789
+ // yield; a late queue completion must never enter the new session.
22790
+ this._v2Send.clearPeer(peerHash);
22791
+ this._peerSyncCapabilitySessions.delete(peerHash);
22792
+ this._peerSyncCapabilityTimestamps.delete(peerHash);
22593
22793
  if (subscribed) {
22594
22794
  const pendingOpeningCapabilities =
22595
22795
  this._openingSyncCapabilitiesByPeer.get(peerHash);
@@ -22629,6 +22829,22 @@ export class SharedLog<
22629
22829
  peerHash,
22630
22830
  openingCapabilities.capabilities,
22631
22831
  );
22832
+ if (openingCapabilities.transportSession === undefined) {
22833
+ this._peerSyncCapabilitySessions.delete(peerHash);
22834
+ } else {
22835
+ this._peerSyncCapabilitySessions.set(
22836
+ peerHash,
22837
+ openingCapabilities.transportSession,
22838
+ );
22839
+ }
22840
+ if (openingCapabilities.timestamp === undefined) {
22841
+ this._peerSyncCapabilityTimestamps.delete(peerHash);
22842
+ } else {
22843
+ this._peerSyncCapabilityTimestamps.set(
22844
+ peerHash,
22845
+ openingCapabilities.timestamp,
22846
+ );
22847
+ }
22632
22848
  this._openingSyncCapabilitiesByPeer.delete(peerHash);
22633
22849
  }
22634
22850
  this._peerSessions.unblockReplicationInfo(peerHash);
@@ -22708,27 +22924,32 @@ export class SharedLog<
22708
22924
  this._liveness.markReplicatorActivity(peerHash);
22709
22925
  this._peerSessions.markOpen(peerHash, expectedSubscriptionEpoch);
22710
22926
 
22711
- if (this._logProperties?.sync?.rawExchangeHeads === true) {
22712
- // One-shot capability advertisement so live append gossip can pick
22713
- // the raw exchange-heads path for this peer without a per-request
22714
- // round trip. Peers that do not know the message drop it.
22715
- this.rpc
22716
- .send(
22717
- new SyncCapabilitiesMessage({
22718
- capabilities: SYNC_CAPABILITY_RAW_EXCHANGE_HEADS,
22719
- }),
22720
- {
22721
- mode: new SilentDelivery({ redundancy: 1, to: [publicKey] }),
22722
- signal: replicationLifecycleController.signal,
22723
- },
22724
- )
22725
- .catch((error) =>
22726
- this.handleReplicationLifecycleSendError(
22727
- error,
22728
- replicationLifecycleController,
22729
- ),
22730
- );
22731
- }
22927
+ // Decode support and receiver-led negotiation readiness are separate bits.
22928
+ // This node never initiates a request in this rollout; normal replication
22929
+ // announcements remain legacy unless a peer explicitly grants one signed,
22930
+ // session-bound V2 destination stream.
22931
+ const receiveCapabilities =
22932
+ SYNC_CAPABILITY_REPLICATION_INFO_V2_DECODE |
22933
+ SYNC_CAPABILITY_REPLICATION_INFO_V2_SEND |
22934
+ (this._logProperties?.sync?.rawExchangeHeads === true
22935
+ ? SYNC_CAPABILITY_RAW_EXCHANGE_HEADS
22936
+ : 0);
22937
+ this.rpc
22938
+ .send(
22939
+ new SyncCapabilitiesMessage({
22940
+ capabilities: receiveCapabilities,
22941
+ }),
22942
+ {
22943
+ mode: new SilentDelivery({ redundancy: 1, to: [publicKey] }),
22944
+ signal: replicationLifecycleController.signal,
22945
+ },
22946
+ )
22947
+ .catch((error) =>
22948
+ this.handleReplicationLifecycleSendError(
22949
+ error,
22950
+ replicationLifecycleController,
22951
+ ),
22952
+ );
22732
22953
 
22733
22954
  let replicationSegments: ReplicationRangeIndexable<R>[];
22734
22955
  try {
@@ -204,8 +204,16 @@ export type ReplicationAnnouncementDeps<R extends "u32" | "u64"> = {
204
204
  // Owner-routed so coordinator spies keep observing re-entrant queueing.
205
205
  queueCurrentReplicationStateAnnouncementRepair: () => void;
206
206
  queueCurrentReplicationStateAnnouncementRetry: (error: unknown) => boolean;
207
+ // Best-effort sidecar. The legacy publish remains the primary operation and
208
+ // retains its exact rejection/retry semantics during mixed-version rollout.
209
+ enqueueReplicationInfoV2: (message: LegacyReplicationInfoMessage) => void;
207
210
  };
208
211
 
212
+ type LegacyReplicationInfoMessage =
213
+ | AllReplicatingSegmentsMessage
214
+ | AddedReplicationSegmentMessage
215
+ | StoppedReplicating;
216
+
209
217
  export class ReplicationAnnouncementCoordinator<R extends "u32" | "u64"> {
210
218
  replicationAnnouncementRetryDebounced:
211
219
  | ReturnType<typeof debounceFixedInterval>
@@ -611,10 +619,7 @@ export class ReplicationAnnouncementCoordinator<R extends "u32" | "u64"> {
611
619
  }
612
620
 
613
621
  async sendReplicationAnnouncement(
614
- message:
615
- | AllReplicatingSegmentsMessage
616
- | AddedReplicationSegmentMessage
617
- | StoppedReplicating,
622
+ message: LegacyReplicationInfoMessage,
618
623
  ownershipLifecycleController = this.deps.captureReplicationOwnershipLifecycle(),
619
624
  options?: { shouldSend?: () => boolean },
620
625
  ): Promise<void> {
@@ -639,6 +644,7 @@ export class ReplicationAnnouncementCoordinator<R extends "u32" | "u64"> {
639
644
  // after that stale send settles.
640
645
  this.rotateAnnouncementSession();
641
646
  this.advanceCurrentReplicationStateAnnouncementRepairGeneration();
647
+ this.deps.enqueueReplicationInfoV2(message);
642
648
  try {
643
649
  await this.deps.getRpc().send(message, {
644
650
  priority: CONVERGENCE_MESSAGE_PRIORITY,