@peerbit/shared-log 16.0.22 → 16.0.23

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
@@ -1752,6 +1752,71 @@ export type DeliveryOptions = {
1752
1752
  signal?: AbortSignal;
1753
1753
  };
1754
1754
 
1755
+ export type PersistedReceiptPeerReadinessPendingReason =
1756
+ | "closed"
1757
+ | "no-current-session"
1758
+ | "session-opening"
1759
+ | "capability-pending"
1760
+ | "replication-state-pending"
1761
+ | "replication-confirmation-pending"
1762
+ | "not-replicating"
1763
+ | "not-entry-leader"
1764
+ | "ownership-changing";
1765
+
1766
+ export type PersistedReceiptPeerReadinessUnsupportedReason =
1767
+ | "persisted-receipts-unsupported"
1768
+ | "replication-confirmation-unsupported";
1769
+
1770
+ /**
1771
+ * Detached view of one public key's current persisted-receipt generation.
1772
+ * `generation` is opaque: callers may compare it for equality, but must not
1773
+ * interpret its contents or use it as a future-session capability. Equal
1774
+ * generations mean the connection/receive/capability binding is unchanged;
1775
+ * leadership and outbound confirmation can still change within a generation.
1776
+ */
1777
+ export type PersistedReceiptPeerReadiness =
1778
+ | Readonly<{
1779
+ status: "ready";
1780
+ generation: string;
1781
+ }>
1782
+ | Readonly<{
1783
+ status: "pending";
1784
+ reason: PersistedReceiptPeerReadinessPendingReason;
1785
+ generation?: string;
1786
+ }>
1787
+ | Readonly<{
1788
+ status: "unsupported";
1789
+ reason: PersistedReceiptPeerReadinessUnsupportedReason;
1790
+ generation: string;
1791
+ }>;
1792
+
1793
+ export type PersistedReceiptPeerReady = Extract<
1794
+ PersistedReceiptPeerReadiness,
1795
+ { status: "ready" }
1796
+ >;
1797
+
1798
+ export type PersistedReceiptPeerReadinessOptions<
1799
+ T,
1800
+ R extends "u32" | "u64",
1801
+ > = Readonly<{
1802
+ /** Require this peer to be a freshly planned leader for every entry. */
1803
+ entries?: readonly (ShallowOrFullEntry<T> | EntryReplicated<R>)[];
1804
+ /**
1805
+ * Total leader-plan replica degree used for `entries`; defaults to this log's
1806
+ * configured minimum. This is not the persisted delivery `minAcks` count.
1807
+ */
1808
+ replicas?: number;
1809
+ }>;
1810
+
1811
+ export type WaitForPersistedReceiptPeerReadinessOptions<
1812
+ T,
1813
+ R extends "u32" | "u64",
1814
+ > = PersistedReceiptPeerReadinessOptions<T, R> &
1815
+ Readonly<{
1816
+ timeout?: number;
1817
+ signal?: AbortSignal;
1818
+ }>;
1819
+
1755
1820
  type PersistedDeliveryOptions = Readonly<{
1756
1821
  reliability: "persisted";
1757
1822
  minAcks: number;
@@ -1793,6 +1858,7 @@ const PERSISTED_RECEIPT_RETRY_MS = 50;
1793
1858
  const MAX_PERSISTED_RECEIPT_ATTEMPT_MS = 2_000;
1794
1859
  const MAX_PERSISTED_RECEIPT_REQUESTS_GLOBAL = 8;
1795
1860
  const MAX_PERSISTED_RECEIPT_REQUESTS_PER_PEER = 2;
1861
+ const MAX_PERSISTED_RECEIPT_READINESS_WAITERS = 1_024;
1796
1862
  const PERSISTED_RECEIPT_INGRESS_PEER_REQUEST_CAPACITY = 16;
1797
1863
  const PERSISTED_RECEIPT_INGRESS_PEER_HASH_CAPACITY = 8_192;
1798
1864
  const PERSISTED_RECEIPT_INGRESS_PEER_REQUESTS_PER_SECOND = 8;
@@ -2167,6 +2233,10 @@ export type ReplicatorLeaveEvent = { publicKey: PublicSignKey };
2167
2233
  export type ReplicationChangeEvent = { publicKey: PublicSignKey };
2168
2234
  export type ReplicatorMatureEvent = { publicKey: PublicSignKey };
2169
2235
  export type ReplicationStatusEvent = ReplicationStatus;
2236
+ /** `peerHash` is the result of `PublicSignKey.hashcode()`. */
2237
+ export type PersistedReceiptPeerReadinessEvent = Readonly<{
2238
+ peerHash: string;
2239
+ }>;
2170
2240
 
2171
2241
  class ReplicationStatusSnapshotChangedError extends Error {
2172
2242
  constructor() {
@@ -2189,6 +2259,12 @@ export interface SharedLogEvents extends ProgramEvents {
2189
2259
  "replication:change": CustomEvent<ReplicationChangeEvent>;
2190
2260
  "replicator:mature": CustomEvent<ReplicatorMatureEvent>;
2191
2261
  "replication:status": CustomEvent<ReplicationStatusEvent>;
2262
+ /**
2263
+ * Non-exhaustive wake hint that a peer may now produce a new readiness
2264
+ * snapshot. Consumers must re-read the snapshot; this event is deliberately
2265
+ * not a durable transition log and a `ready` result remains advisory.
2266
+ */
2267
+ "persisted-receipt:readiness": CustomEvent<PersistedReceiptPeerReadinessEvent>;
2192
2268
  }
2193
2269
 
2194
2270
  export type SharedLogRuntimeSnapshot = Readonly<{
@@ -3742,6 +3818,23 @@ export class SharedLog<
3742
3818
  // parallel map so existing capability-number consumers remain unchanged.
3743
3819
  private _peerSyncCapabilitySessions!: Map<string, bigint>;
3744
3820
  private _peerSyncCapabilityTimestamps!: Map<string, bigint>;
3821
+ // design-note: these fields cache a stable, public diagnostics token for the
3822
+ // composite of PeerSession identity, receive epoch, and signed capability
3823
+ // session. They are not consulted to admit or fence asynchronous work. A
3824
+ // separate opaque token is necessary because exposing any of those internal
3825
+ // identities would leak protocol/session values, while PeerSession alone does
3826
+ // not change when receive or capability state is replaced.
3827
+ private _persistedReceiptReadinessGenerations!: WeakMap<
3828
+ PeerSession,
3829
+ {
3830
+ receiveEpoch: object | null;
3831
+ capabilitySession?: bigint;
3832
+ generation: string;
3833
+ }
3834
+ >;
3835
+ private _persistedReceiptReadinessGenerationPrefix!: string;
3836
+ private _persistedReceiptReadinessGenerationCounter!: number;
3837
+ private _persistedReceiptReadinessWaiters!: Set<object>;
3745
3838
  private _persistedReceiptStorage?: PersistedReceiptStorage;
3746
3839
  private _persistedReceiptRequestsInFlight!: Map<string, number>;
3747
3840
  private _persistedReceiptRequestsInFlightTotal!: number;
@@ -4099,6 +4192,12 @@ export class SharedLog<
4099
4192
  this._peerSyncCapabilities = new Map();
4100
4193
  this._peerSyncCapabilitySessions = new Map();
4101
4194
  this._peerSyncCapabilityTimestamps = new Map();
4195
+ this._persistedReceiptReadinessGenerations = new WeakMap();
4196
+ this._persistedReceiptReadinessGenerationPrefix = toHexString(
4197
+ randomBytes(8),
4198
+ );
4199
+ this._persistedReceiptReadinessGenerationCounter = 0;
4200
+ this._persistedReceiptReadinessWaiters = new Set();
4102
4201
  this._persistedReceiptStorage = undefined;
4103
4202
  this._persistedReceiptRequestsInFlight = new Map();
4104
4203
  this._persistedReceiptRequestsInFlightTotal = 0;
@@ -5061,15 +5160,23 @@ export class SharedLog<
5061
5160
  ) {
5062
5161
  return false;
5063
5162
  }
5163
+ const nextCapabilities = previous.capabilities | capabilities;
5164
+ const nextTimestamp =
5165
+ previous.timestamp === undefined || timestamp > previous.timestamp
5166
+ ? timestamp
5167
+ : previous.timestamp;
5064
5168
  this._openingSyncCapabilitiesByPeer.set(peerHash, {
5065
5169
  epoch: openingSession,
5066
- capabilities: previous.capabilities | capabilities,
5170
+ capabilities: nextCapabilities,
5067
5171
  transportSession,
5068
- timestamp:
5069
- previous.timestamp === undefined || timestamp > previous.timestamp
5070
- ? timestamp
5071
- : previous.timestamp,
5172
+ timestamp: nextTimestamp,
5072
5173
  });
5174
+ if (
5175
+ previous.capabilities !== nextCapabilities ||
5176
+ previous.timestamp === undefined
5177
+ ) {
5178
+ this.dispatchPersistedReceiptReadinessChange(peerHash);
5179
+ }
5073
5180
  return true;
5074
5181
  }
5075
5182
  this._openingSyncCapabilitiesByPeer.set(peerHash, {
@@ -5078,17 +5185,25 @@ export class SharedLog<
5078
5185
  transportSession,
5079
5186
  timestamp,
5080
5187
  });
5188
+ this.dispatchPersistedReceiptReadinessChange(peerHash);
5081
5189
  return true;
5082
5190
  }
5083
5191
 
5084
5192
  if (transportSession === undefined || timestamp === undefined) {
5085
5193
  // Test/in-process synthetic contexts predate signed envelope captures.
5086
5194
  // They may exercise capability-number behavior, but can never authorize V2.
5195
+ const readinessChanged =
5196
+ this._peerSyncCapabilities.get(peerHash) !== capabilities ||
5197
+ this._peerSyncCapabilitySessions.has(peerHash) ||
5198
+ this._peerSyncCapabilityTimestamps.has(peerHash);
5087
5199
  this._peerSyncCapabilities.set(peerHash, capabilities);
5088
5200
  this._peerSyncCapabilitySessions.delete(peerHash);
5089
5201
  this._peerSyncCapabilityTimestamps.delete(peerHash);
5090
5202
  this._v2Send.advancePeerCapability(peerHash);
5091
5203
  this._v2Receive.revokePeerCapability(peerHash);
5204
+ if (readinessChanged) {
5205
+ this.dispatchPersistedReceiptReadinessChange(peerHash);
5206
+ }
5092
5207
  return true;
5093
5208
  }
5094
5209
 
@@ -5113,6 +5228,10 @@ export class SharedLog<
5113
5228
  !sameTransportSession ||
5114
5229
  (previousCapabilities & senderGrantCapabilityMask) !==
5115
5230
  (nextCapabilities & senderGrantCapabilityMask);
5231
+ const readinessChanged =
5232
+ !sameTransportSession ||
5233
+ previousTimestamp === undefined ||
5234
+ previousCapabilities !== nextCapabilities;
5116
5235
  this._peerSyncCapabilities.set(peerHash, nextCapabilities);
5117
5236
  this._peerSyncCapabilitySessions.set(peerHash, transportSession);
5118
5237
  this._peerSyncCapabilityTimestamps.set(
@@ -5129,6 +5248,9 @@ export class SharedLog<
5129
5248
  // recovery re-solicitation may restart from the base interval.
5130
5249
  this.resetReplicationInfoV2RecoveryEscalation(peerHash);
5131
5250
  }
5251
+ if (readinessChanged) {
5252
+ this.dispatchPersistedReceiptReadinessChange(peerHash);
5253
+ }
5132
5254
  return true;
5133
5255
  }
