@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,450 +1,450 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reactivity — subscription manager (db-p2p, wires to the cohort-topic substrate).
|
|
3
|
-
*
|
|
4
|
-
* Drives a subscriber's lifecycle against the participant-facing {@link CohortTopicService}: register at
|
|
5
|
-
* cohort-topic tier **T3 (luxury)** with the reactivity `appPayload`, renew to keep the registration
|
|
6
|
-
* alive within its TTL (Edge 60 s / Core 90 s), and withdraw by ceasing renewal — all the cohort-topic
|
|
7
|
-
* standard (`docs/reactivity.md` §Subscription). The reactivity-specific shape lives in db-core: the
|
|
8
|
-
* tail-anchored `topicId = H(tailId ‖ "reactivity")`, the {@link SubscribeAppPayloadV1}, and the
|
|
9
|
-
* subscriber-side verify/deliver path ({@link ReactivitySubscriber}).
|
|
10
|
-
*
|
|
11
|
-
* Inbound notifications are handed to {@link ReactivitySubscriptionManager.onNotification}, which runs
|
|
12
|
-
* the db-core delivery path (verify against the cached tail-cohort `MembershipCertV1` with one
|
|
13
|
-
* fetch-and-retry, revision-contiguity, gap → backfill seam, `(collectionId, revision)` dedupe, surface).
|
|
14
|
-
* The notification transport (the reactivity application protocol that delivers `NotificationV1` frames
|
|
15
|
-
* to a subscriber's primary) is the sibling tickets' concern; this manager owns attach + delivery logic.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
Tier,
|
|
20
|
-
reactivityTopicId,
|
|
21
|
-
subscribeAppPayloadBytes,
|
|
22
|
-
subscriberTtlForProfile,
|
|
23
|
-
deltaMaxForProfile,
|
|
24
|
-
createNotificationVerifier,
|
|
25
|
-
createReactivitySubscriber,
|
|
26
|
-
createBackfillRequester,
|
|
27
|
-
createStickyCohortHintCache,
|
|
28
|
-
createRejoinJitter,
|
|
29
|
-
applyResumeReply,
|
|
30
|
-
detectRotation,
|
|
31
|
-
planReRegistration,
|
|
32
|
-
bytesToB64url,
|
|
33
|
-
type CohortTopicService,
|
|
34
|
-
type NodeProfile,
|
|
35
|
-
type NotificationV1,
|
|
36
|
-
type DeliveryOutcome,
|
|
37
|
-
type ReactivitySubscriber,
|
|
38
|
-
type NotificationVerifier,
|
|
39
|
-
type RegistrationHandle,
|
|
40
|
-
type BackfillV1,
|
|
41
|
-
type BackfillReplyV1,
|
|
42
|
-
type BackfillTransport,
|
|
43
|
-
type ResumeV1,
|
|
44
|
-
type ResumeReplyV1,
|
|
45
|
-
type ResumeApplyOutcome,
|
|
46
|
-
type StickyCohortHintCache,
|
|
47
|
-
type CheckpointSummary,
|
|
48
|
-
type RejoinJitter,
|
|
49
|
-
type ReRegistrationPlan,
|
|
50
|
-
} from "@optimystic/db-core";
|
|
51
|
-
import { RotationRedirectError } from "./recover-transport.js";
|
|
52
|
-
import { createLogger } from "../logger.js";
|
|
53
|
-
|
|
54
|
-
const log = createLogger("reactivity-subscription");
|
|
55
|
-
|
|
56
|
-
/** Sends a {@link ResumeV1} to the serving cohort and awaits its classified {@link ResumeReplyV1}. */
|
|
57
|
-
export type ResumeTransport = (req: ResumeV1) => Promise<ResumeReplyV1>;
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* A detected tail rotation surfaced to the host so it can schedule the jittered re-registration timer
|
|
61
|
-
* (`docs/reactivity.md` §Tail rotation). The manager has already invalidated the sticky cohort-hint cache
|
|
62
|
-
* (the cached primary is under the old tree).
|
|
63
|
-
*/
|
|
64
|
-
export interface RotationNotice {
|
|
65
|
-
/** The new tail block id the topic rotated to, base64url. */
|
|
66
|
-
readonly newTailId: string;
|
|
67
|
-
/** True iff this was a pre-announce (`rotationHint` on a still-current-tail notification). */
|
|
68
|
-
readonly preAnnounced: boolean;
|
|
69
|
-
/** The jittered re-registration plan (new topicId + `fireAt` + carried `lastRevision`) for the host to schedule. */
|
|
70
|
-
readonly plan: ReRegistrationPlan;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Construction inputs for a {@link ReactivitySubscriptionManager}. */
|
|
74
|
-
export interface ReactivitySubscriptionManagerOptions {
|
|
75
|
-
/** Participant-facing cohort-topic substrate API. */
|
|
76
|
-
readonly service: CohortTopicService;
|
|
77
|
-
/** Stable collection identity (the collection's id block id, raw bytes). */
|
|
78
|
-
readonly collectionId: Uint8Array;
|
|
79
|
-
/**
|
|
80
|
-
* Tail block id at attach time (raw bytes); anchors the rotating topic (`reactivityTopicId` is applied to
|
|
81
|
-
* it) and detects rotation.
|
|
82
|
-
*
|
|
83
|
-
* **Load-bearing encoding contract.** When a production subscribe factory converts a `BlockId` tail to
|
|
84
|
-
* these bytes it MUST use `reactivityTailBytes(tailId)` (`reactivity/topic-bytes.ts`) — the SAME function
|
|
85
|
-
* origination's membership gate uses — NOT db-core's `blockIdToBytes` (which `sha256`s first → a double
|
|
86
|
-
* hash). Origination derives the topic's `coord_0` cohort from `reactivityTopicId(reactivityTailBytes(
|
|
87
|
-
* tailId))`; if this side feeds differently-encoded bytes it subscribes to a *different* coord and
|
|
88
|
-
* origination silently never reaches it (the `topic-bytes-encoding` spec pins the coord-equality).
|
|
89
|
-
*/
|
|
90
|
-
readonly tailIdAtAttach: Uint8Array;
|
|
91
|
-
/** Surface a verified, contiguous notification to the application. */
|
|
92
|
-
readonly deliver: (n: NotificationV1) => void;
|
|
93
|
-
/**
|
|
94
|
-
* Explicit backfill seam — used only when {@link backfillTransport} is **not** supplied. When a
|
|
95
|
-
* transport is given, the manager builds the db-core {@link createBackfillRequester} driver instead and
|
|
96
|
-
* this is ignored.
|
|
97
|
-
*/
|
|
98
|
-
readonly requestBackfill?: (from: number, to: number) => void;
|
|
99
|
-
/**
|
|
100
|
-
* Backfill RPC transport (the reactivity application protocol dialing the serving cohort). When
|
|
101
|
-
* supplied, the manager wires the subscriber's gap-detection seam to the {@link BackfillV1} RPC,
|
|
102
|
-
* replaying the reply through the delivery path. Requires {@link signBackfill}.
|
|
103
|
-
*/
|
|
104
|
-
readonly backfillTransport?: BackfillTransport;
|
|
105
|
-
/** Sign a {@link BackfillV1} over its unsigned image (subscriber peer key); base64url. */
|
|
106
|
-
readonly signBackfill?: (req: Omit<BackfillV1, "signature">) => string;
|
|
107
|
-
/** Resume RPC transport (mobile wake). When supplied, {@link resume} is available. Requires {@link signResume}. */
|
|
108
|
-
readonly resumeTransport?: ResumeTransport;
|
|
109
|
-
/** Sign a {@link ResumeV1} over its unsigned image (subscriber peer key); base64url. */
|
|
110
|
-
readonly signResume?: (req: Omit<ResumeV1, "signature">) => string;
|
|
111
|
-
/**
|
|
112
|
-
* The subscriber's **real ring coordinate**, base64url (the `participantCoord` it registers under at the
|
|
113
|
-
* cohort-topic tier), carried in the signed {@link ResumeV1}. The recover transport replies on the same
|
|
114
|
-
* stream, so this is not used for reply routing today; still, the production factory should source it
|
|
115
|
-
* correctly so the signed field is meaningful and a future out-of-band reply path is unblocked. Absent ⇒
|
|
116
|
-
* the manager falls back to the collection id as a placeholder and logs (the signed field is then merely
|
|
117
|
-
* a stable per-collection token, not the ring coord).
|
|
118
|
-
*/
|
|
119
|
-
readonly subscriberCoord?: string;
|
|
120
|
-
/**
|
|
121
|
-
* Application-level re-attempts the backfill escalation makes after a **transport failure** before giving
|
|
122
|
-
* up to a resume/chain-read (the sticky-hint dial + one cohort-walk fallback already live in the
|
|
123
|
-
* transport, so this is a small outer retry). Default `1`. Distinct from the `available`-window underflow
|
|
124
|
-
* path ({@link onBackfillUnderflow}), which escalates immediately without retrying.
|
|
125
|
-
*/
|
|
126
|
-
readonly backfillMaxRetries?: number;
|
|
127
|
-
/** Apply a verified checkpoint's merged digest on a `checkpoint_window` resume. */
|
|
128
|
-
readonly onCheckpointDigest?: (summary: CheckpointSummary) => void;
|
|
129
|
-
/** Chain-read + fresh subscribe fallback (out_of_window, or an untrusted checkpoint). */
|
|
130
|
-
readonly onChainRead?: (currentTailId: string | undefined, currentRevision: number | undefined) => void;
|
|
131
|
-
/** Re-register under the rotated tail (tail_rotated); also invalidates the sticky cohort-hint cache. */
|
|
132
|
-
readonly onTailRotated?: (newTailId: string, newRevisionAtRotation: number) => void;
|
|
133
|
-
/** Escalation when a backfill's `available` window fell past the gap's low edge (escalate to resume/chain). */
|
|
134
|
-
readonly onBackfillUnderflow?: (requested: { from: number; to: number }, available: { fromRevision: number; toRevision: number }) => void;
|
|
135
|
-
/** Sticky cohort-hint cache for one-RT resume after a flap; defaults to a fresh per-manager cache (Edge). */
|
|
136
|
-
readonly cohortHintCache?: StickyCohortHintCache;
|
|
137
|
-
/**
|
|
138
|
-
* Tail-rotation observer (`docs/reactivity.md` §Tail rotation). Fired once per successor tail when an
|
|
139
|
-
* inbound notification reveals a rotation (delivered `tailId` differs, or a `rotationHint` pre-announce):
|
|
140
|
-
* the manager invalidates the sticky cohort-hint cache and hands the host a jittered re-registration plan
|
|
141
|
-
* to schedule. Absent ⇒ rotation is detected and the cache invalidated, but no plan is surfaced.
|
|
142
|
-
*/
|
|
143
|
-
readonly onRotation?: (notice: RotationNotice) => void;
|
|
144
|
-
/** Re-registration jitter for the rotation plan's `fireAt`; defaults to the `T_rejoin_jitter` curve. */
|
|
145
|
-
readonly rejoinJitter?: RejoinJitter;
|
|
146
|
-
/** Unix-ms clock for resume timestamps (injected for deterministic tests). Default `Date.now`. */
|
|
147
|
-
readonly clock?: () => number;
|
|
148
|
-
/** Last revision already held; `0` (default) for a fresh subscribe. */
|
|
149
|
-
readonly lastKnownRev?: number;
|
|
150
|
-
/** Max delta bytes accepted; defaults to `0` on Edge, `delta_max` on Core via {@link profile}. */
|
|
151
|
-
readonly deltaMaxBytes?: number;
|
|
152
|
-
/** Subscription TTL (ms). Default: derived from {@link profile} (Core 90 s / Edge 60 s). */
|
|
153
|
-
readonly ttlMs?: number;
|
|
154
|
-
/** Node profile used to derive the TTL / delta budget when not given explicitly. */
|
|
155
|
-
readonly profile?: NodeProfile;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Wires one reactivity subscription to the cohort-topic substrate at tier T3. */
|
|
159
|
-
export class ReactivitySubscriptionManager {
|
|
160
|
-
private readonly service: CohortTopicService;
|
|
161
|
-
private readonly collectionId: Uint8Array;
|
|
162
|
-
private readonly collectionIdB64: string;
|
|
163
|
-
private readonly tailIdAtAttach: Uint8Array;
|
|
164
|
-
private readonly topicId: Uint8Array;
|
|
165
|
-
private readonly ttlMs: number;
|
|
166
|
-
private readonly deltaMaxBytes: number;
|
|
167
|
-
private readonly lastKnownRev: number;
|
|
168
|
-
private readonly subscriber: ReactivitySubscriber;
|
|
169
|
-
private readonly verifier: NotificationVerifier;
|
|
170
|
-
private readonly options: ReactivitySubscriptionManagerOptions;
|
|
171
|
-
private readonly cohortHintCache: StickyCohortHintCache;
|
|
172
|
-
private readonly clock: () => number;
|
|
173
|
-
private readonly tailIdAtAttachB64: string;
|
|
174
|
-
private readonly rejoinJitter: RejoinJitter;
|
|
175
|
-
/** Resolved ring coordinate signed into a {@link ResumeV1} (real coord, or the collectionId placeholder). */
|
|
176
|
-
private readonly subscriberCoord: string;
|
|
177
|
-
/** True iff {@link subscriberCoord} fell back to the collectionId placeholder (no real coord supplied). */
|
|
178
|
-
private readonly subscriberCoordIsFallback: boolean;
|
|
179
|
-
/** The successor tail a rotation has already been surfaced for, so the notice fires once per rotation. */
|
|
180
|
-
private rotationHandledFor?: string;
|
|
181
|
-
/** Memoized db-core backfill driver (built on first gap, once `this.subscriber` is assigned). */
|
|
182
|
-
private backfillRequester?: (from: number, to: number) => Promise<BackfillReplyV1>;
|
|
183
|
-
private handle?: RegistrationHandle;
|
|
184
|
-
|
|
185
|
-
constructor(options: ReactivitySubscriptionManagerOptions) {
|
|
186
|
-
this.options = options;
|
|
187
|
-
this.service = options.service;
|
|
188
|
-
this.collectionId = options.collectionId;
|
|
189
|
-
this.collectionIdB64 = bytesToB64url(options.collectionId);
|
|
190
|
-
this.tailIdAtAttach = options.tailIdAtAttach;
|
|
191
|
-
this.topicId = reactivityTopicId(options.tailIdAtAttach);
|
|
192
|
-
this.ttlMs = options.ttlMs ?? (options.profile !== undefined ? subscriberTtlForProfile(options.profile) : undefined) ?? DEFAULT_SUBSCRIBER_TTL_MS;
|
|
193
|
-
this.deltaMaxBytes = options.deltaMaxBytes ?? (options.profile !== undefined ? deltaMaxForProfile(options.profile) : DEFAULT_EDGE_SAFE_DELTA_MAX);
|
|
194
|
-
this.lastKnownRev = options.lastKnownRev ?? 0;
|
|
195
|
-
this.cohortHintCache = options.cohortHintCache ?? createStickyCohortHintCache();
|
|
196
|
-
this.clock = options.clock ?? ((): number => Date.now());
|
|
197
|
-
this.tailIdAtAttachB64 = bytesToB64url(options.tailIdAtAttach);
|
|
198
|
-
this.rejoinJitter = options.rejoinJitter ?? createRejoinJitter();
|
|
199
|
-
this.subscriberCoordIsFallback = options.subscriberCoord === undefined;
|
|
200
|
-
this.subscriberCoord = options.subscriberCoord ?? this.collectionIdB64;
|
|
201
|
-
// Verify against the tail cohort's membership cert (the verifier owns the one fetch-and-retry).
|
|
202
|
-
this.verifier = createNotificationVerifier({ verifier: this.service.verifier(), tier: Tier.T3 });
|
|
203
|
-
this.subscriber = createReactivitySubscriber({
|
|
204
|
-
collectionId: this.collectionIdB64,
|
|
205
|
-
verifier: this.verifier,
|
|
206
|
-
deliver: options.deliver,
|
|
207
|
-
// Bind the seam to a method so it resolves the (possibly transport-backed) driver lazily —
|
|
208
|
-
// `this.subscriber` is not yet assigned during this very call.
|
|
209
|
-
requestBackfill: (from, to): void => this.onBackfillGap(from, to),
|
|
210
|
-
lastKnownRev: this.lastKnownRev,
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* The subscriber detected a revision gap. When a {@link ReactivitySubscriptionManagerOptions.backfillTransport}
|
|
216
|
-
* + signer are configured, drive the db-core {@link createBackfillRequester} (build → sign → send →
|
|
217
|
-
* replay reply through delivery → underflow escalation, built lazily once); otherwise fall back to the
|
|
218
|
-
* explicit {@link ReactivitySubscriptionManagerOptions.requestBackfill} callback (or no-op).
|
|
219
|
-
*/
|
|
220
|
-
private onBackfillGap(from: number, to: number): void {
|
|
221
|
-
const { backfillTransport, signBackfill } = this.options;
|
|
222
|
-
if (backfillTransport !== undefined && signBackfill !== undefined) {
|
|
223
|
-
if (this.backfillRequester === undefined) {
|
|
224
|
-
this.backfillRequester = createBackfillRequester({
|
|
225
|
-
collectionId: this.collectionIdB64,
|
|
226
|
-
sign: signBackfill,
|
|
227
|
-
transport: backfillTransport,
|
|
228
|
-
subscriber: this.subscriber,
|
|
229
|
-
clock: this.clock,
|
|
230
|
-
onUnderflow: this.options.onBackfillUnderflow,
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
// Fire-and-forget off the gap seam (NOT the deliver path — backfill is hint-only and must never
|
|
234
|
-
// block or fault commit/delivery). The driver resolves (no throw) on an `available`-window
|
|
235
|
-
// underflow — that path is handled by `onBackfillUnderflow`; only a genuine **transport failure**
|
|
236
|
-
// rejects, which escalates here. `escalateBackfill` never rejects, so nothing leaks as an
|
|
237
|
-
// unhandled rejection.
|
|
238
|
-
void this.backfillRequester(from, to).catch(() => { void this.escalateBackfill(from, to); });
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
this.options.requestBackfill?.(from, to);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* A backfill RPC failed at the transport (the sticky-hint dial + cohort-walk fallback inside the
|
|
246
|
-
* transport were already exhausted). Re-attempt up to {@link ReactivitySubscriptionManagerOptions.backfillMaxRetries}
|
|
247
|
-
* times, then escalate to {@link resume} (when a resume transport is configured) and finally to
|
|
248
|
-
* {@link ReactivitySubscriptionManagerOptions.onChainRead}. Never rejects (it runs detached off the gap
|
|
249
|
-
* seam) and never touches the commit/delivery path.
|
|
250
|
-
*/
|
|
251
|
-
private async escalateBackfill(from: number, to: number): Promise<void> {
|
|
252
|
-
const max = this.options.backfillMaxRetries ?? DEFAULT_BACKFILL_MAX_RETRIES;
|
|
253
|
-
for (let attempt = 1; attempt <= max; attempt++) {
|
|
254
|
-
try {
|
|
255
|
-
await this.backfillRequester!(from, to);
|
|
256
|
-
return; // a re-attempt closed (or underflow-escalated) the gap
|
|
257
|
-
} catch (err) {
|
|
258
|
-
if (err instanceof RotationRedirectError) {
|
|
259
|
-
// The serving cohort's outgoing tail rotated: move to the new tree (no chain-read fallback).
|
|
260
|
-
this.honorRotationRedirect(err);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
log("backfill retry %d/%d for [%d,%d] failed: %o", attempt, max, from, to, err);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
// Still failing after the bounded retries: fall back to a resume (a wider recovery window) when one is
|
|
267
|
-
// wired, else signal a chain read. Distinct from the underflow seam, which carries the held window.
|
|
268
|
-
if (this.options.resumeTransport !== undefined && this.options.signResume !== undefined) {
|
|
269
|
-
try {
|
|
270
|
-
await this.resume();
|
|
271
|
-
return;
|
|
272
|
-
} catch (err) {
|
|
273
|
-
log("backfill escalation to resume() failed for [%d,%d]: %o", from, to, err);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
this.options.onChainRead?.(undefined, undefined);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** The live registration handle, or `undefined` before the first {@link register}. */
|
|
280
|
-
get registration(): RegistrationHandle | undefined {
|
|
281
|
-
return this.handle;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** Last contiguously-delivered revision. */
|
|
285
|
-
get lastRevision(): number {
|
|
286
|
-
return this.subscriber.lastRevision;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** Register the subscriber at tier T3 with the reactivity `appPayload`. */
|
|
290
|
-
async register(): Promise<RegistrationHandle> {
|
|
291
|
-
const appPayload = subscribeAppPayloadBytes({
|
|
292
|
-
collectionId: bytesToB64url(this.collectionId),
|
|
293
|
-
tailIdAtAttach: bytesToB64url(this.tailIdAtAttach),
|
|
294
|
-
lastKnownRev: this.lastKnownRev,
|
|
295
|
-
deltaMaxBytes: this.deltaMaxBytes,
|
|
296
|
-
});
|
|
297
|
-
this.handle = await this.service.register({
|
|
298
|
-
topicId: this.topicId,
|
|
299
|
-
tier: Tier.T3,
|
|
300
|
-
appPayload,
|
|
301
|
-
ttl: this.ttlMs,
|
|
302
|
-
});
|
|
303
|
-
return this.handle;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Run one renewal cycle (keep-alive touch). No-op before the first {@link register}. */
|
|
307
|
-
async renew(): Promise<void> {
|
|
308
|
-
if (this.handle === undefined) {
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
await this.service.renew(this.handle);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** Withdraw: stop renewing and send a best-effort signed tombstone so the cohort frees the
|
|
315
|
-
* registration immediately (TTL expiry remains the fallback if the primary is unreachable). */
|
|
316
|
-
async withdraw(): Promise<void> {
|
|
317
|
-
if (this.handle !== undefined) {
|
|
318
|
-
await this.service.withdraw(this.handle);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Resume after a sleep/flap (`docs/reactivity.md` §Resume). Sends one {@link ResumeV1} from
|
|
324
|
-
* `lastRevision + 1` to the serving cohort over the injected {@link ResumeTransport} and applies the
|
|
325
|
-
* classified reply via the db-core {@link applyResumeReply}: a `backfill` / `checkpoint_window` reply
|
|
326
|
-
* replays its entries through the delivery path (verified, deduped); `out_of_window` and an untrusted
|
|
327
|
-
* checkpoint escalate to {@link ReactivitySubscriptionManagerOptions.onChainRead}; `tail_rotated`
|
|
328
|
-
* escalates to {@link ReactivitySubscriptionManagerOptions.onTailRotated} and invalidates the sticky
|
|
329
|
-
* cohort-hint cache (the cached primary is under the old tree). Throws if no resume transport/signer
|
|
330
|
-
* was configured.
|
|
331
|
-
*
|
|
332
|
-
* The sticky cohort-hint cache (Edge) lets a resume after a brief flap dial the cached primary directly
|
|
333
|
-
* for a one-RT recovery instead of re-walking from `d_max`; it is the transport's to consult via
|
|
334
|
-
* {@link cohortHint}.
|
|
335
|
-
*/
|
|
336
|
-
async resume(): Promise<ResumeApplyOutcome> {
|
|
337
|
-
const { resumeTransport, signResume } = this.options;
|
|
338
|
-
if (resumeTransport === undefined || signResume === undefined) {
|
|
339
|
-
throw new Error("ReactivitySubscriptionManager.resume: no resumeTransport/signResume configured");
|
|
340
|
-
}
|
|
341
|
-
if (this.subscriberCoordIsFallback) {
|
|
342
|
-
log("resume: no real ring coordinate supplied; signing ResumeV1 with the collectionId as a placeholder subscriberCoord (collection=%s)", this.collectionIdB64);
|
|
343
|
-
}
|
|
344
|
-
const unsigned: Omit<ResumeV1, "signature"> = {
|
|
345
|
-
v: 1,
|
|
346
|
-
collectionId: this.collectionIdB64,
|
|
347
|
-
fromRevision: this.subscriber.lastRevision + 1,
|
|
348
|
-
latestKnownTailId: bytesToB64url(this.tailIdAtAttach),
|
|
349
|
-
subscriberCoord: this.subscriberCoord,
|
|
350
|
-
timestamp: this.clock(),
|
|
351
|
-
};
|
|
352
|
-
const req: ResumeV1 = { ...unsigned, signature: signResume(unsigned) };
|
|
353
|
-
let reply: ResumeReplyV1;
|
|
354
|
-
try {
|
|
355
|
-
reply = await resumeTransport(req);
|
|
356
|
-
} catch (err) {
|
|
357
|
-
if (err instanceof RotationRedirectError) {
|
|
358
|
-
// The resume reached the serving cohort's outgoing (draining) tail: honor the redirect (surface
|
|
359
|
-
// the rotation through onRotation, invalidate the sticky cache) and resolve as a tail rotation —
|
|
360
|
-
// never throw the redirect out to the caller / the gap seam's commit-delivery path.
|
|
361
|
-
this.honorRotationRedirect(err);
|
|
362
|
-
return "tail_rotated";
|
|
363
|
-
}
|
|
364
|
-
throw err;
|
|
365
|
-
}
|
|
366
|
-
return applyResumeReply(reply, {
|
|
367
|
-
subscriber: this.subscriber,
|
|
368
|
-
verifier: this.verifier,
|
|
369
|
-
onCheckpointDigest: this.options.onCheckpointDigest,
|
|
370
|
-
onChainRead: this.options.onChainRead,
|
|
371
|
-
onTailRotated: (newTailId, newRevisionAtRotation): void => {
|
|
372
|
-
// The cached primary is under the now-stale tree; drop it so the re-registration re-walks.
|
|
373
|
-
this.cohortHintCache.invalidate(this.collectionIdB64);
|
|
374
|
-
this.options.onTailRotated?.(newTailId, newRevisionAtRotation);
|
|
375
|
-
},
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** The sticky cohort-hint cache backing one-RT resume after a flap (Edge). */
|
|
380
|
-
get cohortHint(): StickyCohortHintCache {
|
|
381
|
-
return this.cohortHintCache;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Run the db-core delivery path for one inbound notification, then check for tail rotation
|
|
386
|
-
* (`docs/reactivity.md` §Tail rotation): a delivered `tailId` that differs from `tailIdAtAttach`, or a
|
|
387
|
-
* `rotationHint` pre-announce, invalidates the sticky cohort-hint cache (the cached primary is under the
|
|
388
|
-
* old tree) and surfaces a jittered re-registration plan via {@link ReactivitySubscriptionManagerOptions.onRotation}.
|
|
389
|
-
* Fired at most once per successor tail.
|
|
390
|
-
*/
|
|
391
|
-
async onNotification(n: NotificationV1): Promise<DeliveryOutcome> {
|
|
392
|
-
const outcome = await this.subscriber.onNotification(n);
|
|
393
|
-
this.checkRotation(n);
|
|
394
|
-
return outcome;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/** Detect a tail rotation from an inbound notification and surface it once per successor tail. */
|
|
398
|
-
private checkRotation(n: NotificationV1): void {
|
|
399
|
-
const detection = detectRotation(this.tailIdAtAttachB64, n);
|
|
400
|
-
if (!detection.rotated || detection.newTailId === undefined) {
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
this.surfaceRotation(detection.newTailId, detection.preAnnounced);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* Surface a rotation to the host **once per successor tail** (`docs/reactivity.md` §Tail rotation):
|
|
408
|
-
* invalidate the sticky cohort-hint cache (the cached primary is under the now-stale tree, so a later
|
|
409
|
-
* resume re-walks) and — when an {@link ReactivitySubscriptionManagerOptions.onRotation} observer is
|
|
410
|
-
* configured — hand it a jittered re-registration plan carrying `lastRevision` (continuous across the
|
|
411
|
-
* rotation). The single seam both the notification-driven detection ({@link checkRotation}) and the
|
|
412
|
-
* recover-driven {@link RotationRedirectError} ({@link honorRotationRedirect}) end in; the
|
|
413
|
-
* {@link rotationHandledFor} guard self-corrects across a chained OLD→A→B rotation.
|
|
414
|
-
*/
|
|
415
|
-
private surfaceRotation(newTailId: string, preAnnounced: boolean): void {
|
|
416
|
-
if (newTailId === this.rotationHandledFor) {
|
|
417
|
-
return; // already surfaced this successor
|
|
418
|
-
}
|
|
419
|
-
this.rotationHandledFor = newTailId;
|
|
420
|
-
// The cached primary is under the now-stale tree; drop it so the re-registration re-walks.
|
|
421
|
-
this.cohortHintCache.invalidate(this.collectionIdB64);
|
|
422
|
-
if (this.options.onRotation === undefined) {
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
const plan = planReRegistration({
|
|
426
|
-
hint: { newTailId },
|
|
427
|
-
lastRevision: this.subscriber.lastRevision,
|
|
428
|
-
now: this.clock(),
|
|
429
|
-
jitter: this.rejoinJitter,
|
|
430
|
-
});
|
|
431
|
-
this.options.onRotation({ newTailId, preAnnounced, plan });
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* Honor a recover-surfaced {@link RotationRedirectError}: the serving cohort's outgoing tail rotated and
|
|
436
|
-
* bounced this request to the new tree. Route it through the **same** {@link surfaceRotation} seam a
|
|
437
|
-
* delivered pre-announce uses (`preAnnounced: false`), so both the notify-driven and recover-driven
|
|
438
|
-
* rotation paths converge on one `RotationNotice` for the host's re-registration scheduler to consume.
|
|
439
|
-
*/
|
|
440
|
-
private honorRotationRedirect(err: RotationRedirectError): void {
|
|
441
|
-
this.surfaceRotation(err.redirect.newTailId, false);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/** Default application-level backfill re-attempts after a transport failure before escalating to resume/chain. */
|
|
446
|
-
const DEFAULT_BACKFILL_MAX_RETRIES = 1;
|
|
447
|
-
/** Fallback subscriber TTL when neither `ttlMs` nor `profile` is supplied (Core default). */
|
|
448
|
-
const DEFAULT_SUBSCRIBER_TTL_MS = 90_000;
|
|
449
|
-
/** Edge-safe delta budget when neither `deltaMaxBytes` nor `profile` is supplied: decline deltas. */
|
|
450
|
-
const DEFAULT_EDGE_SAFE_DELTA_MAX = 0;
|
|
1
|
+
/**
|
|
2
|
+
* Reactivity — subscription manager (db-p2p, wires to the cohort-topic substrate).
|
|
3
|
+
*
|
|
4
|
+
* Drives a subscriber's lifecycle against the participant-facing {@link CohortTopicService}: register at
|
|
5
|
+
* cohort-topic tier **T3 (luxury)** with the reactivity `appPayload`, renew to keep the registration
|
|
6
|
+
* alive within its TTL (Edge 60 s / Core 90 s), and withdraw by ceasing renewal — all the cohort-topic
|
|
7
|
+
* standard (`docs/reactivity.md` §Subscription). The reactivity-specific shape lives in db-core: the
|
|
8
|
+
* tail-anchored `topicId = H(tailId ‖ "reactivity")`, the {@link SubscribeAppPayloadV1}, and the
|
|
9
|
+
* subscriber-side verify/deliver path ({@link ReactivitySubscriber}).
|
|
10
|
+
*
|
|
11
|
+
* Inbound notifications are handed to {@link ReactivitySubscriptionManager.onNotification}, which runs
|
|
12
|
+
* the db-core delivery path (verify against the cached tail-cohort `MembershipCertV1` with one
|
|
13
|
+
* fetch-and-retry, revision-contiguity, gap → backfill seam, `(collectionId, revision)` dedupe, surface).
|
|
14
|
+
* The notification transport (the reactivity application protocol that delivers `NotificationV1` frames
|
|
15
|
+
* to a subscriber's primary) is the sibling tickets' concern; this manager owns attach + delivery logic.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
Tier,
|
|
20
|
+
reactivityTopicId,
|
|
21
|
+
subscribeAppPayloadBytes,
|
|
22
|
+
subscriberTtlForProfile,
|
|
23
|
+
deltaMaxForProfile,
|
|
24
|
+
createNotificationVerifier,
|
|
25
|
+
createReactivitySubscriber,
|
|
26
|
+
createBackfillRequester,
|
|
27
|
+
createStickyCohortHintCache,
|
|
28
|
+
createRejoinJitter,
|
|
29
|
+
applyResumeReply,
|
|
30
|
+
detectRotation,
|
|
31
|
+
planReRegistration,
|
|
32
|
+
bytesToB64url,
|
|
33
|
+
type CohortTopicService,
|
|
34
|
+
type NodeProfile,
|
|
35
|
+
type NotificationV1,
|
|
36
|
+
type DeliveryOutcome,
|
|
37
|
+
type ReactivitySubscriber,
|
|
38
|
+
type NotificationVerifier,
|
|
39
|
+
type RegistrationHandle,
|
|
40
|
+
type BackfillV1,
|
|
41
|
+
type BackfillReplyV1,
|
|
42
|
+
type BackfillTransport,
|
|
43
|
+
type ResumeV1,
|
|
44
|
+
type ResumeReplyV1,
|
|
45
|
+
type ResumeApplyOutcome,
|
|
46
|
+
type StickyCohortHintCache,
|
|
47
|
+
type CheckpointSummary,
|
|
48
|
+
type RejoinJitter,
|
|
49
|
+
type ReRegistrationPlan,
|
|
50
|
+
} from "@optimystic/db-core";
|
|
51
|
+
import { RotationRedirectError } from "./recover-transport.js";
|
|
52
|
+
import { createLogger } from "../logger.js";
|
|
53
|
+
|
|
54
|
+
const log = createLogger("reactivity-subscription");
|
|
55
|
+
|
|
56
|
+
/** Sends a {@link ResumeV1} to the serving cohort and awaits its classified {@link ResumeReplyV1}. */
|
|
57
|
+
export type ResumeTransport = (req: ResumeV1) => Promise<ResumeReplyV1>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A detected tail rotation surfaced to the host so it can schedule the jittered re-registration timer
|
|
61
|
+
* (`docs/reactivity.md` §Tail rotation). The manager has already invalidated the sticky cohort-hint cache
|
|
62
|
+
* (the cached primary is under the old tree).
|
|
63
|
+
*/
|
|
64
|
+
export interface RotationNotice {
|
|
65
|
+
/** The new tail block id the topic rotated to, base64url. */
|
|
66
|
+
readonly newTailId: string;
|
|
67
|
+
/** True iff this was a pre-announce (`rotationHint` on a still-current-tail notification). */
|
|
68
|
+
readonly preAnnounced: boolean;
|
|
69
|
+
/** The jittered re-registration plan (new topicId + `fireAt` + carried `lastRevision`) for the host to schedule. */
|
|
70
|
+
readonly plan: ReRegistrationPlan;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Construction inputs for a {@link ReactivitySubscriptionManager}. */
|
|
74
|
+
export interface ReactivitySubscriptionManagerOptions {
|
|
75
|
+
/** Participant-facing cohort-topic substrate API. */
|
|
76
|
+
readonly service: CohortTopicService;
|
|
77
|
+
/** Stable collection identity (the collection's id block id, raw bytes). */
|
|
78
|
+
readonly collectionId: Uint8Array;
|
|
79
|
+
/**
|
|
80
|
+
* Tail block id at attach time (raw bytes); anchors the rotating topic (`reactivityTopicId` is applied to
|
|
81
|
+
* it) and detects rotation.
|
|
82
|
+
*
|
|
83
|
+
* **Load-bearing encoding contract.** When a production subscribe factory converts a `BlockId` tail to
|
|
84
|
+
* these bytes it MUST use `reactivityTailBytes(tailId)` (`reactivity/topic-bytes.ts`) — the SAME function
|
|
85
|
+
* origination's membership gate uses — NOT db-core's `blockIdToBytes` (which `sha256`s first → a double
|
|
86
|
+
* hash). Origination derives the topic's `coord_0` cohort from `reactivityTopicId(reactivityTailBytes(
|
|
87
|
+
* tailId))`; if this side feeds differently-encoded bytes it subscribes to a *different* coord and
|
|
88
|
+
* origination silently never reaches it (the `topic-bytes-encoding` spec pins the coord-equality).
|
|
89
|
+
*/
|
|
90
|
+
readonly tailIdAtAttach: Uint8Array;
|
|
91
|
+
/** Surface a verified, contiguous notification to the application. */
|
|
92
|
+
readonly deliver: (n: NotificationV1) => void;
|
|
93
|
+
/**
|
|
94
|
+
* Explicit backfill seam — used only when {@link backfillTransport} is **not** supplied. When a
|
|
95
|
+
* transport is given, the manager builds the db-core {@link createBackfillRequester} driver instead and
|
|
96
|
+
* this is ignored.
|
|
97
|
+
*/
|
|
98
|
+
readonly requestBackfill?: (from: number, to: number) => void;
|
|
99
|
+
/**
|
|
100
|
+
* Backfill RPC transport (the reactivity application protocol dialing the serving cohort). When
|
|
101
|
+
* supplied, the manager wires the subscriber's gap-detection seam to the {@link BackfillV1} RPC,
|
|
102
|
+
* replaying the reply through the delivery path. Requires {@link signBackfill}.
|
|
103
|
+
*/
|
|
104
|
+
readonly backfillTransport?: BackfillTransport;
|
|
105
|
+
/** Sign a {@link BackfillV1} over its unsigned image (subscriber peer key); base64url. */
|
|
106
|
+
readonly signBackfill?: (req: Omit<BackfillV1, "signature">) => string;
|
|
107
|
+
/** Resume RPC transport (mobile wake). When supplied, {@link resume} is available. Requires {@link signResume}. */
|
|
108
|
+
readonly resumeTransport?: ResumeTransport;
|
|
109
|
+
/** Sign a {@link ResumeV1} over its unsigned image (subscriber peer key); base64url. */
|
|
110
|
+
readonly signResume?: (req: Omit<ResumeV1, "signature">) => string;
|
|
111
|
+
/**
|
|
112
|
+
* The subscriber's **real ring coordinate**, base64url (the `participantCoord` it registers under at the
|
|
113
|
+
* cohort-topic tier), carried in the signed {@link ResumeV1}. The recover transport replies on the same
|
|
114
|
+
* stream, so this is not used for reply routing today; still, the production factory should source it
|
|
115
|
+
* correctly so the signed field is meaningful and a future out-of-band reply path is unblocked. Absent ⇒
|
|
116
|
+
* the manager falls back to the collection id as a placeholder and logs (the signed field is then merely
|
|
117
|
+
* a stable per-collection token, not the ring coord).
|
|
118
|
+
*/
|
|
119
|
+
readonly subscriberCoord?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Application-level re-attempts the backfill escalation makes after a **transport failure** before giving
|
|
122
|
+
* up to a resume/chain-read (the sticky-hint dial + one cohort-walk fallback already live in the
|
|
123
|
+
* transport, so this is a small outer retry). Default `1`. Distinct from the `available`-window underflow
|
|
124
|
+
* path ({@link onBackfillUnderflow}), which escalates immediately without retrying.
|
|
125
|
+
*/
|
|
126
|
+
readonly backfillMaxRetries?: number;
|
|
127
|
+
/** Apply a verified checkpoint's merged digest on a `checkpoint_window` resume. */
|
|
128
|
+
readonly onCheckpointDigest?: (summary: CheckpointSummary) => void;
|
|
129
|
+
/** Chain-read + fresh subscribe fallback (out_of_window, or an untrusted checkpoint). */
|
|
130
|
+
readonly onChainRead?: (currentTailId: string | undefined, currentRevision: number | undefined) => void;
|
|
131
|
+
/** Re-register under the rotated tail (tail_rotated); also invalidates the sticky cohort-hint cache. */
|
|
132
|
+
readonly onTailRotated?: (newTailId: string, newRevisionAtRotation: number) => void;
|
|
133
|
+
/** Escalation when a backfill's `available` window fell past the gap's low edge (escalate to resume/chain). */
|
|
134
|
+
readonly onBackfillUnderflow?: (requested: { from: number; to: number }, available: { fromRevision: number; toRevision: number }) => void;
|
|
135
|
+
/** Sticky cohort-hint cache for one-RT resume after a flap; defaults to a fresh per-manager cache (Edge). */
|
|
136
|
+
readonly cohortHintCache?: StickyCohortHintCache;
|
|
137
|
+
/**
|
|
138
|
+
* Tail-rotation observer (`docs/reactivity.md` §Tail rotation). Fired once per successor tail when an
|
|
139
|
+
* inbound notification reveals a rotation (delivered `tailId` differs, or a `rotationHint` pre-announce):
|
|
140
|
+
* the manager invalidates the sticky cohort-hint cache and hands the host a jittered re-registration plan
|
|
141
|
+
* to schedule. Absent ⇒ rotation is detected and the cache invalidated, but no plan is surfaced.
|
|
142
|
+
*/
|
|
143
|
+
readonly onRotation?: (notice: RotationNotice) => void;
|
|
144
|
+
/** Re-registration jitter for the rotation plan's `fireAt`; defaults to the `T_rejoin_jitter` curve. */
|
|
145
|
+
readonly rejoinJitter?: RejoinJitter;
|
|
146
|
+
/** Unix-ms clock for resume timestamps (injected for deterministic tests). Default `Date.now`. */
|
|
147
|
+
readonly clock?: () => number;
|
|
148
|
+
/** Last revision already held; `0` (default) for a fresh subscribe. */
|
|
149
|
+
readonly lastKnownRev?: number;
|
|
150
|
+
/** Max delta bytes accepted; defaults to `0` on Edge, `delta_max` on Core via {@link profile}. */
|
|
151
|
+
readonly deltaMaxBytes?: number;
|
|
152
|
+
/** Subscription TTL (ms). Default: derived from {@link profile} (Core 90 s / Edge 60 s). */
|
|
153
|
+
readonly ttlMs?: number;
|
|
154
|
+
/** Node profile used to derive the TTL / delta budget when not given explicitly. */
|
|
155
|
+
readonly profile?: NodeProfile;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Wires one reactivity subscription to the cohort-topic substrate at tier T3. */
|
|
159
|
+
export class ReactivitySubscriptionManager {
|
|
160
|
+
private readonly service: CohortTopicService;
|
|
161
|
+
private readonly collectionId: Uint8Array;
|
|
162
|
+
private readonly collectionIdB64: string;
|
|
163
|
+
private readonly tailIdAtAttach: Uint8Array;
|
|
164
|
+
private readonly topicId: Uint8Array;
|
|
165
|
+
private readonly ttlMs: number;
|
|
166
|
+
private readonly deltaMaxBytes: number;
|
|
167
|
+
private readonly lastKnownRev: number;
|
|
168
|
+
private readonly subscriber: ReactivitySubscriber;
|
|
169
|
+
private readonly verifier: NotificationVerifier;
|
|
170
|
+
private readonly options: ReactivitySubscriptionManagerOptions;
|
|
171
|
+
private readonly cohortHintCache: StickyCohortHintCache;
|
|
172
|
+
private readonly clock: () => number;
|
|
173
|
+
private readonly tailIdAtAttachB64: string;
|
|
174
|
+
private readonly rejoinJitter: RejoinJitter;
|
|
175
|
+
/** Resolved ring coordinate signed into a {@link ResumeV1} (real coord, or the collectionId placeholder). */
|
|
176
|
+
private readonly subscriberCoord: string;
|
|
177
|
+
/** True iff {@link subscriberCoord} fell back to the collectionId placeholder (no real coord supplied). */
|
|
178
|
+
private readonly subscriberCoordIsFallback: boolean;
|
|
179
|
+
/** The successor tail a rotation has already been surfaced for, so the notice fires once per rotation. */
|
|
180
|
+
private rotationHandledFor?: string;
|
|
181
|
+
/** Memoized db-core backfill driver (built on first gap, once `this.subscriber` is assigned). */
|
|
182
|
+
private backfillRequester?: (from: number, to: number) => Promise<BackfillReplyV1>;
|
|
183
|
+
private handle?: RegistrationHandle;
|
|
184
|
+
|
|
185
|
+
constructor(options: ReactivitySubscriptionManagerOptions) {
|
|
186
|
+
this.options = options;
|
|
187
|
+
this.service = options.service;
|
|
188
|
+
this.collectionId = options.collectionId;
|
|
189
|
+
this.collectionIdB64 = bytesToB64url(options.collectionId);
|
|
190
|
+
this.tailIdAtAttach = options.tailIdAtAttach;
|
|
191
|
+
this.topicId = reactivityTopicId(options.tailIdAtAttach);
|
|
192
|
+
this.ttlMs = options.ttlMs ?? (options.profile !== undefined ? subscriberTtlForProfile(options.profile) : undefined) ?? DEFAULT_SUBSCRIBER_TTL_MS;
|
|
193
|
+
this.deltaMaxBytes = options.deltaMaxBytes ?? (options.profile !== undefined ? deltaMaxForProfile(options.profile) : DEFAULT_EDGE_SAFE_DELTA_MAX);
|
|
194
|
+
this.lastKnownRev = options.lastKnownRev ?? 0;
|
|
195
|
+
this.cohortHintCache = options.cohortHintCache ?? createStickyCohortHintCache();
|
|
196
|
+
this.clock = options.clock ?? ((): number => Date.now());
|
|
197
|
+
this.tailIdAtAttachB64 = bytesToB64url(options.tailIdAtAttach);
|
|
198
|
+
this.rejoinJitter = options.rejoinJitter ?? createRejoinJitter();
|
|
199
|
+
this.subscriberCoordIsFallback = options.subscriberCoord === undefined;
|
|
200
|
+
this.subscriberCoord = options.subscriberCoord ?? this.collectionIdB64;
|
|
201
|
+
// Verify against the tail cohort's membership cert (the verifier owns the one fetch-and-retry).
|
|
202
|
+
this.verifier = createNotificationVerifier({ verifier: this.service.verifier(), tier: Tier.T3 });
|
|
203
|
+
this.subscriber = createReactivitySubscriber({
|
|
204
|
+
collectionId: this.collectionIdB64,
|
|
205
|
+
verifier: this.verifier,
|
|
206
|
+
deliver: options.deliver,
|
|
207
|
+
// Bind the seam to a method so it resolves the (possibly transport-backed) driver lazily —
|
|
208
|
+
// `this.subscriber` is not yet assigned during this very call.
|
|
209
|
+
requestBackfill: (from, to): void => this.onBackfillGap(from, to),
|
|
210
|
+
lastKnownRev: this.lastKnownRev,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The subscriber detected a revision gap. When a {@link ReactivitySubscriptionManagerOptions.backfillTransport}
|
|
216
|
+
* + signer are configured, drive the db-core {@link createBackfillRequester} (build → sign → send →
|
|
217
|
+
* replay reply through delivery → underflow escalation, built lazily once); otherwise fall back to the
|
|
218
|
+
* explicit {@link ReactivitySubscriptionManagerOptions.requestBackfill} callback (or no-op).
|
|
219
|
+
*/
|
|
220
|
+
private onBackfillGap(from: number, to: number): void {
|
|
221
|
+
const { backfillTransport, signBackfill } = this.options;
|
|
222
|
+
if (backfillTransport !== undefined && signBackfill !== undefined) {
|
|
223
|
+
if (this.backfillRequester === undefined) {
|
|
224
|
+
this.backfillRequester = createBackfillRequester({
|
|
225
|
+
collectionId: this.collectionIdB64,
|
|
226
|
+
sign: signBackfill,
|
|
227
|
+
transport: backfillTransport,
|
|
228
|
+
subscriber: this.subscriber,
|
|
229
|
+
clock: this.clock,
|
|
230
|
+
onUnderflow: this.options.onBackfillUnderflow,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// Fire-and-forget off the gap seam (NOT the deliver path — backfill is hint-only and must never
|
|
234
|
+
// block or fault commit/delivery). The driver resolves (no throw) on an `available`-window
|
|
235
|
+
// underflow — that path is handled by `onBackfillUnderflow`; only a genuine **transport failure**
|
|
236
|
+
// rejects, which escalates here. `escalateBackfill` never rejects, so nothing leaks as an
|
|
237
|
+
// unhandled rejection.
|
|
238
|
+
void this.backfillRequester(from, to).catch(() => { void this.escalateBackfill(from, to); });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
this.options.requestBackfill?.(from, to);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A backfill RPC failed at the transport (the sticky-hint dial + cohort-walk fallback inside the
|
|
246
|
+
* transport were already exhausted). Re-attempt up to {@link ReactivitySubscriptionManagerOptions.backfillMaxRetries}
|
|
247
|
+
* times, then escalate to {@link resume} (when a resume transport is configured) and finally to
|
|
248
|
+
* {@link ReactivitySubscriptionManagerOptions.onChainRead}. Never rejects (it runs detached off the gap
|
|
249
|
+
* seam) and never touches the commit/delivery path.
|
|
250
|
+
*/
|
|
251
|
+
private async escalateBackfill(from: number, to: number): Promise<void> {
|
|
252
|
+
const max = this.options.backfillMaxRetries ?? DEFAULT_BACKFILL_MAX_RETRIES;
|
|
253
|
+
for (let attempt = 1; attempt <= max; attempt++) {
|
|
254
|
+
try {
|
|
255
|
+
await this.backfillRequester!(from, to);
|
|
256
|
+
return; // a re-attempt closed (or underflow-escalated) the gap
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (err instanceof RotationRedirectError) {
|
|
259
|
+
// The serving cohort's outgoing tail rotated: move to the new tree (no chain-read fallback).
|
|
260
|
+
this.honorRotationRedirect(err);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
log("backfill retry %d/%d for [%d,%d] failed: %o", attempt, max, from, to, err);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Still failing after the bounded retries: fall back to a resume (a wider recovery window) when one is
|
|
267
|
+
// wired, else signal a chain read. Distinct from the underflow seam, which carries the held window.
|
|
268
|
+
if (this.options.resumeTransport !== undefined && this.options.signResume !== undefined) {
|
|
269
|
+
try {
|
|
270
|
+
await this.resume();
|
|
271
|
+
return;
|
|
272
|
+
} catch (err) {
|
|
273
|
+
log("backfill escalation to resume() failed for [%d,%d]: %o", from, to, err);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
this.options.onChainRead?.(undefined, undefined);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The live registration handle, or `undefined` before the first {@link register}. */
|
|
280
|
+
get registration(): RegistrationHandle | undefined {
|
|
281
|
+
return this.handle;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Last contiguously-delivered revision. */
|
|
285
|
+
get lastRevision(): number {
|
|
286
|
+
return this.subscriber.lastRevision;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Register the subscriber at tier T3 with the reactivity `appPayload`. */
|
|
290
|
+
async register(): Promise<RegistrationHandle> {
|
|
291
|
+
const appPayload = subscribeAppPayloadBytes({
|
|
292
|
+
collectionId: bytesToB64url(this.collectionId),
|
|
293
|
+
tailIdAtAttach: bytesToB64url(this.tailIdAtAttach),
|
|
294
|
+
lastKnownRev: this.lastKnownRev,
|
|
295
|
+
deltaMaxBytes: this.deltaMaxBytes,
|
|
296
|
+
});
|
|
297
|
+
this.handle = await this.service.register({
|
|
298
|
+
topicId: this.topicId,
|
|
299
|
+
tier: Tier.T3,
|
|
300
|
+
appPayload,
|
|
301
|
+
ttl: this.ttlMs,
|
|
302
|
+
});
|
|
303
|
+
return this.handle;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Run one renewal cycle (keep-alive touch). No-op before the first {@link register}. */
|
|
307
|
+
async renew(): Promise<void> {
|
|
308
|
+
if (this.handle === undefined) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
await this.service.renew(this.handle);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Withdraw: stop renewing and send a best-effort signed tombstone so the cohort frees the
|
|
315
|
+
* registration immediately (TTL expiry remains the fallback if the primary is unreachable). */
|
|
316
|
+
async withdraw(): Promise<void> {
|
|
317
|
+
if (this.handle !== undefined) {
|
|
318
|
+
await this.service.withdraw(this.handle);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Resume after a sleep/flap (`docs/reactivity.md` §Resume). Sends one {@link ResumeV1} from
|
|
324
|
+
* `lastRevision + 1` to the serving cohort over the injected {@link ResumeTransport} and applies the
|
|
325
|
+
* classified reply via the db-core {@link applyResumeReply}: a `backfill` / `checkpoint_window` reply
|
|
326
|
+
* replays its entries through the delivery path (verified, deduped); `out_of_window` and an untrusted
|
|
327
|
+
* checkpoint escalate to {@link ReactivitySubscriptionManagerOptions.onChainRead}; `tail_rotated`
|
|
328
|
+
* escalates to {@link ReactivitySubscriptionManagerOptions.onTailRotated} and invalidates the sticky
|
|
329
|
+
* cohort-hint cache (the cached primary is under the old tree). Throws if no resume transport/signer
|
|
330
|
+
* was configured.
|
|
331
|
+
*
|
|
332
|
+
* The sticky cohort-hint cache (Edge) lets a resume after a brief flap dial the cached primary directly
|
|
333
|
+
* for a one-RT recovery instead of re-walking from `d_max`; it is the transport's to consult via
|
|
334
|
+
* {@link cohortHint}.
|
|
335
|
+
*/
|
|
336
|
+
async resume(): Promise<ResumeApplyOutcome> {
|
|
337
|
+
const { resumeTransport, signResume } = this.options;
|
|
338
|
+
if (resumeTransport === undefined || signResume === undefined) {
|
|
339
|
+
throw new Error("ReactivitySubscriptionManager.resume: no resumeTransport/signResume configured");
|
|
340
|
+
}
|
|
341
|
+
if (this.subscriberCoordIsFallback) {
|
|
342
|
+
log("resume: no real ring coordinate supplied; signing ResumeV1 with the collectionId as a placeholder subscriberCoord (collection=%s)", this.collectionIdB64);
|
|
343
|
+
}
|
|
344
|
+
const unsigned: Omit<ResumeV1, "signature"> = {
|
|
345
|
+
v: 1,
|
|
346
|
+
collectionId: this.collectionIdB64,
|
|
347
|
+
fromRevision: this.subscriber.lastRevision + 1,
|
|
348
|
+
latestKnownTailId: bytesToB64url(this.tailIdAtAttach),
|
|
349
|
+
subscriberCoord: this.subscriberCoord,
|
|
350
|
+
timestamp: this.clock(),
|
|
351
|
+
};
|
|
352
|
+
const req: ResumeV1 = { ...unsigned, signature: signResume(unsigned) };
|
|
353
|
+
let reply: ResumeReplyV1;
|
|
354
|
+
try {
|
|
355
|
+
reply = await resumeTransport(req);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
if (err instanceof RotationRedirectError) {
|
|
358
|
+
// The resume reached the serving cohort's outgoing (draining) tail: honor the redirect (surface
|
|
359
|
+
// the rotation through onRotation, invalidate the sticky cache) and resolve as a tail rotation —
|
|
360
|
+
// never throw the redirect out to the caller / the gap seam's commit-delivery path.
|
|
361
|
+
this.honorRotationRedirect(err);
|
|
362
|
+
return "tail_rotated";
|
|
363
|
+
}
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
366
|
+
return applyResumeReply(reply, {
|
|
367
|
+
subscriber: this.subscriber,
|
|
368
|
+
verifier: this.verifier,
|
|
369
|
+
onCheckpointDigest: this.options.onCheckpointDigest,
|
|
370
|
+
onChainRead: this.options.onChainRead,
|
|
371
|
+
onTailRotated: (newTailId, newRevisionAtRotation): void => {
|
|
372
|
+
// The cached primary is under the now-stale tree; drop it so the re-registration re-walks.
|
|
373
|
+
this.cohortHintCache.invalidate(this.collectionIdB64);
|
|
374
|
+
this.options.onTailRotated?.(newTailId, newRevisionAtRotation);
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The sticky cohort-hint cache backing one-RT resume after a flap (Edge). */
|
|
380
|
+
get cohortHint(): StickyCohortHintCache {
|
|
381
|
+
return this.cohortHintCache;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Run the db-core delivery path for one inbound notification, then check for tail rotation
|
|
386
|
+
* (`docs/reactivity.md` §Tail rotation): a delivered `tailId` that differs from `tailIdAtAttach`, or a
|
|
387
|
+
* `rotationHint` pre-announce, invalidates the sticky cohort-hint cache (the cached primary is under the
|
|
388
|
+
* old tree) and surfaces a jittered re-registration plan via {@link ReactivitySubscriptionManagerOptions.onRotation}.
|
|
389
|
+
* Fired at most once per successor tail.
|
|
390
|
+
*/
|
|
391
|
+
async onNotification(n: NotificationV1): Promise<DeliveryOutcome> {
|
|
392
|
+
const outcome = await this.subscriber.onNotification(n);
|
|
393
|
+
this.checkRotation(n);
|
|
394
|
+
return outcome;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Detect a tail rotation from an inbound notification and surface it once per successor tail. */
|
|
398
|
+
private checkRotation(n: NotificationV1): void {
|
|
399
|
+
const detection = detectRotation(this.tailIdAtAttachB64, n);
|
|
400
|
+
if (!detection.rotated || detection.newTailId === undefined) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
this.surfaceRotation(detection.newTailId, detection.preAnnounced);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Surface a rotation to the host **once per successor tail** (`docs/reactivity.md` §Tail rotation):
|
|
408
|
+
* invalidate the sticky cohort-hint cache (the cached primary is under the now-stale tree, so a later
|
|
409
|
+
* resume re-walks) and — when an {@link ReactivitySubscriptionManagerOptions.onRotation} observer is
|
|
410
|
+
* configured — hand it a jittered re-registration plan carrying `lastRevision` (continuous across the
|
|
411
|
+
* rotation). The single seam both the notification-driven detection ({@link checkRotation}) and the
|
|
412
|
+
* recover-driven {@link RotationRedirectError} ({@link honorRotationRedirect}) end in; the
|
|
413
|
+
* {@link rotationHandledFor} guard self-corrects across a chained OLD→A→B rotation.
|
|
414
|
+
*/
|
|
415
|
+
private surfaceRotation(newTailId: string, preAnnounced: boolean): void {
|
|
416
|
+
if (newTailId === this.rotationHandledFor) {
|
|
417
|
+
return; // already surfaced this successor
|
|
418
|
+
}
|
|
419
|
+
this.rotationHandledFor = newTailId;
|
|
420
|
+
// The cached primary is under the now-stale tree; drop it so the re-registration re-walks.
|
|
421
|
+
this.cohortHintCache.invalidate(this.collectionIdB64);
|
|
422
|
+
if (this.options.onRotation === undefined) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const plan = planReRegistration({
|
|
426
|
+
hint: { newTailId },
|
|
427
|
+
lastRevision: this.subscriber.lastRevision,
|
|
428
|
+
now: this.clock(),
|
|
429
|
+
jitter: this.rejoinJitter,
|
|
430
|
+
});
|
|
431
|
+
this.options.onRotation({ newTailId, preAnnounced, plan });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Honor a recover-surfaced {@link RotationRedirectError}: the serving cohort's outgoing tail rotated and
|
|
436
|
+
* bounced this request to the new tree. Route it through the **same** {@link surfaceRotation} seam a
|
|
437
|
+
* delivered pre-announce uses (`preAnnounced: false`), so both the notify-driven and recover-driven
|
|
438
|
+
* rotation paths converge on one `RotationNotice` for the host's re-registration scheduler to consume.
|
|
439
|
+
*/
|
|
440
|
+
private honorRotationRedirect(err: RotationRedirectError): void {
|
|
441
|
+
this.surfaceRotation(err.redirect.newTailId, false);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Default application-level backfill re-attempts after a transport failure before escalating to resume/chain. */
|
|
446
|
+
const DEFAULT_BACKFILL_MAX_RETRIES = 1;
|
|
447
|
+
/** Fallback subscriber TTL when neither `ttlMs` nor `profile` is supplied (Core default). */
|
|
448
|
+
const DEFAULT_SUBSCRIBER_TTL_MS = 90_000;
|
|
449
|
+
/** Edge-safe delta budget when neither `deltaMaxBytes` nor `profile` is supplied: decline deltas. */
|
|
450
|
+
const DEFAULT_EDGE_SAFE_DELTA_MAX = 0;
|