@peerbit/shared-log 16.0.11 → 16.0.13

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.
@@ -5,13 +5,16 @@ import {
5
5
  AcknowledgeDelivery,
6
6
  CONVERGENCE_MESSAGE_PRIORITY,
7
7
  } from "@peerbit/stream-interface";
8
+ import { AbortError, TimeoutError } from "@peerbit/time";
8
9
  import type { TransportMessage } from "./message.js";
9
10
  import type { ReplicationRangeIndexable } from "./ranges.js";
10
- import { deriveReplicationInfoV2ReceiverBinding } from "./replication-info-v2-binding.js";
11
11
  import type { ReplicationInfoMutation } from "./replication-info-mutation.js";
12
+ import { deriveReplicationInfoV2ReceiverBinding } from "./replication-info-v2-binding.js";
12
13
  import {
13
14
  AddedReplicationInfoV2Message,
14
15
  FullReplicationInfoV2Message,
16
+ ReplicationInfoV2AppliedMessage,
17
+ RequestReplicationInfoV2AppliedMessage,
15
18
  RequestReplicationInfoV2Message,
16
19
  StoppedReplicationInfoV2Message,
17
20
  } from "./replication.js";
@@ -21,13 +24,45 @@ const logger = loggerFn("peerbit:shared-log:replication-info-v2-send");
21
24
  const MAX_U64 = (1n << 64n) - 1n;
22
25
  const DEFAULT_SEND_RETRY_MS = 1_000;
23
26
  const DEFAULT_MAX_SEND_RETRY_MS = 30_000;
27
+ const DEFAULT_CONFIRM_RETRY_MS = 1_000;
28
+ const DEFAULT_CONFIRM_TIMEOUT_MS = 30_000;
29
+ const DEFAULT_MAX_CONFIRMATION_WAITERS = 1_024;
30
+ const MAX_TIMER_MS = 2_147_483_647;
24
31
  const MAX_BACKOFF_EXPONENT = 20;
25
32
 
26
33
  export { deriveReplicationInfoV2ReceiverBinding } from "./replication-info-v2-binding.js";
27
34
 
28
35
  type SendRequest =
29
- | { kind: "snapshot" }
30
- | { kind: "mutation"; mutation: ReplicationInfoMutation };
36
+ | { kind: "snapshot"; revision: bigint }
37
+ | {
38
+ kind: "mutation";
39
+ mutation: ReplicationInfoMutation;
40
+ revision: bigint;
41
+ };
42
+
43
+ type ApplicationConfirmationRequest = {
44
+ sequence: bigint;
45
+ revision: bigint;
46
+ controller: AbortController;
47
+ };
48
+
49
+ type ApplicationConfirmationWaiter = {
50
+ revision: bigint;
51
+ minPeers: number;
52
+ resolve: () => void;
53
+ reject: (error: unknown) => void;
54
+ timer?: ReturnType<typeof setTimeout>;
55
+ signal?: AbortSignal;
56
+ onAbort?: () => void;
57
+ };
58
+
59
+ export type ReplicationInfoV2ConfirmationOptions = {
60
+ /** Distinct current peers that must report durable application. Defaults to 1. */
61
+ minPeers?: number;
62
+ /** Maximum wait in milliseconds. Defaults to 30 seconds. */
63
+ timeout?: number;
64
+ signal?: AbortSignal;
65
+ };
31
66
 
