@peerbit/shared-log 13.2.35 → 14.0.0

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
@@ -144,6 +144,7 @@ import {
144
144
  debouncedAccumulatorMap,
145
145
  } from "./debounce.js";
146
146
  import {
147
+ CompatibilityModeRetiredError,
147
148
  NativeDurableCommitError,
148
149
  NoPeersError,
149
150
  isNotStartedError,
@@ -428,6 +429,7 @@ export {
428
429
  EntryReplicatedU32,
429
430
  EntryReplicatedU64,
430
431
  type CoverRange,
432
+ CompatibilityModeRetiredError,
431
433
  NativeDurableCommitError,
432
434
  NoPeersError,
433
435
  };
@@ -1248,7 +1250,6 @@ export type SharedLogOptions<
1248
1250
  waitForPruneDelay?: number;
1249
1251
  distributionDebounceTime?: number;
1250
1252
  strictFullReplicaFallback?: boolean;
1251
- compatibility?: number;
1252
1253
  domain?: ReplicationDomainConstructor<D>;
1253
1254
  eagerBlocks?: EagerBlocksSetting;
1254
1255
  fanout?: SharedLogFanoutOptions;
@@ -1346,6 +1347,13 @@ export const WAIT_FOR_REPLICATOR_TIMEOUT = 20000;
1346
1347
  export const WAIT_FOR_ROLE_MATURITY = 5000;
1347
1348
  export const WAIT_FOR_REPLICATOR_REQUEST_INTERVAL = 1000;
1348
1349
  export const WAIT_FOR_REPLICATOR_REQUEST_MIN_ATTEMPTS = 3;
1350
+ // The V2 recovery scheduler is deliberately persistent (a subscribed peer is
1351
+ // re-solicited for as long as its topic session stays open), but consecutive
1352
+ // fruitless park/unpark cycles double the wait before the next unpark so a
1353
+ // silent-but-subscribed peer converges to one bounded request cycle per cap
1354
+ // window instead of one per base interval. Any applied V2 progress resets it.
1355
+ export const REPLICATION_INFO_V2_RECOVERY_MAX_UNPARK_DELAY = 300_000;
1356
+ const REPLICATION_INFO_V2_RECOVERY_MAX_UNPARK_EXPONENT = 20;
1349
1357
  // TODO(prune): Investigate if/when a non-zero prune delay is required for correctness
1350
1358
  // (e.g. responsibility/replication-info message reordering in multi-peer scenarios).
1351
1359
  // Prefer making pruning robust without timing-based heuristics.
@@ -2087,12 +2095,22 @@ export class SharedLog<
2087
2095
  private _replicationInfoRequestByPeer!: Map<
2088
2096
  string,
2089
2097
  {
2098
+ // Legacy scheduler: sends issued (bounded by maxAttempts). V2 recovery
2099
+ // scheduler: consecutive fruitless unparks — the escalation exponent
2100
+ // for the next unpark delay, reset on any applied V2 progress.
2090
2101
  attempts: number;
2091
2102
  timer?: ReturnType<typeof setTimeout>;
2092
2103
  peerSession?: PeerSession;
2104
+ // V2 recovery scheduler only: when the current park was first observed.
2105
+ parkedSinceMs?: number;
2093
2106
  }
2094
2107
  >;
2095
2108
  private _replicationInfoApplyQueueByPeer!: Map<string, Promise<void>>;
2109
+ // One in-flight targeted subscriber-snapshot request per session-less peer.
2110
+ // A capability burst from a peer whose Subscribe has not been observed must
2111
+ // coalesce into a single pubsub.requestSubscribers call (mirrors the
2112
+ // waitForReplicator in-flight coalescing); a later burst may request again.
2113
+ private _subscriberSnapshotRequestsByPeer!: Map<string, Promise<void>>;
2096
2114
  // Range ids are global primary keys while receive lanes are per peer. Keep
2097
2115
  // reads and writes that decide one mutation in a single global lane.
2098
2116
  private _replicationRangeMutationTail: Promise<void> = Promise.resolve();
@@ -3633,6 +3651,7 @@ export class SharedLog<
3633
3651
  this._pendingIHaveCallbacks = new Set();
3634
3652
  this.latestReplicationInfoMessage = new Map();
3635
3653
  this._replicationInfoRequestByPeer = new Map();
3654
+ this._subscriberSnapshotRequestsByPeer = new Map();
3636
3655
  this._replicationInfoApplyQueueByPeer = new Map();
3637
3656
  // The registry constructor runs resetForOpen(), which creates the
3638
3657
  // replication-info blocked set (fence B5) alongside the session maps —
@@ -3742,7 +3761,10 @@ export class SharedLog<
3742
3761
  }
3743
3762
 