5134
5256
 
@@ -5478,9 +5600,148 @@ export class SharedLog<
5478
5600
  return this.sendFusedRawExchangeHeadsPlan(plan, to, options);
5479
5601
  }
5480
5602
 
5603
+ private persistedReceiptReadinessGeneration(
5604
+ peerSession: PeerSession,
5605
+ receiveEpoch: object | null,
5606
+ capabilitySession: bigint | undefined,
5607
+ ): string {
5608
+ const current = this._persistedReceiptReadinessGenerations.get(peerSession);
5609
+ if (
5610
+ current?.receiveEpoch === receiveEpoch &&
5611
+ current.capabilitySession === capabilitySession
5612
+ ) {
5613
+ return current.generation;
5614
+ }
5615
+ const generation = `${this._persistedReceiptReadinessGenerationPrefix}:${(++this
5616
+ ._persistedReceiptReadinessGenerationCounter).toString(36)}`;
5617
+ this._persistedReceiptReadinessGenerations.set(peerSession, {
5618
+ receiveEpoch,
5619
+ capabilitySession,
5620
+ generation,
5621
+ });
5622
+ return generation;
5623
+ }
5624
+
5625
+ private pendingPersistedReceiptReadiness(
5626
+ reason: PersistedReceiptPeerReadinessPendingReason,
5627
+ generation?: string,
5628
+ ): PersistedReceiptPeerReadiness {
5629
+ return Object.freeze({
5630
+ status: "pending" as const,
5631
+ reason,
5632
+ ...(generation === undefined ? {} : { generation }),
5633
+ });
5634
+ }
5635
+
5636
+ private unsupportedPersistedReceiptReadiness(
5637
+ reason: PersistedReceiptPeerReadinessUnsupportedReason,
5638
+ generation: string,
5639
+ ): PersistedReceiptPeerReadiness {
5640
+ return Object.freeze({
5641
+ status: "unsupported" as const,
5642
+ reason,
5643
+ generation,
5644
+ });
5645
+ }
5646
+
5647
+ private dispatchPersistedReceiptReadinessChange(peerHash: string): void {
5648
+ this.events.dispatchEvent(
5649
+ new CustomEvent<PersistedReceiptPeerReadinessEvent>(
5650
+ "persisted-receipt:readiness",
5651
+ { detail: Object.freeze({ peerHash }) },
5652
+ ),
5653
+ );
5654
+ }
5655
+
5656
+ private persistedReceiptReadinessCandidate(peerHash: string):
5657
+ | {
5658
+ capabilitySession: bigint;
5659
+ peerSession: PeerSession;
5660
+ receiveEpoch: object | null;
5661
+ generation: string;
5662
+ }
5663
+ | PersistedReceiptPeerReadiness {
5664
+ if (this.closed) {
5665
+ return this.pendingPersistedReceiptReadiness("closed");
5666
+ }
5667
+ const peerSession = this._peerSessions.current(peerHash);
5668
+ if (!peerSession) {
5669
+ return this.pendingPersistedReceiptReadiness("no-current-session");
5670
+ }
5671
+ const receiveEpoch = this._peerSessions.receiveEpoch(peerHash);
5672
+ const capabilitySession = this._peerSyncCapabilitySessions.get(peerHash);
5673
+ const generation = this.persistedReceiptReadinessGeneration(
5674
+ peerSession,
5675
+ receiveEpoch,
5676
+ capabilitySession,
5677
+ );
5678
+ if (
5679
+ peerSession.phase !== "open" ||
5680
+ !peerSession.isActive() ||
5681
+ this._peerSessions.isReplicationInfoBlocked(peerHash) ||
5682
+ !this._peerSessions.isReceiveCleanupGateOpen(peerHash)
5683
+ ) {
5684
+ return this.pendingPersistedReceiptReadiness(
5685
+ "session-opening",
5686
+ generation,
5687
+ );
5688
+ }
5689
+ if (
5690
+ capabilitySession === undefined ||
5691
+ !this._peerSyncCapabilityTimestamps.has(peerHash)
5692
+ ) {
5693
+ return this.pendingPersistedReceiptReadiness(
5694
+ "capability-pending",
5695
+ generation,
5696
+ );
5697
+ }
5698
+ const capabilities = this._peerSyncCapabilities.get(peerHash) ?? 0;
5699
+ if ((capabilities & SYNC_CAPABILITY_PERSISTED_ENTRY_RECEIPTS) === 0) {
5700
+ return this.unsupportedPersistedReceiptReadiness(
5701
+ "persisted-receipts-unsupported",
5702
+ generation,
5703
+ );
5704
+ }
5705
+ if ((capabilities & SYNC_CAPABILITY_REPLICATION_INFO_V2_CONFIRM) === 0) {
5706
+ return this.unsupportedPersistedReceiptReadiness(
5707
+ "replication-confirmation-unsupported",
5708
+ generation,
5709
+ );
5710
+ }
5711
+ if (
5712
+ !this._v2Receive.isCurrentActive({
5713
+ peerHash,
5714
+ peerSession,
5715
+ receiveEpoch,
5716
+ senderTransportSession: capabilitySession,
5717
+ })
5718
+ ) {
5719
+ return this.pendingPersistedReceiptReadiness(
5720
+ "replication-state-pending",
5721
+ generation,
5722
+ );
5723
+ }
5724
+ if (!this.uniqueReplicators.has(peerHash)) {
5725
+ return this.pendingPersistedReceiptReadiness(
5726
+ "not-replicating",
5727
+ generation,
5728
+ );
5729
+ }
5730
+ return {
5731
+ capabilitySession,
5732
+ peerSession,
5733
+ receiveEpoch,
5734
+ generation,
5735
+ };
5736
+ }
5737
+
5481
5738
  private persistedReceiptPeerSession(
5482
5739
  peerHash: string,
5483
5740
  ): { capabilitySession: bigint; peerSession: PeerSession } | undefined {
5741
+ // This is a hot receipt/transfer-loop predicate. Keep it allocation-light,
5742
+ // while mirroring every exact-session gate in
5743
+ // persistedReceiptReadinessCandidate (which additionally creates public
5744
+ // reason/generation snapshots).
5484
5745
  const capabilitySession = this._peerSyncCapabilitySessions.get(peerHash);
5485
5746
  const peerSession = this._peerSessions.current(peerHash);
5486
5747
  const receiveEpoch = this._peerSessions.receiveEpoch(peerHash);
@@ -5488,10 +5749,14 @@ export class SharedLog<
5488
5749
  SYNC_CAPABILITY_PERSISTED_ENTRY_RECEIPTS |
5489
5750
  SYNC_CAPABILITY_REPLICATION_INFO_V2_CONFIRM;
5490
5751
  if (
5752
+ this.closed ||
5491
5753
  capabilitySession == null ||
5492
5754
  !peerSession ||
5493
5755
  peerSession.phase !== "open" ||
5494
- !this._peerSessions.isCurrent(peerHash, peerSession) ||
5756
+ !peerSession.isActive() ||
5757
+ this._peerSessions.isReplicationInfoBlocked(peerHash) ||
5758
+ !this._peerSessions.isReceiveCleanupGateOpen(peerHash) ||
5759
+ !this.uniqueReplicators.has(peerHash) ||
5495
5760
  !this._peerSyncCapabilityTimestamps.has(peerHash) ||
5496
5761
  ((this._peerSyncCapabilities.get(peerHash) ?? 0) &
5497
5762
  requiredCapabilities) !==
@@ -8181,8 +8446,11 @@ export class SharedLog<
8181
8446
  ? checkedPruneCoordinator.fencePeerRemoval(keyHash)
8182
8447
  : undefined;
8183
8448
  const blockPeerReceiveAdmission = () => {
8184
- releaseReceiveCleanupGate ??=
8185
- this._peerSessions.acquireReceiveCleanupGate(keyHash);
8449
+ if (!releaseReceiveCleanupGate) {
8450
+ releaseReceiveCleanupGate =
8451
+ this._peerSessions.acquireReceiveCleanupGate(keyHash);
8452
+ this.dispatchPersistedReceiptReadinessChange(keyHash);
8453
+ }
8186
8454
  };
8187
8455
  if (!isMe && !isSpeculativePeerRemoval) {
8188
8456
  // Revoke this peer's receipts synchronously, before this removal can
@@ -8423,7 +8691,10 @@ export class SharedLog<
8423
8691
  });
8424
8692
  removalCallCompleted = true;
8425
8693
  } finally {
8426
- releaseReceiveCleanupGate?.();
8694
+ if (releaseReceiveCleanupGate) {
8695
+ releaseReceiveCleanupGate();
8696
+ this.dispatchPersistedReceiptReadinessChange(keyHash);
8697
+ }
8427
8698
  if (
8428
8699
  replicationInfoRecoveryEpochAdvanced &&
8429
8700
  ownsReplicationOwnershipLifecycle() &&
@@ -16904,6 +17175,12 @@ export class SharedLog<
16904
17175
  this._peerSyncCapabilities = new Map();
16905
17176
  this._peerSyncCapabilitySessions = new Map();
16906
17177
  this._peerSyncCapabilityTimestamps = new Map();
17178
+ this._persistedReceiptReadinessGenerations = new WeakMap();
17179
+ this._persistedReceiptReadinessGenerationPrefix = toHexString(
17180
+ randomBytes(8),
17181
+ );
17182
+ this._persistedReceiptReadinessGenerationCounter = 0;
17183
+ this._persistedReceiptReadinessWaiters = new Set();
16907
17184
  this._persistedReceiptStorage = undefined;
16908
17185
  this._persistedReceiptRequestsInFlight = new Map();
16909
17186
  this._persistedReceiptRequestsInFlightTotal = 0;
@@ -18580,6 +18857,7 @@ export class SharedLog<
18580
18857
  ownershipLifecycleController,
18581
18858
  this._checkedPrune,
18582
18859
  );
18860
+ this.dispatchPersistedReceiptReadinessChange(peerHash);
18583
18861
  }
