@peerbit/shared-log 13.2.34 → 13.2.36

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/dist/src/index.js CHANGED
@@ -370,6 +370,13 @@ export const WAIT_FOR_REPLICATOR_TIMEOUT = 20000;
370
370
  export const WAIT_FOR_ROLE_MATURITY = 5000;
371
371
  export const WAIT_FOR_REPLICATOR_REQUEST_INTERVAL = 1000;
372
372
  export const WAIT_FOR_REPLICATOR_REQUEST_MIN_ATTEMPTS = 3;
373
+ // The V2 recovery scheduler is deliberately persistent (a subscribed peer is
374
+ // re-solicited for as long as its topic session stays open), but consecutive
375
+ // fruitless park/unpark cycles double the wait before the next unpark so a
376
+ // silent-but-subscribed peer converges to one bounded request cycle per cap
377
+ // window instead of one per base interval. Any applied V2 progress resets it.
378
+ export const REPLICATION_INFO_V2_RECOVERY_MAX_UNPARK_DELAY = 300_000;
379
+ const REPLICATION_INFO_V2_RECOVERY_MAX_UNPARK_EXPONENT = 20;
373
380
  // TODO(prune): Investigate if/when a non-zero prune delay is required for correctness
374
381
  // (e.g. responsibility/replication-info message reordering in multi-peer scenarios).
375
382
  // Prefer making pruning robust without timing-based heuristics.