3744
3763
  get compatibility(): number | undefined {
3745
- return this._logProperties?.compatibility;
3764
+ // B12: the open option was removed and any defined value rejects at
3765
+ // open(); this is permanently undefined and dies with the residual
3766
+ // gates in a later cleanup stage.
3767
+ return (this._logProperties as any)?.compatibility;
3746
3768
  }
3747
3769
 
3748
3770
  /**
@@ -4282,6 +4304,9 @@ export class SharedLog<
4282
4304
  );
4283
4305
  if (generationAdvanced) {
4284
4306
  this._v2Send.advancePeerCapability(peerHash);
4307
+ // A fresh signed capability generation is V2 progress from the peer:
4308
+ // recovery re-solicitation may restart from the base interval.
4309
+ this.resetReplicationInfoV2RecoveryEscalation(peerHash);
4285
4310
  }
4286
4311
  return true;
4287
4312
  }
@@ -4312,6 +4337,36 @@ export class SharedLog<
4312
4337
  });
4313
4338
  }
4314
4339
 
4340
+ /**
4341
+ * Coalesced targeted subscriber-snapshot request for the
4342
+ * capability-before-Subscribe recovery path. The observed-capability gate
4343
+ * is sender-paced (any advancing timestamp passes), so a burst of frames
4344
+ * from one session-less peer must not fan out into one GetSubscribers
4345
+ * unicast per frame. One request per peer is in flight at a time; once it
4346
+ * settles, a genuinely new session-less capability may request again.
4347
+ */
4348
+ private requestSubscriberSnapshotForCapability(target: PublicSignKey): void {
4349
+ const peerHash = target.hashcode();
4350
+ if (this._subscriberSnapshotRequestsByPeer.has(peerHash)) {
4351
+ return;
4352
+ }
4353
+ const request = Promise.resolve()
4354
+ .then(() =>
4355
+ this.node.services.pubsub.requestSubscribers(this.topic, target),
4356
+ )
4357
+ .catch((error) => {
4358
+ if (!isNotStartedError(error as Error)) {
4359
+ logger.error(error?.toString?.() ?? String(error));
4360
+ }
4361
+ })
4362
+ .finally(() => {
4363
+ if (this._subscriberSnapshotRequestsByPeer.get(peerHash) === request) {
4364
+ this._subscriberSnapshotRequestsByPeer.delete(peerHash);
4365
+ }
4366
+ });
4367
+ this._subscriberSnapshotRequestsByPeer.set(peerHash, request);
4368
+ }
4369
+
4315
4370
  /**
4316
4371
  * Live append gossip may use the raw exchange-heads path only when we
4317
4372
  * opted into raw sync and every remote recipient advertised raw capability
@@ -14115,6 +14170,17 @@ export class SharedLog<
14115
14170
  }
14116
14171
 
14117
14172
  async open(options?: Args<T, D, R>): Promise<void> {
14173
+ // B12: replication-info network compatibility modes are retired. Read the
14174
+ // RAW argument value (the option no longer exists on the type) so untyped
14175
+ // JS callers cannot smuggle a value past the removed field, and reject
14176
+ // ANY defined value — including 10, which previously behaved like the
14177
+ // default — BEFORE any open-time side effect (rpc.open, index/native
14178
+ // setup, domain resolution, synchronizer creation, subscription setup).
14179
+ // An explicitly-present `undefined` stays accepted.
14180
+ const rawCompatibility = (options as any)?.compatibility;
14181
+ if (rawCompatibility !== undefined) {
14182
+ throw new CompatibilityModeRetiredError(rawCompatibility);
14183
+ }
14118
14184
  this.ensureNativeDurabilityRuntimeState();
14119
14185
  this._nativeStrictDurableTransactionsClosing = false;
14120
14186
  this._replicationRangeMutationsClosing = false;
@@ -14176,7 +14242,8 @@ export class SharedLog<
14176
14242
  this.domain = options?.domain
14177
14243
  ? (options.domain(this) as unknown as D)
14178
14244
  : (createReplicationDomainHash(
14179
- options?.compatibility !== undefined && options.compatibility < 10
14245
+ (options as any)?.compatibility !== undefined &&
14246
+ (options as any).compatibility < 10
14180
14247
  ? "u32"
14181
14248
  : "u64",
14182
14249
  )(this) as unknown as D);
@@ -14191,6 +14258,7 @@ export class SharedLog<
14191
14258
  this._pendingIHaveCallbacks = new Set();
14192
14259
  this.latestReplicationInfoMessage = new Map();
14193
14260
  this._replicationInfoRequestByPeer = new Map();
14261
+ this._subscriberSnapshotRequestsByPeer = new Map();
14194
14262
  // Terminal close/drop drains the previous lifecycle before another open can
14195
14263
  // install fresh lanes and opaque per-subscription ownership tokens.
14196
14264
  this._replicationInfoApplyQueueByPeer = new Map();
@@ -14785,7 +14853,7 @@ export class SharedLog<
14785
14853
  sendOptions?: { priority?: number; signal?: AbortSignal },
14786
14854
  ) => this.trySendFusedRawExchangeHeads(hashes, to, sendOptions),
14787
14855
  warn,
14788
- compatibility: this._logProperties?.compatibility,
14856
+ compatibility: (this._logProperties as any)?.compatibility,
14789
14857
  resolution: this.domain.resolution,
14790
14858
  sync: options?.sync,
14791
14859
  syncronizer: options?.syncronizer,
@@ -19384,18 +19452,7 @@ export class SharedLog<
19384
19452
  // subscriber snapshot; the resulting Subscribe creates the real
19385
19453
  // PeerSession and completes the symmetric capability handshake.
19386
19454
  // Never synthesize membership from capability traffic alone.
19387
- void Promise.resolve()
19388
- .then(() =>
19389
- this.node.services.pubsub.requestSubscribers(
19390
- this.topic,
19391
- context.from,
19392
- ),
19393
- )
19394
- .catch((error) => {
19395
- if (!isNotStartedError(error as Error)) {
19396
- logger.error(error?.toString?.() ?? String(error));
19397
- }
19398
- });
19455
+ this.requestSubscriberSnapshotForCapability(context.from);
19399
19456
  }
19400
19457
  }
19401
19458
  }
@@ -19963,6 +20020,9 @@ export class SharedLog<
19963
20020
  return;
19964
20021
  }
19965
20022
  this._liveness.markReplicatorActivity(fromHash);
20023
+ // A committed V2 announcement is applied progress: the peer answers,
20024
+ // so recovery re-solicitation may restart from the base interval.
20025
+ this.resetReplicationInfoV2RecoveryEscalation(fromHash);
19966
20026
  if (
19967
20027
  msg instanceof FullReplicationInfoV2Message &&
19968
20028
  this.legacyReplicationInfoEnabled
@@ -23432,6 +23492,22 @@ export class SharedLog<
23432
23492
  this._replicationInfoRequestByPeer.delete(peerHash);
23433
23493
  }
23434
23494
 
23495
+ /**
23496
+ * Applied V2 progress from a peer (a committed Full/Added/Stopped, or a
23497
+ * rotated capability generation) proves the peer answers. Reset the
23498
+ * recovery scheduler's unpark escalation so a later stall restarts from
23499
+ * the base interval. Peer-session rotation resets implicitly: the recovery
23500
+ * scheduler creates a fresh per-session state.
23501
+ */
23502
+ private resetReplicationInfoV2RecoveryEscalation(peerHash: string) {
23503
+ const state = this._replicationInfoRequestByPeer.get(peerHash);
23504
+ if (!state || state.peerSession === undefined) {
23505
+ return;
23506
+ }
23507
+ state.attempts = 0;
23508
+ state.parkedSinceMs = undefined;
23509
+ }
23510
+
23435
23511
  private scheduleReplicationInfoV2Recovery(
23436
23512
  peer: PublicSignKey,
23437
23513
  replicationLifecycleController = this._instanceLifecycle
@@ -23463,6 +23539,7 @@ export class SharedLog<
23463
23539
  attempts: number;
23464
23540
  timer?: ReturnType<typeof setTimeout>;
23465
23541
  peerSession: PeerSession;
23542
+ parkedSinceMs?: number;
23466
23543
  } = {
23467
23544
  attempts: 0,
23468
23545
  peerSession,
@@ -23478,6 +23555,24 @@ export class SharedLog<
23478
23555
  requestStates.delete(peerHash);
23479
23556
  };