18584
18862
 
18585
18863
  private cleanupPendingIHavePeer(peerHash: string) {
@@ -18604,6 +18882,7 @@ export class SharedLog<
18604
18882
  receiveEpoch,
18605
18883
  });
18606
18884
  }
18885
+ this.dispatchPersistedReceiptReadinessChange(peerHash);
18607
18886
  }
18608
18887
 
18609
18888
  private async resolveCandidatePeersForHash(
@@ -20045,6 +20324,7 @@ export class SharedLog<
20045
20324
  this._peerSyncCapabilities?.clear();
20046
20325
  this._peerSyncCapabilitySessions?.clear();
20047
20326
  this._peerSyncCapabilityTimestamps?.clear();
20327
+ this._persistedReceiptReadinessGenerations = new WeakMap();
20048
20328
  this._persistedReceiptStorage = undefined;
20049
20329
  this._persistedReceiptRequestsInFlight?.clear();
20050
20330
  this._persistedReceiptRequestsInFlightTotal = 0;
@@ -22601,10 +22881,14 @@ export class SharedLog<
22601
22881
  }
22602
22882
  return;
22603
22883
  } else if (msg instanceof ReplicationInfoV2AppliedMessage) {
22604
- this._v2Send.acceptApplied(msg, {
22605
- from: context.from,
22606
- receiverTransportSession: context.message.header.session,
22607
- });
22884
+ if (
22885
+ this._v2Send.acceptApplied(msg, {
22886
+ from: context.from,
22887
+ receiverTransportSession: context.message.header.session,
22888
+ })
22889
+ ) {
22890
+ this.dispatchPersistedReceiptReadinessChange(receiveFromHash);
22891
+ }
22608
22892
  return;
22609
22893
  } else if (isReplicationInfoV2Message(msg)) {
22610
22894
  await this.handleReplicationInfoV2Announcement(
@@ -23420,6 +23704,7 @@ export class SharedLog<
23420
23704
  // A committed V2 announcement is applied progress: the peer answers,
23421
23705
  // so recovery re-solicitation may restart from the base interval.
23422
23706
  this.resetReplicationInfoV2RecoveryEscalation(fromHash);
23707
+ this.dispatchPersistedReceiptReadinessChange(fromHash);
23423
23708
  });
