@optimystic/db-p2p 0.22.0 → 0.24.1
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/cluster/client.d.ts +10 -0
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +30 -1
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +39 -14
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +226 -119
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/service.d.ts +32 -1
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +43 -2
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cohort-topic/host.js +34 -11
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/cohort-topic/stream-util.d.ts +37 -7
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +77 -19
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +9 -3
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +88 -2
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +134 -28
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +25 -1
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/logger.d.ts +17 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +19 -2
- package/dist/src/logger.js.map +1 -1
- package/dist/src/matchmaking/query-transport.js +3 -3
- package/dist/src/matchmaking/query-transport.js.map +1 -1
- package/dist/src/owned-block-seed.d.ts +6 -3
- package/dist/src/owned-block-seed.d.ts.map +1 -1
- package/dist/src/owned-block-seed.js +16 -3
- package/dist/src/owned-block-seed.js.map +1 -1
- package/dist/src/peer-address-book.d.ts +72 -0
- package/dist/src/peer-address-book.d.ts.map +1 -0
- package/dist/src/peer-address-book.js +123 -0
- package/dist/src/peer-address-book.js.map +1 -0
- package/dist/src/reactivity/notify-transport.d.ts +4 -4
- package/dist/src/reactivity/notify-transport.js +6 -6
- package/dist/src/reactivity/notify-transport.js.map +1 -1
- package/dist/src/reactivity/push-state-gossip.js +2 -2
- package/dist/src/reactivity/push-state-gossip.js.map +1 -1
- package/dist/src/reactivity/recover-transport.d.ts +6 -2
- package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
- package/dist/src/reactivity/recover-transport.js +7 -3
- package/dist/src/reactivity/recover-transport.js.map +1 -1
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +11 -2
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +30 -0
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +95 -3
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +62 -9
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +242 -73
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/rn.d.ts +3 -0
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +3 -0
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +83 -0
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
- package/dist/src/storage/cached-raw-storage.js +152 -0
- package/dist/src/storage/cached-raw-storage.js.map +1 -0
- package/dist/src/storage/cached-store-driver.d.ts +186 -0
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
- package/dist/src/storage/cached-store-driver.js +775 -0
- package/dist/src/storage/cached-store-driver.js.map +1 -0
- package/dist/src/storage/i-raw-storage.d.ts +12 -5
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +234 -0
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
- package/dist/src/storage/shared-cache-pool.js +354 -0
- package/dist/src/storage/shared-cache-pool.js.map +1 -0
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
- package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
- package/dist/src/testing/raw-storage-conformance.js +35 -2
- package/dist/src/testing/raw-storage-conformance.js.map +1 -1
- package/package.json +3 -3
- package/readme.md +668 -668
- package/src/cluster/block-transfer.ts +424 -424
- package/src/cluster/client.ts +119 -88
- package/src/cluster/cluster-error.ts +64 -64
- package/src/cluster/cluster-policy.ts +203 -203
- package/src/cluster/cluster-repo.ts +242 -122
- package/src/cluster/cluster-size-coupling.ts +45 -45
- package/src/cluster/commit-cert.ts +139 -139
- package/src/cluster/i-transaction-state-store.ts +43 -43
- package/src/cluster/memory-transaction-state-store.ts +56 -56
- package/src/cluster/peer-key-binding.ts +37 -37
- package/src/cluster/persistent-transaction-state-store.ts +92 -92
- package/src/cluster/quorum-restore.ts +223 -223
- package/src/cluster/reconcile-block.ts +203 -203
- package/src/cluster/service.ts +293 -241
- package/src/cluster/supermajority-coupling.ts +37 -37
- package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
- package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
- package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
- package/src/cohort-topic/change-bridge.ts +109 -109
- package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
- package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
- package/src/cohort-topic/fret-trust-anchor.ts +153 -153
- package/src/cohort-topic/host.ts +42 -11
- package/src/cohort-topic/index.ts +13 -13
- package/src/cohort-topic/membership-publish-sink.ts +20 -20
- package/src/cohort-topic/membership-source.ts +68 -68
- package/src/cohort-topic/peer-codec.ts +31 -31
- package/src/cohort-topic/peer-sig.ts +86 -86
- package/src/cohort-topic/protocols.ts +71 -71
- package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
- package/src/cohort-topic/size-estimator.ts +16 -16
- package/src/cohort-topic/stream-util.ts +79 -19
- package/src/cohort-topic/threshold-crypto.ts +239 -239
- package/src/cohort-topic/topic-router.ts +77 -77
- package/src/dispute/arbitrator-selection.ts +138 -138
- package/src/dispute/cascade.ts +524 -524
- package/src/dispute/dispute-service.ts +11 -5
- package/src/dispute/invalidation.ts +625 -625
- package/src/inbound-authorization.ts +190 -190
- package/src/index.ts +52 -49
- package/src/libp2p-key-network.ts +1120 -990
- package/src/libp2p-node-base.ts +1675 -1651
- package/src/libp2p-node-rn.ts +30 -30
- package/src/libp2p-node.ts +36 -36
- package/src/logger.ts +19 -2
- package/src/matchmaking/aggregate-counts.ts +104 -104
- package/src/matchmaking/index.ts +20 -20
- package/src/matchmaking/module.ts +363 -363
- package/src/matchmaking/protocols.ts +51 -51
- package/src/matchmaking/provider-manager.ts +95 -95
- package/src/matchmaking/query-handler.ts +88 -88
- package/src/matchmaking/query-transport.ts +3 -3
- package/src/matchmaking/seeker-manager.ts +64 -64
- package/src/matchmaking/seeker-walk-client.ts +293 -293
- package/src/matchmaking/traffic-validation.ts +195 -195
- package/src/optimystic-node.ts +36 -36
- package/src/owned-block-seed.ts +53 -40
- package/src/peer-address-book.ts +149 -0
- package/src/protocol-limits.ts +33 -33
- package/src/reactivity/forwarder-host.ts +438 -438
- package/src/reactivity/index.ts +19 -19
- package/src/reactivity/notify-transport.ts +144 -144
- package/src/reactivity/origination-manager.ts +192 -192
- package/src/reactivity/protocols.ts +61 -61
- package/src/reactivity/push-state-gossip.ts +291 -291
- package/src/reactivity/recover-transport.ts +7 -3
- package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
- package/src/reactivity/subscriber-registry.ts +96 -96
- package/src/reactivity/subscription-manager.ts +450 -450
- package/src/reactivity/topic-bytes.ts +37 -37
- package/src/repo/client.ts +12 -2
- package/src/repo/cluster-coordinator.ts +99 -3
- package/src/repo/coordinator-repo.ts +281 -74
- package/src/repo/types.ts +7 -7
- package/src/rn.ts +39 -36
- package/src/rpc-deadline.ts +45 -45
- package/src/storage/arachnode-partition.ts +74 -74
- package/src/storage/cached-raw-storage.ts +180 -0
- package/src/storage/cached-store-driver.ts +859 -0
- package/src/storage/i-kv-store.ts +8 -8
- package/src/storage/i-raw-storage.ts +12 -5
- package/src/storage/kv-raw-storage.ts +135 -135
- package/src/storage/memory-kv-store.ts +28 -28
- package/src/storage/memory-storage.ts +25 -25
- package/src/storage/memory-store-driver.ts +157 -157
- package/src/storage/raw-store-codec.ts +42 -42
- package/src/storage/raw-store-driver.ts +80 -80
- package/src/storage/ring-selector.ts +317 -317
- package/src/storage/ring-shift-coordinator.ts +271 -271
- package/src/storage/shared-cache-pool.ts +452 -0
- package/src/storage/storage-repo.ts +1014 -1014
- package/src/testing/cohort-topic-mesh-harness.ts +673 -663
- package/src/testing/index.ts +8 -8
- package/src/testing/matchmaking-mesh-harness.ts +475 -475
- package/src/testing/raw-storage-conformance.ts +453 -417
- package/src/testing/reactivity-mesh-harness.ts +922 -922
- package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
- package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
- package/dist/src/storage/restoration-coordinator-v2.js +0 -172
- package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
|
@@ -1,256 +1,256 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reactivity — tail-rotation re-registration timer host seam (`docs/reactivity.md` §Tail rotation step 3).
|
|
3
|
-
*
|
|
4
|
-
* The {@link import("./subscription-manager.js").ReactivitySubscriptionManager} surfaces a
|
|
5
|
-
* {@link import("./subscription-manager.js").RotationNotice} **once per successor tail** (both the
|
|
6
|
-
* notify-driven pre-announce and the recover-driven `RotationRedirectError` converge on the same
|
|
7
|
-
* `surfaceRotation` seam), but it neither schedules the timer nor performs the move. This host component
|
|
8
|
-
* does: it consumes a notice, schedules a one-shot timer for `max(0, plan.fireAt - now())`, and on fire
|
|
9
|
-
* invokes an injected `reRegister(plan)` seam that re-subscribes the watcher at the rotated tree. The node
|
|
10
|
-
* composition that constructs the manager + binds this scheduler to its `onRotation` observer lives in
|
|
11
|
-
* `reactivity-rotation-host-wiring-e2e`; this module is the standalone, unit-testable piece.
|
|
12
|
-
*
|
|
13
|
-
* **Where the stagger lives.** The spread that keeps the new tail from being flooded is entirely in
|
|
14
|
-
* `plan.fireAt`, drawn by the **manager's** `rejoinJitter`. This scheduler only *consumes* `plan.fireAt`; it
|
|
15
|
-
* never sees the jitter. The manager uses the *single*-subscriber planner `planReRegistration` →
|
|
16
|
-
* `RejoinJitter.scheduleRejoin`, which draws a **uniform** offset `now + ⌊U[0, T_rejoin_jitter)⌋` — so the
|
|
17
|
-
* load-bearing knob for this path is the **window** `T_rejoin_jitter` (default 30 s), not a `capPromote`. Each
|
|
18
|
-
* subscriber jitters independently, giving the new tail an inbound rate of ≈ `subscribers / T_rejoin_jitter`
|
|
19
|
-
* in expectation; the burst is absorbed on the *receiving* side by the new tail cohort's `cap_promote_fast`
|
|
20
|
-
* fast-promotion (a cohort-topic promotion mechanism — see `docs/reactivity.md` §Tail rotation rotation-cost
|
|
21
|
-
* and the Worked scenario), which is independent of the jitter's `capPromote`.
|
|
22
|
-
*
|
|
23
|
-
* Note that `RejoinJitter`'s `capPromote` is consulted **only** by the *wave* planner
|
|
24
|
-
* `scheduleWave` / `planReRegistrationWave` (a single host staggering a whole wave with a hard per-window
|
|
25
|
-
* ceiling — used by the mesh harness, not by the production manager). If the composing site
|
|
26
|
-
* (`reactivity-rotation-host-wiring-e2e`) ever switches the manager onto the wave planner, *then* it must
|
|
27
|
-
* build the jitter as `createRejoinJitter({ capPromote: DEFAULT_CAP_PROMOTE_FAST })` (= 32), since the default
|
|
28
|
-
* `createRejoinJitter()` cap is the cohort-failure `cap_promote = 64`. For the current single-planner path,
|
|
29
|
-
* setting `capPromote` has **no effect** on `fireAt`.
|
|
30
|
-
*
|
|
31
|
-
* **Determinism.** An injected `setTimer(fn, delayMs) => cancel` and `now()` clock make the scheduler
|
|
32
|
-
* testable with a fake clock (no wall clock). They default to a production binding: an **unref'd**
|
|
33
|
-
* `setTimeout`/`clearTimeout` and `Date.now`. The unref is load-bearing — an idle re-registration timer must
|
|
34
|
-
* not pin an otherwise-idle process, mirroring the push-state-gossip driver's unref'd timer
|
|
35
|
-
* ({@link import("./push-state-gossip.js").ReactivityPushStateGossipDriver}).
|
|
36
|
-
*
|
|
37
|
-
* **Idempotence.** De-duped by successor `newTopicId` (base64url): a second notice for a successor already
|
|
38
|
-
* scheduled or fired is ignored. The manager already fires once per successor (its `rotationHandledFor`
|
|
39
|
-
* guard), but a redirect and a pre-announce can surface the **same** successor near-simultaneously, so the
|
|
40
|
-
* scheduler is independently idempotent. The de-dupe ledger (`seen`) survives the timer fire (so a late
|
|
41
|
-
* re-surface of an already-fired successor stays a no-op), which on the normal schedule→fire path would let it
|
|
42
|
-
* grow without bound across a long-lived, fast-rotating subscription. It is therefore **bounded**: capped at
|
|
43
|
-
* `SEEN_LEDGER_CAP` entries with oldest-first eviction (insertion order), and eviction **never** drops a key
|
|
44
|
-
* still in `pending` — that would break the "every pending key ∈ `seen`" invariant and could let a duplicate
|
|
45
|
-
* notice for a pending successor arm a second timer over the live one. A re-surface older than the cap (≥ cap
|
|
46
|
-
* distinct successors later) degrades to at most one harmless, idempotent re-register.
|
|
47
|
-
*
|
|
48
|
-
* **Chained rotation OLD→A→B** before A's timer fires → A and B are distinct successors → two independent
|
|
49
|
-
* timers; **both may fire**. Re-registering at A and then immediately rotating A→B is self-correcting via the
|
|
50
|
-
* manager's `rotationHandledFor` guard, so a superseded timer is intentionally **not** cancelled (a
|
|
51
|
-
* superseded re-register is harmless and rare).
|
|
52
|
-
*/
|
|
53
|
-
|
|
54
|
-
import { bytesToB64url, type ReRegistrationPlan } from "@optimystic/db-core";
|
|
55
|
-
import type { RotationNotice } from "./subscription-manager.js";
|
|
56
|
-
import { createLogger } from "../logger.js";
|
|
57
|
-
|
|
58
|
-
const log = createLogger("reactivity-rotation-rereg");
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Upper bound on the idempotence ledger (`seen`). Far above any realistic count of *concurrently-pending*
|
|
62
|
-
* successors (single digits: 1 in the normal case, a handful across a chained rotation), so it never evicts a
|
|
63
|
-
* still-live entry in practice while keeping the ledger's memory to tens of KB of short base64url strings at the
|
|
64
|
-
* ceiling. Tunable — raising it widens the late-re-surface de-dupe window at a proportional memory cost.
|
|
65
|
-
* Exported for the regression test that asserts the bound; production callers don't read it.
|
|
66
|
-
*/
|
|
67
|
-
export const SEEN_LEDGER_CAP = 1024;
|
|
68
|
-
|
|
69
|
-
/** Cancel handle returned by an injected timer; cancels a not-yet-fired timer (safe no-op after fire/cancel). */
|
|
70
|
-
export type RotationTimerCancel = () => void;
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Production timer binding: a one-shot `setTimeout` whose handle is **unref'd** so a pending re-registration
|
|
74
|
-
* never keeps an otherwise-idle process alive. The returned handle clears the timeout (idempotent — clearing
|
|
75
|
-
* an already-fired/cleared timeout is a no-op).
|
|
76
|
-
*/
|
|
77
|
-
function defaultSetTimer(fn: () => void, delayMs: number): RotationTimerCancel {
|
|
78
|
-
const handle = setTimeout(fn, delayMs);
|
|
79
|
-
// Node timers keep the event loop alive; an idle rotation timer must not pin a process (mirror push-state gossip).
|
|
80
|
-
(handle as { unref?: () => void }).unref?.();
|
|
81
|
-
return (): void => clearTimeout(handle);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Construction inputs for a {@link RotationReRegistrationScheduler}. */
|
|
85
|
-
export interface RotationReRegistrationSchedulerOptions {
|
|
86
|
-
/**
|
|
87
|
-
* Perform the actual move: re-subscribe the watcher at the rotated tree (the new `topicId`, carrying the
|
|
88
|
-
* plan's `lastRevision`). A rejection is isolated + logged — a failed move must never throw out of the
|
|
89
|
-
* timer callback. Not retried this pass (see the class doc).
|
|
90
|
-
*/
|
|
91
|
-
readonly reRegister: (plan: ReRegistrationPlan) => Promise<void>;
|
|
92
|
-
/**
|
|
93
|
-
* Schedule a one-shot timer; returns a cancel handle. Defaults to an **unref'd** `setTimeout`. Tests inject
|
|
94
|
-
* a fake timer queue for determinism.
|
|
95
|
-
*/
|
|
96
|
-
readonly setTimer?: (fn: () => void, delayMs: number) => RotationTimerCancel;
|
|
97
|
-
/** Clock (Unix ms). Defaults to `Date.now`; tests inject a fake clock. */
|
|
98
|
-
readonly now?: () => number;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Hosts the per-successor one-shot timers that move a subscriber to the rotated tree. Construct one per
|
|
103
|
-
* subscription manager and bind {@link schedule} to the manager's
|
|
104
|
-
* {@link import("./subscription-manager.js").ReactivitySubscriptionManagerOptions.onRotation} observer.
|
|
105
|
-
*
|
|
106
|
-
* See the module doc for where the re-registration stagger actually lives (the manager's `rejoinJitter`; this
|
|
107
|
-
* scheduler only consumes `plan.fireAt`, it does not draw it).
|
|
108
|
-
*/
|
|
109
|
-
export class RotationReRegistrationScheduler {
|
|
110
|
-
private readonly reRegister: (plan: ReRegistrationPlan) => Promise<void>;
|
|
111
|
-
private readonly setTimer: (fn: () => void, delayMs: number) => RotationTimerCancel;
|
|
112
|
-
private readonly now: () => number;
|
|
113
|
-
|
|
114
|
-
/** Successors with a still-pending timer, keyed by base64url `newTopicId` → its cancel handle. */
|
|
115
|
-
private readonly pending = new Map<string, RotationTimerCancel>();
|
|
116
|
-
/**
|
|
117
|
-
* Successors recently scheduled (pending **or** already fired) — the idempotence ledger that survives fire.
|
|
118
|
-
* Insertion-ordered and **bounded** at {@link SEEN_LEDGER_CAP}: {@link evictSeenOverCap} drops the oldest
|
|
119
|
-
* non-pending entries once over cap (never a still-pending key — see that method's doc).
|
|
120
|
-
*/
|
|
121
|
-
private readonly seen = new Set<string>();
|
|
122
|
-
private stopped = false;
|
|
123
|
-
|
|
124
|
-
constructor(options: RotationReRegistrationSchedulerOptions) {
|
|
125
|
-
this.reRegister = options.reRegister;
|
|
126
|
-
this.setTimer = options.setTimer ?? defaultSetTimer;
|
|
127
|
-
this.now = options.now ?? ((): number => Date.now());
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Successors with a timer still pending (not yet fired/cancelled). Diagnostic / test seam. */
|
|
131
|
-
get pendingCount(): number {
|
|
132
|
-
return this.pending.size;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Size of the idempotence ledger (pending + recently-fired-and-retained). Diagnostic / test seam; the
|
|
137
|
-
* regression test asserts this stays ≤ {@link SEEN_LEDGER_CAP} under unbounded sequential rotations.
|
|
138
|
-
*/
|
|
139
|
-
get seenCount(): number {
|
|
140
|
-
return this.seen.size;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Schedule the re-registration timer for a rotation notice. De-duped by successor `newTopicId`: a second
|
|
145
|
-
* notice for an already-scheduled-or-fired successor is a no-op (a redirect and a pre-announce can surface
|
|
146
|
-
* the same successor). The delay is `max(0, plan.fireAt - now())`, so a `fireAt` already in the past fires
|
|
147
|
-
* on the next tick (clamped to 0, never negative). A no-op once {@link stop} has run.
|
|
148
|
-
*/
|
|
149
|
-
schedule(notice: RotationNotice): void {
|
|
150
|
-
if (this.stopped) {
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
const key = bytesToB64url(notice.plan.newTopicId);
|
|
154
|
-
if (this.seen.has(key)) {
|
|
155
|
-
log("rotation re-registration already scheduled/fired for successor topic=%s — ignoring duplicate notice (preAnnounced=%s)", key, notice.preAnnounced);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
this.seen.add(key);
|
|
159
|
-
const delayMs = Math.max(0, notice.plan.fireAt - this.now());
|
|
160
|
-
const plan = notice.plan;
|
|
161
|
-
const cancel = this.setTimer((): void => {
|
|
162
|
-
this.fire(key, plan);
|
|
163
|
-
}, delayMs);
|
|
164
|
-
this.pending.set(key, cancel);
|
|
165
|
-
// Bound `seen` only AFTER the new key is in `pending`, so eviction sees it as live and never drops it (the
|
|
166
|
-
// "every pending key ∈ seen" invariant). Were this run before `pending.set`, the just-added key would be the
|
|
167
|
-
// one non-pending candidate whenever `pending` is at the cap, and would be wrongly evicted then immediately
|
|
168
|
-
// re-added to `pending` — a pending key absent from `seen`, which a duplicate notice could then re-arm over.
|
|
169
|
-
this.evictSeenOverCap();
|
|
170
|
-
log("scheduled rotation re-registration for successor topic=%s in %dms (preAnnounced=%s)", key, delayMs, notice.preAnnounced);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Cancel a single pending timer (by successor `newTopicId`) or, when called with no argument, **all**
|
|
175
|
-
* pending timers (teardown). Cancelling an already-fired or unknown successor is a safe no-op. A cancelled
|
|
176
|
-
* successor is forgotten (dropped from the idempotence ledger), so a later notice for it would reschedule;
|
|
177
|
-
* use {@link stop} for permanent teardown.
|
|
178
|
-
*/
|
|
179
|
-
cancel(newTopicId?: Uint8Array): void {
|
|
180
|
-
if (newTopicId === undefined) {
|
|
181
|
-
for (const cancelTimer of this.pending.values()) {
|
|
182
|
-
cancelTimer();
|
|
183
|
-
}
|
|
184
|
-
this.pending.clear();
|
|
185
|
-
this.seen.clear();
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
const key = bytesToB64url(newTopicId);
|
|
189
|
-
const cancelTimer = this.pending.get(key);
|
|
190
|
-
if (cancelTimer !== undefined) {
|
|
191
|
-
cancelTimer();
|
|
192
|
-
this.pending.delete(key);
|
|
193
|
-
}
|
|
194
|
-
this.seen.delete(key);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Stop the scheduler permanently (teardown): cancel every pending timer and refuse all further scheduling
|
|
199
|
-
* and firing. A timer that races `stop` (its callback already queued) finds the `stopped` gate and never
|
|
200
|
-
* invokes `reRegister`. Idempotent.
|
|
201
|
-
*/
|
|
202
|
-
stop(): void {
|
|
203
|
-
this.stopped = true;
|
|
204
|
-
for (const cancelTimer of this.pending.values()) {
|
|
205
|
-
cancelTimer();
|
|
206
|
-
}
|
|
207
|
-
this.pending.clear();
|
|
208
|
-
this.seen.clear();
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Bound the idempotence ledger: evict the oldest entries (insertion order) that are **not** still pending,
|
|
213
|
-
* until back within {@link SEEN_LEDGER_CAP}. Never evicts a pending key — that would break the "every pending
|
|
214
|
-
* key ∈ `seen`" invariant and could double-fire a successor whose later duplicate notice then re-armed a timer
|
|
215
|
-
* over the live one. If every over-cap entry is somehow still pending (`pending.size > cap` — not reachable in
|
|
216
|
-
* practice, since pending successors are the most recently scheduled), the loop bails and the ledger is left to
|
|
217
|
-
* grow with `pending` this round; it returns within cap as soon as those timers fire and a later schedule runs.
|
|
218
|
-
*/
|
|
219
|
-
private evictSeenOverCap(): void {
|
|
220
|
-
while (this.seen.size > SEEN_LEDGER_CAP) {
|
|
221
|
-
let evicted = false;
|
|
222
|
-
for (const key of this.seen) { // insertion order: oldest first
|
|
223
|
-
if (!this.pending.has(key)) {
|
|
224
|
-
this.seen.delete(key);
|
|
225
|
-
log("rotation re-registration ledger at cap (%d) — evicted oldest fired successor topic=%s", SEEN_LEDGER_CAP, key);
|
|
226
|
-
evicted = true;
|
|
227
|
-
break;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
if (!evicted) {
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Timer callback: drop the pending entry, then invoke the isolated re-registration (unless stopped). */
|
|
237
|
-
private fire(key: string, plan: ReRegistrationPlan): void {
|
|
238
|
-
this.pending.delete(key);
|
|
239
|
-
if (this.stopped) {
|
|
240
|
-
return; // stop() raced the timer callback — no move after teardown.
|
|
241
|
-
}
|
|
242
|
-
// A failed move must never throw out of the timer callback — an escaping throw / unhandled rejection
|
|
243
|
-
// would surface on the host event loop. Guard BOTH a rejected promise and a (mis-implemented) seam that
|
|
244
|
-
// throws synchronously. `seen` retains the key so a duplicate notice still no-ops. No retry this pass —
|
|
245
|
-
// and because `seen` keeps the key, a re-notice for this *same* successor is deduped (the manager's
|
|
246
|
-
// `rotationHandledFor` already holds it too), so the recovery backstop for a failed move is the
|
|
247
|
-
// subscriber's normal recover/re-walk path, not a re-detected rotation to the same tail.
|
|
248
|
-
try {
|
|
249
|
-
void this.reRegister(plan).catch((err: unknown) => {
|
|
250
|
-
log("rotation re-registration rejected for successor topic=%s (isolated, not retried): %o", key, err);
|
|
251
|
-
});
|
|
252
|
-
} catch (err) {
|
|
253
|
-
log("rotation re-registration threw synchronously for successor topic=%s (isolated): %o", key, err);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reactivity — tail-rotation re-registration timer host seam (`docs/reactivity.md` §Tail rotation step 3).
|
|
3
|
+
*
|
|
4
|
+
* The {@link import("./subscription-manager.js").ReactivitySubscriptionManager} surfaces a
|
|
5
|
+
* {@link import("./subscription-manager.js").RotationNotice} **once per successor tail** (both the
|
|
6
|
+
* notify-driven pre-announce and the recover-driven `RotationRedirectError` converge on the same
|
|
7
|
+
* `surfaceRotation` seam), but it neither schedules the timer nor performs the move. This host component
|
|
8
|
+
* does: it consumes a notice, schedules a one-shot timer for `max(0, plan.fireAt - now())`, and on fire
|
|
9
|
+
* invokes an injected `reRegister(plan)` seam that re-subscribes the watcher at the rotated tree. The node
|
|
10
|
+
* composition that constructs the manager + binds this scheduler to its `onRotation` observer lives in
|
|
11
|
+
* `reactivity-rotation-host-wiring-e2e`; this module is the standalone, unit-testable piece.
|
|
12
|
+
*
|
|
13
|
+
* **Where the stagger lives.** The spread that keeps the new tail from being flooded is entirely in
|
|
14
|
+
* `plan.fireAt`, drawn by the **manager's** `rejoinJitter`. This scheduler only *consumes* `plan.fireAt`; it
|
|
15
|
+
* never sees the jitter. The manager uses the *single*-subscriber planner `planReRegistration` →
|
|
16
|
+
* `RejoinJitter.scheduleRejoin`, which draws a **uniform** offset `now + ⌊U[0, T_rejoin_jitter)⌋` — so the
|
|
17
|
+
* load-bearing knob for this path is the **window** `T_rejoin_jitter` (default 30 s), not a `capPromote`. Each
|
|
18
|
+
* subscriber jitters independently, giving the new tail an inbound rate of ≈ `subscribers / T_rejoin_jitter`
|
|
19
|
+
* in expectation; the burst is absorbed on the *receiving* side by the new tail cohort's `cap_promote_fast`
|
|
20
|
+
* fast-promotion (a cohort-topic promotion mechanism — see `docs/reactivity.md` §Tail rotation rotation-cost
|
|
21
|
+
* and the Worked scenario), which is independent of the jitter's `capPromote`.
|
|
22
|
+
*
|
|
23
|
+
* Note that `RejoinJitter`'s `capPromote` is consulted **only** by the *wave* planner
|
|
24
|
+
* `scheduleWave` / `planReRegistrationWave` (a single host staggering a whole wave with a hard per-window
|
|
25
|
+
* ceiling — used by the mesh harness, not by the production manager). If the composing site
|
|
26
|
+
* (`reactivity-rotation-host-wiring-e2e`) ever switches the manager onto the wave planner, *then* it must
|
|
27
|
+
* build the jitter as `createRejoinJitter({ capPromote: DEFAULT_CAP_PROMOTE_FAST })` (= 32), since the default
|
|
28
|
+
* `createRejoinJitter()` cap is the cohort-failure `cap_promote = 64`. For the current single-planner path,
|
|
29
|
+
* setting `capPromote` has **no effect** on `fireAt`.
|
|
30
|
+
*
|
|
31
|
+
* **Determinism.** An injected `setTimer(fn, delayMs) => cancel` and `now()` clock make the scheduler
|
|
32
|
+
* testable with a fake clock (no wall clock). They default to a production binding: an **unref'd**
|
|
33
|
+
* `setTimeout`/`clearTimeout` and `Date.now`. The unref is load-bearing — an idle re-registration timer must
|
|
34
|
+
* not pin an otherwise-idle process, mirroring the push-state-gossip driver's unref'd timer
|
|
35
|
+
* ({@link import("./push-state-gossip.js").ReactivityPushStateGossipDriver}).
|
|
36
|
+
*
|
|
37
|
+
* **Idempotence.** De-duped by successor `newTopicId` (base64url): a second notice for a successor already
|
|
38
|
+
* scheduled or fired is ignored. The manager already fires once per successor (its `rotationHandledFor`
|
|
39
|
+
* guard), but a redirect and a pre-announce can surface the **same** successor near-simultaneously, so the
|
|
40
|
+
* scheduler is independently idempotent. The de-dupe ledger (`seen`) survives the timer fire (so a late
|
|
41
|
+
* re-surface of an already-fired successor stays a no-op), which on the normal schedule→fire path would let it
|
|
42
|
+
* grow without bound across a long-lived, fast-rotating subscription. It is therefore **bounded**: capped at
|
|
43
|
+
* `SEEN_LEDGER_CAP` entries with oldest-first eviction (insertion order), and eviction **never** drops a key
|
|
44
|
+
* still in `pending` — that would break the "every pending key ∈ `seen`" invariant and could let a duplicate
|
|
45
|
+
* notice for a pending successor arm a second timer over the live one. A re-surface older than the cap (≥ cap
|
|
46
|
+
* distinct successors later) degrades to at most one harmless, idempotent re-register.
|
|
47
|
+
*
|
|
48
|
+
* **Chained rotation OLD→A→B** before A's timer fires → A and B are distinct successors → two independent
|
|
49
|
+
* timers; **both may fire**. Re-registering at A and then immediately rotating A→B is self-correcting via the
|
|
50
|
+
* manager's `rotationHandledFor` guard, so a superseded timer is intentionally **not** cancelled (a
|
|
51
|
+
* superseded re-register is harmless and rare).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { bytesToB64url, type ReRegistrationPlan } from "@optimystic/db-core";
|
|
55
|
+
import type { RotationNotice } from "./subscription-manager.js";
|
|
56
|
+
import { createLogger } from "../logger.js";
|
|
57
|
+
|
|
58
|
+
const log = createLogger("reactivity-rotation-rereg");
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Upper bound on the idempotence ledger (`seen`). Far above any realistic count of *concurrently-pending*
|
|
62
|
+
* successors (single digits: 1 in the normal case, a handful across a chained rotation), so it never evicts a
|
|
63
|
+
* still-live entry in practice while keeping the ledger's memory to tens of KB of short base64url strings at the
|
|
64
|
+
* ceiling. Tunable — raising it widens the late-re-surface de-dupe window at a proportional memory cost.
|
|
65
|
+
* Exported for the regression test that asserts the bound; production callers don't read it.
|
|
66
|
+
*/
|
|
67
|
+
export const SEEN_LEDGER_CAP = 1024;
|
|
68
|
+
|
|
69
|
+
/** Cancel handle returned by an injected timer; cancels a not-yet-fired timer (safe no-op after fire/cancel). */
|
|
70
|
+
export type RotationTimerCancel = () => void;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Production timer binding: a one-shot `setTimeout` whose handle is **unref'd** so a pending re-registration
|
|
74
|
+
* never keeps an otherwise-idle process alive. The returned handle clears the timeout (idempotent — clearing
|
|
75
|
+
* an already-fired/cleared timeout is a no-op).
|
|
76
|
+
*/
|
|
77
|
+
function defaultSetTimer(fn: () => void, delayMs: number): RotationTimerCancel {
|
|
78
|
+
const handle = setTimeout(fn, delayMs);
|
|
79
|
+
// Node timers keep the event loop alive; an idle rotation timer must not pin a process (mirror push-state gossip).
|
|
80
|
+
(handle as { unref?: () => void }).unref?.();
|
|
81
|
+
return (): void => clearTimeout(handle);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Construction inputs for a {@link RotationReRegistrationScheduler}. */
|
|
85
|
+
export interface RotationReRegistrationSchedulerOptions {
|
|
86
|
+
/**
|
|
87
|
+
* Perform the actual move: re-subscribe the watcher at the rotated tree (the new `topicId`, carrying the
|
|
88
|
+
* plan's `lastRevision`). A rejection is isolated + logged — a failed move must never throw out of the
|
|
89
|
+
* timer callback. Not retried this pass (see the class doc).
|
|
90
|
+
*/
|
|
91
|
+
readonly reRegister: (plan: ReRegistrationPlan) => Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Schedule a one-shot timer; returns a cancel handle. Defaults to an **unref'd** `setTimeout`. Tests inject
|
|
94
|
+
* a fake timer queue for determinism.
|
|
95
|
+
*/
|
|
96
|
+
readonly setTimer?: (fn: () => void, delayMs: number) => RotationTimerCancel;
|
|
97
|
+
/** Clock (Unix ms). Defaults to `Date.now`; tests inject a fake clock. */
|
|
98
|
+
readonly now?: () => number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Hosts the per-successor one-shot timers that move a subscriber to the rotated tree. Construct one per
|
|
103
|
+
* subscription manager and bind {@link schedule} to the manager's
|
|
104
|
+
* {@link import("./subscription-manager.js").ReactivitySubscriptionManagerOptions.onRotation} observer.
|
|
105
|
+
*
|
|
106
|
+
* See the module doc for where the re-registration stagger actually lives (the manager's `rejoinJitter`; this
|
|
107
|
+
* scheduler only consumes `plan.fireAt`, it does not draw it).
|
|
108
|
+
*/
|
|
109
|
+
export class RotationReRegistrationScheduler {
|
|
110
|
+
private readonly reRegister: (plan: ReRegistrationPlan) => Promise<void>;
|
|
111
|
+
private readonly setTimer: (fn: () => void, delayMs: number) => RotationTimerCancel;
|
|
112
|
+
private readonly now: () => number;
|
|
113
|
+
|
|
114
|
+
/** Successors with a still-pending timer, keyed by base64url `newTopicId` → its cancel handle. */
|
|
115
|
+
private readonly pending = new Map<string, RotationTimerCancel>();
|
|
116
|
+
/**
|
|
117
|
+
* Successors recently scheduled (pending **or** already fired) — the idempotence ledger that survives fire.
|
|
118
|
+
* Insertion-ordered and **bounded** at {@link SEEN_LEDGER_CAP}: {@link evictSeenOverCap} drops the oldest
|
|
119
|
+
* non-pending entries once over cap (never a still-pending key — see that method's doc).
|
|
120
|
+
*/
|
|
121
|
+
private readonly seen = new Set<string>();
|
|
122
|
+
private stopped = false;
|
|
123
|
+
|
|
124
|
+
constructor(options: RotationReRegistrationSchedulerOptions) {
|
|
125
|
+
this.reRegister = options.reRegister;
|
|
126
|
+
this.setTimer = options.setTimer ?? defaultSetTimer;
|
|
127
|
+
this.now = options.now ?? ((): number => Date.now());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Successors with a timer still pending (not yet fired/cancelled). Diagnostic / test seam. */
|
|
131
|
+
get pendingCount(): number {
|
|
132
|
+
return this.pending.size;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Size of the idempotence ledger (pending + recently-fired-and-retained). Diagnostic / test seam; the
|
|
137
|
+
* regression test asserts this stays ≤ {@link SEEN_LEDGER_CAP} under unbounded sequential rotations.
|
|
138
|
+
*/
|
|
139
|
+
get seenCount(): number {
|
|
140
|
+
return this.seen.size;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Schedule the re-registration timer for a rotation notice. De-duped by successor `newTopicId`: a second
|
|
145
|
+
* notice for an already-scheduled-or-fired successor is a no-op (a redirect and a pre-announce can surface
|
|
146
|
+
* the same successor). The delay is `max(0, plan.fireAt - now())`, so a `fireAt` already in the past fires
|
|
147
|
+
* on the next tick (clamped to 0, never negative). A no-op once {@link stop} has run.
|
|
148
|
+
*/
|
|
149
|
+
schedule(notice: RotationNotice): void {
|
|
150
|
+
if (this.stopped) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const key = bytesToB64url(notice.plan.newTopicId);
|
|
154
|
+
if (this.seen.has(key)) {
|
|
155
|
+
log("rotation re-registration already scheduled/fired for successor topic=%s — ignoring duplicate notice (preAnnounced=%s)", key, notice.preAnnounced);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.seen.add(key);
|
|
159
|
+
const delayMs = Math.max(0, notice.plan.fireAt - this.now());
|
|
160
|
+
const plan = notice.plan;
|
|
161
|
+
const cancel = this.setTimer((): void => {
|
|
162
|
+
this.fire(key, plan);
|
|
163
|
+
}, delayMs);
|
|
164
|
+
this.pending.set(key, cancel);
|
|
165
|
+
// Bound `seen` only AFTER the new key is in `pending`, so eviction sees it as live and never drops it (the
|
|
166
|
+
// "every pending key ∈ seen" invariant). Were this run before `pending.set`, the just-added key would be the
|
|
167
|
+
// one non-pending candidate whenever `pending` is at the cap, and would be wrongly evicted then immediately
|
|
168
|
+
// re-added to `pending` — a pending key absent from `seen`, which a duplicate notice could then re-arm over.
|
|
169
|
+
this.evictSeenOverCap();
|
|
170
|
+
log("scheduled rotation re-registration for successor topic=%s in %dms (preAnnounced=%s)", key, delayMs, notice.preAnnounced);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Cancel a single pending timer (by successor `newTopicId`) or, when called with no argument, **all**
|
|
175
|
+
* pending timers (teardown). Cancelling an already-fired or unknown successor is a safe no-op. A cancelled
|
|
176
|
+
* successor is forgotten (dropped from the idempotence ledger), so a later notice for it would reschedule;
|
|
177
|
+
* use {@link stop} for permanent teardown.
|
|
178
|
+
*/
|
|
179
|
+
cancel(newTopicId?: Uint8Array): void {
|
|
180
|
+
if (newTopicId === undefined) {
|
|
181
|
+
for (const cancelTimer of this.pending.values()) {
|
|
182
|
+
cancelTimer();
|
|
183
|
+
}
|
|
184
|
+
this.pending.clear();
|
|
185
|
+
this.seen.clear();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const key = bytesToB64url(newTopicId);
|
|
189
|
+
const cancelTimer = this.pending.get(key);
|
|
190
|
+
if (cancelTimer !== undefined) {
|
|
191
|
+
cancelTimer();
|
|
192
|
+
this.pending.delete(key);
|
|
193
|
+
}
|
|
194
|
+
this.seen.delete(key);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Stop the scheduler permanently (teardown): cancel every pending timer and refuse all further scheduling
|
|
199
|
+
* and firing. A timer that races `stop` (its callback already queued) finds the `stopped` gate and never
|
|
200
|
+
* invokes `reRegister`. Idempotent.
|
|
201
|
+
*/
|
|
202
|
+
stop(): void {
|
|
203
|
+
this.stopped = true;
|
|
204
|
+
for (const cancelTimer of this.pending.values()) {
|
|
205
|
+
cancelTimer();
|
|
206
|
+
}
|
|
207
|
+
this.pending.clear();
|
|
208
|
+
this.seen.clear();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Bound the idempotence ledger: evict the oldest entries (insertion order) that are **not** still pending,
|
|
213
|
+
* until back within {@link SEEN_LEDGER_CAP}. Never evicts a pending key — that would break the "every pending
|
|
214
|
+
* key ∈ `seen`" invariant and could double-fire a successor whose later duplicate notice then re-armed a timer
|
|
215
|
+
* over the live one. If every over-cap entry is somehow still pending (`pending.size > cap` — not reachable in
|
|
216
|
+
* practice, since pending successors are the most recently scheduled), the loop bails and the ledger is left to
|
|
217
|
+
* grow with `pending` this round; it returns within cap as soon as those timers fire and a later schedule runs.
|
|
218
|
+
*/
|
|
219
|
+
private evictSeenOverCap(): void {
|
|
220
|
+
while (this.seen.size > SEEN_LEDGER_CAP) {
|
|
221
|
+
let evicted = false;
|
|
222
|
+
for (const key of this.seen) { // insertion order: oldest first
|
|
223
|
+
if (!this.pending.has(key)) {
|
|
224
|
+
this.seen.delete(key);
|
|
225
|
+
log("rotation re-registration ledger at cap (%d) — evicted oldest fired successor topic=%s", SEEN_LEDGER_CAP, key);
|
|
226
|
+
evicted = true;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!evicted) {
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Timer callback: drop the pending entry, then invoke the isolated re-registration (unless stopped). */
|
|
237
|
+
private fire(key: string, plan: ReRegistrationPlan): void {
|
|
238
|
+
this.pending.delete(key);
|
|
239
|
+
if (this.stopped) {
|
|
240
|
+
return; // stop() raced the timer callback — no move after teardown.
|
|
241
|
+
}
|
|
242
|
+
// A failed move must never throw out of the timer callback — an escaping throw / unhandled rejection
|
|
243
|
+
// would surface on the host event loop. Guard BOTH a rejected promise and a (mis-implemented) seam that
|
|
244
|
+
// throws synchronously. `seen` retains the key so a duplicate notice still no-ops. No retry this pass —
|
|
245
|
+
// and because `seen` keeps the key, a re-notice for this *same* successor is deduped (the manager's
|
|
246
|
+
// `rotationHandledFor` already holds it too), so the recovery backstop for a failed move is the
|
|
247
|
+
// subscriber's normal recover/re-walk path, not a re-detected rotation to the same tail.
|
|
248
|
+
try {
|
|
249
|
+
void this.reRegister(plan).catch((err: unknown) => {
|
|
250
|
+
log("rotation re-registration rejected for successor topic=%s (isolated, not retried): %o", key, err);
|
|
251
|
+
});
|
|
252
|
+
} catch (err) {
|
|
253
|
+
log("rotation re-registration threw synchronously for successor topic=%s (isolated): %o", key, err);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|