32
67
  export type ReplicationInfoV2SendState = {
33
68
  peerHash: string;
@@ -46,11 +81,14 @@ export type ReplicationInfoV2SendState = {
46
81
  established: boolean;
47
82
  suspended: boolean;
48
83
  inFlightSequence?: bigint;
84
+ inFlightRevision?: bigint;
49
85
  retryTimer?: ReturnType<typeof setTimeout>;
50
86
  retryAttempts: number;
51
87
  controller: AbortController;
52
88
  pending?: SendRequest;
53
89
  worker?: Promise<void>;
90
+ applicationConfirmationRequest?: ApplicationConfirmationRequest;
91
+ appliedRevision?: bigint;
54
92
  };
55
93
 
56
94
  export type ReplicationInfoV2SendDeps<R extends "u32" | "u64"> = {
@@ -68,8 +106,14 @@ export type ReplicationInfoV2SendDeps<R extends "u32" | "u64"> = {
68
106
  isReplicationOwnershipLifecycleActive: (
69
107
  controller: AbortController,
70
108
  ) => boolean;
109
+ supportsApplicationConfirmation?: (
110
+ peerHash: string,
111
+ receiverTransportSession: bigint,
112
+ ) => boolean;
71
113
  sendRetryMs?: number;
72
114
  maxSendRetryMs?: number;
115
+ confirmationRetryMs?: number;
116
+ maxConfirmationWaiters?: number;
73
117
  };
74
118
 
75
119
  const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => {
@@ -94,9 +138,18 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
94
138
  _sendStates!: Map<string, ReplicationInfoV2SendState>;
95
139
  _spentPeerSessions!: WeakSet<object>;
96
140
  _retiringWorkersByPeer!: Map<string, Promise<void>>;
141
+ _confirmations!: Set<ApplicationConfirmationWaiter>;
142
+ _confirmationRetryTimer?: ReturnType<typeof setTimeout>;
143
+ // design-note: This is application-state identity, not an async-lifecycle
144
+ // fence. One transport session can carry several committed replication
145
+ // assignments, so session identity cannot distinguish which assignment a
146
+ // remote applied; the monotonic value also lets superseded waiters coalesce.
147
+ _revision!: bigint;
97
148
 
98
149
  private readonly sendRetryMs: number;
99
150
  private readonly maxSendRetryMs: number;
151
+ private readonly confirmationRetryMs: number;
152
+ private readonly maxConfirmationWaiters: number;
100
153
 
101
154
  constructor(private readonly deps: ReplicationInfoV2SendDeps<R>) {
102
155
  this.sendRetryMs = Math.max(1, deps.sendRetryMs ?? DEFAULT_SEND_RETRY_MS);
@@ -104,10 +157,23 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
104
157
  this.sendRetryMs,
105
158
  deps.maxSendRetryMs ?? DEFAULT_MAX_SEND_RETRY_MS,
106
159
  );
160
+ this.confirmationRetryMs = Math.max(
161
+ 1,
162
+ deps.confirmationRetryMs ?? DEFAULT_CONFIRM_RETRY_MS,
163
+ );
164
+ const configuredMaxConfirmationWaiters =
165
+ deps.maxConfirmationWaiters ?? DEFAULT_MAX_CONFIRMATION_WAITERS;
166
+ this.maxConfirmationWaiters =
167
+ Number.isSafeInteger(configuredMaxConfirmationWaiters) &&
168
+ configuredMaxConfirmationWaiters > 0
169
+ ? configuredMaxConfirmationWaiters
170
+ : DEFAULT_MAX_CONFIRMATION_WAITERS;
107
171
  this._senderEpoch = randomBytes(32);
108
172
  this._sendStates = new Map();
109
173
  this._spentPeerSessions = new WeakSet();
110
174
  this._retiringWorkersByPeer = new Map();
175
+ this._confirmations = new Set();
176
+ this._revision = 0n;
111
177
  }
112
178
 
113
179
  resetForOpen(): void {
@@ -115,9 +181,11 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
115
181
  this._senderEpoch = randomBytes(32);
116
182
  this._sendStates = new Map();
117
183
  this._spentPeerSessions = new WeakSet();
184
+ this._revision = 0n;
118
185
  }
119
186
 
120
187
  clearForClose(): void {
188
+ this.rejectConfirmations(new AbortError("Replication-info sender closed"));
121
189
  for (const state of [...(this._sendStates?.values() ?? [])]) {
122
190
  this.clearState(state);
123
191
  }
@@ -150,11 +218,219 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
150
218
  state.ownershipAbortListener,
151
219
  );
152
220
  state.controller.abort();
221
+ state.applicationConfirmationRequest?.controller.abort();
222
+ state.applicationConfirmationRequest = undefined;
223
+ state.appliedRevision = undefined;
153
224
  if (this._sendStates.get(state.peerHash) === state) {
154
225
  this._sendStates.delete(state.peerHash);
155
226
  }
156
227
  }
157
228
 
229
+ private supportsApplicationConfirmation(
230
+ state: ReplicationInfoV2SendState,
231
+ ): boolean {
232
+ return (
233
+ this.deps.supportsApplicationConfirmation?.(
234
+ state.peerHash,
235
+ state.receiverTransportSession,
236
+ ) === true
237
+ );
238
+ }
239
+
240
+ private clearConfirmationWaiter(waiter: ApplicationConfirmationWaiter): void {
241
+ if (!this._confirmations.delete(waiter)) {
242
+ return;
243
+ }
244
+ if (waiter.timer) {
245
+ clearTimeout(waiter.timer);
246
+ waiter.timer = undefined;
247
+ }
248
+ if (waiter.signal && waiter.onAbort) {
249
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
250
+ }
251
+ waiter.onAbort = undefined;
252
+ for (const state of this._sendStates.values()) {
253
+ const requested = state.applicationConfirmationRequest;
254
+ if (requested && !this.hasConfirmationFor(requested.revision)) {
255
+ state.applicationConfirmationRequest = undefined;
256
+ requested.controller.abort(
257
+ new AbortError("Replication confirmation no longer pending"),
258
+ );
259
+ }
260
+ }
261
+ if (this._confirmations.size === 0 && this._confirmationRetryTimer) {
262
+ clearTimeout(this._confirmationRetryTimer);
263
+ this._confirmationRetryTimer = undefined;
264
+ }
265
+ }
266
+
267
+ private rejectConfirmations(error: unknown): void {
268
+ if (this._confirmationRetryTimer) {
269
+ clearTimeout(this._confirmationRetryTimer);
270
+ this._confirmationRetryTimer = undefined;
271
+ }
272
+ for (const waiter of [...(this._confirmations ?? [])]) {
273
+ this.clearConfirmationWaiter(waiter);
274
+ waiter.reject(error);
275
+ }
276
+ }
277
+
278
+ private countAppliedPeers(revision: bigint): number {
279
+ let applied = 0;
280
+ for (const state of this._sendStates.values()) {
281
+ if (
282
+ state.appliedRevision !== undefined &&
283
+ state.appliedRevision >= revision &&
284
+ this.isDestinationReady(state) &&
285
+ this.supportsApplicationConfirmation(state)
286
+ ) {
287
+ applied++;
288
+ }
289
+ }
290
+ return applied;
291
+ }
292
+
293
+ private settleConfirmations(): void {
294
+ for (const waiter of [...this._confirmations]) {
295
+ if (this.countAppliedPeers(waiter.revision) >= waiter.minPeers) {
296
+ this.clearConfirmationWaiter(waiter);
297
+ waiter.resolve();
298
+ }
299
+ }
300
+ }
301
+
302
+ private hasConfirmationFor(revision: bigint): boolean {
303
+ for (const waiter of this._confirmations) {
304
+ if (waiter.revision <= revision) {
305
+ return true;
306
+ }
307
+ }
308
+ return false;
309
+ }
310
+
311
+ private scheduleConfirmationRetry(): void {
312
+ if (this._confirmations.size === 0 || this._confirmationRetryTimer) {
313
+ return;
314
+ }
315
+ this._confirmationRetryTimer = setTimeout(() => {
316
+ this._confirmationRetryTimer = undefined;
317
+ this.nudgeConfirmations(true);
318
+ }, this.confirmationRetryMs);
319
+ this._confirmationRetryTimer.unref?.();
320
+ }
321
+
322
+ private nudgeConfirmations(forceReassert = false): void {
323
+ this.settleConfirmations();
324
+ if (this._confirmations.size === 0 || this.deps.isClosed()) {
325
+ return;
326
+ }
327
+ for (const state of [...this._sendStates.values()]) {
328
+ if (
329
+ this.isCurrent(state) &&
330
+ this.supportsApplicationConfirmation(state)
331
+ ) {
332
+ const revision = this._revision;
333
+ if (
334
+ (state.appliedRevision !== undefined &&
335
+ state.appliedRevision >= revision) ||
336
+ (state.inFlightRevision !== undefined &&
337
+ state.inFlightRevision >= revision) ||
338
+ (state.pending !== undefined && state.pending.revision >= revision) ||
339
+ (!forceReassert &&
340
+ state.applicationConfirmationRequest !== undefined &&
341
+ state.applicationConfirmationRequest.revision >= revision)
342
+ ) {
343
+ continue;
344
+ }
345
+ this.enqueueState(state, {
346
+ kind: "snapshot",
347
+ revision,
348
+ });
349
+ }
350
+ }
351
+ this.scheduleConfirmationRetry();
352
+ }
353
+
354
+ /**
355
+ * Wait until the latest committed local replication revision is reported as
356
+ * durably applied by the configured number of current peers. Revisions newer
357
+ * than this call satisfy it, so rapid updates coalesce to one authoritative
358
+ * snapshot without weakening older callers' guarantees.
359
+ */
360
+ confirmLatest(
361
+ options: ReplicationInfoV2ConfirmationOptions = {},
362
+ ): Promise<void> {
363
+ const minPeers = options.minPeers ?? 1;
364
+ const timeout = options.timeout ?? DEFAULT_CONFIRM_TIMEOUT_MS;
365
+ if (!Number.isSafeInteger(minPeers) || minPeers <= 0) {
366
+ return Promise.reject(
367
+ new RangeError("Replication confirmation minPeers must be positive"),
368
+ );
369
+ }
370
+ if (
371
+ !Number.isSafeInteger(timeout) ||
372
+ timeout <= 0 ||
373
+ timeout > MAX_TIMER_MS
374
+ ) {
375
+ return Promise.reject(
376
+ new RangeError(
377
+ `Replication confirmation timeout must be an integer from 1 to ${MAX_TIMER_MS} milliseconds`,
378
+ ),
379
+ );
380
+ }
381
+ if (this.deps.isClosed()) {
382
+ return Promise.reject(new AbortError("Replication-info sender closed"));
383
+ }
384
+ if (options.signal?.aborted) {
385
+ return Promise.reject(
386
+ options.signal.reason instanceof Error
387
+ ? options.signal.reason
388
+ : new AbortError("Replication confirmation aborted"),
389
+ );
390
+ }
391
+ if (this._confirmations.size >= this.maxConfirmationWaiters) {
392
+ return Promise.reject(
393
+ new RangeError(
394
+ `Too many pending replication confirmations (maximum ${this.maxConfirmationWaiters})`,
395
+ ),
396
+ );
397
+ }
398
+
399
+ return new Promise<void>((resolve, reject) => {
400
+ const waiter: ApplicationConfirmationWaiter = {
401
+ revision: this._revision,
402
+ minPeers,
403
+ resolve,
404
+ reject,
405
+ signal: options.signal,
406
+ };
407
+ waiter.timer = setTimeout(() => {
408
+ this.clearConfirmationWaiter(waiter);
409
+ reject(
410
+ new TimeoutError(
411
+ `Timed out waiting for ${minPeers} peer${minPeers === 1 ? "" : "s"} to apply replication revision ${waiter.revision}`,
412
+ ),
413
+ );
414
+ }, timeout);
415
+ waiter.timer.unref?.();
416
+ if (options.signal) {
417
+ waiter.onAbort = () => {
418
+ this.clearConfirmationWaiter(waiter);
419
+ reject(
420
+ options.signal!.reason instanceof Error
421
+ ? options.signal!.reason
422
+ : new AbortError("Replication confirmation aborted"),
423
+ );
424
+ };
425
+ options.signal.addEventListener("abort", waiter.onAbort, {
426
+ once: true,
427
+ });
428
+ }
429
+ this._confirmations.add(waiter);
430
+ this.nudgeConfirmations();
431
+ });
432
+ }
433
+
158
434
  private trackRetiringWorker(state: ReplicationInfoV2SendState): void {
159
435
  const worker = state.worker;
160
436
  if (!worker) {
@@ -216,6 +492,9 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
216
492
  */
217
493
  private parkSnapshotForRetry(state: ReplicationInfoV2SendState): void {
218
494
  state.inFlightSequence = undefined;
495
+ state.inFlightRevision = undefined;
496
+ state.applicationConfirmationRequest?.controller.abort();
497
+ state.applicationConfirmationRequest = undefined;
219
498
  if (state.nextSequence > MAX_U64) {
220
499
  this.retireSpentState(state);
221
500
  return;
@@ -238,7 +517,7 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
238
517
  }
239
518
 
240
519
  state.suspended = true;
241
- state.pending = { kind: "snapshot" };
520
+ state.pending = { kind: "snapshot", revision: this._revision };
242
521
  this.scheduleRetry(state);
243
522
  }
244
523
 
@@ -320,8 +599,14 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
320
599
  previous.retryTimer = undefined;
321
600
  }
322
601
  previous.suspended = false;
323
- previous.pending = { kind: "snapshot" };
324
- this.enqueueState(previous, { kind: "snapshot" });
602
+ previous.pending = {
603
+ kind: "snapshot",
604
+ revision: this._revision,
605
+ };
606
+ this.enqueueState(previous, {
607
+ kind: "snapshot",
608
+ revision: this._revision,
609
+ });
325
610
  return true;
326
611
  }
327
612
 
@@ -382,13 +667,24 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
382
667
  { once: true },
383
668
  );