23480
23557
  const intervalMs = Math.max(50, this.waitForReplicatorRequestIntervalMs);
23558
+ const maxUnparkDelayMs = Math.max(
23559
+ intervalMs,
23560
+ REPLICATION_INFO_V2_RECOVERY_MAX_UNPARK_DELAY,
23561
+ );
23562
+ const unparkDelayMs = () =>
23563
+ Math.min(
23564
+ maxUnparkDelayMs,
23565
+ intervalMs *
23566
+ 2 **
23567
+ Math.min(
23568
+ state.attempts,
23569
+ REPLICATION_INFO_V2_RECOVERY_MAX_UNPARK_EXPONENT,
23570
+ ),
23571
+ );
23572
+ const arm = (delayMs: number) => {
23573
+ state.timer = setTimeout(tick, delayMs);
23574
+ state.timer.unref?.();
23575
+ };
23481
23576
  const tick = () => {
23482
23577
  if (
23483
23578
  !this.isReplicationLifecycleActive(replicationLifecycleController) ||
@@ -23487,14 +23582,38 @@ export class SharedLog<
23487
23582
  cancel();
23488
23583
  return;
23489
23584
  }
23490
- this._v2Receive.resumeParkedRequest({
23491
- peerHash,
23492
- peerSession,
23493
- receiveEpoch: this._peerSessions.receiveEpoch(peerHash),
23494
- });
23495
- state.attempts++;
23496
- state.timer = setTimeout(tick, intervalMs);
23497
- state.timer.unref?.();
23585
+ const receiveEpoch = this._peerSessions.receiveEpoch(peerHash);
23586
+ if (
23587
+ !this._v2Receive.isRequestParked({ peerHash, peerSession, receiveEpoch })
23588
+ ) {
23589
+ // Active, or a bounded request cycle is still running its own
23590
+ // exponential retries. Keep polling for the next park.
23591
+ state.parkedSinceMs = undefined;
23592
+ arm(intervalMs);
23593
+ return;
23594
+ }
23595
+ const now = Date.now();
23596
+ if (state.parkedSinceMs === undefined) {
23597
+ state.parkedSinceMs = now;
23598
+ }
23599
+ const resumeAtMs = state.parkedSinceMs + unparkDelayMs();
23600
+ if (now < resumeAtMs) {
23601
+ arm(Math.max(50, resumeAtMs - now));
23602
+ return;
23603
+ }
23604
+ if (
23605
+ this._v2Receive.resumeParkedRequest({
23606
+ peerHash,
23607
+ peerSession,
23608
+ receiveEpoch,
23609
+ })
23610
+ ) {
23611
+ // Fruitless until proven otherwise: escalate the next unpark wait.
23612
+ // Applied progress resets via resetReplicationInfoV2RecoveryEscalation.
23613
+ state.attempts++;
23614
+ state.parkedSinceMs = undefined;
23615
+ }
23616
+ arm(intervalMs);
23498
23617
  };