@@ -695,6 +702,11 @@ let SharedLog = (() => {
695
702
  // reconnect barrier commits. See PeerSessionRegistry._replicationInfoBlockedPeers.
696
703
  _replicationInfoRequestByPeer;
697
704
  _replicationInfoApplyQueueByPeer;
705
+ // One in-flight targeted subscriber-snapshot request per session-less peer.
706
+ // A capability burst from a peer whose Subscribe has not been observed must
707
+ // coalesce into a single pubsub.requestSubscribers call (mirrors the
708
+ // waitForReplicator in-flight coalescing); a later burst may request again.
709
+ _subscriberSnapshotRequestsByPeer;
698
710
  // Range ids are global primary keys while receive lanes are per peer. Keep
699
711
  // reads and writes that decide one mutation in a single global lane.
700
712
  _replicationRangeMutationTail = Promise.resolve();
@@ -1701,6 +1713,7 @@ let SharedLog = (() => {
1701
1713
  queueCurrentReplicationStateAnnouncementRepair: () => this._announcements.queueCurrentReplicationStateAnnouncementRepair(),
1702
1714
  queueCurrentReplicationStateAnnouncementRetry: (error) => this._announcements.queueCurrentReplicationStateAnnouncementRetry(error),
1703
1715
  enqueueReplicationInfoV2: (message) => this._v2Send.enqueue(message),
1716
+ isLegacyReplicationInfoEnabled: () => this.legacyReplicationInfoEnabled,
1704
1717
  isClosed: () => this.closed,
1705
1718
  getCloseSignal: () => this._closeController.signal,
1706
1719
  getMyReplicationSegments: () => this.getMyReplicationSegments(),
@@ -1874,6 +1887,7 @@ let SharedLog = (() => {
1874
1887
  this._pendingIHaveCallbacks = new Set();
1875
1888
  this.latestReplicationInfoMessage = new Map();
1876
1889
  this._replicationInfoRequestByPeer = new Map();
1890
+ this._subscriberSnapshotRequestsByPeer = new Map();
1877
1891
  this._replicationInfoApplyQueueByPeer = new Map();
1878
1892
  // The registry constructor runs resetForOpen(), which creates the
1879
1893
  // replication-info blocked set (fence B5) alongside the session maps —
@@ -1974,6 +1988,13 @@ let SharedLog = (() => {
1974
1988
  get compatibility() {
1975
1989
  return this._logProperties?.compatibility;
1976
1990
  }
1991
+ /**
1992
+ * Legacy replication-info is an explicit compatibility fallback. Current
1993
+ * logs never infer or re-enable it from a remote peer's capabilities.
1994
+ */
1995
+ get legacyReplicationInfoEnabled() {
1996
+ return this.compatibility !== undefined && this.compatibility < 10;
1997
+ }
1977
1998
  get isAdaptiveReplicating() {
1978
1999
  return this._isAdaptiveReplicating;
1979
2000
  }
@@ -2379,6 +2400,9 @@ let SharedLog = (() => {
2379
2400
  : previousTimestamp);
2380
2401
  if (generationAdvanced) {
2381
2402
  this._v2Send.advancePeerCapability(peerHash);
2403
+ // A fresh signed capability generation is V2 progress from the peer:
2404
+ // recovery re-solicitation may restart from the base interval.
2405
+ this.resetReplicationInfoV2RecoveryEscalation(peerHash);
2382
2406
  }
2383
2407
  return true;
2384
2408
  }
@@ -2400,6 +2424,33 @@ let SharedLog = (() => {
2400
2424
  capabilityTimestamp,
2401
2425
  });
2402
2426
  }
2427
+ /**
2428
+ * Coalesced targeted subscriber-snapshot request for the
2429
+ * capability-before-Subscribe recovery path. The observed-capability gate
2430
+ * is sender-paced (any advancing timestamp passes), so a burst of frames
2431
+ * from one session-less peer must not fan out into one GetSubscribers
2432
+ * unicast per frame. One request per peer is in flight at a time; once it
2433
+ * settles, a genuinely new session-less capability may request again.
2434
+ */
2435
+ requestSubscriberSnapshotForCapability(target) {
2436
+ const peerHash = target.hashcode();
2437
+ if (this._subscriberSnapshotRequestsByPeer.has(peerHash)) {
2438
+ return;
2439
+ }
2440
+ const request = Promise.resolve()
2441
+ .then(() => this.node.services.pubsub.requestSubscribers(this.topic, target))
2442
+ .catch((error) => {
2443
+ if (!isNotStartedError(error)) {
2444
+ logger.error(error?.toString?.() ?? String(error));
2445
+ }
2446
+ })
2447
+ .finally(() => {
2448
+ if (this._subscriberSnapshotRequestsByPeer.get(peerHash) === request) {
2449
+ this._subscriberSnapshotRequestsByPeer.delete(peerHash);
2450
+ }
2451
+ });
2452
+ this._subscriberSnapshotRequestsByPeer.set(peerHash, request);
2453
+ }
2403
2454
  /**
2404
2455
  * Live append gossip may use the raw exchange-heads path only when we
2405
2456
  * opted into raw sync and every remote recipient advertised raw capability
@@ -9108,7 +9159,9 @@ let SharedLog = (() => {
9108
9159
  this._logProperties = options;
9109
9160
  this.domain = options?.domain
9110
9161
  ? options.domain(this)
9111
- : createReplicationDomainHash(options?.compatibility && options?.compatibility < 10 ? "u32" : "u64")(this);
9162
+ : createReplicationDomainHash(options?.compatibility !== undefined && options.compatibility < 10
9163
+ ? "u32"
9164
+ : "u64")(this);
9112
9165
  this.indexableDomain = createIndexableDomainFromResolution(this.domain.resolution);
9113
9166
  this._respondToIHaveTimeout = options?.respondToIHaveTimeout ?? 2e4;
9114
9167
  this._checkedPrune = new CheckedPruneCoordinator();
@@ -9118,6 +9171,7 @@ let SharedLog = (() => {
9118
9171
  this._pendingIHaveCallbacks = new Set();
9119
9172
  this.latestReplicationInfoMessage = new Map();
9120
9173
  this._replicationInfoRequestByPeer = new Map();
9174
+ this._subscriberSnapshotRequestsByPeer = new Map();
9121
9175
  // Terminal close/drop drains the previous lifecycle before another open can
9122
9176
  // install fresh lanes and opaque per-subscription ownership tokens.
9123
9177
  this._replicationInfoApplyQueueByPeer = new Map();
@@ -9226,8 +9280,13 @@ let SharedLog = (() => {
9226
9280
  throw new Error("waitForReplicatorRequestMaxAttempts must be a positive number");
9227
9281
  }
9228
9282
  this._closeController = new AbortController();
9229
- this._announcements.setupReplicationAnnouncementRetryFunction();
9230
- this._announcements.setupReplicationAnnouncementRepairFunction();
9283
+ if (this.legacyReplicationInfoEnabled) {
9284
+ this._announcements.setupReplicationAnnouncementRetryFunction();
9285
+ this._announcements.setupReplicationAnnouncementRepairFunction();
9286
+ }
9287
+ else {
9288
+ this._announcements.cancelCurrentReplicationStateAnnouncementRetry();
9289
+ }
9231
9290
  this._closeController.signal.addEventListener("abort", () => {
9232
9291
  for (const [_peer, state] of this._replicationInfoRequestByPeer) {
9233
9292
  if (state.timer)
@@ -10146,7 +10205,12 @@ let SharedLog = (() => {
10146
10205
  }
10147
10206
  async afterOpen() {
10148
10207
  await super.afterOpen();
10149
- const existingSubscribersPromise = this._getTopicSubscribers(this.topic);
10208
+ // Start the broader discovery eagerly, in parallel with rebalance, for its
10209
+ // routing/cache side effects. It also contains connected/provider/fanout
10210
+ // candidates that have not subscribed to this log, so only the authoritative
10211
+ // pubsub snapshot below may create subscription fallback sessions.
10212
+ const subscriberDiscoveryPromise = this._getTopicSubscribers(this.topic);
10213
+ const existingSubscribersPromise = this.node.services.pubsub.getSubscribers(this.topic);
10150
10214
  const replicationLifecycleController = this._instanceLifecycle?.membershipLifecycleController;
10151
10215
  // We do this here, because these calls requires this.closed == false
10152
10216
  void this.pruneOfflineReplicators()
@@ -10163,6 +10227,7 @@ let SharedLog = (() => {
10163
10227
  });
10164
10228
  this._liveness.startReplicatorLivenessSweep();
10165
10229
  await this.rebalanceParticipation();
10230
+ await subscriberDiscoveryPromise;
10166
10231
  // Take into account existing subscription
10167
10232
  (await existingSubscribersPromise)?.forEach((v) => {
10168
10233
  if (v.equals(this.node.identity.publicKey)) {
@@ -10171,6 +10236,14 @@ let SharedLog = (() => {
10171
10236
  if (this.closed) {
10172
10237
  return;
10173
10238
  }
10239
+ // The live subscribe event and this after-open snapshot can report the
10240
+ // same initial transport generation. The live callback rotates its
10241
+ // PeerSession synchronously, so any current session here proves the
10242
+ // fallback is stale/duplicate. Rotating again would erase the signed
10243
+ // capability binding that the first callback just established.
10244
+ if (this._peerSessions.current(v.hashcode()) !== null) {
10245
+ return;
10246
+ }
10174
10247
  void this.runSubscriptionChangeCallback(() => this.handleSubscriptionChange(v, [this.topic], true));
10175
10248
  });
10176
10249
  }
@@ -10309,7 +10382,9 @@ let SharedLog = (() => {
10309
10382
  cleanupPeerDisconnectTracking(peerHash, ownershipLifecycleController = this.captureReplicationOwnershipLifecycle()) {
10310
10383
  const peerSession = this._peerSessions.current(peerHash);
10311
10384
  const preserveV2Session = peerSession?.phase === "open" && peerSession.isActive();
10312
- this.cancelReplicationInfoRequests(peerHash);
10385
+ if (this.legacyReplicationInfoEnabled || !preserveV2Session) {
10386
+ this.cancelReplicationInfoRequests(peerHash);
10387
+ }
10313
10388
  this._liveness._replicatorLivenessFailures.delete(peerHash);
10314
10389
  this._liveness._replicatorLastActivityAt.delete(peerHash);
10315
10390
  if (!preserveV2Session) {
@@ -11269,15 +11344,16 @@ let SharedLog = (() => {
11269
11344
  }, 2_000);
11270
11345
  try {
11271
11346
  const reset = new AllReplicatingSegmentsMessage({ segments: [] });
11272
- await Promise.all([
11273
- this.rpc
11347
+ const resets = [this._v2Send.sendTerminalReset(abort.signal)];
11348
+ if (this.legacyReplicationInfoEnabled) {
11349
+ resets.push(this.rpc
11274
11350
  .send(reset, {
11275
11351
  priority: CONVERGENCE_MESSAGE_PRIORITY,
11276
11352
  signal: abort.signal,
11277
11353
  })
11278
- .catch(() => { }),
11279
- this._v2Send.sendTerminalReset(abort.signal),
11280
- ]);
11354
+ .catch(() => { }));
11355
+ }
11356
+ await Promise.all(resets);
11281
11357
  }
11282
11358
  finally {
11283
11359
  clearTimeout(abortTimer);
@@ -11397,15 +11473,16 @@ let SharedLog = (() => {
11397
11473
  }, 2_000);
11398
11474
  try {
11399
11475
  const reset = new AllReplicatingSegmentsMessage({ segments: [] });
11400
- await Promise.all([
11401
- this.rpc
11476
+ const resets = [this._v2Send.sendTerminalReset(abort.signal)];
11477
+ if (this.legacyReplicationInfoEnabled) {
11478
+ resets.push(this.rpc
11402
11479
  .send(reset, {
11403
11480
  priority: CONVERGENCE_MESSAGE_PRIORITY,
11404
11481
  signal: abort.signal,
11405
11482
  })
11406
- .catch(() => { }),
11407
- this._v2Send.sendTerminalReset(abort.signal),
11408
- ]);
11483
+ .catch(() => { }));
11484
+ }
11485
+ await Promise.all(resets);
11409
11486
  }
11410
11487
  finally {
11411
11488
  clearTimeout(abortTimer);
@@ -11829,6 +11906,17 @@ let SharedLog = (() => {
11829
11906
  if (!context.from) {
11830
11907
  throw new Error("Missing from in update role message");
11831
11908
  }
11909
+ if (!this.legacyReplicationInfoEnabled &&
11910
+ (msg instanceof RequestReplicationInfoMessage ||
11911
+ msg instanceof ResponseRoleMessage ||
11912
+ msg instanceof AllReplicatingSegmentsMessage ||
11913
+ msg instanceof AddedReplicationSegmentMessage ||
11914
+ msg instanceof StoppedReplicating)) {
11915
+ // These variants remain registered decode tombstones, but current logs
11916
+ // fail closed before leases, synchronizer work, liveness, watermarks or
11917
+ // mutations. Only an explicit pre-v10 compatibility open admits them.
11918
+ return;
11919
+ }
11832
11920
  // Snapshot receive ownership before any async handler gets a chance to
11833
11921
  // yield. Replication-info messages reach their branch only after the
11834
11922
  // synchronizer declines them, and a U/S transition can happen meanwhile.
@@ -13243,14 +13331,24 @@ let SharedLog = (() => {
13243
13331
  openingSession: receiveSession,
13244
13332
  });
13245
13333
  }
13246
- else if (this.observePeerSyncCapabilities({
13247
- peerHash: receiveFromHash,
13248
- capabilities: msg.capabilities,
13249
- transportSession: capabilityTransportSession,
13250
- timestamp: capabilityTimestamp,
13251
- }) &&
13252
- receiveSession?.phase === "open") {
13253
- this.promoteReplicationInfoV2ReceiveCapability(context.from, receiveSession);
13334
+ else {
13335
+ const observed = this.observePeerSyncCapabilities({
13336
+ peerHash: receiveFromHash,
13337
+ capabilities: msg.capabilities,
13338
+ transportSession: capabilityTransportSession,
13339
+ timestamp: capabilityTimestamp,
13340
+ });
13341
+ if (observed && receiveSession?.phase === "open") {
13342
+ this.promoteReplicationInfoV2ReceiveCapability(context.from, receiveSession);
13343
+ }
13344
+ else if (observed && receiveSession === null) {
13345
+ // A capability can arrive before the sender's topic Subscribe after
13346
+ // reconnect. Ask that authenticated peer for its authoritative
13347
+ // subscriber snapshot; the resulting Subscribe creates the real
13348
+ // PeerSession and completes the symmetric capability handshake.
13349
+ // Never synthesize membership from capability traffic alone.
13350
+ this.requestSubscriberSnapshotForCapability(context.from);
13351
+ }
13254
13352
  }
13255
13353
  }
13256
13354
  return;
@@ -13679,7 +13777,11 @@ let SharedLog = (() => {
13679
13777
  return;
13680
13778
  }
13681
13779
  this._liveness.markReplicatorActivity(fromHash);
13682
- if (msg instanceof FullReplicationInfoV2Message) {
13780
+ // A committed V2 announcement is applied progress: the peer answers,
13781
+ // so recovery re-solicitation may restart from the base interval.
13782
+ this.resetReplicationInfoV2RecoveryEscalation(fromHash);
13783
+ if (msg instanceof FullReplicationInfoV2Message &&
13784
+ this.legacyReplicationInfoEnabled) {
13683
13785
  this.cancelReplicationInfoRequests(fromHash);
13684
13786
  }
13685
13787
  });
@@ -14183,9 +14285,30 @@ let SharedLog = (() => {
14183
14285
  reject(new TimeoutError(`Timeout waiting for replicator ${key.hashcode()}`));
14184
14286
  }, timeoutMs);
14185
14287
  let requestAttempts = 0;
14288
+ let subscriberSnapshotInFlight;
14186
14289
  const requestIntervalMs = this.waitForReplicatorRequestIntervalMs;
14187
14290
  const maxRequestAttempts = this.waitForReplicatorRequestMaxAttempts ??
14188
14291
  Math.max(WAIT_FOR_REPLICATOR_REQUEST_MIN_ATTEMPTS, Math.ceil(timeoutMs / requestIntervalMs));
14292
+ const requestSubscriberSnapshot = () => {
14293
+ if (subscriberSnapshotInFlight) {
14294
+ return;
14295
+ }
14296
+ subscriberSnapshotInFlight = Promise.resolve()
14297
+ .then(async () => {
14298
+ if (settled || this.closed) {
14299
+ return;
14300
+ }
14301
+ await this.node.services.pubsub.requestSubscribers(this.topic, key);
14302
+ })
14303
+ .catch((error) => {
14304
+ if (!isNotStartedError(error)) {
14305
+ logger.error(error?.toString?.() ?? String(error));
14306
+ }
14307
+ })
14308
+ .finally(() => {
14309
+ subscriberSnapshotInFlight = undefined;
14310
+ });
14311
+ };
14189
14312
  const requestReplicationInfo = () => {
14190
14313
  if (settled || this.closed) {
14191
14314
  return;
@@ -14194,17 +14317,37 @@ let SharedLog = (() => {
14194
14317
  return;
14195
14318
  }
14196
14319
  requestAttempts++;
14197
- this.rpc
14198
- .send(new RequestReplicationInfoMessage(), {
14199
- mode: new AcknowledgeDelivery({ redundancy: 1, to: [key] }),
14200
- })
14201
- .catch((e) => {
14202
- // Best-effort: missing peers / unopened RPC should not fail the wait logic.
14203
- if (isNotStartedError(e)) {
14204
- return;
14320
+ if (this.legacyReplicationInfoEnabled) {
14321
+ this.rpc
14322
+ .send(new RequestReplicationInfoMessage(), {
14323
+ mode: new AcknowledgeDelivery({ redundancy: 1, to: [key] }),
14324
+ })
14325
+ .catch((e) => {
14326
+ // Best-effort: missing peers / unopened RPC should not fail the wait logic.
14327
+ if (isNotStartedError(e)) {
14328
+ return;
14329
+ }
14330
+ logger.error(e?.toString?.() ?? String(e));
14331
+ });
14332
+ }
14333
+ else {
14334
+ const peerHash = key.hashcode();
14335
+ const peerSession = this._peerSessions.current(peerHash);
14336
+ if (peerSession?.phase === "open") {
14337
+ this._v2Receive.resumeParkedRequest({
14338
+ peerHash,
14339
+ peerSession,
14340
+ receiveEpoch: this._peerSessions.receiveEpoch(peerHash),
14341
+ });
14205
14342
  }
14206
- logger.error(e?.toString?.() ?? String(e));
14207
- });
14343
+ else if (peerSession === null || peerSession.phase === "departing") {
14344
+ // A peer can be known to routing before its SharedLog topic
14345
+ // subscription has been observed. Legacy requests used to bootstrap
14346
+ // that case directly; V2 needs an authoritative Subscribe snapshot
14347
+ // before it can create a fenced PeerSession and request a Full.
14348
+ requestSubscriberSnapshot();
14349
+ }
14350
+ }
14208
14351
  if (requestAttempts < maxRequestAttempts) {
14209
14352
  requestTimer = setTimeout(requestReplicationInfo, requestIntervalMs);
14210
14353
  }
@@ -16016,12 +16159,114 @@ let SharedLog = (() => {
16016
16159
  }
16017
16160
  this._replicationInfoRequestByPeer.delete(peerHash);
16018
16161
  }
16162
+ /**
16163
+ * Applied V2 progress from a peer (a committed Full/Added/Stopped, or a
16164
+ * rotated capability generation) proves the peer answers. Reset the
16165
+ * recovery scheduler's unpark escalation so a later stall restarts from
16166
+ * the base interval. Peer-session rotation resets implicitly: the recovery
16167
+ * scheduler creates a fresh per-session state.
16168
+ */
16169
+ resetReplicationInfoV2RecoveryEscalation(peerHash) {
16170
+ const state = this._replicationInfoRequestByPeer.get(peerHash);
16171
+ if (!state || state.peerSession === undefined) {
16172
+ return;
16173
+ }
16174
+ state.attempts = 0;
16175
+ state.parkedSinceMs = undefined;
16176
+ }
16177
+ scheduleReplicationInfoV2Recovery(peer, replicationLifecycleController = this._instanceLifecycle
16178
+ ?.membershipLifecycleController) {
16179
+ if (!replicationLifecycleController ||
16180
+ !this.isReplicationLifecycleActive(replicationLifecycleController)) {
16181
+ return;
16182
+ }
16183
+ const peerHash = peer.hashcode();
16184
+ const peerSession = this._peerSessions.current(peerHash);
16185
+ if (!peerSession || peerSession.phase !== "open") {
16186
+ return;
16187
+ }
16188
+ const requestStates = this._replicationInfoRequestByPeer;
16189
+ const existing = requestStates.get(peerHash);
16190
+ if (existing?.peerSession === peerSession) {
16191
+ return;
16192
+ }
16193
+ if (existing) {
16194
+ if (existing.timer) {
16195
+ clearTimeout(existing.timer);
16196
+ }
16197
+ requestStates.delete(peerHash);
16198
+ }
16199
+ const state = {
16200
+ attempts: 0,
16201
+ peerSession,
16202
+ };
16203
+ requestStates.set(peerHash, state);
16204
+ const cancel = () => {
16205
+ if (requestStates.get(peerHash) !== state) {
16206
+ return;
16207
+ }
16208
+ if (state.timer) {
16209
+ clearTimeout(state.timer);
16210
+ }
16211
+ requestStates.delete(peerHash);
16212
+ };
16213
+ const intervalMs = Math.max(50, this.waitForReplicatorRequestIntervalMs);
16214
+ const maxUnparkDelayMs = Math.max(intervalMs, REPLICATION_INFO_V2_RECOVERY_MAX_UNPARK_DELAY);
16215
+ const unparkDelayMs = () => Math.min(maxUnparkDelayMs, intervalMs *
16216
+ 2 **
16217
+ Math.min(state.attempts, REPLICATION_INFO_V2_RECOVERY_MAX_UNPARK_EXPONENT));
16218
+ const arm = (delayMs) => {
16219
+ state.timer = setTimeout(tick, delayMs);
16220
+ state.timer.unref?.();
16221
+ };
16222
+ const tick = () => {
16223
+ if (!this.isReplicationLifecycleActive(replicationLifecycleController) ||
16224
+ peerSession.phase !== "open" ||
16225
+ !this._peerSessions.isCurrent(peerHash, peerSession)) {
16226
+ cancel();
16227
+ return;
16228
+ }
16229
+ const receiveEpoch = this._peerSessions.receiveEpoch(peerHash);
16230
+ if (!this._v2Receive.isRequestParked({ peerHash, peerSession, receiveEpoch })) {
16231
+ // Active, or a bounded request cycle is still running its own
16232
+ // exponential retries. Keep polling for the next park.
16233
+ state.parkedSinceMs = undefined;
16234
+ arm(intervalMs);
16235
+ return;
16236
+ }
16237
+ const now = Date.now();
16238
+ if (state.parkedSinceMs === undefined) {
16239
+ state.parkedSinceMs = now;
16240
+ }
16241
+ const resumeAtMs = state.parkedSinceMs + unparkDelayMs();
16242
+ if (now < resumeAtMs) {
16243
+ arm(Math.max(50, resumeAtMs - now));
16244
+ return;
16245
+ }
16246
+ if (this._v2Receive.resumeParkedRequest({
16247
+ peerHash,
16248
+ peerSession,
16249
+ receiveEpoch,
16250
+ })) {
16251
+ // Fruitless until proven otherwise: escalate the next unpark wait.
16252
+ // Applied progress resets via resetReplicationInfoV2RecoveryEscalation.
16253
+ state.attempts++;
16254
+ state.parkedSinceMs = undefined;
16255
+ }
16256
+ arm(intervalMs);
16257
+ };
16258
+ tick();
16259
+ }
16019
16260
  scheduleReplicationInfoRequests(peer, replicationLifecycleController = this._instanceLifecycle
16020
16261
  ?.membershipLifecycleController) {
16021
16262
  if (!replicationLifecycleController ||
16022
16263
  !this.isReplicationLifecycleActive(replicationLifecycleController)) {
16023
16264
  return;
16024
16265
  }
16266
+ if (!this.legacyReplicationInfoEnabled) {
16267
+ this.scheduleReplicationInfoV2Recovery(peer, replicationLifecycleController);
16268
+ return;
16269
+ }
16025
16270
  const peerHash = peer.hashcode();
16026
16271
  const requestStates = this._replicationInfoRequestByPeer;
16027
16272
  if (requestStates.has(peerHash)) {
@@ -16072,7 +16317,7 @@ let SharedLog = (() => {
16072
16317
  };
16073
16318
  tick();
16074
16319
  }
16075
- async handleSubscriptionChange(publicKey, topics, subscribed, subscriptionEpoch) {
16320
+ async handleSubscriptionChange(publicKey, topics, subscribed, subscriptionEpoch, subscriptionTransportSession) {
16076
16321
  if (!topics.includes(this.topic)) {
16077
16322
  return;
16078
16323
  }
@@ -16088,13 +16333,29 @@ let SharedLog = (() => {
16088
16333
  if (!ownsSubscriptionEpoch()) {
16089
16334
  return;
16090
16335
  }
16336
+ // A reconnect can arrive before the previous exact-session recovery tick
16337
+ // observes its stale session. Retire that job synchronously so it cannot
16338
+ // suppress the replacement session's scheduler in the shared peer slot.
16339
+ this.cancelReplicationInfoRequests(peerHash);
16091
16340
  // A destination stream is scoped to exactly one topic-subscription
16092
16341
  // session. Abort the predecessor synchronously before either barrier can
16093
16342
  // yield; a late queue completion must never enter the new session.
16094
16343
  this._v2Receive.clearPeer(peerHash);
16095
16344
  this._v2Send.clearPeer(peerHash);
16096
- this._peerSyncCapabilitySessions.delete(peerHash);
16097
- this._peerSyncCapabilityTimestamps.delete(peerHash);
16345
+ const capabilityTransportSession = this._peerSyncCapabilitySessions.get(peerHash);
16346
+ const canInheritCapability = subscribed &&
16347
+ (subscriptionTransportSession !== undefined
16348
+ ? capabilityTransportSession === subscriptionTransportSession
16349
+ : !expectedSubscriptionEpoch.hasPredecessor);
16350
+ if (!canInheritCapability) {
16351
+ // Departures and successor openings revoke the signed transport binding.
16352
+ // A live successor may inherit a capability that arrived just before its
16353
+ // Subscribe only when both signed frames belong to the same transport
16354
+ // generation. Snapshot fallbacks lack that proof, so only their first
16355
+ // opening may inherit pre-opening capability state.
16356
+ this._peerSyncCapabilitySessions.delete(peerHash);
16357
+ this._peerSyncCapabilityTimestamps.delete(peerHash);
16358
+ }
16098
16359
  if (subscribed) {
16099
16360
  const pendingOpeningCapabilities = this._openingSyncCapabilitiesByPeer.get(peerHash);
16100
16361
  if (pendingOpeningCapabilities &&
@@ -16219,6 +16480,14 @@ let SharedLog = (() => {
16219
16480
  receiveEpoch,
16220
16481
  signal: replicationLifecycleController.signal,
16221
16482
  });
16483
+ if (!this.legacyReplicationInfoEnabled) {
16484
+ // Current logs have no legacy startup work to order ahead of RequestV2.
16485
+ // Releasing is synchronous and exact-session fenced; the ACK may still
16486
+ // arrive later and promote readiness through the coordinator.
16487
+ localCapabilityAdvertisement.releaseLegacyBarrier();
16488
+ this.scheduleReplicationInfoV2Recovery(publicKey, replicationLifecycleController);
16489
+ return;
16490
+ }
16222
16491
  try {
16223
16492
  let replicationSegments;
16224
16493
  try {
@@ -17275,7 +17544,7 @@ let SharedLog = (() => {
17275
17544
  this.remoteBlocks.onReachable(evt.detail.from);
17276
17545
  this._peerSessions.blockReplicationInfo(fromHash);
17277
17546
  this.invalidateSharedLogTopicSubscribersCache();
17278
- await this.handleSubscriptionChange(evt.detail.from, evt.detail.topics, true, subscriptionEpoch);
17547
+ await this.handleSubscriptionChange(evt.detail.from, evt.detail.topics, true, subscriptionEpoch, evt.detail.session);
17279
17548
  }
17280
17549
  async rebalanceParticipation(ownershipLifecycleController = this.captureReplicationOwnershipLifecycle(), rebalanceParticipationDebounced = this.rebalanceParticipationDebounced) {
17281
17550
  // Stage 3: the lifecycle owns all three identity terms. `lifecycle` may