384
669
  this._sendStates.set(peerHash, state);
385
- this.enqueueState(state, { kind: "snapshot" });
670
+ this.enqueueState(state, {
671
+ kind: "snapshot",
672
+ revision: this._revision,
673
+ });
386
674
  return true;
387
675
  }
388
676
 
389
677
  enqueue(mutation: ReplicationInfoMutation): void {
678
+ if (this._revision === MAX_U64) {
679
+ throw new Error("Replication-info confirmation revision exhausted");
680
+ }
681
+ this._revision += 1n;
390
682
  for (const state of [...this._sendStates.values()]) {
391
- this.enqueueState(state, { kind: "mutation", mutation });
683
+ this.enqueueState(state, {
684
+ kind: "mutation",
685
+ mutation,
686
+ revision: this._revision,
687
+ });
392
688
  }
393
689
  }
394
690
 
@@ -456,7 +752,7 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
456
752
 
457
753
  // One pending item is the hard bound. Once another mutation arrives,
458
754
  // replace the pending delta with a current authoritative snapshot.
459
- state.pending = { kind: "snapshot" };
755
+ state.pending = { kind: "snapshot", revision: this._revision };
460
756
  }
461
757
 
462
758
  private retryDelay(state: ReplicationInfoV2SendState): number {
@@ -498,8 +794,11 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
498
794
  return;
499
795
  }
