@peerbit/shared-log 16.0.12 → 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.
- package/dist/src/exchange-heads.d.ts +6 -0
- package/dist/src/exchange-heads.d.ts.map +1 -1
- package/dist/src/exchange-heads.js +6 -0
- package/dist/src/exchange-heads.js.map +1 -1
- package/dist/src/index.d.ts +13 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +168 -30
- package/dist/src/index.js.map +1 -1
- package/dist/src/receive-signature-verification.d.ts +9 -0
- package/dist/src/receive-signature-verification.d.ts.map +1 -0
- package/dist/src/receive-signature-verification.js +54 -0
- package/dist/src/receive-signature-verification.js.map +1 -0
- package/dist/src/replication-info-v2-receive.d.ts +12 -1
- package/dist/src/replication-info-v2-receive.d.ts.map +1 -1
- package/dist/src/replication-info-v2-receive.js +30 -1
- package/dist/src/replication-info-v2-receive.js.map +1 -1
- package/dist/src/replication-info-v2-send.d.ts +61 -1
- package/dist/src/replication-info-v2-send.d.ts.map +1 -1
- package/dist/src/replication-info-v2-send.js +335 -9
- package/dist/src/replication-info-v2-send.js.map +1 -1
- package/dist/src/replication.d.ts +31 -0
- package/dist/src/replication.d.ts.map +1 -1
- package/dist/src/replication.js +115 -0
- package/dist/src/replication.js.map +1 -1
- package/package.json +18 -16
- package/src/exchange-heads.ts +7 -0
- package/src/index.ts +226 -30
- package/src/receive-signature-verification.ts +66 -0
- package/src/replication-info-v2-receive.ts +42 -0
- package/src/replication-info-v2-send.ts +452 -11
- package/src/replication.ts +69 -0
|
@@ -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
|
-
| {
|
|
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 = {
|
|
324
|
-
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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;
|
package/src/replication.ts
CHANGED
|
@@ -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
|