23499
23618
  tick();
23500
23619
  }
@@ -991,6 +991,28 @@ export class ReplicationInfoV2ReceiveCoordinator {
991
991
  return true;
992
992
  }
993
993
 
994
+ /**
995
+ * Whether the request generation for this exact peer state is parked: the
996
+ * bounded retry cycle exhausted its attempts and no timer or send is in
997
+ * flight. Read-only probe for the host's recovery scheduler.
998
+ */
999
+ isRequestParked(properties: {
1000
+ peerHash: string;
1001
+ peerSession: object;
1002
+ receiveEpoch: object | null;
1003
+ }): boolean {
1004
+ const state = this._receiveStates.get(properties.peerHash);
1005
+ return (
1006
+ state !== undefined &&
1007
+ state.peerSession === properties.peerSession &&
1008
+ state.receiveEpoch === properties.receiveEpoch &&
1009
+ state.phase !== "active" &&
1010
+ state.requestParked &&
1011
+ state.requestTimer === undefined &&
1012
+ state.requestInFlight === undefined
1013
+ );
1014
+ }
1015
+
994
1016
  /**
995
1017
  * Resume only a request generation that exhausted its bounded retries.
996
1018
  * Wait/liveness callers may nudge recovery without invalidating an active,
@@ -1023,7 +1045,24 @@ export class ReplicationInfoV2ReceiveCoordinator {
1023
1045
  }
1024
1046
  state.requestAttempts = 0;
1025
1047
  state.requestsSinceCapabilityRefresh = 0;
1026
- state.capabilityRefreshRequired = true;
1048
+ if (!state.capabilityRefreshRequired) {
1049
+ // Only rotate the grant when it is genuinely stale: the peer's
1050
+ // capability rotation paths already flagged a refresh, and a missing
1051
+ // or transport-outdated local grant cannot authorize a request. A
1052
+ // still-current grant must keep its challenge so a Full already in
1053
+ // flight for the pre-park request generation still applies.
1054
+ const ready = this._localCapabilityReadyBySession.get(state.peerSession);
1055
+ const grantCurrent =
1056
+ ready !== undefined &&
1057
+ ready.peerHash === state.peerHash &&
1058
+ ready.receiveEpoch === state.receiveEpoch &&
1059
+ ready.receiverTransportSession === state.receiverTransportSession &&
1060
+ ready.receiverTransportSession ===
1061
+ this.deps.getReceiverTransportSession();
1062
+ if (!grantCurrent) {
1063
+ state.capabilityRefreshRequired = true;
1064
+ }
1065
+ }
1027
1066
  state.requestParked = false;
1028
1067
  this.armRequest(state, 0);
1029
1068
  return true;