@peerbit/shared-log 13.2.24 → 13.2.26

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.
Files changed (81) hide show
  1. package/dist/src/checked-prune.d.ts +12 -0
  2. package/dist/src/checked-prune.d.ts.map +1 -1
  3. package/dist/src/checked-prune.js +33 -0
  4. package/dist/src/checked-prune.js.map +1 -1
  5. package/dist/src/coordinate-persistence.d.ts +211 -0
  6. package/dist/src/coordinate-persistence.d.ts.map +1 -0
  7. package/dist/src/coordinate-persistence.js +1133 -0
  8. package/dist/src/coordinate-persistence.js.map +1 -0
  9. package/dist/src/errors.d.ts +1 -0
  10. package/dist/src/errors.d.ts.map +1 -1
  11. package/dist/src/errors.js +19 -0
  12. package/dist/src/errors.js.map +1 -1
  13. package/dist/src/index.d.ts +127 -133
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +1400 -4269
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/instance-lifecycle.d.ts +117 -0
  18. package/dist/src/instance-lifecycle.d.ts.map +1 -0
  19. package/dist/src/instance-lifecycle.js +254 -0
  20. package/dist/src/instance-lifecycle.js.map +1 -0
  21. package/dist/src/join-warmup.d.ts +86 -0
  22. package/dist/src/join-warmup.d.ts.map +1 -0
  23. package/dist/src/join-warmup.js +351 -0
  24. package/dist/src/join-warmup.js.map +1 -0
  25. package/dist/src/native-write-through-block-store.d.ts +123 -0
  26. package/dist/src/native-write-through-block-store.d.ts.map +1 -0
  27. package/dist/src/native-write-through-block-store.js +989 -0
  28. package/dist/src/native-write-through-block-store.js.map +1 -0
  29. package/dist/src/peer-session.d.ts +117 -0
  30. package/dist/src/peer-session.d.ts.map +1 -0
  31. package/dist/src/peer-session.js +259 -0
  32. package/dist/src/peer-session.js.map +1 -0
  33. package/dist/src/replication-announcement.d.ts +116 -0
  34. package/dist/src/replication-announcement.d.ts.map +1 -0
  35. package/dist/src/replication-announcement.js +515 -0
  36. package/dist/src/replication-announcement.js.map +1 -0
  37. package/dist/src/replicator-liveness.d.ts +59 -0
  38. package/dist/src/replicator-liveness.d.ts.map +1 -0
  39. package/dist/src/replicator-liveness.js +304 -0
  40. package/dist/src/replicator-liveness.js.map +1 -0
  41. package/dist/src/sync/dispatch-lifecycle.d.ts +48 -0
  42. package/dist/src/sync/dispatch-lifecycle.d.ts.map +1 -0
  43. package/dist/src/sync/dispatch-lifecycle.js +141 -0
  44. package/dist/src/sync/dispatch-lifecycle.js.map +1 -0
  45. package/dist/src/sync/factory.d.ts +29 -0
  46. package/dist/src/sync/factory.d.ts.map +1 -0
  47. package/dist/src/sync/factory.js +87 -0
  48. package/dist/src/sync/factory.js.map +1 -0
  49. package/dist/src/sync/pending-sync-store.d.ts +105 -0
  50. package/dist/src/sync/pending-sync-store.d.ts.map +1 -0
  51. package/dist/src/sync/pending-sync-store.js +877 -0
  52. package/dist/src/sync/pending-sync-store.js.map +1 -0
  53. package/dist/src/sync/rateless-iblt.d.ts +4 -1
  54. package/dist/src/sync/rateless-iblt.d.ts.map +1 -1
  55. package/dist/src/sync/rateless-iblt.js +102 -106
  56. package/dist/src/sync/rateless-iblt.js.map +1 -1
  57. package/dist/src/sync/simple.d.ts +10 -41
  58. package/dist/src/sync/simple.d.ts.map +1 -1
  59. package/dist/src/sync/simple.js +259 -952
  60. package/dist/src/sync/simple.js.map +1 -1
  61. package/dist/src/sync/sync-peer-state.d.ts +16 -0
  62. package/dist/src/sync/sync-peer-state.d.ts.map +1 -0
  63. package/dist/src/sync/sync-peer-state.js +82 -0
  64. package/dist/src/sync/sync-peer-state.js.map +1 -0
  65. package/package.json +12 -12
  66. package/src/checked-prune.ts +35 -0
  67. package/src/coordinate-persistence.ts +1796 -0
  68. package/src/errors.ts +21 -0
  69. package/src/index.ts +4014 -7858
  70. package/src/instance-lifecycle.ts +351 -0
  71. package/src/join-warmup.ts +507 -0
  72. package/src/native-write-through-block-store.ts +1170 -0
  73. package/src/peer-session.ts +336 -0
  74. package/src/replication-announcement.ts +744 -0
  75. package/src/replicator-liveness.ts +439 -0
  76. package/src/sync/dispatch-lifecycle.ts +230 -0
  77. package/src/sync/factory.ts +154 -0
  78. package/src/sync/pending-sync-store.ts +1085 -0
  79. package/src/sync/rateless-iblt.ts +122 -132
  80. package/src/sync/simple.ts +334 -1144
  81. package/src/sync/sync-peer-state.ts +107 -0