500
796
  state.suspended = false;
501
- state.pending = { kind: "snapshot" };
502
- this.enqueueState(state, { kind: "snapshot" });
797
+ state.pending = { kind: "snapshot", revision: this._revision };
798
+ this.enqueueState(state, {
799
+ kind: "snapshot",
800
+ revision: this._revision,
801
+ });
503
802
  }, this.retryDelay(state));
504
803
  state.retryTimer.unref?.();
505
804
  }
@@ -542,6 +841,102 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
542
841
  });
543
842
  }
544
843
 
844
+ /**
845
+ * Detach coordinator progress from a transport that ignores abort. The
846
+ * transport promise still gets a rejection handler, while this logical send
847
+ * drops its listener as soon as either side settles.
848
+ */
849
+ private sendConfirmationRequest(
850
+ request: RequestReplicationInfoV2AppliedMessage,
851
+ state: ReplicationInfoV2SendState,
852
+ signal: AbortSignal,
853
+ ): Promise<void> {
854
+ return new Promise<void>((resolve, reject) => {
855
+ let settled = false;
856
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
857
+ const succeed = () => {
858
+ if (settled) return;
859
+ settled = true;
860
+ cleanup();
861
+ resolve();
862
+ };
863
+ const fail = (error: unknown) => {
864
+ if (settled) return;
865
+ settled = true;
866
+ cleanup();
867
+ reject(error);
868
+ };
869
+ const onAbort = () =>
870
+ fail(
871
+ signal.reason instanceof Error
872
+ ? signal.reason
873
+ : new AbortError("Replication confirmation send aborted"),
874
+ );
875
+ signal.addEventListener("abort", onAbort, { once: true });
876
+ if (signal.aborted) {
877
+ onAbort();
878
+ return;
879
+ }
880
+ void Promise.resolve()
881
+ .then(() => {
882
+ if (signal.aborted) {
883
+ throw signal.reason instanceof Error
884
+ ? signal.reason
885
+ : new AbortError("Replication confirmation send aborted");
886
+ }
887
+ return this.deps.getRpc().send(request, {
888
+ mode: new AcknowledgeDelivery({
889
+ to: [state.target],
890
+ redundancy: 1,
891
+ }),
892
+ priority: CONVERGENCE_MESSAGE_PRIORITY,
893
+ signal,
894
+ });
895
+ })
896
+ .then(succeed, fail);
897
+ });
898
+ }
899
+
900
+ /** Accept an exact, signed response to this destination's latest query. */
901
+ acceptApplied(
902
+ message: ReplicationInfoV2AppliedMessage,
903
+ properties: {
904
+ from: PublicSignKey;
905
+ receiverTransportSession: bigint;
906
+ },
907
+ ): boolean {
908
+ const peerHash = properties.from.hashcode();
909
+ const state = this._sendStates.get(peerHash);
910
+ const requested = state?.applicationConfirmationRequest;
911
+ if (
912
+ !state ||
913
+ !requested ||
914
+ !state.target.equals(properties.from) ||
915
+ state.receiverTransportSession !== properties.receiverTransportSession ||
916
+ !this.isDestinationReady(state) ||
917
+ !this.supportsApplicationConfirmation(state) ||
918
+ !bytesEqual(message.receiverChallenge, state.receiverChallenge) ||
919
+ !bytesEqual(message.senderEpoch, state.senderEpoch) ||
920
+ message.sequence !== requested.sequence ||
921
+ message.revision !== requested.revision ||
922
+ message.revision > this._revision
923
+ ) {
924
+ return false;
925
+ }
926
+ state.applicationConfirmationRequest = undefined;
927
+ requested.controller.abort(
928
+ new AbortError("Replication confirmation response received"),
929
+ );
930
+ if (
931
+ state.appliedRevision === undefined ||
932
+ message.revision > state.appliedRevision
933
+ ) {
934
+ state.appliedRevision = message.revision;
935
+ }
936
+ this.settleConfirmations();
937
+ return true;
938
+ }
939
+
545
940
  private async runWorker(state: ReplicationInfoV2SendState): Promise<void> {
546
941
  while (true) {
547
942
  if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
@@ -553,6 +948,7 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
553
948
  return;
554
949
  }
555
950
  state.pending = undefined;
951
+ state.inFlightRevision = request.revision;
556
952
  const message = await this.createMessage(state, request);
557
953
  if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
558
954
  this.parkSnapshotForRetry(state);
@@ -576,6 +972,51 @@ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
576
972
  ]),
