@optimystic/db-core 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/README.md +336 -336
- package/dist/src/cluster/structs.d.ts +39 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js +24 -0
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/collection.d.ts +17 -0
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +24 -2
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +5 -0
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +7 -0
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/network/i-peer-network.d.ts +16 -0
- package/dist/src/network/i-peer-network.d.ts.map +1 -1
- package/dist/src/network/struct.d.ts +39 -2
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js +18 -0
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +95 -8
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +121 -8
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +1 -1
- package/dist/src/transaction/transaction.js +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +48 -13
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +25 -2
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/package.json +1 -1
- package/src/cluster/membership.ts +85 -85
- package/src/cluster/structs.ts +43 -4
- package/src/cohort-topic/addressing.ts +120 -120
- package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
- package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
- package/src/cohort-topic/antidos/index.ts +5 -5
- package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
- package/src/cohort-topic/antidos/replay-guard.ts +146 -146
- package/src/cohort-topic/antidos/topic-budget.ts +160 -160
- package/src/cohort-topic/antiflood/index.ts +2 -2
- package/src/cohort-topic/antiflood/invariants.ts +108 -108
- package/src/cohort-topic/antiflood/jitter.ts +117 -117
- package/src/cohort-topic/coldstart.ts +237 -237
- package/src/cohort-topic/dmax.ts +88 -88
- package/src/cohort-topic/gossip/bus.ts +254 -254
- package/src/cohort-topic/gossip/index.ts +3 -3
- package/src/cohort-topic/gossip/records.ts +45 -45
- package/src/cohort-topic/gossip/view.ts +91 -91
- package/src/cohort-topic/index.ts +20 -20
- package/src/cohort-topic/load/barometer.ts +134 -134
- package/src/cohort-topic/load/index.ts +1 -1
- package/src/cohort-topic/member-engine.ts +430 -430
- package/src/cohort-topic/membership/index.ts +3 -3
- package/src/cohort-topic/membership/publisher.ts +163 -163
- package/src/cohort-topic/membership/source.ts +41 -41
- package/src/cohort-topic/membership/verifier.ts +461 -461
- package/src/cohort-topic/ports.ts +157 -157
- package/src/cohort-topic/promotion.ts +405 -405
- package/src/cohort-topic/registration/bytes.ts +37 -37
- package/src/cohort-topic/registration/handoff.ts +154 -154
- package/src/cohort-topic/registration/index.ts +6 -6
- package/src/cohort-topic/registration/renewal.ts +495 -495
- package/src/cohort-topic/registration/sharding.ts +61 -61
- package/src/cohort-topic/registration/store.ts +81 -81
- package/src/cohort-topic/registration/types.ts +91 -91
- package/src/cohort-topic/ring-hash.ts +50 -50
- package/src/cohort-topic/service.ts +416 -416
- package/src/cohort-topic/sig/index.ts +2 -2
- package/src/cohort-topic/sig/payloads.ts +59 -59
- package/src/cohort-topic/sig/threshold.ts +64 -64
- package/src/cohort-topic/tiers.ts +74 -74
- package/src/cohort-topic/traffic.ts +233 -233
- package/src/cohort-topic/walk.ts +326 -326
- package/src/cohort-topic/willingness.ts +237 -237
- package/src/cohort-topic/wire/codec.ts +216 -216
- package/src/cohort-topic/wire/index.ts +18 -18
- package/src/cohort-topic/wire/payloads.ts +126 -126
- package/src/cohort-topic/wire/primitives.ts +188 -188
- package/src/cohort-topic/wire/types.ts +475 -475
- package/src/cohort-topic/wire/validate.ts +512 -512
- package/src/collection/collection-type-registry.ts +37 -37
- package/src/collection/collection.ts +25 -2
- package/src/collections/diary/diary.ts +68 -68
- package/src/collections/tree/readme.md +4 -0
- package/src/collections/tree/tree.ts +320 -312
- package/src/matchmaking/capability-filter.ts +45 -45
- package/src/matchmaking/config.ts +98 -98
- package/src/matchmaking/index.ts +21 -21
- package/src/matchmaking/multi-cohort-seeker.ts +234 -234
- package/src/matchmaking/provider.ts +123 -123
- package/src/matchmaking/query-eval.ts +105 -105
- package/src/matchmaking/seeker-walk.ts +127 -127
- package/src/matchmaking/seeker.ts +86 -86
- package/src/matchmaking/topic-anchor.ts +90 -90
- package/src/matchmaking/voting-quorum.ts +394 -394
- package/src/matchmaking/wire.ts +603 -603
- package/src/network/i-peer-network.ts +17 -0
- package/src/network/stale-failure.ts +43 -43
- package/src/network/struct.ts +41 -2
- package/src/network/types.ts +37 -37
- package/src/reactivity/backfill.ts +220 -220
- package/src/reactivity/backpressure.ts +191 -191
- package/src/reactivity/checkpoint.ts +308 -308
- package/src/reactivity/config.ts +172 -172
- package/src/reactivity/dedupe.ts +132 -132
- package/src/reactivity/forwarder.ts +87 -87
- package/src/reactivity/index.ts +34 -34
- package/src/reactivity/notification.ts +123 -123
- package/src/reactivity/policy.ts +79 -79
- package/src/reactivity/push-state.ts +310 -310
- package/src/reactivity/recover.ts +153 -153
- package/src/reactivity/replay-buffer.ts +141 -141
- package/src/reactivity/resume.ts +549 -549
- package/src/reactivity/rotation.ts +415 -415
- package/src/reactivity/subscriber.ts +132 -132
- package/src/reactivity/subscription.ts +66 -66
- package/src/reactivity/topic-anchor.ts +71 -71
- package/src/reactivity/verify.ts +73 -73
- package/src/reactivity/wire-validate.ts +13 -13
- package/src/reactivity/wire.ts +224 -224
- package/src/testing/async-wait.ts +65 -65
- package/src/testing/index.ts +2 -2
- package/src/testing/test-transactor.ts +638 -502
- package/src/transaction/errors.ts +91 -91
- package/src/transaction/operations-hash.ts +196 -196
- package/src/transaction/read-dependency-collector.ts +78 -78
- package/src/transaction/transaction.ts +1 -1
- package/src/transactor/change-notifier.ts +80 -80
- package/src/transactor/index.ts +5 -5
- package/src/transactor/network-transactor.ts +49 -14
- package/src/transactor/transactor-source.ts +25 -2
- package/src/transform/atomic-proxy.ts +92 -92
- package/src/transform/helpers.ts +159 -159
- package/src/utility/backoff.ts +95 -95
- package/src/utility/batch-coordinator.ts +191 -191
- package/dist/src/transaction/context.d.ts +0 -60
- package/dist/src/transaction/context.d.ts.map +0 -1
- package/dist/src/transaction/context.js +0 -91
- package/dist/src/transaction/context.js.map +0 -1
|
@@ -1,416 +1,416 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic substrate — participant-facing service composition.
|
|
3
|
-
*
|
|
4
|
-
* This is the substrate's public contract to applications (`docs/cohort-topic.md` §Application
|
|
5
|
-
* policies): given a `topicId` and a `tier`, reliably find a willing primary (or fail with a clear
|
|
6
|
-
* back-off), keep a registration alive within its TTL, and verify cohort identity/membership. It is
|
|
7
|
-
* the participant half of the substrate; the cohort half is {@link import("./member-engine.js").CohortMemberEngine}.
|
|
8
|
-
*
|
|
9
|
-
* The service is FRET-free: it drives the {@link ITopicRouter} (walk / register / direct-dial) and the
|
|
10
|
-
* other db-core ports by injection. db-p2p binds those ports to FRET + libp2p and constructs the
|
|
11
|
-
* service on the same node it runs the member engine on, so a node is simultaneously a participant and
|
|
12
|
-
* a cohort member (`docs/cohort-topic.md` §FRET integration — all four protocols on one node).
|
|
13
|
-
*
|
|
14
|
-
* Composition wired here: {@link WalkEngine} (lookup / register), the `d_max` computer + tier
|
|
15
|
-
* addressing, the participant-side {@link RenewalParticipant} (the `ttl/3` ping with crash-failover),
|
|
16
|
-
* and the application integration hooks ({@link CohortGossipBus}, {@link MembershipVerifier}). The
|
|
17
|
-
* cohort-side modules (willingness, promotion, traffic, store, membership publisher, anti-DoS) are
|
|
18
|
-
* assembled by the member engine the FRET host wires into the `RouteAndMaybeAct` activity callback.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { randomBytes } from "@noble/hashes/utils.js";
|
|
22
|
-
import { createTierAddressing, type TierAddressing } from "./addressing.js";
|
|
23
|
-
import { makeDMaxComputer, type DMaxComputer } from "./dmax.js";
|
|
24
|
-
import { DEFAULT_FANOUT } from "./addressing.js";
|
|
25
|
-
import type { ISizeEstimator, ITopicRouter, IRingHash, RingCoord } from "./ports.js";
|
|
26
|
-
import { createWalkEngine, type RegisterMessageFactory, type WalkEngine, type WalkOutcome } from "./walk.js";
|
|
27
|
-
import { createRenewalParticipant, type RenewalParticipant, type RenewalParticipantTransport, type UnsignedRenew } from "./registration/renewal.js";
|
|
28
|
-
import type { RegistrationRecord } from "./registration/types.js";
|
|
29
|
-
import { DEFAULT_TTL_MS } from "./registration/types.js";
|
|
30
|
-
import { recordKey } from "./registration/bytes.js";
|
|
31
|
-
import type { CohortGossipBus } from "./gossip/bus.js";
|
|
32
|
-
import type { MembershipVerifier } from "./membership/verifier.js";
|
|
33
|
-
import type { Tier } from "./tiers.js";
|
|
34
|
-
import { bytesToB64url, b64urlToBytes, decodeRenewReplyV1, encodeCohortMessage } from "./wire/codec.js";
|
|
35
|
-
import type { RegisterReplyV1, RegisterV1, RenewReplyV1, TopicTrafficV1 } from "./wire/types.js";
|
|
36
|
-
import type { CollectionChangeEvent, CommitCert } from "../transactor/change-notifier.js";
|
|
37
|
-
|
|
38
|
-
/** A resolved cohort for a topic/tier — the return of {@link CohortTopicService.lookup}. */
|
|
39
|
-
export interface CohortHint {
|
|
40
|
-
readonly topicId: Uint8Array;
|
|
41
|
-
readonly tier: Tier;
|
|
42
|
-
/** Serving cohort member. */
|
|
43
|
-
readonly primary: Uint8Array;
|
|
44
|
-
/** Warm-failover cohort members (1..2). */
|
|
45
|
-
readonly backups: Uint8Array[];
|
|
46
|
-
readonly cohortEpoch: Uint8Array;
|
|
47
|
-
/** Full cohort member set, for client-side caching. */
|
|
48
|
-
readonly cohortMembers: Uint8Array[];
|
|
49
|
-
/** Coarse traffic barometer, when the cohort attached one. */
|
|
50
|
-
readonly topicTraffic?: TopicTrafficV1;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** A live registration: a {@link CohortHint} plus the participant-side renewal handle behind it. */
|
|
54
|
-
export interface RegistrationHandle extends CohortHint {
|
|
55
|
-
/** Internal renewal driver (the `ttl/3` ping loop). Opaque to applications. */
|
|
56
|
-
readonly renewal: RenewalParticipant;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* The substrate's **origination hook**: a commit that lands on a node which is a cohort member for the
|
|
61
|
-
* collection's reactivity topic is delivered here by the local change-notifier bridge
|
|
62
|
-
* (`local-change-notifier-bridge`), carrying the raw {@link CollectionChangeEvent} and the
|
|
63
|
-
* pass-through {@link CommitCert} (extracted from cluster consensus, forwarded UNCHANGED). Reactivity
|
|
64
|
-
* (and later matchmaking) set this hook to fan the event out — reusing `commitCert.thresholdSig`
|
|
65
|
-
* directly, never re-signing. The bridge swallows + logs any throw so origination can never break the
|
|
66
|
-
* commit.
|
|
67
|
-
*/
|
|
68
|
-
export type LocalChangeHook = (event: CollectionChangeEvent, commitCert: CommitCert) => void;
|
|
69
|
-
|
|
70
|
-
/** Thrown when the substrate cannot place a registration right now; carries the back-off delay. */
|
|
71
|
-
export class CohortBackoffError extends Error {
|
|
72
|
-
constructor(readonly afterMs: number) {
|
|
73
|
-
super(`cohort-topic: no willing primary right now; retry after ${afterMs}ms`);
|
|
74
|
-
this.name = "CohortBackoffError";
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** A registration request (`docs/cohort-topic.md` §Application policies). */
|
|
79
|
-
export interface RegisterRequest {
|
|
80
|
-
readonly topicId: Uint8Array;
|
|
81
|
-
readonly tier: Tier;
|
|
82
|
-
/** Opaque application slot (reactivity / matchmaking define the contents). */
|
|
83
|
-
readonly appPayload?: Uint8Array;
|
|
84
|
-
/** Registration TTL (ms); defaults to the tier default. */
|
|
85
|
-
readonly ttl?: number;
|
|
86
|
-
/** Mark this a cold-root bootstrap request. */
|
|
87
|
-
readonly bootstrap?: boolean;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** The substrate's participant-facing contract. */
|
|
91
|
-
export interface CohortTopicService {
|
|
92
|
-
/** Walk → register for `topicId` at `tier`; resolves a live {@link RegistrationHandle} or throws {@link CohortBackoffError}. */
|
|
93
|
-
register(req: RegisterRequest): Promise<RegistrationHandle>;
|
|
94
|
-
/** Run one `ttl/3` renewal cycle for `handle` (handles `primary_moved` + crash-failover). */
|
|
95
|
-
renew(handle: RegistrationHandle): Promise<void>;
|
|
96
|
-
/** Resolve the cohort for `topicId` at `tier` without keeping a live registration. */
|
|
97
|
-
lookup(topicId: Uint8Array, tier: Tier): Promise<CohortHint>;
|
|
98
|
-
/** Stop renewing `handle` and send a best-effort signed withdraw tombstone so the cohort frees the
|
|
99
|
-
* record immediately (TTL expiry remains the fallback if the primary is unreachable). */
|
|
100
|
-
withdraw(handle: RegistrationHandle): Promise<void>;
|
|
101
|
-
/** Origination hook the change-notifier bridge invokes per local member commit; see {@link LocalChangeHook}. */
|
|
102
|
-
onLocalCommit?: LocalChangeHook;
|
|
103
|
-
/** Cohort gossip bus — applications fold app state into the existing gossip. */
|
|
104
|
-
cohortGossip(): CohortGossipBus;
|
|
105
|
-
/** Membership verifier — applications verify threshold-signed app messages. */
|
|
106
|
-
verifier(): MembershipVerifier;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Signs the participant's outbound `RegisterV1` / `RenewV1` bodies (db-p2p supplies the peer key).
|
|
111
|
-
*
|
|
112
|
-
* Async because the underlying libp2p `PrivateKey.sign` is async; both call sites already `await`
|
|
113
|
-
* (`messageFactory.build` and the renewal `sign` hook). The signature is over the canonical body
|
|
114
|
-
* image ({@link import("./wire/payloads.js").registerSigningPayload} /
|
|
115
|
-
* {@link import("./wire/payloads.js").renewSigningPayload}) so a cohort member can recompute and
|
|
116
|
-
* verify it against the participant's peer key.
|
|
117
|
-
*/
|
|
118
|
-
export interface ParticipantSigner {
|
|
119
|
-
/** Sign a `RegisterV1` (minus its signature); resolves the base64url signature. */
|
|
120
|
-
signRegister(body: Omit<RegisterV1, "signature">): Promise<string>;
|
|
121
|
-
/** Sign a `RenewV1` (minus its signature); resolves the base64url signature. */
|
|
122
|
-
signRenew(body: UnsignedRenew): Promise<string>;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Per-service tunables (cohort size, threshold, fan-out, TTL); all optional. */
|
|
126
|
-
export interface CohortServiceConfig {
|
|
127
|
-
/** Fan-out per tier `F`. Default {@link DEFAULT_FANOUT}. */
|
|
128
|
-
readonly fanout?: number;
|
|
129
|
-
/** Requested cohort size `wantK`. Default 16. */
|
|
130
|
-
readonly wantK?: number;
|
|
131
|
-
/** Threshold signers `minSigs = k − x`. Default 14. */
|
|
132
|
-
readonly minSigs?: number;
|
|
133
|
-
/** Default registration TTL (ms). Default {@link DEFAULT_TTL_MS}. */
|
|
134
|
-
readonly ttl?: number;
|
|
135
|
-
/** Frame ceiling for encode/decode. Defaults to the codec default. */
|
|
136
|
-
readonly maxMessageBytes?: number;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export interface CohortTopicServiceDeps {
|
|
140
|
-
/** This participant's peer id (the `P` in `coord_d(P, topicId)`). */
|
|
141
|
-
readonly self: Uint8Array;
|
|
142
|
-
/** Hash + ring math (db-core's own SHA-256). */
|
|
143
|
-
readonly hash: IRingHash;
|
|
144
|
-
/** FRET-backed router (walk / register / direct-dial). */
|
|
145
|
-
readonly router: ITopicRouter;
|
|
146
|
-
/** FRET-backed network-size estimator feeding `d_max`. */
|
|
147
|
-
readonly sizeEstimator: ISizeEstimator;
|
|
148
|
-
/** Participant body signer. */
|
|
149
|
-
readonly signer: ParticipantSigner;
|
|
150
|
-
/** Cohort gossip bus (the host constructs it for this node's cohort). */
|
|
151
|
-
readonly gossipBus: CohortGossipBus;
|
|
152
|
-
/** Participant-side membership verifier. */
|
|
153
|
-
readonly verifier: MembershipVerifier;
|
|
154
|
-
/** Monotonic-ish wall clock (unix ms); injectable for tests. Default `Date.now`. */
|
|
155
|
-
readonly clock?: () => number;
|
|
156
|
-
/**
|
|
157
|
-
* Optional cold-start bootstrap-evidence builder (db-p2p supplies it). Invoked on **either** cold-start
|
|
158
|
-
* re-issue — the root `bootstrap: true` or the deeper-tier `followOn: true` (both gated by the same
|
|
159
|
-
* evidence policy) — with the register's own canonical fields, the same
|
|
160
|
-
* `(topicId, tier, participantCoord, timestamp)` tuple a verifier binds via
|
|
161
|
-
* `bootstrapBoundImage`. It returns the **raw** envelope JSON bytes — `utf8(JSON.stringify(env))`,
|
|
162
|
-
* NOT the already-base64url string from `serializeBootstrapEvidenceEnvelope` — or `undefined` to
|
|
163
|
-
* attach none; the service base64url-encodes those bytes into `RegisterV1.bootstrapEvidence`
|
|
164
|
-
* **before** the body is signed, so the participant signature covers them and a MITM cannot strip or
|
|
165
|
-
* swap the proof. (Returning the `serialize()` string's bytes would double-encode the field.)
|
|
166
|
-
* Absent (default) → no evidence is attached, exactly today's behavior. The minting logic (PoW
|
|
167
|
-
* search, reputation/parent-ref signing) is the db-p2p follow-on.
|
|
168
|
-
*/
|
|
169
|
-
readonly buildBootstrapEvidence?: (params: {
|
|
170
|
-
readonly topicId: string;
|
|
171
|
-
readonly tier: number;
|
|
172
|
-
readonly participantCoord: string;
|
|
173
|
-
readonly timestamp: number;
|
|
174
|
-
}) => Promise<Uint8Array | undefined>;
|
|
175
|
-
readonly config?: CohortServiceConfig;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
class WalkRegisterService implements CohortTopicService {
|
|
179
|
-
public onLocalCommit?: LocalChangeHook;
|
|
180
|
-
|
|
181
|
-
private readonly addressing: TierAddressing;
|
|
182
|
-
private readonly dmax: DMaxComputer;
|
|
183
|
-
private readonly walk: WalkEngine;
|
|
184
|
-
private readonly clock: () => number;
|
|
185
|
-
private readonly ttl: number;
|
|
186
|
-
private readonly maxMessageBytes?: number;
|
|
187
|
-
/** Live renewal drivers, keyed by `(topicId, participantId)` so renew/withdraw find their handle. */
|
|
188
|
-
private readonly renewals = new Map<string, RenewalParticipant>();
|
|
189
|
-
private readonly participantId: Uint8Array;
|
|
190
|
-
|
|
191
|
-
constructor(private readonly deps: CohortTopicServiceDeps) {
|
|
192
|
-
const cfg = deps.config ?? {};
|
|
193
|
-
const fanout = cfg.fanout ?? DEFAULT_FANOUT;
|
|
194
|
-
this.clock = deps.clock ?? ((): number => Date.now());
|
|
195
|
-
this.ttl = cfg.ttl ?? DEFAULT_TTL_MS;
|
|
196
|
-
this.maxMessageBytes = cfg.maxMessageBytes;
|
|
197
|
-
// The participant identity carried on the wire (`participantCoord` on register, `participantId`
|
|
198
|
-
// on renew) IS `self` — the dialable peer id db-p2p supplies via its peer-codec. It is the
|
|
199
|
-
// record key the cohort stores AND the signer id its peer-key signature verifies against, so it
|
|
200
|
-
// must round-trip back to a peer id rather than being re-hashed here (§Tier addressing names the
|
|
201
|
-
// routing coord `P = self` directly; slot assignment hashes it internally for uniformity).
|
|
202
|
-
this.participantId = deps.self;
|
|
203
|
-
this.addressing = createTierAddressing(deps.hash, fanout);
|
|
204
|
-
this.dmax = makeDMaxComputer({ estimator: deps.sizeEstimator, F: fanout });
|
|
205
|
-
this.walk = createWalkEngine({
|
|
206
|
-
router: deps.router,
|
|
207
|
-
addressing: this.addressing,
|
|
208
|
-
dmax: this.dmax,
|
|
209
|
-
self: deps.self,
|
|
210
|
-
factory: this.messageFactory(),
|
|
211
|
-
config: { wantK: cfg.wantK, minSigs: cfg.minSigs, maxMessageBytes: cfg.maxMessageBytes },
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
async register(req: RegisterRequest): Promise<RegistrationHandle> {
|
|
216
|
-
const outcome = await this.walk.register(req.topicId, req.tier, req.appPayload);
|
|
217
|
-
return this.handleFromOutcome(req, outcome);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async lookup(topicId: Uint8Array, tier: Tier): Promise<CohortHint> {
|
|
221
|
-
// A read-only probe: it walks to the responsible cohort exactly as a register would and returns the
|
|
222
|
-
// same cohort snapshot, but admits NOTHING — no soft-state record, no arrival, no promotion trigger,
|
|
223
|
-
// no topic-budget touch, and never a cold-start instantiation. A topic served nowhere resolves to a
|
|
224
|
-
// `CohortBackoffError` (the probe never bootstraps a cold root), so a lookup leaves no throwaway
|
|
225
|
-
// registration behind to TTL-expire (§Application policies; the lookup-as-register interim is gone).
|
|
226
|
-
const outcome = await this.walk.register(topicId, tier, undefined, { probe: true });
|
|
227
|
-
if (outcome.kind !== "accepted") {
|
|
228
|
-
throw new CohortBackoffError(outcome.kind === "retry_later" ? outcome.afterMs : 0);
|
|
229
|
-
}
|
|
230
|
-
return this.hintFromReply(topicId, tier, outcome.reply);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async renew(handle: RegistrationHandle): Promise<void> {
|
|
234
|
-
// Act only if this handle is still the live entry. A stale handle (superseded by a second
|
|
235
|
-
// register() for the same pair) must not drive the new registration's ping loop, and a
|
|
236
|
-
// withdrawn handle must not silently re-start it.
|
|
237
|
-
if (this.renewals.get(recordKey(handle.topicId, this.participantId)) !== handle.renewal) {
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
await handle.renewal.pingLoop();
|
|
241
|
-
this.syncHandle(handle);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
async withdraw(handle: RegistrationHandle): Promise<void> {
|
|
245
|
-
// Two halves: (1) drop the handle from the live set so further renew() pings no-op (see renew()),
|
|
246
|
-
// stopping the local ping loop; (2) fire a best-effort signed withdraw tombstone to the current
|
|
247
|
-
// primary so the cohort frees the record immediately instead of holding it for up to a full TTL.
|
|
248
|
-
// The delete happens FIRST so a concurrent renew() already no-ops before the tombstone is sent. If
|
|
249
|
-
// the tombstone send fails (primary unreachable), the cohort soft-state TTL-expires as the
|
|
250
|
-
// fallback — withdraw never throws on a transport failure. Idempotent: a second withdraw finds
|
|
251
|
-
// `renewal !== handle.renewal` (undefined ≠ any object) and no-ops.
|
|
252
|
-
// Guard: act only if this handle is still the live entry. A stale handle must not evict the
|
|
253
|
-
// new registration or send a tombstone on its behalf.
|
|
254
|
-
const key = recordKey(handle.topicId, this.participantId);
|
|
255
|
-
const renewal = this.renewals.get(key);
|
|
256
|
-
if (renewal !== handle.renewal) {
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
this.renewals.delete(key);
|
|
260
|
-
await renewal.withdraw();
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
cohortGossip(): CohortGossipBus {
|
|
264
|
-
return this.deps.gossipBus;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
verifier(): MembershipVerifier {
|
|
268
|
-
return this.deps.verifier;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// --- internals ---
|
|
272
|
-
|
|
273
|
-
private handleFromOutcome(req: RegisterRequest, outcome: WalkOutcome): RegistrationHandle {
|
|
274
|
-
if (outcome.kind !== "accepted") {
|
|
275
|
-
throw new CohortBackoffError(outcome.kind === "retry_later" ? outcome.afterMs : 0);
|
|
276
|
-
}
|
|
277
|
-
const hint = this.hintFromReply(req.topicId, req.tier, outcome.reply);
|
|
278
|
-
const renewal = this.startRenewal(req, hint, outcome.correlationId);
|
|
279
|
-
return { ...hint, renewal };
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
private startRenewal(req: RegisterRequest, hint: CohortHint, correlationId: string): RenewalParticipant {
|
|
283
|
-
const ttl = req.ttl ?? this.ttl;
|
|
284
|
-
const initial: RegistrationRecord = {
|
|
285
|
-
topicId: req.topicId,
|
|
286
|
-
participantId: this.participantId,
|
|
287
|
-
tier: req.tier,
|
|
288
|
-
primary: hint.primary,
|
|
289
|
-
backups: hint.backups,
|
|
290
|
-
attachedAt: this.clock(),
|
|
291
|
-
lastPing: this.clock(),
|
|
292
|
-
ttl,
|
|
293
|
-
appState: req.appPayload,
|
|
294
|
-
};
|
|
295
|
-
const transport = this.renewalTransport(req);
|
|
296
|
-
const renewal = createRenewalParticipant(initial, {
|
|
297
|
-
transport,
|
|
298
|
-
clock: this.clock,
|
|
299
|
-
sign: (body: UnsignedRenew): Promise<string> => this.deps.signer.signRenew(body),
|
|
300
|
-
// Echo the accepted RegisterV1's correlationId: RenewV1 `correlationId` "matches original
|
|
301
|
-
// RegisterV1" (docs §Wire, RenewV1), so a future renew-path freshness/replay guard can correlate
|
|
302
|
-
// a renew back to the registration it renews.
|
|
303
|
-
// NOTE: every periodic renew for this registration shares this ONE correlationId (as it always
|
|
304
|
-
// has — this only makes the shared id equal the register's, not an independent nonce). A future
|
|
305
|
-
// renew replay guard must therefore key renews by (correlationId, timestamp-window), NOT "drop
|
|
306
|
-
// any repeated correlationId" — else it would reject the 2nd+ legitimate renew.
|
|
307
|
-
correlationId,
|
|
308
|
-
initialCohortEpoch: hint.cohortEpoch,
|
|
309
|
-
});
|
|
310
|
-
// NOTE: a second register() for the same (topicId, participantId) overwrites the map entry here,
|
|
311
|
-
// orphaning the prior renewal. renew()/withdraw() on the stale handle now no-op (identity guard),
|
|
312
|
-
// but no tombstone is sent for the superseded record — the cohort frees it via TTL expiry. Harmless
|
|
313
|
-
// for occasional re-registers; if callers ever churn-register the same pair, send a withdraw for the
|
|
314
|
-
// displaced renewal here so the cohort reclaims immediately instead of holding up to a full TTL.
|
|
315
|
-
this.renewals.set(recordKey(req.topicId, this.participantId), renewal);
|
|
316
|
-
return renewal;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** Renewal transport: dial the cached primary directly; a full failure re-runs the register walk. */
|
|
320
|
-
private renewalTransport(req: RegisterRequest): RenewalParticipantTransport {
|
|
321
|
-
return {
|
|
322
|
-
send: async (target: Uint8Array, msg): Promise<RenewReplyV1> => {
|
|
323
|
-
const raw = await this.deps.router.dialMember({ id: target }, encodeCohortMessage(msg, this.maxMessageBytes));
|
|
324
|
-
return decodeRenewReplyV1(raw, this.maxMessageBytes);
|
|
325
|
-
},
|
|
326
|
-
relookup: async (): Promise<void> => {
|
|
327
|
-
await this.walk.register(req.topicId, req.tier, req.appPayload);
|
|
328
|
-
},
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
private hintFromReply(topicId: Uint8Array, tier: Tier, reply: RegisterReplyV1): CohortHint {
|
|
333
|
-
if (reply.primary === undefined || reply.cohortEpoch === undefined) {
|
|
334
|
-
throw new CohortBackoffError(0);
|
|
335
|
-
}
|
|
336
|
-
return {
|
|
337
|
-
topicId,
|
|
338
|
-
tier,
|
|
339
|
-
primary: b64urlToBytes(reply.primary),
|
|
340
|
-
backups: (reply.backups ?? []).map(b64urlToBytes),
|
|
341
|
-
cohortEpoch: b64urlToBytes(reply.cohortEpoch),
|
|
342
|
-
cohortMembers: (reply.cohortMembers ?? []).map(b64urlToBytes),
|
|
343
|
-
topicTraffic: reply.topicTraffic,
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
private syncHandle(handle: RegistrationHandle): void {
|
|
348
|
-
const rec = handle.renewal.record;
|
|
349
|
-
(handle as { primary: Uint8Array }).primary = rec.primary;
|
|
350
|
-
(handle as { backups: Uint8Array[] }).backups = [...rec.backups];
|
|
351
|
-
const epoch = handle.renewal.cohortEpochHint;
|
|
352
|
-
if (epoch !== undefined) {
|
|
353
|
-
(handle as { cohortEpoch: Uint8Array }).cohortEpoch = epoch;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** The per-probe `RegisterV1` builder: stamps participant coord/ttl/correlation and signs. */
|
|
358
|
-
private messageFactory(): RegisterMessageFactory {
|
|
359
|
-
const participantCoord = bytesToB64url(this.participantId);
|
|
360
|
-
return {
|
|
361
|
-
build: async (params): Promise<RegisterV1> => {
|
|
362
|
-
const body: Omit<RegisterV1, "signature"> = {
|
|
363
|
-
v: 1,
|
|
364
|
-
topicId: bytesToB64url(params.topicId),
|
|
365
|
-
tier: params.tier,
|
|
366
|
-
treeTier: params.treeTier,
|
|
367
|
-
participantCoord,
|
|
368
|
-
ttl: this.ttl,
|
|
369
|
-
timestamp: this.clock(),
|
|
370
|
-
correlationId: this.freshCorrelationId(),
|
|
371
|
-
};
|
|
372
|
-
if (params.probe) {
|
|
373
|
-
// A read-only lookup probe. Mutually exclusive with bootstrap/followOn (the walk never sets
|
|
374
|
-
// either on a probe), so the cold-start-evidence branch below cannot run for a probe.
|
|
375
|
-
body.probe = true;
|
|
376
|
-
}
|
|
377
|
-
// A cold-start re-issue — root (`bootstrap`) or deeper-tier follow-on (`followOn`). Both are
|
|
378
|
-
// gated by the identical evidence policy (§Anti-DoS), so both mint and attach the same envelope
|
|
379
|
-
// bound to the body's own canonical fields. Mutually exclusive with each other and with probe.
|
|
380
|
-
if (params.bootstrap || params.followOn) {
|
|
381
|
-
if (params.followOn) {
|
|
382
|
-
body.followOn = true;
|
|
383
|
-
} else {
|
|
384
|
-
body.bootstrap = true;
|
|
385
|
-
}
|
|
386
|
-
// Attach cold-start evidence BEFORE signing (so the signature covers it). Bind the
|
|
387
|
-
// builder to the body's own canonical fields — the exact tuple a verifier reconstructs.
|
|
388
|
-
const evidence = await this.deps.buildBootstrapEvidence?.({
|
|
389
|
-
topicId: body.topicId,
|
|
390
|
-
tier: body.tier,
|
|
391
|
-
participantCoord: body.participantCoord,
|
|
392
|
-
timestamp: body.timestamp,
|
|
393
|
-
});
|
|
394
|
-
if (evidence !== undefined && evidence.length > 0) {
|
|
395
|
-
body.bootstrapEvidence = bytesToB64url(evidence);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
if (params.appPayload !== undefined) {
|
|
399
|
-
body.appPayload = bytesToB64url(params.appPayload);
|
|
400
|
-
}
|
|
401
|
-
return { ...body, signature: await this.deps.signer.signRegister(body) };
|
|
402
|
-
},
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/** 16 fresh CSPRNG bytes, base64url. A correlation id must be unique per probe — the replay guard
|
|
407
|
-
* keys on it — so it cannot be derived from the clock (two probes in the same ms would collide). */
|
|
408
|
-
private freshCorrelationId(): string {
|
|
409
|
-
return bytesToB64url(randomBytes(16));
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/** Build the participant-facing {@link CohortTopicService} over the injected ports. */
|
|
414
|
-
export function createCohortTopicService(deps: CohortTopicServiceDeps): CohortTopicService {
|
|
415
|
-
return new WalkRegisterService(deps);
|
|
416
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic substrate — participant-facing service composition.
|
|
3
|
+
*
|
|
4
|
+
* This is the substrate's public contract to applications (`docs/cohort-topic.md` §Application
|
|
5
|
+
* policies): given a `topicId` and a `tier`, reliably find a willing primary (or fail with a clear
|
|
6
|
+
* back-off), keep a registration alive within its TTL, and verify cohort identity/membership. It is
|
|
7
|
+
* the participant half of the substrate; the cohort half is {@link import("./member-engine.js").CohortMemberEngine}.
|
|
8
|
+
*
|
|
9
|
+
* The service is FRET-free: it drives the {@link ITopicRouter} (walk / register / direct-dial) and the
|
|
10
|
+
* other db-core ports by injection. db-p2p binds those ports to FRET + libp2p and constructs the
|
|
11
|
+
* service on the same node it runs the member engine on, so a node is simultaneously a participant and
|
|
12
|
+
* a cohort member (`docs/cohort-topic.md` §FRET integration — all four protocols on one node).
|
|
13
|
+
*
|
|
14
|
+
* Composition wired here: {@link WalkEngine} (lookup / register), the `d_max` computer + tier
|
|
15
|
+
* addressing, the participant-side {@link RenewalParticipant} (the `ttl/3` ping with crash-failover),
|
|
16
|
+
* and the application integration hooks ({@link CohortGossipBus}, {@link MembershipVerifier}). The
|
|
17
|
+
* cohort-side modules (willingness, promotion, traffic, store, membership publisher, anti-DoS) are
|
|
18
|
+
* assembled by the member engine the FRET host wires into the `RouteAndMaybeAct` activity callback.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { randomBytes } from "@noble/hashes/utils.js";
|
|
22
|
+
import { createTierAddressing, type TierAddressing } from "./addressing.js";
|
|
23
|
+
import { makeDMaxComputer, type DMaxComputer } from "./dmax.js";
|
|
24
|
+
import { DEFAULT_FANOUT } from "./addressing.js";
|
|
25
|
+
import type { ISizeEstimator, ITopicRouter, IRingHash, RingCoord } from "./ports.js";
|
|
26
|
+
import { createWalkEngine, type RegisterMessageFactory, type WalkEngine, type WalkOutcome } from "./walk.js";
|
|
27
|
+
import { createRenewalParticipant, type RenewalParticipant, type RenewalParticipantTransport, type UnsignedRenew } from "./registration/renewal.js";
|
|
28
|
+
import type { RegistrationRecord } from "./registration/types.js";
|
|
29
|
+
import { DEFAULT_TTL_MS } from "./registration/types.js";
|
|
30
|
+
import { recordKey } from "./registration/bytes.js";
|
|
31
|
+
import type { CohortGossipBus } from "./gossip/bus.js";
|
|
32
|
+
import type { MembershipVerifier } from "./membership/verifier.js";
|
|
33
|
+
import type { Tier } from "./tiers.js";
|
|
34
|
+
import { bytesToB64url, b64urlToBytes, decodeRenewReplyV1, encodeCohortMessage } from "./wire/codec.js";
|
|
35
|
+
import type { RegisterReplyV1, RegisterV1, RenewReplyV1, TopicTrafficV1 } from "./wire/types.js";
|
|
36
|
+
import type { CollectionChangeEvent, CommitCert } from "../transactor/change-notifier.js";
|
|
37
|
+
|
|
38
|
+
/** A resolved cohort for a topic/tier — the return of {@link CohortTopicService.lookup}. */
|
|
39
|
+
export interface CohortHint {
|
|
40
|
+
readonly topicId: Uint8Array;
|
|
41
|
+
readonly tier: Tier;
|
|
42
|
+
/** Serving cohort member. */
|
|
43
|
+
readonly primary: Uint8Array;
|
|
44
|
+
/** Warm-failover cohort members (1..2). */
|
|
45
|
+
readonly backups: Uint8Array[];
|
|
46
|
+
readonly cohortEpoch: Uint8Array;
|
|
47
|
+
/** Full cohort member set, for client-side caching. */
|
|
48
|
+
readonly cohortMembers: Uint8Array[];
|
|
49
|
+
/** Coarse traffic barometer, when the cohort attached one. */
|
|
50
|
+
readonly topicTraffic?: TopicTrafficV1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A live registration: a {@link CohortHint} plus the participant-side renewal handle behind it. */
|
|
54
|
+
export interface RegistrationHandle extends CohortHint {
|
|
55
|
+
/** Internal renewal driver (the `ttl/3` ping loop). Opaque to applications. */
|
|
56
|
+
readonly renewal: RenewalParticipant;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The substrate's **origination hook**: a commit that lands on a node which is a cohort member for the
|
|
61
|
+
* collection's reactivity topic is delivered here by the local change-notifier bridge
|
|
62
|
+
* (`local-change-notifier-bridge`), carrying the raw {@link CollectionChangeEvent} and the
|
|
63
|
+
* pass-through {@link CommitCert} (extracted from cluster consensus, forwarded UNCHANGED). Reactivity
|
|
64
|
+
* (and later matchmaking) set this hook to fan the event out — reusing `commitCert.thresholdSig`
|
|
65
|
+
* directly, never re-signing. The bridge swallows + logs any throw so origination can never break the
|
|
66
|
+
* commit.
|
|
67
|
+
*/
|
|
68
|
+
export type LocalChangeHook = (event: CollectionChangeEvent, commitCert: CommitCert) => void;
|
|
69
|
+
|
|
70
|
+
/** Thrown when the substrate cannot place a registration right now; carries the back-off delay. */
|
|
71
|
+
export class CohortBackoffError extends Error {
|
|
72
|
+
constructor(readonly afterMs: number) {
|
|
73
|
+
super(`cohort-topic: no willing primary right now; retry after ${afterMs}ms`);
|
|
74
|
+
this.name = "CohortBackoffError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A registration request (`docs/cohort-topic.md` §Application policies). */
|
|
79
|
+
export interface RegisterRequest {
|
|
80
|
+
readonly topicId: Uint8Array;
|
|
81
|
+
readonly tier: Tier;
|
|
82
|
+
/** Opaque application slot (reactivity / matchmaking define the contents). */
|
|
83
|
+
readonly appPayload?: Uint8Array;
|
|
84
|
+
/** Registration TTL (ms); defaults to the tier default. */
|
|
85
|
+
readonly ttl?: number;
|
|
86
|
+
/** Mark this a cold-root bootstrap request. */
|
|
87
|
+
readonly bootstrap?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The substrate's participant-facing contract. */
|
|
91
|
+
export interface CohortTopicService {
|
|
92
|
+
/** Walk → register for `topicId` at `tier`; resolves a live {@link RegistrationHandle} or throws {@link CohortBackoffError}. */
|
|
93
|
+
register(req: RegisterRequest): Promise<RegistrationHandle>;
|
|
94
|
+
/** Run one `ttl/3` renewal cycle for `handle` (handles `primary_moved` + crash-failover). */
|
|
95
|
+
renew(handle: RegistrationHandle): Promise<void>;
|
|
96
|
+
/** Resolve the cohort for `topicId` at `tier` without keeping a live registration. */
|
|
97
|
+
lookup(topicId: Uint8Array, tier: Tier): Promise<CohortHint>;
|
|
98
|
+
/** Stop renewing `handle` and send a best-effort signed withdraw tombstone so the cohort frees the
|
|
99
|
+
* record immediately (TTL expiry remains the fallback if the primary is unreachable). */
|
|
100
|
+
withdraw(handle: RegistrationHandle): Promise<void>;
|
|
101
|
+
/** Origination hook the change-notifier bridge invokes per local member commit; see {@link LocalChangeHook}. */
|
|
102
|
+
onLocalCommit?: LocalChangeHook;
|
|
103
|
+
/** Cohort gossip bus — applications fold app state into the existing gossip. */
|
|
104
|
+
cohortGossip(): CohortGossipBus;
|
|
105
|
+
/** Membership verifier — applications verify threshold-signed app messages. */
|
|
106
|
+
verifier(): MembershipVerifier;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Signs the participant's outbound `RegisterV1` / `RenewV1` bodies (db-p2p supplies the peer key).
|
|
111
|
+
*
|
|
112
|
+
* Async because the underlying libp2p `PrivateKey.sign` is async; both call sites already `await`
|
|
113
|
+
* (`messageFactory.build` and the renewal `sign` hook). The signature is over the canonical body
|
|
114
|
+
* image ({@link import("./wire/payloads.js").registerSigningPayload} /
|
|
115
|
+
* {@link import("./wire/payloads.js").renewSigningPayload}) so a cohort member can recompute and
|
|
116
|
+
* verify it against the participant's peer key.
|
|
117
|
+
*/
|
|
118
|
+
export interface ParticipantSigner {
|
|
119
|
+
/** Sign a `RegisterV1` (minus its signature); resolves the base64url signature. */
|
|
120
|
+
signRegister(body: Omit<RegisterV1, "signature">): Promise<string>;
|
|
121
|
+
/** Sign a `RenewV1` (minus its signature); resolves the base64url signature. */
|
|
122
|
+
signRenew(body: UnsignedRenew): Promise<string>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Per-service tunables (cohort size, threshold, fan-out, TTL); all optional. */
|
|
126
|
+
export interface CohortServiceConfig {
|
|
127
|
+
/** Fan-out per tier `F`. Default {@link DEFAULT_FANOUT}. */
|
|
128
|
+
readonly fanout?: number;
|
|
129
|
+
/** Requested cohort size `wantK`. Default 16. */
|
|
130
|
+
readonly wantK?: number;
|
|
131
|
+
/** Threshold signers `minSigs = k − x`. Default 14. */
|
|
132
|
+
readonly minSigs?: number;
|
|
133
|
+
/** Default registration TTL (ms). Default {@link DEFAULT_TTL_MS}. */
|
|
134
|
+
readonly ttl?: number;
|
|
135
|
+
/** Frame ceiling for encode/decode. Defaults to the codec default. */
|
|
136
|
+
readonly maxMessageBytes?: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface CohortTopicServiceDeps {
|
|
140
|
+
/** This participant's peer id (the `P` in `coord_d(P, topicId)`). */
|
|
141
|
+
readonly self: Uint8Array;
|
|
142
|
+
/** Hash + ring math (db-core's own SHA-256). */
|
|
143
|
+
readonly hash: IRingHash;
|
|
144
|
+
/** FRET-backed router (walk / register / direct-dial). */
|
|
145
|
+
readonly router: ITopicRouter;
|
|
146
|
+
/** FRET-backed network-size estimator feeding `d_max`. */
|
|
147
|
+
readonly sizeEstimator: ISizeEstimator;
|
|
148
|
+
/** Participant body signer. */
|
|
149
|
+
readonly signer: ParticipantSigner;
|
|
150
|
+
/** Cohort gossip bus (the host constructs it for this node's cohort). */
|
|
151
|
+
readonly gossipBus: CohortGossipBus;
|
|
152
|
+
/** Participant-side membership verifier. */
|
|
153
|
+
readonly verifier: MembershipVerifier;
|
|
154
|
+
/** Monotonic-ish wall clock (unix ms); injectable for tests. Default `Date.now`. */
|
|
155
|
+
readonly clock?: () => number;
|
|
156
|
+
/**
|
|
157
|
+
* Optional cold-start bootstrap-evidence builder (db-p2p supplies it). Invoked on **either** cold-start
|
|
158
|
+
* re-issue — the root `bootstrap: true` or the deeper-tier `followOn: true` (both gated by the same
|
|
159
|
+
* evidence policy) — with the register's own canonical fields, the same
|
|
160
|
+
* `(topicId, tier, participantCoord, timestamp)` tuple a verifier binds via
|
|
161
|
+
* `bootstrapBoundImage`. It returns the **raw** envelope JSON bytes — `utf8(JSON.stringify(env))`,
|
|
162
|
+
* NOT the already-base64url string from `serializeBootstrapEvidenceEnvelope` — or `undefined` to
|
|
163
|
+
* attach none; the service base64url-encodes those bytes into `RegisterV1.bootstrapEvidence`
|
|
164
|
+
* **before** the body is signed, so the participant signature covers them and a MITM cannot strip or
|
|
165
|
+
* swap the proof. (Returning the `serialize()` string's bytes would double-encode the field.)
|
|
166
|
+
* Absent (default) → no evidence is attached, exactly today's behavior. The minting logic (PoW
|
|
167
|
+
* search, reputation/parent-ref signing) is the db-p2p follow-on.
|
|
168
|
+
*/
|
|
169
|
+
readonly buildBootstrapEvidence?: (params: {
|
|
170
|
+
readonly topicId: string;
|
|
171
|
+
readonly tier: number;
|
|
172
|
+
readonly participantCoord: string;
|
|
173
|
+
readonly timestamp: number;
|
|
174
|
+
}) => Promise<Uint8Array | undefined>;
|
|
175
|
+
readonly config?: CohortServiceConfig;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
class WalkRegisterService implements CohortTopicService {
|
|
179
|
+
public onLocalCommit?: LocalChangeHook;
|
|
180
|
+
|
|
181
|
+
private readonly addressing: TierAddressing;
|
|
182
|
+
private readonly dmax: DMaxComputer;
|
|
183
|
+
private readonly walk: WalkEngine;
|
|
184
|
+
private readonly clock: () => number;
|
|
185
|
+
private readonly ttl: number;
|
|
186
|
+
private readonly maxMessageBytes?: number;
|
|
187
|
+
/** Live renewal drivers, keyed by `(topicId, participantId)` so renew/withdraw find their handle. */
|
|
188
|
+
private readonly renewals = new Map<string, RenewalParticipant>();
|
|
189
|
+
private readonly participantId: Uint8Array;
|
|
190
|
+
|
|
191
|
+
constructor(private readonly deps: CohortTopicServiceDeps) {
|
|
192
|
+
const cfg = deps.config ?? {};
|
|
193
|
+
const fanout = cfg.fanout ?? DEFAULT_FANOUT;
|
|
194
|
+
this.clock = deps.clock ?? ((): number => Date.now());
|
|
195
|
+
this.ttl = cfg.ttl ?? DEFAULT_TTL_MS;
|
|
196
|
+
this.maxMessageBytes = cfg.maxMessageBytes;
|
|
197
|
+
// The participant identity carried on the wire (`participantCoord` on register, `participantId`
|
|
198
|
+
// on renew) IS `self` — the dialable peer id db-p2p supplies via its peer-codec. It is the
|
|
199
|
+
// record key the cohort stores AND the signer id its peer-key signature verifies against, so it
|
|
200
|
+
// must round-trip back to a peer id rather than being re-hashed here (§Tier addressing names the
|
|
201
|
+
// routing coord `P = self` directly; slot assignment hashes it internally for uniformity).
|
|
202
|
+
this.participantId = deps.self;
|
|
203
|
+
this.addressing = createTierAddressing(deps.hash, fanout);
|
|
204
|
+
this.dmax = makeDMaxComputer({ estimator: deps.sizeEstimator, F: fanout });
|
|
205
|
+
this.walk = createWalkEngine({
|
|
206
|
+
router: deps.router,
|
|
207
|
+
addressing: this.addressing,
|
|
208
|
+
dmax: this.dmax,
|
|
209
|
+
self: deps.self,
|
|
210
|
+
factory: this.messageFactory(),
|
|
211
|
+
config: { wantK: cfg.wantK, minSigs: cfg.minSigs, maxMessageBytes: cfg.maxMessageBytes },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async register(req: RegisterRequest): Promise<RegistrationHandle> {
|
|
216
|
+
const outcome = await this.walk.register(req.topicId, req.tier, req.appPayload);
|
|
217
|
+
return this.handleFromOutcome(req, outcome);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async lookup(topicId: Uint8Array, tier: Tier): Promise<CohortHint> {
|
|
221
|
+
// A read-only probe: it walks to the responsible cohort exactly as a register would and returns the
|
|
222
|
+
// same cohort snapshot, but admits NOTHING — no soft-state record, no arrival, no promotion trigger,
|
|
223
|
+
// no topic-budget touch, and never a cold-start instantiation. A topic served nowhere resolves to a
|
|
224
|
+
// `CohortBackoffError` (the probe never bootstraps a cold root), so a lookup leaves no throwaway
|
|
225
|
+
// registration behind to TTL-expire (§Application policies; the lookup-as-register interim is gone).
|
|
226
|
+
const outcome = await this.walk.register(topicId, tier, undefined, { probe: true });
|
|
227
|
+
if (outcome.kind !== "accepted") {
|
|
228
|
+
throw new CohortBackoffError(outcome.kind === "retry_later" ? outcome.afterMs : 0);
|
|
229
|
+
}
|
|
230
|
+
return this.hintFromReply(topicId, tier, outcome.reply);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async renew(handle: RegistrationHandle): Promise<void> {
|
|
234
|
+
// Act only if this handle is still the live entry. A stale handle (superseded by a second
|
|
235
|
+
// register() for the same pair) must not drive the new registration's ping loop, and a
|
|
236
|
+
// withdrawn handle must not silently re-start it.
|
|
237
|
+
if (this.renewals.get(recordKey(handle.topicId, this.participantId)) !== handle.renewal) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
await handle.renewal.pingLoop();
|
|
241
|
+
this.syncHandle(handle);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async withdraw(handle: RegistrationHandle): Promise<void> {
|
|
245
|
+
// Two halves: (1) drop the handle from the live set so further renew() pings no-op (see renew()),
|
|
246
|
+
// stopping the local ping loop; (2) fire a best-effort signed withdraw tombstone to the current
|
|
247
|
+
// primary so the cohort frees the record immediately instead of holding it for up to a full TTL.
|
|
248
|
+
// The delete happens FIRST so a concurrent renew() already no-ops before the tombstone is sent. If
|
|
249
|
+
// the tombstone send fails (primary unreachable), the cohort soft-state TTL-expires as the
|
|
250
|
+
// fallback — withdraw never throws on a transport failure. Idempotent: a second withdraw finds
|
|
251
|
+
// `renewal !== handle.renewal` (undefined ≠ any object) and no-ops.
|
|
252
|
+
// Guard: act only if this handle is still the live entry. A stale handle must not evict the
|
|
253
|
+
// new registration or send a tombstone on its behalf.
|
|
254
|
+
const key = recordKey(handle.topicId, this.participantId);
|
|
255
|
+
const renewal = this.renewals.get(key);
|
|
256
|
+
if (renewal !== handle.renewal) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.renewals.delete(key);
|
|
260
|
+
await renewal.withdraw();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
cohortGossip(): CohortGossipBus {
|
|
264
|
+
return this.deps.gossipBus;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
verifier(): MembershipVerifier {
|
|
268
|
+
return this.deps.verifier;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// --- internals ---
|
|
272
|
+
|
|
273
|
+
private handleFromOutcome(req: RegisterRequest, outcome: WalkOutcome): RegistrationHandle {
|
|
274
|
+
if (outcome.kind !== "accepted") {
|
|
275
|
+
throw new CohortBackoffError(outcome.kind === "retry_later" ? outcome.afterMs : 0);
|
|
276
|
+
}
|
|
277
|
+
const hint = this.hintFromReply(req.topicId, req.tier, outcome.reply);
|
|
278
|
+
const renewal = this.startRenewal(req, hint, outcome.correlationId);
|
|
279
|
+
return { ...hint, renewal };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private startRenewal(req: RegisterRequest, hint: CohortHint, correlationId: string): RenewalParticipant {
|
|
283
|
+
const ttl = req.ttl ?? this.ttl;
|
|
284
|
+
const initial: RegistrationRecord = {
|
|
285
|
+
topicId: req.topicId,
|
|
286
|
+
participantId: this.participantId,
|
|
287
|
+
tier: req.tier,
|
|
288
|
+
primary: hint.primary,
|
|
289
|
+
backups: hint.backups,
|
|
290
|
+
attachedAt: this.clock(),
|
|
291
|
+
lastPing: this.clock(),
|
|
292
|
+
ttl,
|
|
293
|
+
appState: req.appPayload,
|
|
294
|
+
};
|
|
295
|
+
const transport = this.renewalTransport(req);
|
|
296
|
+
const renewal = createRenewalParticipant(initial, {
|
|
297
|
+
transport,
|
|
298
|
+
clock: this.clock,
|
|
299
|
+
sign: (body: UnsignedRenew): Promise<string> => this.deps.signer.signRenew(body),
|
|
300
|
+
// Echo the accepted RegisterV1's correlationId: RenewV1 `correlationId` "matches original
|
|
301
|
+
// RegisterV1" (docs §Wire, RenewV1), so a future renew-path freshness/replay guard can correlate
|
|
302
|
+
// a renew back to the registration it renews.
|
|
303
|
+
// NOTE: every periodic renew for this registration shares this ONE correlationId (as it always
|
|
304
|
+
// has — this only makes the shared id equal the register's, not an independent nonce). A future
|
|
305
|
+
// renew replay guard must therefore key renews by (correlationId, timestamp-window), NOT "drop
|
|
306
|
+
// any repeated correlationId" — else it would reject the 2nd+ legitimate renew.
|
|
307
|
+
correlationId,
|
|
308
|
+
initialCohortEpoch: hint.cohortEpoch,
|
|
309
|
+
});
|
|
310
|
+
// NOTE: a second register() for the same (topicId, participantId) overwrites the map entry here,
|
|
311
|
+
// orphaning the prior renewal. renew()/withdraw() on the stale handle now no-op (identity guard),
|
|
312
|
+
// but no tombstone is sent for the superseded record — the cohort frees it via TTL expiry. Harmless
|
|
313
|
+
// for occasional re-registers; if callers ever churn-register the same pair, send a withdraw for the
|
|
314
|
+
// displaced renewal here so the cohort reclaims immediately instead of holding up to a full TTL.
|
|
315
|
+
this.renewals.set(recordKey(req.topicId, this.participantId), renewal);
|
|
316
|
+
return renewal;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Renewal transport: dial the cached primary directly; a full failure re-runs the register walk. */
|
|
320
|
+
private renewalTransport(req: RegisterRequest): RenewalParticipantTransport {
|
|
321
|
+
return {
|
|
322
|
+
send: async (target: Uint8Array, msg): Promise<RenewReplyV1> => {
|
|
323
|
+
const raw = await this.deps.router.dialMember({ id: target }, encodeCohortMessage(msg, this.maxMessageBytes));
|
|
324
|
+
return decodeRenewReplyV1(raw, this.maxMessageBytes);
|
|
325
|
+
},
|
|
326
|
+
relookup: async (): Promise<void> => {
|
|
327
|
+
await this.walk.register(req.topicId, req.tier, req.appPayload);
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private hintFromReply(topicId: Uint8Array, tier: Tier, reply: RegisterReplyV1): CohortHint {
|
|
333
|
+
if (reply.primary === undefined || reply.cohortEpoch === undefined) {
|
|
334
|
+
throw new CohortBackoffError(0);
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
topicId,
|
|
338
|
+
tier,
|
|
339
|
+
primary: b64urlToBytes(reply.primary),
|
|
340
|
+
backups: (reply.backups ?? []).map(b64urlToBytes),
|
|
341
|
+
cohortEpoch: b64urlToBytes(reply.cohortEpoch),
|
|
342
|
+
cohortMembers: (reply.cohortMembers ?? []).map(b64urlToBytes),
|
|
343
|
+
topicTraffic: reply.topicTraffic,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private syncHandle(handle: RegistrationHandle): void {
|
|
348
|
+
const rec = handle.renewal.record;
|
|
349
|
+
(handle as { primary: Uint8Array }).primary = rec.primary;
|
|
350
|
+
(handle as { backups: Uint8Array[] }).backups = [...rec.backups];
|
|
351
|
+
const epoch = handle.renewal.cohortEpochHint;
|
|
352
|
+
if (epoch !== undefined) {
|
|
353
|
+
(handle as { cohortEpoch: Uint8Array }).cohortEpoch = epoch;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The per-probe `RegisterV1` builder: stamps participant coord/ttl/correlation and signs. */
|
|
358
|
+
private messageFactory(): RegisterMessageFactory {
|
|
359
|
+
const participantCoord = bytesToB64url(this.participantId);
|
|
360
|
+
return {
|
|
361
|
+
build: async (params): Promise<RegisterV1> => {
|
|
362
|
+
const body: Omit<RegisterV1, "signature"> = {
|
|
363
|
+
v: 1,
|
|
364
|
+
topicId: bytesToB64url(params.topicId),
|
|
365
|
+
tier: params.tier,
|
|
366
|
+
treeTier: params.treeTier,
|
|
367
|
+
participantCoord,
|
|
368
|
+
ttl: this.ttl,
|
|
369
|
+
timestamp: this.clock(),
|
|
370
|
+
correlationId: this.freshCorrelationId(),
|
|
371
|
+
};
|
|
372
|
+
if (params.probe) {
|
|
373
|
+
// A read-only lookup probe. Mutually exclusive with bootstrap/followOn (the walk never sets
|
|
374
|
+
// either on a probe), so the cold-start-evidence branch below cannot run for a probe.
|
|
375
|
+
body.probe = true;
|
|
376
|
+
}
|
|
377
|
+
// A cold-start re-issue — root (`bootstrap`) or deeper-tier follow-on (`followOn`). Both are
|
|
378
|
+
// gated by the identical evidence policy (§Anti-DoS), so both mint and attach the same envelope
|
|
379
|
+
// bound to the body's own canonical fields. Mutually exclusive with each other and with probe.
|
|
380
|
+
if (params.bootstrap || params.followOn) {
|
|
381
|
+
if (params.followOn) {
|
|
382
|
+
body.followOn = true;
|
|
383
|
+
} else {
|
|
384
|
+
body.bootstrap = true;
|
|
385
|
+
}
|
|
386
|
+
// Attach cold-start evidence BEFORE signing (so the signature covers it). Bind the
|
|
387
|
+
// builder to the body's own canonical fields — the exact tuple a verifier reconstructs.
|
|
388
|
+
const evidence = await this.deps.buildBootstrapEvidence?.({
|
|
389
|
+
topicId: body.topicId,
|
|
390
|
+
tier: body.tier,
|
|
391
|
+
participantCoord: body.participantCoord,
|
|
392
|
+
timestamp: body.timestamp,
|
|
393
|
+
});
|
|
394
|
+
if (evidence !== undefined && evidence.length > 0) {
|
|
395
|
+
body.bootstrapEvidence = bytesToB64url(evidence);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (params.appPayload !== undefined) {
|
|
399
|
+
body.appPayload = bytesToB64url(params.appPayload);
|
|
400
|
+
}
|
|
401
|
+
return { ...body, signature: await this.deps.signer.signRegister(body) };
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 16 fresh CSPRNG bytes, base64url. A correlation id must be unique per probe — the replay guard
|
|
407
|
+
* keys on it — so it cannot be derived from the clock (two probes in the same ms would collide). */
|
|
408
|
+
private freshCorrelationId(): string {
|
|
409
|
+
return bytesToB64url(randomBytes(16));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Build the participant-facing {@link CohortTopicService} over the injected ports. */
|
|
414
|
+
export function createCohortTopicService(deps: CohortTopicServiceDeps): CohortTopicService {
|
|
415
|
+
return new WalkRegisterService(deps);
|
|
416
|
+
}
|