@optimystic/db-p2p 0.24.0 → 0.24.2
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/service.d.ts +8 -0
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +16 -4
- 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 +25 -11
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +31 -19
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +68 -0
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +123 -14
- 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 +8 -5
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +2 -2
- package/dist/src/matchmaking/query-transport.js +3 -3
- package/dist/src/matchmaking/query-transport.js.map +1 -1
- package/dist/src/peer-address-book.d.ts +69 -0
- package/dist/src/peer-address-book.d.ts.map +1 -1
- package/dist/src/peer-address-book.js +110 -15
- package/dist/src/peer-address-book.js.map +1 -1
- 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/service.d.ts +6 -0
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +12 -2
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
- package/dist/src/routing/libp2p-known-peers.js +5 -0
- package/dist/src/routing/libp2p-known-peers.js.map +1 -1
- 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/package.json +3 -3
- package/src/cluster/service.ts +305 -293
- package/src/cohort-topic/host.ts +2932 -2901
- package/src/cohort-topic/stream-util.ts +147 -135
- package/src/libp2p-key-network.ts +1235 -1120
- package/src/libp2p-node-base.ts +1678 -1675
- package/src/logger.ts +27 -27
- package/src/matchmaking/query-transport.ts +492 -492
- package/src/peer-address-book.ts +266 -149
- package/src/reactivity/notify-transport.ts +144 -144
- package/src/reactivity/push-state-gossip.ts +291 -291
- package/src/reactivity/recover-transport.ts +412 -408
- package/src/repo/service.ts +323 -313
- package/src/routing/libp2p-known-peers.ts +31 -26
- package/src/testing/cohort-topic-mesh-harness.ts +673 -663
|
@@ -1,408 +1,412 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reactivity recover RPC transport — the live libp2p backfill/resume recovery protocol
|
|
3
|
-
* (`docs/reactivity.md` §Backfill RPC, §Resume).
|
|
4
|
-
*
|
|
5
|
-
* This is the **pull** companion to the one-way notify transport: a subscriber that detected a gap (or
|
|
6
|
-
* woke from sleep) asks a serving cohort member "what did I miss?" and is brought current. The protocol is
|
|
7
|
-
* **request-reply** — one {@link RecoverRequestV1} frame out, one {@link RecoverReplyV1} frame back over a
|
|
8
|
-
* single stream — so it rides the cohort-topic {@link requestResponse} / {@link handleRequestResponse}
|
|
9
|
-
* helpers (NOT the one-way notify helpers, which would desync the dialer).
|
|
10
|
-
*
|
|
11
|
-
* Two pieces live here, both decoupled from the live node assembly so they unit-test in isolation:
|
|
12
|
-
*
|
|
13
|
-
* - {@link Libp2pReactivityRecoverTransport} — the **outbound** side. Supplies the two db-core seams
|
|
14
|
-
* (`BackfillTransport` / `ResumeTransport`) against one node: pick a target (sticky cohort-hint primary
|
|
15
|
-
* first for the one-RT happy path, else a cohort-walk member), frame the request, exchange it over the
|
|
16
|
-
* recover protocol, and return the inner reply. The wire exchange is injected as a {@link RecoverDialer}
|
|
17
|
-
* so the target-selection + framing logic tests without a real socket; {@link createLibp2pRecoverDialer}
|
|
18
|
-
* is the production libp2p-backed implementation.
|
|
19
|
-
* - {@link createRecoverRequestHandler} / {@link registerRecoverHandler} — the **inbound** serve handler.
|
|
20
|
-
* Decode (bounded) → verify the request's peer-key signature against the dialing peer → reject a
|
|
21
|
-
* replay/stale request → resolve the live `PushState` → `serveBackfill` / `serveResume` → reply. Any
|
|
22
|
-
* failure produces **no reply** (the stream aborts) rather than throwing out of the handler.
|
|
23
|
-
*
|
|
24
|
-
* ## Dial-target encoding bridge (load-bearing)
|
|
25
|
-
*
|
|
26
|
-
* The sticky {@link import("@optimystic/db-core").ReactivityCohortHint}`.primary` is **base64url of
|
|
27
|
-
* cohort member-id bytes** (`base64url(utf8(peerIdString))`), per its db-core JSDoc, whereas
|
|
28
|
-
* {@link requestResponse} needs a `peerIdFromString`-parseable peer-id **string**. Feeding the raw
|
|
29
|
-
* base64url straight to `peerIdFromString` throws → the dial is swallowed → recovery silently never
|
|
30
|
-
* reaches the cohort. {@link decodeCohortHintTarget} pins the conversion
|
|
31
|
-
* (`bytesToPeerIdString(b64urlToBytes(primary))`); the `resolveCohort` walk, by contrast, already returns
|
|
32
|
-
* peer-id strings (the cohort-topic `CohortPeerResolver` space) and is used as-is.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import type { Libp2p } from "libp2p";
|
|
36
|
-
import type { PeerId, PrivateKey } from "@libp2p/interface";
|
|
37
|
-
import { peerIdFromString } from "@libp2p/peer-id";
|
|
38
|
-
import {
|
|
39
|
-
b64urlToBytes,
|
|
40
|
-
bytesToB64url,
|
|
41
|
-
reactivityTopicId,
|
|
42
|
-
serveBackfill,
|
|
43
|
-
serveResume,
|
|
44
|
-
backfillSigningPayload,
|
|
45
|
-
resumeSigningPayload,
|
|
46
|
-
encodeRecoverRequestV1,
|
|
47
|
-
decodeRecoverRequestV1,
|
|
48
|
-
encodeRecoverReplyV1,
|
|
49
|
-
decodeRecoverReplyV1,
|
|
50
|
-
type BackfillV1,
|
|
51
|
-
type BackfillReplyV1,
|
|
52
|
-
type BackfillSignable,
|
|
53
|
-
type ResumeV1,
|
|
54
|
-
type ResumeSignable,
|
|
55
|
-
type RecoverKind,
|
|
56
|
-
type RecoverReplyV1,
|
|
57
|
-
type BackfillTransport,
|
|
58
|
-
type PushState,
|
|
59
|
-
type StickyCohortHintCache,
|
|
60
|
-
type CorrelationReplayGuard,
|
|
61
|
-
type RotationRedirectV1,
|
|
62
|
-
} from "@optimystic/db-core";
|
|
63
|
-
import { requestResponse, handleRequestResponse, DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
|
|
64
|
-
import { verifyPeerSig, signPeerSig } from "../cohort-topic/peer-sig.js";
|
|
65
|
-
import { peerIdToBytes, bytesToPeerIdString } from "../cohort-topic/peer-codec.js";
|
|
66
|
-
import { PROTOCOL_REACTIVITY_RECOVER } from "./protocols.js";
|
|
67
|
-
import type { ResumeTransport } from "./subscription-manager.js";
|
|
68
|
-
import { createLogger } from "../logger.js";
|
|
69
|
-
|
|
70
|
-
const log = createLogger("reactivity-recover");
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Decode a sticky-cohort-hint `primary` (base64url of member-id bytes = `base64url(utf8(peerIdString))`)
|
|
74
|
-
* back to the dialable peer-id string. The inverse trap of feeding the raw value to `peerIdFromString`
|
|
75
|
-
* (which expects a peer-id string, not base64url-of-its-utf8) — pinned by the dial-target encoding test.
|
|
76
|
-
*/
|
|
77
|
-
export function decodeCohortHintTarget(primary: string): string {
|
|
78
|
-
return bytesToPeerIdString(b64urlToBytes(primary));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// --- subscriber request signing (the manager's synchronous signer seam) ---
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* The synchronous request-signing seam the production subscribe factory feeds a
|
|
85
|
-
* {@link import("./subscription-manager.js").ReactivitySubscriptionManager}: it builds a
|
|
86
|
-
* {@link BackfillV1} / {@link ResumeV1} signature (base64url) over the unsigned image.
|
|
87
|
-
*/
|
|
88
|
-
export interface RecoverRequestSigners {
|
|
89
|
-
/** Sign a {@link BackfillV1} over {@link backfillSigningPayload} of its unsigned image. */
|
|
90
|
-
readonly signBackfill: (unsigned: BackfillSignable) => string;
|
|
91
|
-
/** Sign a {@link ResumeV1} over {@link resumeSigningPayload} of its unsigned image. */
|
|
92
|
-
readonly signResume: (unsigned: ResumeSignable) => string;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Build the subscriber's recover request signers from the node's libp2p Ed25519 private key.
|
|
97
|
-
*
|
|
98
|
-
* This resolves the recover wiring's one design point: the subscription-manager `signBackfill` /
|
|
99
|
-
* `signResume` ports are **synchronous** (`(unsigned) => string`) — the db-core driver builds the
|
|
100
|
-
* unsigned image internally, so a pre-signed value is impossible — but libp2p `PrivateKey.sign` is
|
|
101
|
-
* **async**. Rather than make the seam (and `createBackfillRequester`'s `sign`, a db-core change) async,
|
|
102
|
-
* sign with the synchronous {@link signPeerSig} (noble, over the raw Ed25519 seed) over db-core's
|
|
103
|
-
* canonical signing payloads. The produced signature verifies on the serving side under
|
|
104
|
-
* {@link verifyPeerSig} over the same bytes (both noble, RFC8032), so the round trip is symmetric.
|
|
105
|
-
*/
|
|
106
|
-
export function createRecoverRequestSigners(privateKey: PrivateKey): RecoverRequestSigners {
|
|
107
|
-
return {
|
|
108
|
-
signBackfill: (unsigned: BackfillSignable): string => bytesToB64url(signPeerSig(privateKey, backfillSigningPayload(unsigned))),
|
|
109
|
-
signResume: (unsigned: ResumeSignable): string => bytesToB64url(signPeerSig(privateKey, resumeSigningPayload(unsigned))),
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// --- outbound transport ---
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Thrown out of {@link Libp2pReactivityRecoverTransport.backfillTransport} / `resumeTransport` when the
|
|
117
|
-
* dialed cohort answered with a `kind: "rotated"` recover reply: the outgoing tail this request reached has
|
|
118
|
-
* rotated and is draining, so it bounced the request to the new tree (`docs/reactivity.md` §Tail rotation).
|
|
119
|
-
* It carries the {@link RotationRedirectV1} so the subscription manager can move itself to the successor.
|
|
120
|
-
*
|
|
121
|
-
* A `kind: "rotated"` reply is **terminal** for the cohort-walk — the dialed member spoke authoritatively for
|
|
122
|
-
* the cohort, so the transport stops rather than falling through to the next target (contrast a *dial
|
|
123
|
-
* failure*, which still falls through). The subscriber honors it like a notification-driven rotation.
|
|
124
|
-
*/
|
|
125
|
-
export class RotationRedirectError extends Error {
|
|
126
|
-
/** The drain-window redirect the serving cohort returned (`newTailId` / `newTopicId` / `effectiveAtRevision`). */
|
|
127
|
-
readonly redirect: RotationRedirectV1;
|
|
128
|
-
constructor(redirect: RotationRedirectV1) {
|
|
129
|
-
super(`reactivity recover: cohort rotated to tail ${redirect.newTailId} (effectiveAtRevision ${redirect.effectiveAtRevision})`);
|
|
130
|
-
this.name = "RotationRedirectError";
|
|
131
|
-
this.redirect = redirect;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** The wire exchange seam: open the recover protocol to `target`, send `frame`, return the bounded reply. */
|
|
136
|
-
export interface RecoverDialer {
|
|
137
|
-
/** Exchange one request frame for one reply frame with `target` (peer-id string). Rejects on a dial failure. */
|
|
138
|
-
exchange(target: string, frame: Uint8Array): Promise<Uint8Array>;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Build the production libp2p-backed {@link RecoverDialer} over {@link requestResponse}. */
|
|
142
|
-
export function createLibp2pRecoverDialer(node: Libp2p, recoverProtocol: string = PROTOCOL_REACTIVITY_RECOVER, maxBytes: number = DEFAULT_STREAM_MAX_BYTES): RecoverDialer {
|
|
143
|
-
return {
|
|
144
|
-
exchange: (target, frame) => requestResponse(node, peerIdFromString(target), recoverProtocol, frame, maxBytes),
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Construction inputs for a {@link Libp2pReactivityRecoverTransport}. */
|
|
149
|
-
export interface Libp2pReactivityRecoverTransportOptions {
|
|
150
|
-
/** Wire exchange (e.g. {@link createLibp2pRecoverDialer} over the live node). */
|
|
151
|
-
readonly dialer: RecoverDialer;
|
|
152
|
-
/** This node's peer-id string (`node.peerId.toString()`); never dialed — a co-located serve is the node wiring's. */
|
|
153
|
-
readonly selfPeerId: string;
|
|
154
|
-
/** Sticky cohort-hint cache, shared with the subscription manager (the one-RT primary for the happy path). */
|
|
155
|
-
readonly cohortHintCache: StickyCohortHintCache;
|
|
156
|
-
/** FRET cohort-walk fallback: a topic id → dialable cohort member peer-id strings. */
|
|
157
|
-
readonly resolveCohort: (topicId: Uint8Array) => string[];
|
|
158
|
-
/** Per-frame ceiling for encode/decode; default {@link DEFAULT_STREAM_MAX_BYTES}. */
|
|
159
|
-
readonly maxBytes?: number;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* The outbound recover transport: one instance per node, exposing the two db-core function seams against it.
|
|
164
|
-
* Each returned transport dials the **sticky primary first** (one round trip after a brief flap), falling
|
|
165
|
-
* back to a **cohort-walk** member on a dial failure (any member that holds the topic's gossiped `PushState`
|
|
166
|
-
* can answer). A kind mismatch or all-targets-failed surfaces as a rejection so the caller's retry/escalation
|
|
167
|
-
* policy (the subscription manager's backfill escalation, or `manager.resume()`'s caller) takes over.
|
|
168
|
-
*/
|
|
169
|
-
export class Libp2pReactivityRecoverTransport {
|
|
170
|
-
private readonly dialer: RecoverDialer;
|
|
171
|
-
private readonly selfPeerId: string;
|
|
172
|
-
private readonly cohortHintCache: StickyCohortHintCache;
|
|
173
|
-
private readonly resolveCohort: (topicId: Uint8Array) => string[];
|
|
174
|
-
private readonly maxBytes: number;
|
|
175
|
-
|
|
176
|
-
constructor(options: Libp2pReactivityRecoverTransportOptions) {
|
|
177
|
-
this.dialer = options.dialer;
|
|
178
|
-
this.selfPeerId = options.selfPeerId;
|
|
179
|
-
this.cohortHintCache = options.cohortHintCache;
|
|
180
|
-
this.resolveCohort = options.resolveCohort;
|
|
181
|
-
this.maxBytes = options.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** The db-core {@link BackfillTransport} for `(topicId, collectionId)` — frames + dials a signed {@link BackfillV1}. */
|
|
185
|
-
backfillTransport(topicId: Uint8Array, collectionId: string): BackfillTransport {
|
|
186
|
-
return async (req: BackfillV1): Promise<BackfillReplyV1> => {
|
|
187
|
-
const frame = encodeRecoverRequestV1({ v: 1, kind: "backfill", backfill: req }, this.maxBytes);
|
|
188
|
-
const reply = await this.exchange("backfill", frame, topicId, collectionId);
|
|
189
|
-
if (reply.backfillReply === undefined) {
|
|
190
|
-
throw new Error("reactivity recover: backfill reply missing its body");
|
|
191
|
-
}
|
|
192
|
-
return reply.backfillReply;
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** The {@link ResumeTransport} for `(topicId, collectionId)` — frames + dials a signed {@link ResumeV1}. */
|
|
197
|
-
resumeTransport(topicId: Uint8Array, collectionId: string): ResumeTransport {
|
|
198
|
-
return async (req: ResumeV1) => {
|
|
199
|
-
const frame = encodeRecoverRequestV1({ v: 1, kind: "resume", resume: req }, this.maxBytes);
|
|
200
|
-
const reply = await this.exchange("resume", frame, topicId, collectionId);
|
|
201
|
-
if (reply.resumeReply === undefined) {
|
|
202
|
-
throw new Error("reactivity recover: resume reply missing its body");
|
|
203
|
-
}
|
|
204
|
-
return reply.resumeReply;
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Exchange one recover frame with the first reachable target: sticky primary, then each cohort-walk
|
|
210
|
-
* member. A **dial failure** falls through to the next candidate; a successful dial is **terminal** (the
|
|
211
|
-
* member answered for the cohort) — a `kind: "rotated"` redirect throws {@link RotationRedirectError}, and
|
|
212
|
-
* a reply decoding to the wrong `kind` is a protocol error. Throws when no candidate succeeds.
|
|
213
|
-
*/
|
|
214
|
-
private async exchange(kind: RecoverKind, frame: Uint8Array, topicId: Uint8Array, collectionId: string): Promise<RecoverReplyV1> {
|
|
215
|
-
const targets = this.selectTargets(topicId, collectionId);
|
|
216
|
-
if (targets.length === 0) {
|
|
217
|
-
throw new Error("reactivity recover: no serving cohort target resolved");
|
|
218
|
-
}
|
|
219
|
-
let lastErr: unknown;
|
|
220
|
-
for (const target of targets) {
|
|
221
|
-
let replyFrame: Uint8Array;
|
|
222
|
-
try {
|
|
223
|
-
replyFrame = await this.dialer.exchange(target, frame);
|
|
224
|
-
} catch (err) {
|
|
225
|
-
lastErr = err; // dial failure → fall back to the next candidate (sticky → walk)
|
|
226
|
-
log("recover dial to %s failed, trying next target: %o", target, err);
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
const reply = decodeRecoverReplyV1(replyFrame, this.maxBytes); // a decode failure here is terminal
|
|
230
|
-
// A rotated cohort answered authoritatively: surface the redirect and stop the walk (terminal, never
|
|
231
|
-
// a fallthrough — the dialed member spoke for the cohort).
|
|
232
|
-
if (reply.kind === "rotated") {
|
|
233
|
-
throw new RotationRedirectError(reply.rotated!);
|
|
234
|
-
}
|
|
235
|
-
if (reply.kind !== kind) {
|
|
236
|
-
throw new Error(`reactivity recover: reply kind "${reply.kind}" does not match request kind "${kind}"`);
|
|
237
|
-
}
|
|
238
|
-
return reply;
|
|
239
|
-
}
|
|
240
|
-
throw lastErr ?? new Error("reactivity recover: all targets failed");
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Ordered dial candidates for a recover: the sticky primary (decoded from its base64url-of-bytes form)
|
|
245
|
-
* first, then the cohort-walk members. Self is never dialed (a co-located serve is the node wiring's
|
|
246
|
-
* concern), and duplicates collapse.
|
|
247
|
-
*/
|
|
248
|
-
private selectTargets(topicId: Uint8Array, collectionId: string): string[] {
|
|
249
|
-
const seen = new Set<string>();
|
|
250
|
-
const targets: string[] = [];
|
|
251
|
-
const add = (target: string | undefined): void => {
|
|
252
|
-
if (target === undefined || target === this.selfPeerId || seen.has(target)) {
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
seen.add(target);
|
|
256
|
-
targets.push(target);
|
|
257
|
-
};
|
|
258
|
-
const hint = this.cohortHintCache.get(collectionId);
|
|
259
|
-
if (hint?.primary !== undefined) {
|
|
260
|
-
try {
|
|
261
|
-
add(decodeCohortHintTarget(hint.primary));
|
|
262
|
-
} catch (err) {
|
|
263
|
-
log("recover: malformed sticky primary for %s, skipping to cohort-walk: %o", collectionId, err);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
for (const member of this.resolveCohort(topicId)) {
|
|
267
|
-
add(member);
|
|
268
|
-
}
|
|
269
|
-
return targets;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// --- inbound serve handler ---
|
|
274
|
-
|
|
275
|
-
/** Live `PushState` resolvers + replay guard the recover serve handler dispatches against. */
|
|
276
|
-
export interface RecoverServeDeps {
|
|
277
|
-
/** Resolve the served `PushState` for an exact reactivity topic id (resume's stale-tail lookup). */
|
|
278
|
-
readonly pushStateFor: (topicId: Uint8Array) => PushState | undefined;
|
|
279
|
-
/** Resolve the served `PushState` for a collection id — the current tail (backfill, and rotated resume). */
|
|
280
|
-
readonly pushStateForCollection: (collectionId: string) => PushState | undefined;
|
|
281
|
-
/** Node-level freshness + anti-replay gate keyed on the request signature bytes. */
|
|
282
|
-
readonly replayGuard: CorrelationReplayGuard;
|
|
283
|
-
/**
|
|
284
|
-
* The drain-window redirect for a request that reached an **outgoing (rotated)** tail still in its drain
|
|
285
|
-
* window, or `undefined` if the resolved topic never rotated / has drained. The node wiring binds this to
|
|
286
|
-
* `ReactivityForwarderHost.rotationRedirectFor`, resolving the old topic from the request: for **resume**
|
|
287
|
-
* the request carries `topicId = reactivityTopicId(latestKnownTailId)`; for **backfill** (no `topicId`)
|
|
288
|
-
* the binding resolves the collection's current served topic. When it returns a redirect the serve replies
|
|
289
|
-
* `kind: "rotated"` instead of serving data, moving the subscriber to the new tree. Absent ⇒ never redirect.
|
|
290
|
-
*/
|
|
291
|
-
readonly rotationFor?: (req: { topicId?: Uint8Array; collectionId: string }, now: number) => RotationRedirectV1 | undefined;
|
|
292
|
-
/** Unix-ms clock for the replay-guard window. Default `Date.now`. */
|
|
293
|
-
readonly clock?: () => number;
|
|
294
|
-
/** Per-frame decode ceiling; default {@link DEFAULT_STREAM_MAX_BYTES}. */
|
|
295
|
-
readonly maxBytes?: number;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Verify the request's peer-key signature against the dialing peer, then admit it through the freshness +
|
|
300
|
-
* anti-replay guard. The dialing peer's id **is** the signer (no signer-id field on the wire); the signature
|
|
301
|
-
* bytes are the anti-replay key (globally unique + authenticated). Returns `false` (reject, no reply) on a
|
|
302
|
-
* bad signature, a stale/future timestamp, or a replay.
|
|
303
|
-
*/
|
|
304
|
-
function verifyAndAdmit(deps: RecoverServeDeps, signerId: string, payload: Uint8Array, signatureB64: string, timestamp: number, now: number): boolean {
|
|
305
|
-
const signature = b64urlToBytes(signatureB64);
|
|
306
|
-
if (!verifyPeerSig(signerId, payload, signature)) {
|
|
307
|
-
log("recover serve: signature verification failed for %s (no reply)", signerId);
|
|
308
|
-
return false;
|
|
309
|
-
}
|
|
310
|
-
if (!deps.replayGuard.accept(signature, peerIdToBytes(signerId), timestamp, now)) {
|
|
311
|
-
log("recover serve: replay/stale request from %s (no reply)", signerId);
|
|
312
|
-
return false;
|
|
313
|
-
}
|
|
314
|
-
return true;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Serve a backfill from the collection's current served tail, or `undefined` if this node serves none. When
|
|
319
|
-
* the node serves **only** the outgoing (draining) tail, `rotationFor` returns the drain redirect and the
|
|
320
|
-
* reply is `kind: "rotated"` instead — a best-effort secondary path (the primary mechanism for an active
|
|
321
|
-
* subscriber is notify-driven detection). Once both tails coexist `pushStateForCollection` resolves the new
|
|
322
|
-
* tail, so the redirect is emitted only while the old tail is the sole served state (see §Tail rotation).
|
|
323
|
-
*/
|
|
324
|
-
function serveBackfillReply(deps: RecoverServeDeps, req: BackfillV1, now: number): Uint8Array | undefined {
|
|
325
|
-
const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
326
|
-
const redirect = deps.rotationFor?.({ collectionId: req.collectionId }, now);
|
|
327
|
-
if (redirect !== undefined) {
|
|
328
|
-
return encodeRecoverReplyV1({ v: 1, kind: "rotated", rotated: redirect }, maxBytes);
|
|
329
|
-
}
|
|
330
|
-
const ps = deps.pushStateForCollection(req.collectionId);
|
|
331
|
-
if (ps === undefined) {
|
|
332
|
-
return undefined; // not a serving member for this collection → no reply (subscriber walks/chain-reads)
|
|
333
|
-
}
|
|
334
|
-
const backfillReply = serveBackfill(ps.replayBuffer, req, ps.collectionId);
|
|
335
|
-
return encodeRecoverReplyV1({ v: 1, kind: "backfill", backfillReply }, maxBytes);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Serve a resume against the live `PushState`. A resume reaching the **outgoing (draining)** tail — its
|
|
340
|
-
* `latestKnownTailId` anchors a topic this node has marked rotated — is answered with the drain redirect
|
|
341
|
-
* (`kind: "rotated"`), moving the subscriber to the new tree. Otherwise prefer the exact topic the request's
|
|
342
|
-
* `latestKnownTailId` anchors (so a non-rotated subscriber classifies into backfill/checkpoint/out_of_window);
|
|
343
|
-
* if this node no longer serves that tail's topic, fall back to the collection's current tail so the cohort
|
|
344
|
-
* can still answer `tail_rotated` (its `currentTailId` differs from the request's stale tail) or, for a span
|
|
345
|
-
* that crosses a rotation, serve from the new tail's `inheritedCheckpoint`. `undefined` ⇒ no served state.
|
|
346
|
-
*/
|
|
347
|
-
function serveResumeReply(deps: RecoverServeDeps, req: ResumeV1, now: number): Uint8Array | undefined {
|
|
348
|
-
const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
349
|
-
const staleTopic = reactivityTopicId(b64urlToBytes(req.latestKnownTailId));
|
|
350
|
-
const redirect = deps.rotationFor?.({ topicId: staleTopic, collectionId: req.collectionId }, now);
|
|
351
|
-
if (redirect !== undefined) {
|
|
352
|
-
return encodeRecoverReplyV1({ v: 1, kind: "rotated", rotated: redirect }, maxBytes);
|
|
353
|
-
}
|
|
354
|
-
const ps = deps.pushStateFor(staleTopic) ?? deps.pushStateForCollection(req.collectionId);
|
|
355
|
-
if (ps === undefined) {
|
|
356
|
-
return undefined;
|
|
357
|
-
}
|
|
358
|
-
const resumeReply = serveResume(req, {
|
|
359
|
-
buffer: ps.replayBuffer,
|
|
360
|
-
checkpoint: ps.checkpoint,
|
|
361
|
-
inheritedCheckpoint: ps.inheritedCheckpoint,
|
|
362
|
-
currentTailId: ps.tailIdAtJoin,
|
|
363
|
-
currentRevision: ps.lastRevision,
|
|
364
|
-
rotationRevision: ps.lastRevision,
|
|
365
|
-
expectedCollectionId: ps.collectionId,
|
|
366
|
-
});
|
|
367
|
-
return encodeRecoverReplyV1({ v: 1, kind: "resume", resumeReply }, maxBytes);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Build the recover serve callback for {@link handleRequestResponse}: it returns the reply frame, or
|
|
372
|
-
* `undefined`
|
|
373
|
-
*
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
return Promise.resolve(
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Reactivity recover RPC transport — the live libp2p backfill/resume recovery protocol
|
|
3
|
+
* (`docs/reactivity.md` §Backfill RPC, §Resume).
|
|
4
|
+
*
|
|
5
|
+
* This is the **pull** companion to the one-way notify transport: a subscriber that detected a gap (or
|
|
6
|
+
* woke from sleep) asks a serving cohort member "what did I miss?" and is brought current. The protocol is
|
|
7
|
+
* **request-reply** — one {@link RecoverRequestV1} frame out, one {@link RecoverReplyV1} frame back over a
|
|
8
|
+
* single stream — so it rides the cohort-topic {@link requestResponse} / {@link handleRequestResponse}
|
|
9
|
+
* helpers (NOT the one-way notify helpers, which would desync the dialer).
|
|
10
|
+
*
|
|
11
|
+
* Two pieces live here, both decoupled from the live node assembly so they unit-test in isolation:
|
|
12
|
+
*
|
|
13
|
+
* - {@link Libp2pReactivityRecoverTransport} — the **outbound** side. Supplies the two db-core seams
|
|
14
|
+
* (`BackfillTransport` / `ResumeTransport`) against one node: pick a target (sticky cohort-hint primary
|
|
15
|
+
* first for the one-RT happy path, else a cohort-walk member), frame the request, exchange it over the
|
|
16
|
+
* recover protocol, and return the inner reply. The wire exchange is injected as a {@link RecoverDialer}
|
|
17
|
+
* so the target-selection + framing logic tests without a real socket; {@link createLibp2pRecoverDialer}
|
|
18
|
+
* is the production libp2p-backed implementation.
|
|
19
|
+
* - {@link createRecoverRequestHandler} / {@link registerRecoverHandler} — the **inbound** serve handler.
|
|
20
|
+
* Decode (bounded) → verify the request's peer-key signature against the dialing peer → reject a
|
|
21
|
+
* replay/stale request → resolve the live `PushState` → `serveBackfill` / `serveResume` → reply. Any
|
|
22
|
+
* failure produces **no reply** (the stream aborts) rather than throwing out of the handler.
|
|
23
|
+
*
|
|
24
|
+
* ## Dial-target encoding bridge (load-bearing)
|
|
25
|
+
*
|
|
26
|
+
* The sticky {@link import("@optimystic/db-core").ReactivityCohortHint}`.primary` is **base64url of
|
|
27
|
+
* cohort member-id bytes** (`base64url(utf8(peerIdString))`), per its db-core JSDoc, whereas
|
|
28
|
+
* {@link requestResponse} needs a `peerIdFromString`-parseable peer-id **string**. Feeding the raw
|
|
29
|
+
* base64url straight to `peerIdFromString` throws → the dial is swallowed → recovery silently never
|
|
30
|
+
* reaches the cohort. {@link decodeCohortHintTarget} pins the conversion
|
|
31
|
+
* (`bytesToPeerIdString(b64urlToBytes(primary))`); the `resolveCohort` walk, by contrast, already returns
|
|
32
|
+
* peer-id strings (the cohort-topic `CohortPeerResolver` space) and is used as-is.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Libp2p } from "libp2p";
|
|
36
|
+
import type { PeerId, PrivateKey } from "@libp2p/interface";
|
|
37
|
+
import { peerIdFromString } from "@libp2p/peer-id";
|
|
38
|
+
import {
|
|
39
|
+
b64urlToBytes,
|
|
40
|
+
bytesToB64url,
|
|
41
|
+
reactivityTopicId,
|
|
42
|
+
serveBackfill,
|
|
43
|
+
serveResume,
|
|
44
|
+
backfillSigningPayload,
|
|
45
|
+
resumeSigningPayload,
|
|
46
|
+
encodeRecoverRequestV1,
|
|
47
|
+
decodeRecoverRequestV1,
|
|
48
|
+
encodeRecoverReplyV1,
|
|
49
|
+
decodeRecoverReplyV1,
|
|
50
|
+
type BackfillV1,
|
|
51
|
+
type BackfillReplyV1,
|
|
52
|
+
type BackfillSignable,
|
|
53
|
+
type ResumeV1,
|
|
54
|
+
type ResumeSignable,
|
|
55
|
+
type RecoverKind,
|
|
56
|
+
type RecoverReplyV1,
|
|
57
|
+
type BackfillTransport,
|
|
58
|
+
type PushState,
|
|
59
|
+
type StickyCohortHintCache,
|
|
60
|
+
type CorrelationReplayGuard,
|
|
61
|
+
type RotationRedirectV1,
|
|
62
|
+
} from "@optimystic/db-core";
|
|
63
|
+
import { requestResponse, handleRequestResponse, DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
|
|
64
|
+
import { verifyPeerSig, signPeerSig } from "../cohort-topic/peer-sig.js";
|
|
65
|
+
import { peerIdToBytes, bytesToPeerIdString } from "../cohort-topic/peer-codec.js";
|
|
66
|
+
import { PROTOCOL_REACTIVITY_RECOVER } from "./protocols.js";
|
|
67
|
+
import type { ResumeTransport } from "./subscription-manager.js";
|
|
68
|
+
import { createLogger } from "../logger.js";
|
|
69
|
+
|
|
70
|
+
const log = createLogger("reactivity-recover");
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decode a sticky-cohort-hint `primary` (base64url of member-id bytes = `base64url(utf8(peerIdString))`)
|
|
74
|
+
* back to the dialable peer-id string. The inverse trap of feeding the raw value to `peerIdFromString`
|
|
75
|
+
* (which expects a peer-id string, not base64url-of-its-utf8) — pinned by the dial-target encoding test.
|
|
76
|
+
*/
|
|
77
|
+
export function decodeCohortHintTarget(primary: string): string {
|
|
78
|
+
return bytesToPeerIdString(b64urlToBytes(primary));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- subscriber request signing (the manager's synchronous signer seam) ---
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The synchronous request-signing seam the production subscribe factory feeds a
|
|
85
|
+
* {@link import("./subscription-manager.js").ReactivitySubscriptionManager}: it builds a
|
|
86
|
+
* {@link BackfillV1} / {@link ResumeV1} signature (base64url) over the unsigned image.
|
|
87
|
+
*/
|
|
88
|
+
export interface RecoverRequestSigners {
|
|
89
|
+
/** Sign a {@link BackfillV1} over {@link backfillSigningPayload} of its unsigned image. */
|
|
90
|
+
readonly signBackfill: (unsigned: BackfillSignable) => string;
|
|
91
|
+
/** Sign a {@link ResumeV1} over {@link resumeSigningPayload} of its unsigned image. */
|
|
92
|
+
readonly signResume: (unsigned: ResumeSignable) => string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the subscriber's recover request signers from the node's libp2p Ed25519 private key.
|
|
97
|
+
*
|
|
98
|
+
* This resolves the recover wiring's one design point: the subscription-manager `signBackfill` /
|
|
99
|
+
* `signResume` ports are **synchronous** (`(unsigned) => string`) — the db-core driver builds the
|
|
100
|
+
* unsigned image internally, so a pre-signed value is impossible — but libp2p `PrivateKey.sign` is
|
|
101
|
+
* **async**. Rather than make the seam (and `createBackfillRequester`'s `sign`, a db-core change) async,
|
|
102
|
+
* sign with the synchronous {@link signPeerSig} (noble, over the raw Ed25519 seed) over db-core's
|
|
103
|
+
* canonical signing payloads. The produced signature verifies on the serving side under
|
|
104
|
+
* {@link verifyPeerSig} over the same bytes (both noble, RFC8032), so the round trip is symmetric.
|
|
105
|
+
*/
|
|
106
|
+
export function createRecoverRequestSigners(privateKey: PrivateKey): RecoverRequestSigners {
|
|
107
|
+
return {
|
|
108
|
+
signBackfill: (unsigned: BackfillSignable): string => bytesToB64url(signPeerSig(privateKey, backfillSigningPayload(unsigned))),
|
|
109
|
+
signResume: (unsigned: ResumeSignable): string => bytesToB64url(signPeerSig(privateKey, resumeSigningPayload(unsigned))),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- outbound transport ---
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Thrown out of {@link Libp2pReactivityRecoverTransport.backfillTransport} / `resumeTransport` when the
|
|
117
|
+
* dialed cohort answered with a `kind: "rotated"` recover reply: the outgoing tail this request reached has
|
|
118
|
+
* rotated and is draining, so it bounced the request to the new tree (`docs/reactivity.md` §Tail rotation).
|
|
119
|
+
* It carries the {@link RotationRedirectV1} so the subscription manager can move itself to the successor.
|
|
120
|
+
*
|
|
121
|
+
* A `kind: "rotated"` reply is **terminal** for the cohort-walk — the dialed member spoke authoritatively for
|
|
122
|
+
* the cohort, so the transport stops rather than falling through to the next target (contrast a *dial
|
|
123
|
+
* failure*, which still falls through). The subscriber honors it like a notification-driven rotation.
|
|
124
|
+
*/
|
|
125
|
+
export class RotationRedirectError extends Error {
|
|
126
|
+
/** The drain-window redirect the serving cohort returned (`newTailId` / `newTopicId` / `effectiveAtRevision`). */
|
|
127
|
+
readonly redirect: RotationRedirectV1;
|
|
128
|
+
constructor(redirect: RotationRedirectV1) {
|
|
129
|
+
super(`reactivity recover: cohort rotated to tail ${redirect.newTailId} (effectiveAtRevision ${redirect.effectiveAtRevision})`);
|
|
130
|
+
this.name = "RotationRedirectError";
|
|
131
|
+
this.redirect = redirect;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The wire exchange seam: open the recover protocol to `target`, send `frame`, return the bounded reply. */
|
|
136
|
+
export interface RecoverDialer {
|
|
137
|
+
/** Exchange one request frame for one reply frame with `target` (peer-id string). Rejects on a dial failure. */
|
|
138
|
+
exchange(target: string, frame: Uint8Array): Promise<Uint8Array>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Build the production libp2p-backed {@link RecoverDialer} over {@link requestResponse}. */
|
|
142
|
+
export function createLibp2pRecoverDialer(node: Libp2p, recoverProtocol: string = PROTOCOL_REACTIVITY_RECOVER, maxBytes: number = DEFAULT_STREAM_MAX_BYTES): RecoverDialer {
|
|
143
|
+
return {
|
|
144
|
+
exchange: (target, frame) => requestResponse(node, peerIdFromString(target), recoverProtocol, frame, maxBytes),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Construction inputs for a {@link Libp2pReactivityRecoverTransport}. */
|
|
149
|
+
export interface Libp2pReactivityRecoverTransportOptions {
|
|
150
|
+
/** Wire exchange (e.g. {@link createLibp2pRecoverDialer} over the live node). */
|
|
151
|
+
readonly dialer: RecoverDialer;
|
|
152
|
+
/** This node's peer-id string (`node.peerId.toString()`); never dialed — a co-located serve is the node wiring's. */
|
|
153
|
+
readonly selfPeerId: string;
|
|
154
|
+
/** Sticky cohort-hint cache, shared with the subscription manager (the one-RT primary for the happy path). */
|
|
155
|
+
readonly cohortHintCache: StickyCohortHintCache;
|
|
156
|
+
/** FRET cohort-walk fallback: a topic id → dialable cohort member peer-id strings. */
|
|
157
|
+
readonly resolveCohort: (topicId: Uint8Array) => string[];
|
|
158
|
+
/** Per-frame ceiling for encode/decode; default {@link DEFAULT_STREAM_MAX_BYTES}. */
|
|
159
|
+
readonly maxBytes?: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The outbound recover transport: one instance per node, exposing the two db-core function seams against it.
|
|
164
|
+
* Each returned transport dials the **sticky primary first** (one round trip after a brief flap), falling
|
|
165
|
+
* back to a **cohort-walk** member on a dial failure (any member that holds the topic's gossiped `PushState`
|
|
166
|
+
* can answer). A kind mismatch or all-targets-failed surfaces as a rejection so the caller's retry/escalation
|
|
167
|
+
* policy (the subscription manager's backfill escalation, or `manager.resume()`'s caller) takes over.
|
|
168
|
+
*/
|
|
169
|
+
export class Libp2pReactivityRecoverTransport {
|
|
170
|
+
private readonly dialer: RecoverDialer;
|
|
171
|
+
private readonly selfPeerId: string;
|
|
172
|
+
private readonly cohortHintCache: StickyCohortHintCache;
|
|
173
|
+
private readonly resolveCohort: (topicId: Uint8Array) => string[];
|
|
174
|
+
private readonly maxBytes: number;
|
|
175
|
+
|
|
176
|
+
constructor(options: Libp2pReactivityRecoverTransportOptions) {
|
|
177
|
+
this.dialer = options.dialer;
|
|
178
|
+
this.selfPeerId = options.selfPeerId;
|
|
179
|
+
this.cohortHintCache = options.cohortHintCache;
|
|
180
|
+
this.resolveCohort = options.resolveCohort;
|
|
181
|
+
this.maxBytes = options.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The db-core {@link BackfillTransport} for `(topicId, collectionId)` — frames + dials a signed {@link BackfillV1}. */
|
|
185
|
+
backfillTransport(topicId: Uint8Array, collectionId: string): BackfillTransport {
|
|
186
|
+
return async (req: BackfillV1): Promise<BackfillReplyV1> => {
|
|
187
|
+
const frame = encodeRecoverRequestV1({ v: 1, kind: "backfill", backfill: req }, this.maxBytes);
|
|
188
|
+
const reply = await this.exchange("backfill", frame, topicId, collectionId);
|
|
189
|
+
if (reply.backfillReply === undefined) {
|
|
190
|
+
throw new Error("reactivity recover: backfill reply missing its body");
|
|
191
|
+
}
|
|
192
|
+
return reply.backfillReply;
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The {@link ResumeTransport} for `(topicId, collectionId)` — frames + dials a signed {@link ResumeV1}. */
|
|
197
|
+
resumeTransport(topicId: Uint8Array, collectionId: string): ResumeTransport {
|
|
198
|
+
return async (req: ResumeV1) => {
|
|
199
|
+
const frame = encodeRecoverRequestV1({ v: 1, kind: "resume", resume: req }, this.maxBytes);
|
|
200
|
+
const reply = await this.exchange("resume", frame, topicId, collectionId);
|
|
201
|
+
if (reply.resumeReply === undefined) {
|
|
202
|
+
throw new Error("reactivity recover: resume reply missing its body");
|
|
203
|
+
}
|
|
204
|
+
return reply.resumeReply;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Exchange one recover frame with the first reachable target: sticky primary, then each cohort-walk
|
|
210
|
+
* member. A **dial failure** falls through to the next candidate; a successful dial is **terminal** (the
|
|
211
|
+
* member answered for the cohort) — a `kind: "rotated"` redirect throws {@link RotationRedirectError}, and
|
|
212
|
+
* a reply decoding to the wrong `kind` is a protocol error. Throws when no candidate succeeds.
|
|
213
|
+
*/
|
|
214
|
+
private async exchange(kind: RecoverKind, frame: Uint8Array, topicId: Uint8Array, collectionId: string): Promise<RecoverReplyV1> {
|
|
215
|
+
const targets = this.selectTargets(topicId, collectionId);
|
|
216
|
+
if (targets.length === 0) {
|
|
217
|
+
throw new Error("reactivity recover: no serving cohort target resolved");
|
|
218
|
+
}
|
|
219
|
+
let lastErr: unknown;
|
|
220
|
+
for (const target of targets) {
|
|
221
|
+
let replyFrame: Uint8Array;
|
|
222
|
+
try {
|
|
223
|
+
replyFrame = await this.dialer.exchange(target, frame);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
lastErr = err; // dial failure → fall back to the next candidate (sticky → walk)
|
|
226
|
+
log("recover dial to %s failed, trying next target: %o", target, err);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const reply = decodeRecoverReplyV1(replyFrame, this.maxBytes); // a decode failure here is terminal
|
|
230
|
+
// A rotated cohort answered authoritatively: surface the redirect and stop the walk (terminal, never
|
|
231
|
+
// a fallthrough — the dialed member spoke for the cohort).
|
|
232
|
+
if (reply.kind === "rotated") {
|
|
233
|
+
throw new RotationRedirectError(reply.rotated!);
|
|
234
|
+
}
|
|
235
|
+
if (reply.kind !== kind) {
|
|
236
|
+
throw new Error(`reactivity recover: reply kind "${reply.kind}" does not match request kind "${kind}"`);
|
|
237
|
+
}
|
|
238
|
+
return reply;
|
|
239
|
+
}
|
|
240
|
+
throw lastErr ?? new Error("reactivity recover: all targets failed");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Ordered dial candidates for a recover: the sticky primary (decoded from its base64url-of-bytes form)
|
|
245
|
+
* first, then the cohort-walk members. Self is never dialed (a co-located serve is the node wiring's
|
|
246
|
+
* concern), and duplicates collapse.
|
|
247
|
+
*/
|
|
248
|
+
private selectTargets(topicId: Uint8Array, collectionId: string): string[] {
|
|
249
|
+
const seen = new Set<string>();
|
|
250
|
+
const targets: string[] = [];
|
|
251
|
+
const add = (target: string | undefined): void => {
|
|
252
|
+
if (target === undefined || target === this.selfPeerId || seen.has(target)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
seen.add(target);
|
|
256
|
+
targets.push(target);
|
|
257
|
+
};
|
|
258
|
+
const hint = this.cohortHintCache.get(collectionId);
|
|
259
|
+
if (hint?.primary !== undefined) {
|
|
260
|
+
try {
|
|
261
|
+
add(decodeCohortHintTarget(hint.primary));
|
|
262
|
+
} catch (err) {
|
|
263
|
+
log("recover: malformed sticky primary for %s, skipping to cohort-walk: %o", collectionId, err);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
for (const member of this.resolveCohort(topicId)) {
|
|
267
|
+
add(member);
|
|
268
|
+
}
|
|
269
|
+
return targets;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- inbound serve handler ---
|
|
274
|
+
|
|
275
|
+
/** Live `PushState` resolvers + replay guard the recover serve handler dispatches against. */
|
|
276
|
+
export interface RecoverServeDeps {
|
|
277
|
+
/** Resolve the served `PushState` for an exact reactivity topic id (resume's stale-tail lookup). */
|
|
278
|
+
readonly pushStateFor: (topicId: Uint8Array) => PushState | undefined;
|
|
279
|
+
/** Resolve the served `PushState` for a collection id — the current tail (backfill, and rotated resume). */
|
|
280
|
+
readonly pushStateForCollection: (collectionId: string) => PushState | undefined;
|
|
281
|
+
/** Node-level freshness + anti-replay gate keyed on the request signature bytes. */
|
|
282
|
+
readonly replayGuard: CorrelationReplayGuard;
|
|
283
|
+
/**
|
|
284
|
+
* The drain-window redirect for a request that reached an **outgoing (rotated)** tail still in its drain
|
|
285
|
+
* window, or `undefined` if the resolved topic never rotated / has drained. The node wiring binds this to
|
|
286
|
+
* `ReactivityForwarderHost.rotationRedirectFor`, resolving the old topic from the request: for **resume**
|
|
287
|
+
* the request carries `topicId = reactivityTopicId(latestKnownTailId)`; for **backfill** (no `topicId`)
|
|
288
|
+
* the binding resolves the collection's current served topic. When it returns a redirect the serve replies
|
|
289
|
+
* `kind: "rotated"` instead of serving data, moving the subscriber to the new tree. Absent ⇒ never redirect.
|
|
290
|
+
*/
|
|
291
|
+
readonly rotationFor?: (req: { topicId?: Uint8Array; collectionId: string }, now: number) => RotationRedirectV1 | undefined;
|
|
292
|
+
/** Unix-ms clock for the replay-guard window. Default `Date.now`. */
|
|
293
|
+
readonly clock?: () => number;
|
|
294
|
+
/** Per-frame decode ceiling; default {@link DEFAULT_STREAM_MAX_BYTES}. */
|
|
295
|
+
readonly maxBytes?: number;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Verify the request's peer-key signature against the dialing peer, then admit it through the freshness +
|
|
300
|
+
* anti-replay guard. The dialing peer's id **is** the signer (no signer-id field on the wire); the signature
|
|
301
|
+
* bytes are the anti-replay key (globally unique + authenticated). Returns `false` (reject, no reply) on a
|
|
302
|
+
* bad signature, a stale/future timestamp, or a replay.
|
|
303
|
+
*/
|
|
304
|
+
function verifyAndAdmit(deps: RecoverServeDeps, signerId: string, payload: Uint8Array, signatureB64: string, timestamp: number, now: number): boolean {
|
|
305
|
+
const signature = b64urlToBytes(signatureB64);
|
|
306
|
+
if (!verifyPeerSig(signerId, payload, signature)) {
|
|
307
|
+
log("recover serve: signature verification failed for %s (no reply)", signerId);
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
if (!deps.replayGuard.accept(signature, peerIdToBytes(signerId), timestamp, now)) {
|
|
311
|
+
log("recover serve: replay/stale request from %s (no reply)", signerId);
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Serve a backfill from the collection's current served tail, or `undefined` if this node serves none. When
|
|
319
|
+
* the node serves **only** the outgoing (draining) tail, `rotationFor` returns the drain redirect and the
|
|
320
|
+
* reply is `kind: "rotated"` instead — a best-effort secondary path (the primary mechanism for an active
|
|
321
|
+
* subscriber is notify-driven detection). Once both tails coexist `pushStateForCollection` resolves the new
|
|
322
|
+
* tail, so the redirect is emitted only while the old tail is the sole served state (see §Tail rotation).
|
|
323
|
+
*/
|
|
324
|
+
function serveBackfillReply(deps: RecoverServeDeps, req: BackfillV1, now: number): Uint8Array | undefined {
|
|
325
|
+
const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
326
|
+
const redirect = deps.rotationFor?.({ collectionId: req.collectionId }, now);
|
|
327
|
+
if (redirect !== undefined) {
|
|
328
|
+
return encodeRecoverReplyV1({ v: 1, kind: "rotated", rotated: redirect }, maxBytes);
|
|
329
|
+
}
|
|
330
|
+
const ps = deps.pushStateForCollection(req.collectionId);
|
|
331
|
+
if (ps === undefined) {
|
|
332
|
+
return undefined; // not a serving member for this collection → no reply (subscriber walks/chain-reads)
|
|
333
|
+
}
|
|
334
|
+
const backfillReply = serveBackfill(ps.replayBuffer, req, ps.collectionId);
|
|
335
|
+
return encodeRecoverReplyV1({ v: 1, kind: "backfill", backfillReply }, maxBytes);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Serve a resume against the live `PushState`. A resume reaching the **outgoing (draining)** tail — its
|
|
340
|
+
* `latestKnownTailId` anchors a topic this node has marked rotated — is answered with the drain redirect
|
|
341
|
+
* (`kind: "rotated"`), moving the subscriber to the new tree. Otherwise prefer the exact topic the request's
|
|
342
|
+
* `latestKnownTailId` anchors (so a non-rotated subscriber classifies into backfill/checkpoint/out_of_window);
|
|
343
|
+
* if this node no longer serves that tail's topic, fall back to the collection's current tail so the cohort
|
|
344
|
+
* can still answer `tail_rotated` (its `currentTailId` differs from the request's stale tail) or, for a span
|
|
345
|
+
* that crosses a rotation, serve from the new tail's `inheritedCheckpoint`. `undefined` ⇒ no served state.
|
|
346
|
+
*/
|
|
347
|
+
function serveResumeReply(deps: RecoverServeDeps, req: ResumeV1, now: number): Uint8Array | undefined {
|
|
348
|
+
const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
349
|
+
const staleTopic = reactivityTopicId(b64urlToBytes(req.latestKnownTailId));
|
|
350
|
+
const redirect = deps.rotationFor?.({ topicId: staleTopic, collectionId: req.collectionId }, now);
|
|
351
|
+
if (redirect !== undefined) {
|
|
352
|
+
return encodeRecoverReplyV1({ v: 1, kind: "rotated", rotated: redirect }, maxBytes);
|
|
353
|
+
}
|
|
354
|
+
const ps = deps.pushStateFor(staleTopic) ?? deps.pushStateForCollection(req.collectionId);
|
|
355
|
+
if (ps === undefined) {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
const resumeReply = serveResume(req, {
|
|
359
|
+
buffer: ps.replayBuffer,
|
|
360
|
+
checkpoint: ps.checkpoint,
|
|
361
|
+
inheritedCheckpoint: ps.inheritedCheckpoint,
|
|
362
|
+
currentTailId: ps.tailIdAtJoin,
|
|
363
|
+
currentRevision: ps.lastRevision,
|
|
364
|
+
rotationRevision: ps.lastRevision,
|
|
365
|
+
expectedCollectionId: ps.collectionId,
|
|
366
|
+
});
|
|
367
|
+
return encodeRecoverReplyV1({ v: 1, kind: "resume", resumeReply }, maxBytes);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Build the recover serve callback for {@link handleRequestResponse}: it returns the reply frame, or
|
|
372
|
+
* `undefined` on a decode/verify/replay/resolve failure. It never throws out of the handler.
|
|
373
|
+
*
|
|
374
|
+
* NOTE: `undefined` reaches the dialer as an explicit zero-length frame, and
|
|
375
|
+
* {@link Libp2pReactivityRecoverTransport.exchange} decodes that as a *terminal* protocol error rather
|
|
376
|
+
* than falling through to the next cohort candidate — one member declining ends the whole recover walk.
|
|
377
|
+
* Tracked as `debt-stream-reply-no-result-untyped`.
|
|
378
|
+
*/
|
|
379
|
+
export function createRecoverRequestHandler(deps: RecoverServeDeps): (frame: Uint8Array, fromPeer: PeerId) => Promise<Uint8Array | undefined> {
|
|
380
|
+
const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
381
|
+
const clock = deps.clock ?? ((): number => Date.now());
|
|
382
|
+
return (frame: Uint8Array, fromPeer: PeerId): Promise<Uint8Array | undefined> => {
|
|
383
|
+
try {
|
|
384
|
+
const req = decodeRecoverRequestV1(frame, maxBytes);
|
|
385
|
+
const signerId = fromPeer.toString();
|
|
386
|
+
const now = clock();
|
|
387
|
+
if (req.kind === "backfill") {
|
|
388
|
+
const b = req.backfill;
|
|
389
|
+
if (b === undefined || !verifyAndAdmit(deps, signerId, backfillSigningPayload(b), b.signature, b.timestamp, now)) {
|
|
390
|
+
return Promise.resolve(undefined);
|
|
391
|
+
}
|
|
392
|
+
return Promise.resolve(serveBackfillReply(deps, b, now));
|
|
393
|
+
}
|
|
394
|
+
const r = req.resume;
|
|
395
|
+
if (r === undefined || !verifyAndAdmit(deps, signerId, resumeSigningPayload(r), r.signature, r.timestamp, now)) {
|
|
396
|
+
return Promise.resolve(undefined);
|
|
397
|
+
}
|
|
398
|
+
return Promise.resolve(serveResumeReply(deps, r, now));
|
|
399
|
+
} catch (err) {
|
|
400
|
+
// A malformed/foreign request (decode failure, foreign collectionId from serve*) must never throw
|
|
401
|
+
// out of the stream handler: log + no reply (a zero-length reply frame; see the note above).
|
|
402
|
+
log("recover serve: dropping request (no reply): %o", err);
|
|
403
|
+
return Promise.resolve(undefined);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Register the inbound recover protocol handler on `node` (request-reply over the recover protocol). */
|
|
409
|
+
export function registerRecoverHandler(node: Libp2p, protocol: string, deps: RecoverServeDeps): void {
|
|
410
|
+
const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
411
|
+
handleRequestResponse(node, protocol, createRecoverRequestHandler(deps), maxBytes);
|
|
412
|
+
}
|