577
973
  });
578
974
  state.inFlightSequence = undefined;
975
+ if (
976
+ this.hasConfirmationFor(request.revision) &&
977
+ this.supportsApplicationConfirmation(state) &&
978
+ this.isCurrent(state)
979
+ ) {
980
+ const confirmationRequest: ApplicationConfirmationRequest = {
981
+ sequence: message.sequence,
982
+ revision: request.revision,
983
+ controller: new AbortController(),
984
+ };
985
+ // Install before invoking transport: an in-process or very fast remote
986
+ // response may arrive before this send promise resolves.
987
+ state.applicationConfirmationRequest?.controller.abort(
988
+ new AbortError("Replication confirmation superseded"),
989
+ );
990
+ state.applicationConfirmationRequest = confirmationRequest;
991
+ const confirmationSignal = AbortSignal.any([
992
+ state.controller.signal,
993
+ state.ownershipLifecycleController.signal,
994
+ confirmationRequest.controller.signal,
995
+ ]);
996
+ try {
997
+ await this.sendConfirmationRequest(
998
+ new RequestReplicationInfoV2AppliedMessage({
999
+ receiverChallenge: state.receiverChallenge.slice(),
1000
+ senderEpoch: state.senderEpoch.slice(),
1001
+ sequence: message.sequence,
1002
+ revision: request.revision,
1003
+ }),
1004
+ state,
1005
+ confirmationSignal,
1006
+ );
1007
+ } catch (error) {
1008
+ if (state.applicationConfirmationRequest === confirmationRequest) {
1009
+ state.applicationConfirmationRequest = undefined;
1010
+ }
1011
+ if (confirmationRequest.controller.signal.aborted) {
1012
+ if (!this.isCurrent(state)) return;
1013
+ state.inFlightRevision = undefined;
1014
+ continue;
1015
+ }
1016
+ throw error;
1017
+ }
1018
+ }
1019
+ state.inFlightRevision = undefined;
579
1020
  if (state.nextSequence > MAX_U64 || !this.isCurrent(state)) {
580
1021
  this.parkSnapshotForRetry(state);
581
1022
  return;
@@ -230,6 +230,75 @@ export class RequestReplicationInfoV2Message extends TransportMessage {
230
230
  }
231
231
  }