23424
23709
  } finally {
23425
23710
  this._v2Receive.release(admission);
@@ -23774,6 +24059,465 @@ export class SharedLog<
23774
24059
  throwIfInactive();
23775
24060
  }
23776
24061
 
24062
+ private nudgePersistedReceiptPeerReadiness(publicKey: PublicSignKey): void {
24063
+ if (this.closed) return;
24064
+ const peerHash = publicKey.hashcode();
24065
+ const peerSession = this._peerSessions.current(peerHash);
24066
+ if (
24067
+ !peerSession ||
24068
+ peerSession.phase === "departing" ||
24069
+ (peerSession.phase === "opening" &&
24070
+ !peerSession.openingBarrierActive)
24071
+ ) {
24072
+ // A barrier rejection deliberately leaves the current session in its
24073
+ // fail-closed opening phase after the barrier window has settled. Ask the
24074
+ // authenticated peer for a fresh subscriber snapshot so the replacement
24075
+ // session can recover; never rotate a barrier that is still in flight.
24076
+ this.requestSubscriberSnapshotForCapability(publicKey);
24077
+ return;
24078
+ }
24079
+ if (peerSession.phase !== "open" || !peerSession.isActive()) {
24080
+ return;
24081
+ }
24082
+ const receiveEpoch = this._peerSessions.receiveEpoch(peerHash);
24083
+ this.promoteReplicationInfoV2ReceiveCapability(publicKey, peerSession);
24084
+ this._v2Receive.reAdvertiseLocalCapabilityForRecovery({
24085
+ peerHash,
24086
+ peerSession,
24087
+ receiveEpoch,
24088
+ });
24089
+ this._v2Receive.ensureRequestProgress({
24090
+ peerHash,
24091
+ peerSession,
24092
+ receiveEpoch,
24093
+ });
24094
+ this.scheduleReplicationInfoV2Recovery(publicKey);
24095
+ }
24096
+
24097
+ /**
24098
+ * Inspect whether one public key's exact current connection generation can
24099
+ * supply persisted-receipt evidence. The returned object is frozen and never
24100
+ * exposes the internal PeerSession token. When `entries` are supplied, the
24101
+ * peer must also be present in a fresh leader plan for every entry.
24102
+ *
24103
+ * This is advisory preflight state. Persisted delivery repeats every
24104
+ * generation, leadership, ownership and storage check at receipt time; a
24105
+ * `ready` snapshot is never itself authority to dispose a source copy.
24106
+ */
24107
+ async getPersistedReceiptPeerReadiness(
24108
+ key: PublicSignKey,
24109
+ options: PersistedReceiptPeerReadinessOptions<T, R> = {},
24110
+ ): Promise<PersistedReceiptPeerReadiness> {
24111
+ return this.inspectPersistedReceiptPeerReadiness(key, options);
24112
+ }
24113
+
24114
+ private async inspectPersistedReceiptPeerReadiness(
24115
+ key: PublicSignKey,
24116
+ options: PersistedReceiptPeerReadinessOptions<T, R>,
24117
+ assertContinue?: () => void,
24118
+ ): Promise<PersistedReceiptPeerReadiness> {
24119
+ // Capture and validate caller-owned planning input before consulting live
24120
+ // peer state. Invalid options must not appear to work merely because the
24121
+ // peer is currently absent, then fail later when the same session connects.
24122
+ const entries = options.entries ? [...options.entries] : [];
24123
+ const replicas =
24124
+ options.replicas ??
24125
+ (entries.length > 0 ? this.replicas.min.getValue(this) : undefined);
24126
+ if (replicas !== undefined) {
24127
+ if (!Number.isSafeInteger(replicas) || replicas <= 0) {
24128
+ throw new RangeError(
24129
+ "Persisted-receipt readiness replicas must be a positive integer",
24130
+ );
24131
+ }
24132
+ checkMinReplicasLimit(replicas);
24133
+ }
24134
+
24135
+ const peerHash = key.hashcode();
24136
+ const captured = this.persistedReceiptReadinessCandidate(peerHash);
24137
+ if ("status" in captured) {
24138
+ return captured;
24139
+ }
24140
+ assertContinue?.();
24141
+
24142
+ if (entries.length > 0) {
24143
+ const ownershipLifecycleController =
24144
+ this.captureReplicationOwnershipLifecycle();
24145
+ const ownershipRevision =
24146
+ this._instanceLifecycle?._receiveOwnershipRevision ?? 0;
24147
+ if (!this.isReceiveOwnershipSnapshotStable(ownershipRevision)) {
24148
+ return this.pendingPersistedReceiptReadiness(
24149
+ "ownership-changing",
24150
+ captured.generation,
24151
+ );
24152
+ }
24153
+ for (const entry of entries) {
24154
+ assertContinue?.();
24155
+ const leaders = await this.findLeadersFromEntry(
24156
+ entry,
24157
+ replicas!,
24158
+ { freshLeaderPlan: true },
24159
+ ownershipLifecycleController,
24160
+ );
24161
+ assertContinue?.();
24162
+ if (!this.isReceiveOwnershipSnapshotStable(ownershipRevision)) {
24163
+ return this.pendingPersistedReceiptReadiness(
24164
+ "ownership-changing",
24165
+ captured.generation,
24166
+ );
24167
+ }
24168
+ const current = this.persistedReceiptReadinessCandidate(peerHash);
24169
+ if ("status" in current) {
24170
+ return current;
24171
+ }
24172
+ if (
24173
+ current.peerSession !== captured.peerSession ||
24174
+ current.receiveEpoch !== captured.receiveEpoch ||
24175
+ current.capabilitySession !== captured.capabilitySession
24176
+ ) {
24177
+ return this.pendingPersistedReceiptReadiness(
24178
+ "replication-state-pending",
24179
+ current.generation,
24180
+ );
24181
+ }
24182
+ if (!leaders.has(peerHash)) {
24183
+ return this.pendingPersistedReceiptReadiness(
24184
+ "not-entry-leader",
24185
+ captured.generation,
24186
+ );
24187
+ }
24188
+ }
24189
+ }
24190
+
24191
+ assertContinue?.();
24192
+ const current = this.persistedReceiptReadinessCandidate(peerHash);
24193
+ if ("status" in current) {
24194
+ return current;
24195
+ }
24196
+ if (
24197
+ current.peerSession !== captured.peerSession ||
24198
+ current.receiveEpoch !== captured.receiveEpoch ||
24199
+ current.capabilitySession !== captured.capabilitySession
24200
+ ) {
24201
+ return this.pendingPersistedReceiptReadiness(
24202
+ "replication-state-pending",
24203
+ current.generation,
24204
+ );
24205
+ }
24206
+ if (
24207
+ !this._v2Send.isLatestConfirmedForPeer({
24208
+ peerHash,
24209
+ peerSession: captured.peerSession,
24210
+ receiverTransportSession: captured.capabilitySession,
24211
+ })
24212
+ ) {
24213
+ return this.pendingPersistedReceiptReadiness(
24214
+ "replication-confirmation-pending",
24215
+ captured.generation,
24216
+ );
24217
+ }
24218
+ return Object.freeze({
24219
+ status: "ready" as const,
24220
+ generation: captured.generation,
24221
+ });
24222
+ }
24223
+
24224
+ /**
24225
+ * Wait for a public key's current (or replacement) connection generation to
24226
+ * become persisted-receipt ready. Transition listeners are installed before
24227
+ * the first asynchronous inspection, and a bounded recovery tick repairs
24228
+ * missed subscriber/capability wakes without retaining stale PeerSessions.
24229
+ * This waiter is advisory only; the following persisted delivery remains the
24230
+ * operation that proves the requested remote durability quorum.
24231
+ */
24232
+ async waitForPersistedReceiptPeerReadiness(
24233
+ key: PublicSignKey,
24234
+ options: WaitForPersistedReceiptPeerReadinessOptions<T, R> = {},
24235
+ ): Promise<PersistedReceiptPeerReady> {
24236
+ if (this.closed) {
24237
+ throw new ClosedError();
24238
+ }
24239
+ const timeoutMs = options.timeout ?? this.waitForReplicatorTimeout;
24240
+ if (
24241
+ !Number.isSafeInteger(timeoutMs) ||
24242
+ timeoutMs <= 0 ||
24243
+ timeoutMs > MAX_PERSISTED_DELIVERY_TIMEOUT_MS
24244
+ ) {
24245
+ throw new RangeError(
24246
+ `Persisted-receipt readiness timeout must be an integer from 1 to ${MAX_PERSISTED_DELIVERY_TIMEOUT_MS} milliseconds`,
24247
+ );
24248
+ }
24249
+ if (options.signal?.aborted) {
24250
+ throw options.signal.reason instanceof Error
24251
+ ? options.signal.reason
24252
+ : new AbortError("Persisted-receipt readiness wait aborted");
24253
+ }
24254
+
24255
+ // Capture caller-owned inputs before reserving a waiter slot. A throwing
24256
+ // iterator/key implementation must not strand capacity permanently.
24257
+ const entries = options.entries ? [...options.entries] : undefined;
24258
+ const inspectOptions: PersistedReceiptPeerReadinessOptions<T, R> = {
24259
+ ...(entries ? { entries } : {}),
24260
+ ...(options.replicas === undefined ? {} : { replicas: options.replicas }),
24261
+ };
24262
+ const peerHash = key.hashcode();
24263
+ const waiterSet = this._persistedReceiptReadinessWaiters;
24264
+ if (waiterSet.size >= MAX_PERSISTED_RECEIPT_READINESS_WAITERS) {
24265
+ throw new RangeError(
24266
+ `Too many pending persisted-receipt readiness waits (maximum ${MAX_PERSISTED_RECEIPT_READINESS_WAITERS})`,
24267
+ );
24268
+ }
24269
+ const waiterToken = {};
24270
+ waiterSet.add(waiterToken);
24271
+ const deadline = Date.now() + timeoutMs;
24272
+ const closeSignal = this._closeController.signal;
24273
+ const operationController = new AbortController();
24274
+ const operationSignal = AbortSignal.any(
24275
+ [options.signal, closeSignal, operationController.signal].filter(
24276
+ (value): value is AbortSignal => value !== undefined,
24277
+ ),
24278
+ );
24279
+ const deferred = pDefer<PersistedReceiptPeerReady>();
24280
+ let settled = false;
24281
+ let checkScheduled = false;
24282
+ let checkInFlight = false;
24283
+ let rerun = false;
24284
+ let recoveryTimer: ReturnType<typeof setTimeout> | undefined;
24285
+ let confirmationController: AbortController | undefined;
24286
+ let lastSnapshot: PersistedReceiptPeerReadiness | undefined;
24287
+ const createTimeoutError = () => {
24288
+ const suffix = lastSnapshot
24289
+ ? ` (last status: ${lastSnapshot.status}${
24290
+ "reason" in lastSnapshot ? `/${lastSnapshot.reason}` : ""
24291
+ })`
24292
+ : "";
24293
+ return new TimeoutError(
24294
+ `Timeout waiting for persisted-receipt readiness from ${peerHash}${suffix}`,
24295
+ );
24296
+ };
24297
+
24298
+ const cleanup = () => {
24299
+ waiterSet.delete(waiterToken);
24300
+ this.events.removeEventListener(
24301
+ "persisted-receipt:readiness",
24302
+ onReadinessChange,
24303
+ );
24304
+ this.events.removeEventListener("replication:change", onRoleChange);
24305
+ this.events.removeEventListener("replicator:mature", onRoleChange);
24306
+ options.signal?.removeEventListener("abort", onCallerAbort);
24307
+ closeSignal.removeEventListener("abort", onClose);
24308
+ if (recoveryTimer) {
24309
+ clearTimeout(recoveryTimer);
24310
+ recoveryTimer = undefined;
24311
+ }
24312
+ confirmationController?.abort(
24313
+ new AbortError("Persisted-receipt readiness generation changed"),
24314
+ );
24315
+ confirmationController = undefined;
24316
+ operationController.abort(
24317
+ new AbortError("Persisted-receipt readiness wait settled"),
24318
+ );
24319
+ };
24320
+ const resolve = (snapshot: PersistedReceiptPeerReady) => {
24321
+ if (settled) return;
24322
+ settled = true;
24323
+ cleanup();
24324
+ deferred.resolve(snapshot);
24325
+ };
24326
+ const reject = (error: unknown) => {
24327
+ if (settled) return;
24328
+ settled = true;
24329
+ cleanup();
24330
+ deferred.reject(
24331
+ error instanceof Error ? error : new Error(String(error)),
24332
+ );
24333
+ };
24334
+ const onCallerAbort = () =>
24335
+ reject(
24336
+ options.signal?.reason instanceof Error
24337
+ ? options.signal.reason
24338
+ : new AbortError("Persisted-receipt readiness wait aborted"),
24339
+ );
24340
+ const onClose = () => reject(new ClosedError());
24341
+ const continueWait = () => {
24342
+ if (settled) return false;
24343
+ if (closeSignal.aborted) {
24344
+ onClose();
24345
+ return false;
24346
+ }
24347
+ if (options.signal?.aborted) {
24348
+ onCallerAbort();
24349
+ return false;
24350
+ }
24351
+ if (Date.now() >= deadline) {
24352
+ reject(createTimeoutError());
24353
+ return false;
24354
+ }
24355
+ return true;
24356
+ };
24357
+ const assertInspectionCurrent = () => {
24358
+ if (!continueWait()) {
24359
+ throw new AbortError("Persisted-receipt readiness wait settled");
24360
+ }
24361
+ };
24362
+ const armRecoveryTick = () => {
24363
+ if (settled || recoveryTimer) return;
24364
+ const delayMs = Math.max(
24365
+ 50,
24366
+ Math.min(1_000, this.waitForReplicatorRequestIntervalMs),
24367
+ );
24368
+ recoveryTimer = setTimeout(() => {
24369
+ recoveryTimer = undefined;
24370
+ if (!continueWait()) return;
24371
+ this.nudgePersistedReceiptPeerReadiness(key);
24372
+ scheduleCheck();
24373
+ }, delayMs);
24374
+ recoveryTimer.unref?.();
24375
+ };
24376
+ const runCheck = async () => {
24377
+ checkScheduled = false;
24378
+ if (!continueWait()) return;
24379
+ if (checkInFlight) {
24380
+ rerun = true;
24381
+ return;
24382
+ }
24383
+ checkInFlight = true;
24384
+ try {
24385
+ let snapshot = await this.inspectPersistedReceiptPeerReadiness(
24386
+ key,
24387
+ inspectOptions,
24388
+ assertInspectionCurrent,
24389
+ );
24390
+ lastSnapshot = snapshot;
24391
+ if (!continueWait()) return;
24392
+ if (rerun) return;
24393
+ if (snapshot.status === "ready") {
24394
+ // A wake observed while the asynchronous inspection was running may
24395
+ // already have invalidated this snapshot. Drain that coalesced wake
24396
+ // before publishing readiness.
24397
+ resolve(snapshot);
24398
+ return;
24399
+ }
24400
+ if (
24401
+ snapshot.status === "pending" &&
24402
+ snapshot.reason === "replication-confirmation-pending"
24403
+ ) {
24404
+ const target = this.persistedReceiptPeerSession(peerHash);
24405
+ if (target) {
24406
+ const currentConfirmationController = new AbortController();
24407
+ confirmationController = currentConfirmationController;
24408
+ try {
24409
+ await this._v2Send.confirmLatestForPeer(
24410
+ {
24411
+ peerHash,
24412
+ peerSession: target.peerSession,
24413
+ receiverTransportSession: target.capabilitySession,
24414
+ },
24415
+ {
24416
+ timeout: Math.max(1, deadline - Date.now()),
24417
+ signal: AbortSignal.any([
24418
+ operationSignal,
24419
+ currentConfirmationController.signal,
24420
+ ]),
24421
+ },
24422
+ );
24423
+ } catch (error) {
24424
+ if (!continueWait()) return;
24425
+ if (!(error instanceof AbortError)) {
24426
+ throw error;
24427
+ }
24428
+ rerun = true;
24429
+ } finally {
24430
+ if (confirmationController === currentConfirmationController) {
24431
+ confirmationController = undefined;
24432
+ }
24433
+ }
24434
+ if (!continueWait()) return;
24435
+ snapshot = await this.inspectPersistedReceiptPeerReadiness(
24436
+ key,
24437
+ inspectOptions,
24438
+ assertInspectionCurrent,
24439
+ );
24440
+ lastSnapshot = snapshot;
24441
+ if (!continueWait()) return;
24442
+ if (rerun) return;
24443
+ if (snapshot.status === "ready") {
24444
+ resolve(snapshot);
24445
+ return;
24446
+ }
24447
+ }
24448
+ }
24449
+ if (!continueWait()) return;
24450
+ this.nudgePersistedReceiptPeerReadiness(key);
24451
+ } catch (error) {
24452
+ if (!settled) reject(error);
24453
+ } finally {
24454
+ checkInFlight = false;
24455
+ if (!settled && rerun) {
24456
+ rerun = false;
24457
+ scheduleCheck();
24458
+ } else {
24459
+ armRecoveryTick();
24460
+ }
24461
+ }
24462
+ };
24463
+ const scheduleCheck = (interruptConfirmation = false) => {
24464
+ if (settled) return;
24465
+ if (recoveryTimer) {
24466
+ clearTimeout(recoveryTimer);
24467
+ recoveryTimer = undefined;
24468
+ }
24469
+ if (checkInFlight) {
24470
+ rerun = true;
24471
+ if (interruptConfirmation) {
24472
+ confirmationController?.abort(
24473
+ new AbortError(
24474
+ "Persisted-receipt readiness changed during confirmation",
24475
+ ),
24476
+ );
24477
+ }
24478
+ return;
24479
+ }
24480
+ if (checkScheduled) return;
24481
+ checkScheduled = true;
24482
+ void Promise.resolve().then(runCheck);
24483
+ };
24484
+ const onReadinessChange = (
24485
+ event: CustomEvent<PersistedReceiptPeerReadinessEvent>,
24486
+ ) => {
24487
+ if (event.detail.peerHash === peerHash) scheduleCheck(true);
24488
+ };
24489
+ const onRoleChange = (event: CustomEvent<ReplicationChangeEvent>) => {
24490
+ if (
24491
+ (entries?.length ?? 0) > 0 ||
24492
+ event.detail.publicKey.hashcode() === peerHash
24493
+ ) {
24494
+ scheduleCheck(true);
24495
+ }
24496
+ };
24497
+
24498
+ // Register wake sources before the first state inspection. EventTarget does
24499
+ // not replay a transition that fired between an async check and registration.
24500
+ this.events.addEventListener(
24501
+ "persisted-receipt:readiness",
24502
+ onReadinessChange,
24503
+ );
24504
+ this.events.addEventListener("replication:change", onRoleChange);
24505
+ this.events.addEventListener("replicator:mature", onRoleChange);
24506
+ options.signal?.addEventListener("abort", onCallerAbort, { once: true });
24507
+ closeSignal.addEventListener("abort", onClose, { once: true });
24508
+ if (options.signal?.aborted) {
24509
+ onCallerAbort();
24510
+ } else if (closeSignal.aborted) {
24511
+ onClose();
24512
+ } else {
24513
+ scheduleCheck();
24514
+ }
24515
+
24516
+ const timeout = setTimeout(() => reject(createTimeoutError()), timeoutMs);
24517
+ timeout.unref?.();
24518
+ return deferred.promise.finally(() => clearTimeout(timeout));
24519
+ }
24520
+
23777
24521
  async waitForReplicator(
23778
24522
  key: PublicSignKey,
23779
24523
  options?: {
@@ -23783,19 +24527,28 @@ export class SharedLog<
23783
24527
  timeout?: number;
23784
24528
  },
23785
24529
  ) {
24530
+ if (options?.signal?.aborted) {
24531
+ throw new AbortError();
24532
+ }
23786
24533
  const deferred = pDefer<void>();
23787
24534
  const timeoutMs = options?.timeout ?? this.waitForReplicatorTimeout;
23788
24535
  const resolvedRoleAge = options?.eager
23789
24536
  ? undefined
23790
24537
  : (options?.roleAge ?? (await this.getDefaultMinRoleAge()));
24538
+ if (options?.signal?.aborted) {
24539
+ throw new AbortError();
24540
+ }
23791
24541
 
23792
24542
  let settled = false;
23793
24543
  let timer: ReturnType<typeof setTimeout> | undefined;
23794
24544
  let requestTimer: ReturnType<typeof setTimeout> | undefined;
24545
+ let checkInFlight = false;
24546
+ let checkAgain = false;
23795
24547
 
23796
24548
  const clear = () => {
23797
- this.events.removeEventListener("replicator:mature", check);
23798
- this.events.removeEventListener("replication:change", check);
24549
+ checkAgain = false;
24550
+ this.events.removeEventListener("replicator:mature", runCheck);
24551
+ this.events.removeEventListener("replication:change", runCheck);
23799
24552
  options?.signal?.removeEventListener("abort", onAbort);
23800
24553
  if (timer != null) {
23801
24554
  clearTimeout(timer);
@@ -23931,11 +24684,34 @@ export class SharedLog<
23931
24684
  await iterator?.close();
23932
24685
  }
23933
24686
  };
24687
+ const runCheck = () => {
24688
+ if (settled) return;
24689
+ if (checkInFlight) {
24690
+ checkAgain = true;
24691
+ return;
24692
+ }
24693
+ // Reserve synchronously before `check()` can dispatch/re-enter from an
24694
+ // index implementation's first `next()` call.
24695
+ checkInFlight = true;
24696
+ void check()
24697
+ .catch((error) =>
24698
+ reject(error instanceof Error ? error : new Error(String(error))),
24699
+ )
24700
+ .finally(() => {
24701
+ checkInFlight = false;
24702
+ if (!settled && checkAgain) {
24703
+ checkAgain = false;
24704
+ runCheck();
24705
+ }
24706
+ });
24707
+ };
23934
24708
 
24709
+ // Register before the first asynchronous index read. EventTarget does not
24710
+ // replay a maturity/change event that fires while that read is in flight.
24711
+ this.events.addEventListener("replicator:mature", runCheck);
24712
+ this.events.addEventListener("replication:change", runCheck);
23935
24713
  requestReplicationInfo();
23936
- check();
23937
- this.events.addEventListener("replicator:mature", check);
23938
- this.events.addEventListener("replication:change", check);
24714
+ runCheck();
23939
24715
 
23940
24716
  return deferred.promise.finally(clear);
23941
24717
  }
@@ -26825,6 +27601,7 @@ export class SharedLog<
26825
27601
  if (!ownsSubscriptionEpoch()) {
26826
27602
  return;
26827
27603
  }
27604
+ this.dispatchPersistedReceiptReadinessChange(peerHash);
26828
27605
  // A reconnect can arrive before the previous exact-session recovery tick
26829
27606
  // observes its stale session. Retire that job synchronously so it cannot
26830
27607
  // suppress the replacement session's scheduler in the shared peer slot.
@@ -26997,6 +27774,7 @@ export class SharedLog<
26997
27774
  publicKey,
26998
27775
  replicationLifecycleController,
26999
27776
  );
27777
+ this.dispatchPersistedReceiptReadinessChange(peerHash);
27000
27778
  }
27001
27779
 
27002
27780
  private getClampedReplicas(customValue?: MinReplicas) {