@@ -0,0 +1,744 @@
1
+ import type { PublicSignKey } from "@peerbit/crypto";
2
+ import { logger as loggerFn } from "@peerbit/logger";
3
+ import type { RPC } from "@peerbit/rpc";
4
+ import {
5
+ AcknowledgeDelivery,
6
+ CONVERGENCE_MESSAGE_PRIORITY,
7
+ DeliveryError,
8
+ } from "@peerbit/stream-interface";
9
+ import { TimeoutError, debounceFixedInterval } from "@peerbit/time";
10
+ import { isNotStartedError } from "./errors.js";
11
+ import type { TransportMessage } from "./message.js";
12
+ import type { ReplicationRangeIndexable } from "./ranges.js";
13
+ import {
14
+ AddedReplicationSegmentMessage,
15
+ AllReplicatingSegmentsMessage,
16
+ StoppedReplicating,
17
+ } from "./replication.js";
18
+
19
+ const logger = loggerFn("peerbit:shared-log");
20
+
21
+ const REPLICATION_ANNOUNCEMENT_RETRY_INTERVAL = 1000;
22
+ const REPLICATION_ANNOUNCEMENT_REPAIR_INTERVAL = 1000;
23
+ const REPLICATION_ANNOUNCEMENT_REPAIR_MAX_ATTEMPTS = 3;
24
+ // Repair one bounded cohort per mutation generation. The subscriber snapshot
25
+ // is a best-effort cache and can contain thousands of entries, so attempting
26
+ // the whole cache after every role mutation would turn convergence repair into
27
+ // an unbounded burst of separately signed, acknowledged messages. A cursor
28
+ // retained across generations rotates best-effort coverage over later changes.
29
+ const REPLICATION_ANNOUNCEMENT_REPAIR_TARGETS_PER_GENERATION = 8;
30
+
31
+ /**
32
+ * Replication announcements are best-effort convergence messages. A detached
33
+ * fanout shard can time out even though the shared log itself remains open.
34
+ * Keep retries deliberately limited to concrete TimeoutErrors: abort/close and
35
+ * unexpected programming/data errors must retain their existing semantics.
36
+ *
37
+ * Exact constructor/name checks complement `instanceof` for errors crossing
38
+ * worker or duplicate-package boundaries in browsers.
39
+ */
40
+ export const isTransientReplicationAnnouncementError = (
41
+ error: unknown,
42
+ seen = new Set<unknown>(),
43
+ ): boolean => {
44
+ if (
45
+ error != null &&
46
+ (typeof error === "object" || typeof error === "function")
47
+ ) {
48
+ if (seen.has(error)) {
49
+ return false;
50
+ }
51
+ seen.add(error);
52
+ }
53
+
54
+ if (error instanceof TimeoutError) {
55
+ return true;
56
+ }
57
+
58
+ const nested = (error as { errors?: unknown })?.errors;
59
+ if (Array.isArray(nested) && nested.length > 0) {
60
+ return nested.every((item) =>
61
+ isTransientReplicationAnnouncementError(item, new Set(seen)),
62
+ );
63
+ }
64
+
65
+ const cause = (error as { cause?: unknown })?.cause;
66
+ if (cause != null && isTransientReplicationAnnouncementError(cause, seen)) {
67
+ return true;
68
+ }
69
+
70
+ const constructorName =
71
+ typeof (error as { constructor?: { name?: unknown } })?.constructor
72
+ ?.name === "string"
73
+ ? (error as { constructor: { name: string } }).constructor.name
74
+ : "";
75
+ const name =
76
+ typeof (error as { name?: unknown })?.name === "string"
77
+ ? (error as { name: string }).name
78
+ : "";
79
+ return constructorName === "TimeoutError" || name === "TimeoutError";
80
+ };
81
+
82
+ /**
83
+ * Directed transport-delivery repair is allowed to retry explicit delivery
84
+ * failures in addition to timeouts. A DirectStream ACK confirms receipt of the
85
+ * signed envelope, not successful application by the receiver. Keep this
86
+ * separate from the primary fanout classifier above so replicate() rejection
87
+ * semantics remain unchanged for programming, serialization, and lifecycle
88
+ * errors.
89
+ */
90
+ const isTransientReplicationAnnouncementRepairError = (
91
+ error: unknown,
92
+ seen = new Set<unknown>(),
93
+ ): boolean => {
94
+ if (
95
+ error != null &&
96
+ (typeof error === "object" || typeof error === "function")
97
+ ) {
98
+ if (seen.has(error)) {
99
+ return false;
100
+ }
101
+ seen.add(error);
102
+ }
103
+
104
+ if (error instanceof DeliveryError || error instanceof TimeoutError) {
105
+ return true;
106
+ }
107
+
108
+ const nested = (error as { errors?: unknown })?.errors;
109
+ if (Array.isArray(nested) && nested.length > 0) {
110
+ return nested.every((item) =>
111
+ isTransientReplicationAnnouncementRepairError(item, new Set(seen)),
112
+ );
113
+ }
114
+
115
+ const cause = (error as { cause?: unknown })?.cause;
116
+ if (
117
+ cause != null &&
118
+ isTransientReplicationAnnouncementRepairError(cause, seen)
119
+ ) {
120
+ return true;
121
+ }
122
+
123
+ const constructorName =
124
+ typeof (error as { constructor?: { name?: unknown } })?.constructor
125
+ ?.name === "string"
126
+ ? (error as { constructor: { name: string } }).constructor.name
127
+ : "";
128
+ const name =
129
+ typeof (error as { name?: unknown })?.name === "string"
130
+ ? (error as { name: string }).name
131
+ : "";
132
+ return (
133
+ constructorName === "DeliveryError" ||
134
+ name === "DeliveryError" ||
135
+ constructorName === "TimeoutError" ||
136
+ name === "TimeoutError"
137
+ );
138
+ };
139
+
140
+ /**
141
+ * One session per announcement window — rotated at exactly the sites that
142
+ * bumped the legacy retry-generation counter: construction, resetForOpen,
143
+ * cancelCurrentReplicationStateAnnouncementRetry, and the pre-send bump in
144
+ * sendReplicationAnnouncement. Identity comparison replaces every numeric
145
+ * generation compare. Unlike the number, identity cannot alias across open
146
+ * cycles (the legacy reset-to-0 was safe only by the convention that a
147
+ * companion controller check accompanied every compare site).
148
+ */
149
+ export class AnnouncementWorkerSession {
150
+ readonly createdAt = Date.now(); // diagnostics only
151
+ }
152
+
153
+ type ReplicationAnnouncementRepairTarget = {
154
+ key: PublicSignKey;
155
+ session: AnnouncementWorkerSession;
156
+ attempts: number;
157
+ done: boolean;
158
+ };
159
+
160
+ /**
161
+ * Repair-side adoption of a session. Replaces the legacy compound of the
162
+ * repair generation number, its generation controller, and the repair
163
+ * pending / targets / cohort-selected fields. One binding per adopted
164
+ * session; every rotation aborts the old binding's controller FIRST, then
165
+ * reassigns (abort listeners run synchronously and must observe
166
+ * pre-rotation state, as before). The fair cursor hash and max attempts
167
+ * deliberately stay OUTSIDE the binding: the cursor rotates best-effort
168
+ * coverage ACROSS generations, and max-attempts is a setup-scoped tunable.
169
+ */
170
+ export type ReplicationAnnouncementRepairBinding = {
171
+ session: AnnouncementWorkerSession;
172
+ controller: AbortController;
173
+ pending: boolean;
174
+ targets: Map<string, ReplicationAnnouncementRepairTarget>;
175
+ cohortSelected: boolean;
176
+ };
177
+
178
+ export type ReplicationAnnouncementRepairWorkerContext = {
179
+ session: AnnouncementWorkerSession;
180
+ lifecycleController: AbortController;
181
+ binding: ReplicationAnnouncementRepairBinding;
182
+ };
183
+
184
+ export type ReplicationAnnouncementDeps<R extends "u32" | "u64"> = {
185
+ isClosed: () => boolean;
186
+ getCloseSignal: () => AbortSignal;
187
+ getMyReplicationSegments: () => Promise<ReplicationRangeIndexable<R>[]>;
188
+ validatePersistedReplicationRangeSnapshot: (
189
+ ranges: readonly { mode: unknown }[],
190
+ ) => void;
191
+ getSubscribers: () =>
192
+ | Promise<PublicSignKey[] | undefined>
193
+ | PublicSignKey[]
194
+ | undefined;
195
+ getSelfHash: () => string;
196
+ isBlockedPeer: (hash: string) => boolean;
197
+ getRpc: () => RPC<TransportMessage, TransportMessage>;
198
+ captureReplicationOwnershipLifecycle: () => AbortController;
199
+ throwIfReplicationOwnershipLifecycleInactive: (
200
+ controller: AbortController,
201
+ ) => void;
202
+ isAdaptiveReplicating: () => boolean;
203
+ callRebalanceParticipationDebounced: () => unknown;
204
+ // Owner-routed so coordinator spies keep observing re-entrant queueing.
205
+ queueCurrentReplicationStateAnnouncementRepair: () => void;
206
+ queueCurrentReplicationStateAnnouncementRetry: (error: unknown) => boolean;
207
+ };
208
+
209
+ export class ReplicationAnnouncementCoordinator<R extends "u32" | "u64"> {
210
+ replicationAnnouncementRetryDebounced:
211
+ | ReturnType<typeof debounceFixedInterval>
212
+ | undefined;
213
+ _replicationAnnouncementRetryPending!: boolean;
214
+ _announcementSession!: AnnouncementWorkerSession;
215
+ _replicationAnnouncementRetryController!: AbortController;
216
+ // Publish local ownership announcements in committed mutation order. This
217
+ // prevents an older Added message with a delayed transport completion from
218
+ // overtaking a newer authoritative empty snapshot.
219
+ _replicationAnnouncementSendTails?: WeakMap<AbortController, Promise<void>>;
220
+ replicationAnnouncementRepairDebounced:
221
+ | ReturnType<typeof debounceFixedInterval>
222
+ | undefined;
223
+ _announcementRepairBinding!: ReplicationAnnouncementRepairBinding;
224
+ _replicationAnnouncementRepairFairCursorHash!: string | undefined;
225
+ _replicationAnnouncementRepairMaxAttempts!: number;
226
+ _replicationAnnouncementRepairController!: AbortController;
227
+
228
+ constructor(private readonly deps: ReplicationAnnouncementDeps<R>) {
229
+ this._replicationAnnouncementRetryPending = false;
230
+ this._announcementSession = new AnnouncementWorkerSession();
231
+ this._replicationAnnouncementRetryController = new AbortController();
232
+ this._replicationAnnouncementSendTails = new WeakMap();
233
+ this._announcementRepairBinding = this.createRepairBinding(
234
+ this._announcementSession,
235
+ );
236
+ this._replicationAnnouncementRepairFairCursorHash = undefined;
237
+ this._replicationAnnouncementRepairMaxAttempts =
238
+ REPLICATION_ANNOUNCEMENT_REPAIR_MAX_ATTEMPTS;
239
+ this._replicationAnnouncementRepairController = new AbortController();
240
+ }
241
+
242
+ private rotateAnnouncementSession(): AnnouncementWorkerSession {
243
+ return (this._announcementSession = new AnnouncementWorkerSession());
244
+ }
245
+
246
+ private createRepairBinding(
247
+ session: AnnouncementWorkerSession,
248
+ ): ReplicationAnnouncementRepairBinding {
249
+ return {
250
+ session,
251
+ controller: new AbortController(),
252
+ pending: false,
253
+ targets: new Map(),
254
+ cohortSelected: false,
255
+ };
256
+ }
257
+
258
+ resetForOpen(): void {
259
+ this._replicationAnnouncementRetryPending = false;
260
+ this.rotateAnnouncementSession();
261
+ }
262
+
263
+ queueCurrentReplicationStateAnnouncementRetry(error: unknown): boolean {
264
+ if (
265
+ this.deps.isClosed() ||
266
+ this.deps.getCloseSignal().aborted ||
267
+ this._replicationAnnouncementRetryController.signal.aborted ||
268
+ !isTransientReplicationAnnouncementError(error)
269
+ ) {
270
+ return false;
271
+ }
272
+
273
+ this._replicationAnnouncementRetryPending = true;
274
+ void this.replicationAnnouncementRetryDebounced?.call();
275
+ return true;
276
+ }
277
+
278
+ setupReplicationAnnouncementRetryFunction(
279
+ interval = REPLICATION_ANNOUNCEMENT_RETRY_INTERVAL,
280
+ ): void {
281
+ this.replicationAnnouncementRetryDebounced?.close();
282
+ this._replicationAnnouncementRetryController?.abort();
283
+ this._replicationAnnouncementRetryController = new AbortController();
284
+ this.replicationAnnouncementRetryDebounced = debounceFixedInterval(
285
+ () => this.retryCurrentReplicationStateAnnouncement(),
286
+ interval,
287
+ {
288
+ leading: false,
289
+ onError: (error) => {
290
+ if (
291
+ this.deps.isClosed() ||
292
+ this.deps.getCloseSignal().aborted ||
293
+ isNotStartedError(error)
294
+ ) {
295
+ return;
296
+ }
297
+ logger.error(error);
298
+ },
299
+ },
300
+ );
301
+ }
302
+
303
+ setupReplicationAnnouncementRepairFunction(
304
+ interval = REPLICATION_ANNOUNCEMENT_REPAIR_INTERVAL,
305
+ maxAttempts = REPLICATION_ANNOUNCEMENT_REPAIR_MAX_ATTEMPTS,
306
+ ): void {
307
+ if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0) {
308
+ throw new RangeError(
309
+ "Replication announcement repair attempts must be positive",
310
+ );
311
+ }
312
+ this.replicationAnnouncementRepairDebounced?.close();
313
+ this._replicationAnnouncementRepairController?.abort();
314
+ this._announcementRepairBinding?.controller.abort();
315
+ this._replicationAnnouncementRepairController = new AbortController();
316
+ this._announcementRepairBinding = this.createRepairBinding(
317
+ this._announcementSession,
318
+ );
319
+ this._replicationAnnouncementRepairFairCursorHash = undefined;
320
+ this._replicationAnnouncementRepairMaxAttempts = maxAttempts;
321
+ this.replicationAnnouncementRepairDebounced = debounceFixedInterval(
322
+ () => this.runCurrentReplicationStateAnnouncementRepair(),
323
+ interval,
324
+ {
325
+ leading: false,
326
+ // The wrapper catches worker failures while it still owns the generation
327
+ // context. Keep this boundary visibility-only: it must never mutate a
328
+ // possibly newer generation's pending state.
329
+ onError: (error) => logger.error(error),
330
+ },
331
+ );
332
+ }
333
+
334
+ cancelCurrentReplicationStateAnnouncementRepair(): void {
335
+ this._announcementRepairBinding.pending = false;
336
+ this._replicationAnnouncementRepairController?.abort();
337
+ this._announcementRepairBinding.controller.abort();
338
+ this.replicationAnnouncementRepairDebounced?.close();
339
+ this._announcementRepairBinding.targets.clear();
340
+ }
341
+
342
+ advanceCurrentReplicationStateAnnouncementRepairGeneration(): void {
343
+ const session = this._announcementSession;
344
+ if (session === this._announcementRepairBinding.session) {
345
+ return;
346
+ }
347
+
348
+ // Abort acknowledged sends carrying the old full-state snapshot before the
349
+ // primary announcement for the new mutation waits on transport. Otherwise a
350
+ // stale batch can hold the current state behind DirectStream's seek timeout.
351
+ this._announcementRepairBinding.controller.abort();
352
+ this._announcementRepairBinding = this.createRepairBinding(session);
353
+ }
354
+
355
+ queueCurrentReplicationStateAnnouncementRepair(): void {
356
+ if (
357
+ this.deps.isClosed() ||
358
+ this.deps.getCloseSignal().aborted ||
359
+ this._replicationAnnouncementRepairController.signal.aborted ||
360
+ !this.replicationAnnouncementRepairDebounced
361
+ ) {
362
+ return;
363
+ }
364
+
365
+ this.advanceCurrentReplicationStateAnnouncementRepairGeneration();
366
+ this._announcementRepairBinding.pending = true;
367
+ void this.replicationAnnouncementRepairDebounced.call();
368
+ }
369
+
370
+ /**
371
+ * Single validity predicate for announcement-repair workers. "stale":
372
+ * the store closed or a lifecycle controller aborted — exit silently.
373
+ * "superseded": a newer announcement session took over — the worker
374
+ * must requeue so the current session gets serviced. The binding
375
+ * identity comparison stays at the one call site that historically
376
+ * required it; folding it in here is a stage-5 semantic decision, not a
377
+ * consolidation.
378
+ */
379
+ private announcementRepairWorkerStatus(
380
+ worker: ReplicationAnnouncementRepairWorkerContext,
381
+ ): "current" | "stale" | "superseded" {
382
+ if (
383
+ this.deps.isClosed() ||
384
+ this.deps.getCloseSignal().aborted ||
385
+ worker.lifecycleController.signal.aborted ||
386
+ worker.binding.controller.signal.aborted
387
+ ) {
388
+ return "stale";
389
+ }
390
+ if (worker.session !== this._announcementSession) {
391
+ return "superseded";
392
+ }
393
+ return "current";
394
+ }
395
+
396
+ async runCurrentReplicationStateAnnouncementRepair(): Promise<void> {
397
+ const session = this._announcementSession;
398
+ const lifecycleController = this._replicationAnnouncementRepairController;
399
+ const binding = this._announcementRepairBinding;
400
+ try {
401
+ await this.repairCurrentReplicationStateAnnouncement({
402
+ session,
403
+ lifecycleController,
404
+ binding,
405
+ });
406
+ } catch (error) {
407
+ if (
408
+ this.announcementRepairWorkerStatus({
409
+ session,
410
+ lifecycleController,
411
+ binding,
412
+ }) !== "current" ||
413
+ binding !== this._announcementRepairBinding
414
+ ) {
415
+ return;
416
+ }
417
+ if (isNotStartedError(error as Error)) {
418
+ return;
419
+ }
420
+
421
+ // Only the worker that still owns the current session may conclude
422
+ // that its repair failed. A stale worker must not clear a newer call's
423
+ // pending flag or attribute its error to the new session.
424
+ this._announcementRepairBinding.pending = false;
425
+ logger.error(error);
426
+ }
427
+ }
428
+
429
+ async repairCurrentReplicationStateAnnouncement(
430
+ context?: ReplicationAnnouncementRepairWorkerContext,
431
+ ): Promise<void> {
432
+ if (!this._announcementRepairBinding.pending) {
433
+ return;
434
+ }
435
+ const session = context?.session ?? this._announcementSession;
436
+ const lifecycleController =
437
+ context?.lifecycleController ??
438
+ this._replicationAnnouncementRepairController;
439
+ const binding = context?.binding ?? this._announcementRepairBinding;
440
+ const segments = (await this.deps.getMyReplicationSegments()).map((range) =>
441
+ range.toReplicationRange(),
442
+ );
443
+ switch (
444
+ this.announcementRepairWorkerStatus({
445
+ session,
446
+ lifecycleController,
447
+ binding,
448
+ })
449
+ ) {
450
+ case "stale":
451
+ return;
452
+ case "superseded":
453
+ this.queueCurrentReplicationStateAnnouncementRepair();
454
+ return;
455
+ }
456
+ this.deps.validatePersistedReplicationRangeSnapshot(segments);
457
+
458
+ const subscribers = (await this.deps.getSubscribers()) ?? [];
459
+ switch (
460
+ this.announcementRepairWorkerStatus({
461
+ session,
462
+ lifecycleController,
463
+ binding,
464
+ })
465
+ ) {
466
+ case "stale":
467
+ return;
468
+ case "superseded":
469
+ this.queueCurrentReplicationStateAnnouncementRepair();
470
+ return;
471
+ }
472
+
473
+ const selfHash = this.deps.getSelfHash();
474
+ const currentTargets = new Map<string, PublicSignKey>();
475
+ for (const key of subscribers) {
476
+ const hash = key.hashcode();
477
+ if (
478
+ hash !== selfHash &&
479
+ !this.deps.isBlockedPeer(hash) &&
480
+ !currentTargets.has(hash)
481
+ ) {
482
+ currentTargets.set(hash, key);
483
+ }
484
+ }
485
+
486
+ for (const [hash, target] of this._announcementRepairBinding.targets) {
487
+ if (target.session !== session || !currentTargets.has(hash)) {
488
+ this._announcementRepairBinding.targets.delete(hash);
489
+ } else {
490
+ target.key = currentTargets.get(hash)!;
491
+ }
492
+ }
493
+ if (!this._announcementRepairBinding.cohortSelected) {
494
+ const candidates = [...currentTargets.entries()].sort(([left], [right]) =>
495
+ left.localeCompare(right),
496
+ );
497
+ const cursorIndex = this._replicationAnnouncementRepairFairCursorHash
498
+ ? candidates.findIndex(
499
+ ([hash]) =>
500
+ hash.localeCompare(
501
+ this._replicationAnnouncementRepairFairCursorHash!,
502
+ ) > 0,
503
+ )
504
+ : 0;
505
+ const fairStart = cursorIndex < 0 ? 0 : cursorIndex;
506
+ const fairOrder = [
507
+ ...candidates.slice(fairStart),
508
+ ...candidates.slice(0, fairStart),
509
+ ];
510
+ const cohort = fairOrder.slice(
511
+ 0,
512
+ REPLICATION_ANNOUNCEMENT_REPAIR_TARGETS_PER_GENERATION,
513
+ );
514
+ for (const [hash, key] of cohort) {
515
+ this._announcementRepairBinding.targets.set(hash, {
516
+ key,
517
+ session,
518
+ attempts: 0,
519
+ done: false,
520
+ });
521
+ }
522
+ if (cohort.length > 0) {
523
+ this._replicationAnnouncementRepairFairCursorHash =
524
+ cohort[cohort.length - 1][0];
525
+ }
526
+ this._announcementRepairBinding.cohortSelected = true;
527
+ }
528
+
529
+ const batch = [...this._announcementRepairBinding.targets.entries()].filter(
530
+ ([, target]) => !target.done,
531
+ );
532
+ const snapshot = new AllReplicatingSegmentsMessage({ segments });
533
+ const results = await Promise.allSettled(
534
+ batch.map(([, target]) =>
535
+ this.deps.getRpc().send(snapshot, {
536
+ mode: new AcknowledgeDelivery({
537
+ to: [target.key],
538
+ redundancy: 1,
539
+ }),
540
+ priority: CONVERGENCE_MESSAGE_PRIORITY,
541
+ signal: binding.controller.signal,
542
+ }),
543
+ ),
544
+ );
545
+ switch (
546
+ this.announcementRepairWorkerStatus({
547
+ session,
548
+ lifecycleController,
549
+ binding,
550
+ })
551
+ ) {
552
+ case "stale":
553
+ return;
554
+ case "superseded":
555
+ this.queueCurrentReplicationStateAnnouncementRepair();
556
+ return;
557
+ }
558
+
559
+ for (const [index, result] of results.entries()) {
560
+ const [hash, attemptedTarget] = batch[index];
561
+ const target = this._announcementRepairBinding.targets.get(hash);
562
+ if (target !== attemptedTarget || target.session !== session) {
563
+ continue;
564
+ }
565
+ if (result.status === "fulfilled") {
566
+ // DirectStream ACKs confirm that the signed transport envelope reached
567
+ // the target. Applying the contained replication state remains a
568
+ // receiver-local, best-effort operation.
569
+ target.done = true;
570
+ continue;
571
+ }
572
+
573
+ target.attempts += 1;
574
+ if (!isTransientReplicationAnnouncementRepairError(result.reason)) {
575
+ target.done = true;
576
+ logger.error(result.reason);
577
+ } else if (
578
+ target.attempts >= this._replicationAnnouncementRepairMaxAttempts
579
+ ) {
580
+ target.done = true;
581
+ logger.trace(
582
+ "Acknowledged replication announcement repair exhausted for %s",
583
+ hash,
584
+ );
585
+ }
586
+ }
587
+
588
+ if (session !== this._announcementSession) {
589
+ this.queueCurrentReplicationStateAnnouncementRepair();
590
+ return;
591
+ }
592
+ if (
593
+ [...this._announcementRepairBinding.targets.values()].some(
594
+ (target) => !target.done,
595
+ )
596
+ ) {
597
+ void this.replicationAnnouncementRepairDebounced?.call();
598
+ return;
599
+ }
600
+
601
+ this._announcementRepairBinding.pending = false;
602
+ this._announcementRepairBinding.targets.clear();
603
+ }
604
+
605
+ cancelCurrentReplicationStateAnnouncementRetry(): void {
606
+ this.rotateAnnouncementSession();
607
+ this._replicationAnnouncementRetryPending = false;
608
+ this._replicationAnnouncementRetryController?.abort();
609
+ this.replicationAnnouncementRetryDebounced?.close();
610
+ this.cancelCurrentReplicationStateAnnouncementRepair();
611
+ }
612
+
613
+ async sendReplicationAnnouncement(
614
+ message:
615
+ | AllReplicatingSegmentsMessage
616
+ | AddedReplicationSegmentMessage
617
+ | StoppedReplicating,
618
+ ownershipLifecycleController = this.deps.captureReplicationOwnershipLifecycle(),
619
+ options?: { shouldSend?: () => boolean },
620
+ ): Promise<void> {
621
+ const tails = (this._replicationAnnouncementSendTails ??= new WeakMap<
622
+ AbortController,
623
+ Promise<void>
624
+ >());
625
+ const previous =
626
+ tails.get(ownershipLifecycleController) ?? Promise.resolve();
627
+ const send = previous
628
+ .catch(() => {})
629
+ .then(async () => {
630
+ this.deps.throwIfReplicationOwnershipLifecycleInactive(
631
+ ownershipLifecycleController,
632
+ );
633
+ if (options?.shouldSend && !options.shouldSend()) {
634
+ return;
635
+ }
636
+ // Advance before every post-mutation send, including successful ones. An
637
+ // authoritative retry already in flight may have captured the previous
638
+ // local state; the session mismatch forces one more current snapshot
639
+ // after that stale send settles.
640
+ this.rotateAnnouncementSession();
641
+ this.advanceCurrentReplicationStateAnnouncementRepairGeneration();
642
+ try {
643
+ await this.deps.getRpc().send(message, {
644
+ priority: CONVERGENCE_MESSAGE_PRIORITY,
645
+ signal: ownershipLifecycleController.signal,
646
+ });
647
+ this.deps.throwIfReplicationOwnershipLifecycleInactive(
648
+ ownershipLifecycleController,
649
+ );
650
+ this.deps.queueCurrentReplicationStateAnnouncementRepair();
651
+ } catch (error) {
652
+ // An old send can reject only after poison or close has installed a new
653
+ // ownership generation. Never enqueue its retry work into that generation.
654
+ this.deps.throwIfReplicationOwnershipLifecycleInactive(
655
+ ownershipLifecycleController,
656
+ );
657
+ // The local replication-index mutation precedes all calls to this
658
+ // wrapper. Preserve the explicit caller's rejection, but independently
659
+ // schedule an authoritative snapshot so peers eventually observe the
660
+ // already-committed local state.
661
+ this.deps.queueCurrentReplicationStateAnnouncementRetry(error);
662
+ throw error;
663
+ }
664
+ });
665
+ // Keep the ordering barrier usable after a caller-observed send rejection.
666
+ tails.set(
667
+ ownershipLifecycleController,
668
+ send.catch(() => {}),
669
+ );
670
+ return send;
671
+ }
672
+
673
+ async retryCurrentReplicationStateAnnouncement(): Promise<void> {
674
+ const session = this._announcementSession;
675
+ const controller = this._replicationAnnouncementRetryController;
676
+ try {
677
+ const segments = (await this.deps.getMyReplicationSegments()).map(
678
+ (range) => range.toReplicationRange(),
679
+ );
680
+ if (
681
+ this.deps.isClosed() ||
682
+ this.deps.getCloseSignal().aborted ||
683
+ controller.signal.aborted
684
+ ) {
685
+ return;
686
+ }
687
+ if (session !== this._announcementSession) {
688
+ void this.replicationAnnouncementRetryDebounced?.call();
689
+ return;
690
+ }
691
+ this.deps.validatePersistedReplicationRangeSnapshot(segments);
692
+
693
+ await this.deps
694
+ .getRpc()
695
+ .send(new AllReplicatingSegmentsMessage({ segments }), {
696
+ priority: CONVERGENCE_MESSAGE_PRIORITY,
697
+ signal: controller.signal,
698
+ });
699
+ this.queueCurrentReplicationStateAnnouncementRepair();
700
+ } catch (error) {
701
+ if (
702
+ this.deps.isClosed() ||
703
+ this.deps.getCloseSignal().aborted ||
704
+ controller.signal.aborted
705
+ ) {
706
+ return;
707
+ }
708
+ if (this.queueCurrentReplicationStateAnnouncementRetry(error)) {
709
+ return;
710
+ }
711
+ if (session === this._announcementSession) {
712
+ this._replicationAnnouncementRetryPending = false;
713
+ } else {
714
+ void this.replicationAnnouncementRetryDebounced?.call();
715
+ }
716
+ throw error;
717
+ }
718
+ if (
719
+ this.deps.isClosed() ||
720
+ this.deps.getCloseSignal().aborted ||
721
+ controller.signal.aborted
722
+ ) {
723
+ return;
724
+ }
725
+
726
+ // A newer mutation announcement may have started while this snapshot was
727
+ // in flight. In that case keep the repair pending so the newer current
728
+ // state is also announced in full, regardless of whether its incremental
729
+ // send succeeded or failed.
730
+ if (session === this._announcementSession) {
731
+ this._replicationAnnouncementRetryPending = false;
732
+ if (
733
+ !this.deps.isClosed() &&
734
+ !this.deps.getCloseSignal().aborted &&
735
+ !controller.signal.aborted &&
736
+ this.deps.isAdaptiveReplicating()
737
+ ) {
738
+ void this.deps.callRebalanceParticipationDebounced();
739
+ }
740
+ } else {
741
+ void this.replicationAnnouncementRetryDebounced?.call();
742
+ }
743
+ }
744
+ }