232
232
 
233
+ /**
234
+ * Opt-in query for proof that one V2 stream sequence has reached the receiver's
235
+ * durable application boundary. `revision` is sender-local and opaque to the
236
+ * receiver; echoing it lets the sender coalesce newer authoritative snapshots
237
+ * without retaining an unbounded sequence-to-revision history.
238
+ */
239
+ @variant([1, 10])
240
+ export class RequestReplicationInfoV2AppliedMessage extends TransportMessage {
241
+ @field({ type: fixedArray("u8", 32) })
242
+ receiverChallenge: Uint8Array;
243
+
244
+ @field({ type: fixedArray("u8", 32) })
245
+ senderEpoch: Uint8Array;
246
+
247
+ @field({ type: "u64" })
248
+ sequence: bigint;
249
+
250
+ // design-note: This opaque wire correlation value identifies multiple
251
+ // application states within one sender epoch/session. Lifecycle identity
252
+ // cannot tell a receiver which of those states the sender is confirming.
253
+ @field({ type: "u64" })
254
+ revision: bigint;
255
+
256
+ constructor(properties: {
257
+ receiverChallenge: Uint8Array;
258
+ senderEpoch: Uint8Array;
259
+ sequence: bigint;
260
+ revision: bigint;
261
+ }) {
262
+ super();
263
+ this.receiverChallenge = properties.receiverChallenge;
264
+ this.senderEpoch = properties.senderEpoch;
265
+ this.sequence = properties.sequence;
266
+ this.revision = properties.revision;
267
+ }
268
+ }
269
+
270
+ /** Signed receiver response emitted only after the queried sequence applied. */
271
+ @variant([1, 11])
272
+ export class ReplicationInfoV2AppliedMessage extends TransportMessage {
273
+ @field({ type: fixedArray("u8", 32) })
274
+ receiverChallenge: Uint8Array;
275
+
276
+ @field({ type: fixedArray("u8", 32) })
277
+ senderEpoch: Uint8Array;
278
+
279
+ @field({ type: "u64" })
280
+ sequence: bigint;
281
+
282
+ // design-note: Echoing the query's application-state token proves the exact
283
+ // state requested within one still-current session; it is not used to fence
284
+ // an asynchronous continuation.
285
+ @field({ type: "u64" })
286
+ revision: bigint;
287
+
288
+ constructor(properties: {
289
+ receiverChallenge: Uint8Array;
290
+ senderEpoch: Uint8Array;
291
+ sequence: bigint;
292
+ revision: bigint;
293
+ }) {
294
+ super();
295
+ this.receiverChallenge = properties.receiverChallenge;
296
+ this.senderEpoch = properties.senderEpoch;
297
+ this.sequence = properties.sequence;
298
+ this.revision = properties.revision;
299
+ }
300
+ }
301
+
233
302
  export type ReplicationInfoV2Message =
234
303
  | FullReplicationInfoV2Message
235
304
  | AddedReplicationInfoV2Message