@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
package/src/cohort-topic/walk.ts
CHANGED
|
@@ -1,326 +1,326 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic substrate — walk-toward-root lookup / registration.
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/cohort-topic.md` §Tree growth and lookup (§Lookup loop) and folded back
|
|
5
|
-
* from the simulator-validated `packages/substrate-simulator/src/walk.ts`. A participant resolves a
|
|
6
|
-
* topic by walking **toward the root** from `d_max`, probing one tier coordinate per RPC. The walk's
|
|
7
|
-
* single-direction discipline is the anti-flood backbone: it only ever moves inward (toward the root)
|
|
8
|
-
* on `NoState`, and the *only* outward move is following an explicit `Promoted` redirect.
|
|
9
|
-
*
|
|
10
|
-
* Reply handling (§Lookup):
|
|
11
|
-
* - **`accepted`** → done; return the reply (carries `primary`/`backups`/`cohortEpoch` to cache).
|
|
12
|
-
* - **`no_state`** at tier `d` → step one tier toward the root (`d − 1`); no traffic signal. At the
|
|
13
|
-
* root (`d − 1 < 0`) re-issue once at tier 0 with `bootstrap: true` (cold-start request). **If this
|
|
14
|
-
* `no_state` is the redirect target of a `Promoted` this walk just followed** (`followedPromoted`),
|
|
15
|
-
* the deeper child is cold: a register re-issues **once** at the same tier with `followOn: true` (a
|
|
16
|
-
* deeper-tier cold-start, gated by the same evidence a `bootstrap` pays), then backs off if that too
|
|
17
|
-
* returns `no_state`; a probe never instantiates, so it backs off immediately. This is what lets a
|
|
18
|
-
* join that lands on a freshly-promoted-but-not-yet-grown branch converge instead of oscillating.
|
|
19
|
-
* - **`promoted(targetTier)`** → recompute `coord_targetTier(self, topicId)` and register there — the
|
|
20
|
-
* one outward move, taken only on this explicit redirect.
|
|
21
|
-
* - **`unwilling_member(candidates)`** → retry the **same** coord at a named alternative member
|
|
22
|
-
* (spatial move within the cohort), via a direct dial.
|
|
23
|
-
* - **`unwilling_cohort(retryAfter)`** → back off in **time**; the walk terminates with
|
|
24
|
-
* {@link RetryLaterOutcome} and the caller restarts a fresh {@link WalkEngine.register} after
|
|
25
|
-
* `afterMs` — which begins again at `d_max`, decorrelating retries across the ring (§Anti-flood
|
|
26
|
-
* claim 4: never re-hit the declined coord immediately).
|
|
27
|
-
*
|
|
28
|
-
* This module is FRET-free: it drives the {@link ITopicRouter} port (db-p2p binds it to FRET's
|
|
29
|
-
* `RouteAndMaybeAct` / direct dial) and the {@link TierAddressing} math, and delegates building +
|
|
30
|
-
* signing the {@link RegisterV1} to the injected {@link RegisterMessageFactory} (participant identity
|
|
31
|
-
* and crypto live there, not here).
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import type { ITopicRouter, PeerRef } from "./ports.js";
|
|
35
|
-
import type { TierAddressing } from "./addressing.js";
|
|
36
|
-
import type { DMaxComputer } from "./dmax.js";
|
|
37
|
-
import { DEFAULT_D_MAX_CAP } from "./dmax.js";
|
|
38
|
-
import { backoffRetryMs } from "./willingness.js";
|
|
39
|
-
import { b64urlToBytes, decodeRegisterReplyV1, encodeCohortMessage } from "./wire/codec.js";
|
|
40
|
-
import type { RegisterReplyV1, RegisterV1 } from "./wire/types.js";
|
|
41
|
-
|
|
42
|
-
/** The walk landed: the cohort accepted the registration. `reply` carries the cohort cache fields. */
|
|
43
|
-
export interface AcceptedWalkOutcome {
|
|
44
|
-
readonly kind: "accepted";
|
|
45
|
-
readonly reply: RegisterReplyV1;
|
|
46
|
-
/**
|
|
47
|
-
* The accepted register probe's own `correlationId`. The participant echoes it on every renew for this
|
|
48
|
-
* registration so `RenewV1.correlationId` "matches original `RegisterV1`" (docs §Wire, RenewV1). Each
|
|
49
|
-
* probe carries a distinct correlationId; this is the one the cohort admitted.
|
|
50
|
-
*/
|
|
51
|
-
readonly correlationId: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* The walk hit a `Promoted` redirect and the engine was configured **not** to follow it
|
|
56
|
-
* ({@link WalkConfig.followPromoted} `= false`): the caller recomputes `coord_targetTier` and
|
|
57
|
-
* registers there itself. With the default (`followPromoted = true`) the engine follows the redirect
|
|
58
|
-
* internally and this outcome never surfaces.
|
|
59
|
-
*/
|
|
60
|
-
export interface PromotedWalkOutcome {
|
|
61
|
-
readonly kind: "promoted";
|
|
62
|
-
readonly targetTier: number;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* The walk backed off in time (`unwilling_cohort`, exhausted sibling retries, a failed cold-start, or
|
|
67
|
-
* the safety step cap). The caller waits `afterMs` then calls {@link WalkEngine.register} again, which
|
|
68
|
-
* restarts at `d_max`.
|
|
69
|
-
*/
|
|
70
|
-
export interface RetryLaterOutcome {
|
|
71
|
-
readonly kind: "retry_later";
|
|
72
|
-
readonly afterMs: number;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export type WalkOutcome = AcceptedWalkOutcome | PromotedWalkOutcome | RetryLaterOutcome;
|
|
76
|
-
|
|
77
|
-
/** Builds (and signs) the {@link RegisterV1} for one probe; owns participant identity + crypto. */
|
|
78
|
-
export interface RegisterMessageFactory {
|
|
79
|
-
/**
|
|
80
|
-
* Produce a signed `RegisterV1` for this participant at walk position `treeTier`. `bootstrap` is set
|
|
81
|
-
* only on the root cold-start re-issue; `followOn` only on the dedicated re-issue after a `Promoted`
|
|
82
|
-
* redirect target answered `NoState` (§Cold-start). `appPayload` is the opaque application slot. On
|
|
83
|
-
* either cold-start re-issue the factory mints and attaches the signed `bootstrapEvidence` envelope
|
|
84
|
-
* (§Anti-DoS — a follow-on is gated identically to a bootstrap) via the injected builder seam before
|
|
85
|
-
* signing — keyed off `bootstrap`/`followOn`, so no extra parameter is needed (the walk decides both
|
|
86
|
-
* internally, not the application). `bootstrap`, `followOn`, and `probe` are mutually exclusive.
|
|
87
|
-
*/
|
|
88
|
-
build(params: {
|
|
89
|
-
topicId: Uint8Array;
|
|
90
|
-
tier: number;
|
|
91
|
-
treeTier: number;
|
|
92
|
-
bootstrap: boolean;
|
|
93
|
-
/** Follow-on cold-start re-issue after a `Promoted` redirect target answered `NoState` (`treeTier >= 1`). */
|
|
94
|
-
followOn: boolean;
|
|
95
|
-
/** Read-only lookup probe: the factory stamps `RegisterV1.probe` and never mints cold-start evidence. */
|
|
96
|
-
probe: boolean;
|
|
97
|
-
appPayload?: Uint8Array;
|
|
98
|
-
}): Promise<RegisterV1>;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export interface WalkConfig {
|
|
102
|
-
/** Cohort size requested from the router (`wantK`). Default 16. */
|
|
103
|
-
wantK?: number;
|
|
104
|
-
/** Threshold signers requested from the router (`minSigs = k − x`). Default 14. */
|
|
105
|
-
minSigs?: number;
|
|
106
|
-
/** Max `unwilling_member` sibling retries at one coord before treating it as a cohort decline. Default `wantK`. */
|
|
107
|
-
maxMemberRetries?: number;
|
|
108
|
-
/**
|
|
109
|
-
* Whether to follow a `Promoted` redirect internally (recompute coord + continue) or surface it as
|
|
110
|
-
* a {@link PromotedWalkOutcome} for the caller to drive. Default `true` (self-contained walk).
|
|
111
|
-
*/
|
|
112
|
-
followPromoted?: boolean;
|
|
113
|
-
/**
|
|
114
|
-
* Hard cap on probe RPCs in one walk — a safety valve against pathological oscillation between an
|
|
115
|
-
* inward `NoState` step and an outward `Promoted` redirect in a malformed tree. Default scales with
|
|
116
|
-
* `d_max`. Exceeding it yields a {@link RetryLaterOutcome}.
|
|
117
|
-
*/
|
|
118
|
-
maxSteps?: number;
|
|
119
|
-
/** `max_message_bytes` ceiling for the encoded register frame. Defaults to the codec default. */
|
|
120
|
-
maxMessageBytes?: number;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export interface WalkEngineDeps {
|
|
124
|
-
router: ITopicRouter;
|
|
125
|
-
addressing: TierAddressing;
|
|
126
|
-
/** Computes the walk start tier `d_max` from the current network-size estimate. */
|
|
127
|
-
dmax: DMaxComputer;
|
|
128
|
-
/** This participant's peer id — the `P` in `coord_d(P, topicId)`. */
|
|
129
|
-
self: Uint8Array;
|
|
130
|
-
/** Builds + signs the per-probe `RegisterV1`. */
|
|
131
|
-
factory: RegisterMessageFactory;
|
|
132
|
-
config?: WalkConfig;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Drives a participant's walk-toward-root registration over the injected router + addressing. */
|
|
136
|
-
export interface WalkEngine {
|
|
137
|
-
/**
|
|
138
|
-
* Walk from `d_max` toward the root registering for `topicId` at op `tier`, following `Promoted`
|
|
139
|
-
* redirects outward. Resolves with the terminal {@link WalkOutcome}. With `opts.probe` the walk is a
|
|
140
|
-
* **read-only lookup**: identical routing discipline, but the terminal cohort classifies rather than
|
|
141
|
-
* admits and the root `no_state` branch backs off instead of issuing a `bootstrap: true` cold-start
|
|
142
|
-
* (a probe never instantiates a cold root).
|
|
143
|
-
*/
|
|
144
|
-
register(topicId: Uint8Array, tier: number, appPayload?: Uint8Array, opts?: { probe?: boolean }): Promise<WalkOutcome>;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* A walk tier is valid iff it is an integer in `0..DEFAULT_D_MAX_CAP` — the substrate's own walk-depth
|
|
149
|
-
* ceiling. A `promoted` reply's explicit `targetTier` is untrusted: an out-of-range value (non-integer,
|
|
150
|
-
* negative, or above the ceiling) cannot name a real cohort and would reach `addressing.coord()` →
|
|
151
|
-
* `coordD`, which throws a raw `RangeError`. Matches the range the wire `treeTier` validator enforces.
|
|
152
|
-
*/
|
|
153
|
-
function isValidTreeTier(value: number): boolean {
|
|
154
|
-
return Number.isInteger(value) && value >= 0 && value <= DEFAULT_D_MAX_CAP;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
class RouterWalkEngine implements WalkEngine {
|
|
158
|
-
private readonly wantK: number;
|
|
159
|
-
private readonly minSigs: number;
|
|
160
|
-
private readonly maxMemberRetries: number;
|
|
161
|
-
private readonly followPromoted: boolean;
|
|
162
|
-
private readonly configuredMaxSteps?: number;
|
|
163
|
-
private readonly maxMessageBytes?: number;
|
|
164
|
-
|
|
165
|
-
constructor(private readonly deps: WalkEngineDeps) {
|
|
166
|
-
const cfg = deps.config ?? {};
|
|
167
|
-
this.wantK = cfg.wantK ?? 16;
|
|
168
|
-
this.minSigs = cfg.minSigs ?? 14;
|
|
169
|
-
this.maxMemberRetries = cfg.maxMemberRetries ?? this.wantK;
|
|
170
|
-
this.followPromoted = cfg.followPromoted ?? true;
|
|
171
|
-
this.configuredMaxSteps = cfg.maxSteps;
|
|
172
|
-
this.maxMessageBytes = cfg.maxMessageBytes;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async register(topicId: Uint8Array, tier: number, appPayload?: Uint8Array, opts?: { probe?: boolean }): Promise<WalkOutcome> {
|
|
176
|
-
const dMax = this.deps.dmax.dMax();
|
|
177
|
-
const maxSteps = this.configuredMaxSteps ?? 2 * (dMax + 2) + this.maxMemberRetries + 8;
|
|
178
|
-
const probe = opts?.probe ?? false;
|
|
179
|
-
|
|
180
|
-
let d = dMax;
|
|
181
|
-
let bootstrap = false;
|
|
182
|
-
let followOn = false;
|
|
183
|
-
// Member ids (base64url) already dialed on this walk's `unwilling_member` retries. Tracking WHICH
|
|
184
|
-
// members were tried — not a positional counter — lets each fresh candidate list be consumed from
|
|
185
|
-
// its best (index-0) member; a counter would permanently skip index 0 of every list after the first.
|
|
186
|
-
const triedMembers = new Set<string>();
|
|
187
|
-
let dialTarget: PeerRef | undefined;
|
|
188
|
-
let steps = 0;
|
|
189
|
-
// True once this walk has followed a `Promoted` redirect outward (either mode). On a subsequent
|
|
190
|
-
// `NoState` the redirect target is cold: a probe backs off (never instantiates), a register
|
|
191
|
-
// re-issues once with `followOn: true` to instantiate the child, then backs off.
|
|
192
|
-
let followedPromoted = false;
|
|
193
|
-
// True once the register path has spent its single `followOn: true` re-issue at the cold child.
|
|
194
|
-
let followOnReissued = false;
|
|
195
|
-
|
|
196
|
-
for (;;) {
|
|
197
|
-
if (++steps > maxSteps) {
|
|
198
|
-
// Safety valve: a well-formed tree converges well within this bound. Surface a temporal
|
|
199
|
-
// back-off rather than spin.
|
|
200
|
-
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const reg = await this.deps.factory.build({ topicId, tier, treeTier: d, bootstrap, followOn, probe, appPayload });
|
|
204
|
-
const activity = encodeCohortMessage(reg, this.maxMessageBytes);
|
|
205
|
-
const raw = dialTarget !== undefined
|
|
206
|
-
? await this.deps.router.dialMember(dialTarget, activity)
|
|
207
|
-
: await this.deps.router.routeAndAct(this.deps.addressing.coord(d, this.deps.self, topicId), activity, {
|
|
208
|
-
wantK: this.wantK,
|
|
209
|
-
minSigs: this.minSigs,
|
|
210
|
-
});
|
|
211
|
-
const reply = decodeRegisterReplyV1(raw, this.maxMessageBytes);
|
|
212
|
-
|
|
213
|
-
switch (reply.result) {
|
|
214
|
-
case "accepted": {
|
|
215
|
-
// Surface the accepted probe's correlationId so the participant's renewals can echo it
|
|
216
|
-
// (RenewV1 correlationId "matches original RegisterV1"). `reg` is the frame just admitted.
|
|
217
|
-
return { kind: "accepted", reply, correlationId: reg.correlationId };
|
|
218
|
-
}
|
|
219
|
-
case "no_state": {
|
|
220
|
-
// Step toward the root. The cohort served nothing here; no spatial sibling state.
|
|
221
|
-
dialTarget = undefined;
|
|
222
|
-
triedMembers.clear(); // a spatial move to a new coord starts sibling retries fresh
|
|
223
|
-
if (followedPromoted) {
|
|
224
|
-
// The `Promoted` redirect target is cold (not yet instantiated). Walking inward to the
|
|
225
|
-
// promoting ancestor would just re-trigger the redirect and oscillate, so handle it here.
|
|
226
|
-
if (probe) {
|
|
227
|
-
// A probe never instantiates — back off immediately (mirror of the register re-issue).
|
|
228
|
-
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
229
|
-
}
|
|
230
|
-
if (!followOnReissued) {
|
|
231
|
-
// Re-issue ONCE at the SAME child tier as a follow-on cold-start: RegisterV1{ followOn:
|
|
232
|
-
// true } + minted evidence. The mirror of the root NoState → bootstrap:true re-issue.
|
|
233
|
-
followOn = true;
|
|
234
|
-
followOnReissued = true;
|
|
235
|
-
break; // re-register at the same coord/tier, now carrying followOn
|
|
236
|
-
}
|
|
237
|
-
// The follow-on re-issue still got NoState → the cold child's quorum is unwilling to
|
|
238
|
-
// instantiate. Back off in time; do NOT loop inward.
|
|
239
|
-
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
240
|
-
}
|
|
241
|
-
const next = d - 1;
|
|
242
|
-
if (next < 0) {
|
|
243
|
-
if (bootstrap) {
|
|
244
|
-
// Already re-issued at the root as a bootstrap and still nothing — no cohort
|
|
245
|
-
// anywhere will instantiate right now. Back off in time.
|
|
246
|
-
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
247
|
-
}
|
|
248
|
-
if (probe) {
|
|
249
|
-
// A read-only probe never instantiates a cold root: the topic exists nowhere, so
|
|
250
|
-
// resolve "not found / back off" rather than re-issuing with bootstrap:true.
|
|
251
|
-
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
252
|
-
}
|
|
253
|
-
// Root returned NoState → cold-start: re-issue once at tier 0 with bootstrap:true.
|
|
254
|
-
d = 0;
|
|
255
|
-
bootstrap = true;
|
|
256
|
-
break;
|
|
257
|
-
}
|
|
258
|
-
d = next;
|
|
259
|
-
bootstrap = false;
|
|
260
|
-
break;
|
|
261
|
-
}
|
|
262
|
-
case "promoted": {
|
|
263
|
-
dialTarget = undefined;
|
|
264
|
-
triedMembers.clear(); // spatial move to the redirect target: sibling retries start fresh
|
|
265
|
-
bootstrap = false;
|
|
266
|
-
const targetTier = reply.targetTier ?? d + 1;
|
|
267
|
-
// The cohort names the tier to jump outward to. When it supplied `targetTier` EXPLICITLY it is
|
|
268
|
-
// untrusted: an out-of-range value would reach `coord()` → `coordD` and throw a raw RangeError,
|
|
269
|
-
// an unclassified crash out of register()/lookup() rather than a clean outcome. Bound it before
|
|
270
|
-
// BOTH adoption sites (the followPromoted-false surface below and the `d = targetTier` hop) and
|
|
271
|
-
// back off in time instead. Only the EXPLICIT attacker value is the hazard; the `d + 1`
|
|
272
|
-
// fallback is left unchecked because `d` is walk-bounded to `dMax + maxSteps` (≈190 under the
|
|
273
|
-
// default `maxSteps`), comfortably under coordD's 255 range.
|
|
274
|
-
// NOTE: `maxSteps` is operator-configurable — a value above ~195 plus an adversarial chain of
|
|
275
|
-
// no-`targetTier` `promoted` replies (each bumps `d` by +1) could push the `d + 1` fallback
|
|
276
|
-
// past coordD's range and reintroduce the RangeError. If maxSteps is ever raised that high,
|
|
277
|
-
// bound the fallback here too.
|
|
278
|
-
if (reply.targetTier !== undefined && !isValidTreeTier(targetTier)) {
|
|
279
|
-
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
280
|
-
}
|
|
281
|
-
// Following a (fresh) redirect: mark it, and reset the follow-on latch so a cold child at
|
|
282
|
-
// THIS target gets its own single follow-on re-issue. The honest flow re-registers the
|
|
283
|
-
// child with a PLAIN frame first (followOn false) and only escalates to followOn on its
|
|
284
|
-
// NoState — so clear the flag here; the NoState branch re-arms it.
|
|
285
|
-
followedPromoted = true;
|
|
286
|
-
followOn = false;
|
|
287
|
-
followOnReissued = false;
|
|
288
|
-
if (!this.followPromoted) {
|
|
289
|
-
return { kind: "promoted", targetTier };
|
|
290
|
-
}
|
|
291
|
-
d = targetTier; // the one outward move — recompute coord at the redirect target
|
|
292
|
-
break;
|
|
293
|
-
}
|
|
294
|
-
case "unwilling_member": {
|
|
295
|
-
const candidates = reply.candidateMembers ?? [];
|
|
296
|
-
// Consume this (possibly fresh) list from its best (index-0) member: pick the FIRST
|
|
297
|
-
// candidate not already dialed on this walk. A positional `memberAttempts % len` offset
|
|
298
|
-
// would skip index 0 of every list after the first, permanently starving the best member.
|
|
299
|
-
const next = candidates.find((c) => !triedMembers.has(c));
|
|
300
|
-
if (next === undefined || triedMembers.size >= this.maxMemberRetries) {
|
|
301
|
-
// No untried candidate offered (or the retry cap is spent) → fall through to a
|
|
302
|
-
// cohort-level temporal back-off, restarting at d_max on the caller's retry.
|
|
303
|
-
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
304
|
-
}
|
|
305
|
-
// Retry the SAME coord at a named alternative member (spatial move within the cohort).
|
|
306
|
-
triedMembers.add(next);
|
|
307
|
-
dialTarget = { id: b64urlToBytes(next) };
|
|
308
|
-
break;
|
|
309
|
-
}
|
|
310
|
-
case "unwilling_cohort": {
|
|
311
|
-
// Back off in TIME, no spatial move; the caller restarts at d_max after the delay.
|
|
312
|
-
return { kind: "retry_later", afterMs: reply.retryAfterMs ?? backoffRetryMs(0) };
|
|
313
|
-
}
|
|
314
|
-
default: {
|
|
315
|
-
// Exhaustive over RegisterResult; an unknown result is treated as a temporal decline.
|
|
316
|
-
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** Build a {@link WalkEngine} over the injected router, addressing, `d_max`, and message factory. */
|
|
324
|
-
export function createWalkEngine(deps: WalkEngineDeps): WalkEngine {
|
|
325
|
-
return new RouterWalkEngine(deps);
|
|
326
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic substrate — walk-toward-root lookup / registration.
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/cohort-topic.md` §Tree growth and lookup (§Lookup loop) and folded back
|
|
5
|
+
* from the simulator-validated `packages/substrate-simulator/src/walk.ts`. A participant resolves a
|
|
6
|
+
* topic by walking **toward the root** from `d_max`, probing one tier coordinate per RPC. The walk's
|
|
7
|
+
* single-direction discipline is the anti-flood backbone: it only ever moves inward (toward the root)
|
|
8
|
+
* on `NoState`, and the *only* outward move is following an explicit `Promoted` redirect.
|
|
9
|
+
*
|
|
10
|
+
* Reply handling (§Lookup):
|
|
11
|
+
* - **`accepted`** → done; return the reply (carries `primary`/`backups`/`cohortEpoch` to cache).
|
|
12
|
+
* - **`no_state`** at tier `d` → step one tier toward the root (`d − 1`); no traffic signal. At the
|
|
13
|
+
* root (`d − 1 < 0`) re-issue once at tier 0 with `bootstrap: true` (cold-start request). **If this
|
|
14
|
+
* `no_state` is the redirect target of a `Promoted` this walk just followed** (`followedPromoted`),
|
|
15
|
+
* the deeper child is cold: a register re-issues **once** at the same tier with `followOn: true` (a
|
|
16
|
+
* deeper-tier cold-start, gated by the same evidence a `bootstrap` pays), then backs off if that too
|
|
17
|
+
* returns `no_state`; a probe never instantiates, so it backs off immediately. This is what lets a
|
|
18
|
+
* join that lands on a freshly-promoted-but-not-yet-grown branch converge instead of oscillating.
|
|
19
|
+
* - **`promoted(targetTier)`** → recompute `coord_targetTier(self, topicId)` and register there — the
|
|
20
|
+
* one outward move, taken only on this explicit redirect.
|
|
21
|
+
* - **`unwilling_member(candidates)`** → retry the **same** coord at a named alternative member
|
|
22
|
+
* (spatial move within the cohort), via a direct dial.
|
|
23
|
+
* - **`unwilling_cohort(retryAfter)`** → back off in **time**; the walk terminates with
|
|
24
|
+
* {@link RetryLaterOutcome} and the caller restarts a fresh {@link WalkEngine.register} after
|
|
25
|
+
* `afterMs` — which begins again at `d_max`, decorrelating retries across the ring (§Anti-flood
|
|
26
|
+
* claim 4: never re-hit the declined coord immediately).
|
|
27
|
+
*
|
|
28
|
+
* This module is FRET-free: it drives the {@link ITopicRouter} port (db-p2p binds it to FRET's
|
|
29
|
+
* `RouteAndMaybeAct` / direct dial) and the {@link TierAddressing} math, and delegates building +
|
|
30
|
+
* signing the {@link RegisterV1} to the injected {@link RegisterMessageFactory} (participant identity
|
|
31
|
+
* and crypto live there, not here).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { ITopicRouter, PeerRef } from "./ports.js";
|
|
35
|
+
import type { TierAddressing } from "./addressing.js";
|
|
36
|
+
import type { DMaxComputer } from "./dmax.js";
|
|
37
|
+
import { DEFAULT_D_MAX_CAP } from "./dmax.js";
|
|
38
|
+
import { backoffRetryMs } from "./willingness.js";
|
|
39
|
+
import { b64urlToBytes, decodeRegisterReplyV1, encodeCohortMessage } from "./wire/codec.js";
|
|
40
|
+
import type { RegisterReplyV1, RegisterV1 } from "./wire/types.js";
|
|
41
|
+
|
|
42
|
+
/** The walk landed: the cohort accepted the registration. `reply` carries the cohort cache fields. */
|
|
43
|
+
export interface AcceptedWalkOutcome {
|
|
44
|
+
readonly kind: "accepted";
|
|
45
|
+
readonly reply: RegisterReplyV1;
|
|
46
|
+
/**
|
|
47
|
+
* The accepted register probe's own `correlationId`. The participant echoes it on every renew for this
|
|
48
|
+
* registration so `RenewV1.correlationId` "matches original `RegisterV1`" (docs §Wire, RenewV1). Each
|
|
49
|
+
* probe carries a distinct correlationId; this is the one the cohort admitted.
|
|
50
|
+
*/
|
|
51
|
+
readonly correlationId: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The walk hit a `Promoted` redirect and the engine was configured **not** to follow it
|
|
56
|
+
* ({@link WalkConfig.followPromoted} `= false`): the caller recomputes `coord_targetTier` and
|
|
57
|
+
* registers there itself. With the default (`followPromoted = true`) the engine follows the redirect
|
|
58
|
+
* internally and this outcome never surfaces.
|
|
59
|
+
*/
|
|
60
|
+
export interface PromotedWalkOutcome {
|
|
61
|
+
readonly kind: "promoted";
|
|
62
|
+
readonly targetTier: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The walk backed off in time (`unwilling_cohort`, exhausted sibling retries, a failed cold-start, or
|
|
67
|
+
* the safety step cap). The caller waits `afterMs` then calls {@link WalkEngine.register} again, which
|
|
68
|
+
* restarts at `d_max`.
|
|
69
|
+
*/
|
|
70
|
+
export interface RetryLaterOutcome {
|
|
71
|
+
readonly kind: "retry_later";
|
|
72
|
+
readonly afterMs: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type WalkOutcome = AcceptedWalkOutcome | PromotedWalkOutcome | RetryLaterOutcome;
|
|
76
|
+
|
|
77
|
+
/** Builds (and signs) the {@link RegisterV1} for one probe; owns participant identity + crypto. */
|
|
78
|
+
export interface RegisterMessageFactory {
|
|
79
|
+
/**
|
|
80
|
+
* Produce a signed `RegisterV1` for this participant at walk position `treeTier`. `bootstrap` is set
|
|
81
|
+
* only on the root cold-start re-issue; `followOn` only on the dedicated re-issue after a `Promoted`
|
|
82
|
+
* redirect target answered `NoState` (§Cold-start). `appPayload` is the opaque application slot. On
|
|
83
|
+
* either cold-start re-issue the factory mints and attaches the signed `bootstrapEvidence` envelope
|
|
84
|
+
* (§Anti-DoS — a follow-on is gated identically to a bootstrap) via the injected builder seam before
|
|
85
|
+
* signing — keyed off `bootstrap`/`followOn`, so no extra parameter is needed (the walk decides both
|
|
86
|
+
* internally, not the application). `bootstrap`, `followOn`, and `probe` are mutually exclusive.
|
|
87
|
+
*/
|
|
88
|
+
build(params: {
|
|
89
|
+
topicId: Uint8Array;
|
|
90
|
+
tier: number;
|
|
91
|
+
treeTier: number;
|
|
92
|
+
bootstrap: boolean;
|
|
93
|
+
/** Follow-on cold-start re-issue after a `Promoted` redirect target answered `NoState` (`treeTier >= 1`). */
|
|
94
|
+
followOn: boolean;
|
|
95
|
+
/** Read-only lookup probe: the factory stamps `RegisterV1.probe` and never mints cold-start evidence. */
|
|
96
|
+
probe: boolean;
|
|
97
|
+
appPayload?: Uint8Array;
|
|
98
|
+
}): Promise<RegisterV1>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface WalkConfig {
|
|
102
|
+
/** Cohort size requested from the router (`wantK`). Default 16. */
|
|
103
|
+
wantK?: number;
|
|
104
|
+
/** Threshold signers requested from the router (`minSigs = k − x`). Default 14. */
|
|
105
|
+
minSigs?: number;
|
|
106
|
+
/** Max `unwilling_member` sibling retries at one coord before treating it as a cohort decline. Default `wantK`. */
|
|
107
|
+
maxMemberRetries?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Whether to follow a `Promoted` redirect internally (recompute coord + continue) or surface it as
|
|
110
|
+
* a {@link PromotedWalkOutcome} for the caller to drive. Default `true` (self-contained walk).
|
|
111
|
+
*/
|
|
112
|
+
followPromoted?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Hard cap on probe RPCs in one walk — a safety valve against pathological oscillation between an
|
|
115
|
+
* inward `NoState` step and an outward `Promoted` redirect in a malformed tree. Default scales with
|
|
116
|
+
* `d_max`. Exceeding it yields a {@link RetryLaterOutcome}.
|
|
117
|
+
*/
|
|
118
|
+
maxSteps?: number;
|
|
119
|
+
/** `max_message_bytes` ceiling for the encoded register frame. Defaults to the codec default. */
|
|
120
|
+
maxMessageBytes?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface WalkEngineDeps {
|
|
124
|
+
router: ITopicRouter;
|
|
125
|
+
addressing: TierAddressing;
|
|
126
|
+
/** Computes the walk start tier `d_max` from the current network-size estimate. */
|
|
127
|
+
dmax: DMaxComputer;
|
|
128
|
+
/** This participant's peer id — the `P` in `coord_d(P, topicId)`. */
|
|
129
|
+
self: Uint8Array;
|
|
130
|
+
/** Builds + signs the per-probe `RegisterV1`. */
|
|
131
|
+
factory: RegisterMessageFactory;
|
|
132
|
+
config?: WalkConfig;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Drives a participant's walk-toward-root registration over the injected router + addressing. */
|
|
136
|
+
export interface WalkEngine {
|
|
137
|
+
/**
|
|
138
|
+
* Walk from `d_max` toward the root registering for `topicId` at op `tier`, following `Promoted`
|
|
139
|
+
* redirects outward. Resolves with the terminal {@link WalkOutcome}. With `opts.probe` the walk is a
|
|
140
|
+
* **read-only lookup**: identical routing discipline, but the terminal cohort classifies rather than
|
|
141
|
+
* admits and the root `no_state` branch backs off instead of issuing a `bootstrap: true` cold-start
|
|
142
|
+
* (a probe never instantiates a cold root).
|
|
143
|
+
*/
|
|
144
|
+
register(topicId: Uint8Array, tier: number, appPayload?: Uint8Array, opts?: { probe?: boolean }): Promise<WalkOutcome>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A walk tier is valid iff it is an integer in `0..DEFAULT_D_MAX_CAP` — the substrate's own walk-depth
|
|
149
|
+
* ceiling. A `promoted` reply's explicit `targetTier` is untrusted: an out-of-range value (non-integer,
|
|
150
|
+
* negative, or above the ceiling) cannot name a real cohort and would reach `addressing.coord()` →
|
|
151
|
+
* `coordD`, which throws a raw `RangeError`. Matches the range the wire `treeTier` validator enforces.
|
|
152
|
+
*/
|
|
153
|
+
function isValidTreeTier(value: number): boolean {
|
|
154
|
+
return Number.isInteger(value) && value >= 0 && value <= DEFAULT_D_MAX_CAP;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
class RouterWalkEngine implements WalkEngine {
|
|
158
|
+
private readonly wantK: number;
|
|
159
|
+
private readonly minSigs: number;
|
|
160
|
+
private readonly maxMemberRetries: number;
|
|
161
|
+
private readonly followPromoted: boolean;
|
|
162
|
+
private readonly configuredMaxSteps?: number;
|
|
163
|
+
private readonly maxMessageBytes?: number;
|
|
164
|
+
|
|
165
|
+
constructor(private readonly deps: WalkEngineDeps) {
|
|
166
|
+
const cfg = deps.config ?? {};
|
|
167
|
+
this.wantK = cfg.wantK ?? 16;
|
|
168
|
+
this.minSigs = cfg.minSigs ?? 14;
|
|
169
|
+
this.maxMemberRetries = cfg.maxMemberRetries ?? this.wantK;
|
|
170
|
+
this.followPromoted = cfg.followPromoted ?? true;
|
|
171
|
+
this.configuredMaxSteps = cfg.maxSteps;
|
|
172
|
+
this.maxMessageBytes = cfg.maxMessageBytes;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async register(topicId: Uint8Array, tier: number, appPayload?: Uint8Array, opts?: { probe?: boolean }): Promise<WalkOutcome> {
|
|
176
|
+
const dMax = this.deps.dmax.dMax();
|
|
177
|
+
const maxSteps = this.configuredMaxSteps ?? 2 * (dMax + 2) + this.maxMemberRetries + 8;
|
|
178
|
+
const probe = opts?.probe ?? false;
|
|
179
|
+
|
|
180
|
+
let d = dMax;
|
|
181
|
+
let bootstrap = false;
|
|
182
|
+
let followOn = false;
|
|
183
|
+
// Member ids (base64url) already dialed on this walk's `unwilling_member` retries. Tracking WHICH
|
|
184
|
+
// members were tried — not a positional counter — lets each fresh candidate list be consumed from
|
|
185
|
+
// its best (index-0) member; a counter would permanently skip index 0 of every list after the first.
|
|
186
|
+
const triedMembers = new Set<string>();
|
|
187
|
+
let dialTarget: PeerRef | undefined;
|
|
188
|
+
let steps = 0;
|
|
189
|
+
// True once this walk has followed a `Promoted` redirect outward (either mode). On a subsequent
|
|
190
|
+
// `NoState` the redirect target is cold: a probe backs off (never instantiates), a register
|
|
191
|
+
// re-issues once with `followOn: true` to instantiate the child, then backs off.
|
|
192
|
+
let followedPromoted = false;
|
|
193
|
+
// True once the register path has spent its single `followOn: true` re-issue at the cold child.
|
|
194
|
+
let followOnReissued = false;
|
|
195
|
+
|
|
196
|
+
for (;;) {
|
|
197
|
+
if (++steps > maxSteps) {
|
|
198
|
+
// Safety valve: a well-formed tree converges well within this bound. Surface a temporal
|
|
199
|
+
// back-off rather than spin.
|
|
200
|
+
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const reg = await this.deps.factory.build({ topicId, tier, treeTier: d, bootstrap, followOn, probe, appPayload });
|
|
204
|
+
const activity = encodeCohortMessage(reg, this.maxMessageBytes);
|
|
205
|
+
const raw = dialTarget !== undefined
|
|
206
|
+
? await this.deps.router.dialMember(dialTarget, activity)
|
|
207
|
+
: await this.deps.router.routeAndAct(this.deps.addressing.coord(d, this.deps.self, topicId), activity, {
|
|
208
|
+
wantK: this.wantK,
|
|
209
|
+
minSigs: this.minSigs,
|
|
210
|
+
});
|
|
211
|
+
const reply = decodeRegisterReplyV1(raw, this.maxMessageBytes);
|
|
212
|
+
|
|
213
|
+
switch (reply.result) {
|
|
214
|
+
case "accepted": {
|
|
215
|
+
// Surface the accepted probe's correlationId so the participant's renewals can echo it
|
|
216
|
+
// (RenewV1 correlationId "matches original RegisterV1"). `reg` is the frame just admitted.
|
|
217
|
+
return { kind: "accepted", reply, correlationId: reg.correlationId };
|
|
218
|
+
}
|
|
219
|
+
case "no_state": {
|
|
220
|
+
// Step toward the root. The cohort served nothing here; no spatial sibling state.
|
|
221
|
+
dialTarget = undefined;
|
|
222
|
+
triedMembers.clear(); // a spatial move to a new coord starts sibling retries fresh
|
|
223
|
+
if (followedPromoted) {
|
|
224
|
+
// The `Promoted` redirect target is cold (not yet instantiated). Walking inward to the
|
|
225
|
+
// promoting ancestor would just re-trigger the redirect and oscillate, so handle it here.
|
|
226
|
+
if (probe) {
|
|
227
|
+
// A probe never instantiates — back off immediately (mirror of the register re-issue).
|
|
228
|
+
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
229
|
+
}
|
|
230
|
+
if (!followOnReissued) {
|
|
231
|
+
// Re-issue ONCE at the SAME child tier as a follow-on cold-start: RegisterV1{ followOn:
|
|
232
|
+
// true } + minted evidence. The mirror of the root NoState → bootstrap:true re-issue.
|
|
233
|
+
followOn = true;
|
|
234
|
+
followOnReissued = true;
|
|
235
|
+
break; // re-register at the same coord/tier, now carrying followOn
|
|
236
|
+
}
|
|
237
|
+
// The follow-on re-issue still got NoState → the cold child's quorum is unwilling to
|
|
238
|
+
// instantiate. Back off in time; do NOT loop inward.
|
|
239
|
+
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
240
|
+
}
|
|
241
|
+
const next = d - 1;
|
|
242
|
+
if (next < 0) {
|
|
243
|
+
if (bootstrap) {
|
|
244
|
+
// Already re-issued at the root as a bootstrap and still nothing — no cohort
|
|
245
|
+
// anywhere will instantiate right now. Back off in time.
|
|
246
|
+
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
247
|
+
}
|
|
248
|
+
if (probe) {
|
|
249
|
+
// A read-only probe never instantiates a cold root: the topic exists nowhere, so
|
|
250
|
+
// resolve "not found / back off" rather than re-issuing with bootstrap:true.
|
|
251
|
+
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
252
|
+
}
|
|
253
|
+
// Root returned NoState → cold-start: re-issue once at tier 0 with bootstrap:true.
|
|
254
|
+
d = 0;
|
|
255
|
+
bootstrap = true;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
d = next;
|
|
259
|
+
bootstrap = false;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case "promoted": {
|
|
263
|
+
dialTarget = undefined;
|
|
264
|
+
triedMembers.clear(); // spatial move to the redirect target: sibling retries start fresh
|
|
265
|
+
bootstrap = false;
|
|
266
|
+
const targetTier = reply.targetTier ?? d + 1;
|
|
267
|
+
// The cohort names the tier to jump outward to. When it supplied `targetTier` EXPLICITLY it is
|
|
268
|
+
// untrusted: an out-of-range value would reach `coord()` → `coordD` and throw a raw RangeError,
|
|
269
|
+
// an unclassified crash out of register()/lookup() rather than a clean outcome. Bound it before
|
|
270
|
+
// BOTH adoption sites (the followPromoted-false surface below and the `d = targetTier` hop) and
|
|
271
|
+
// back off in time instead. Only the EXPLICIT attacker value is the hazard; the `d + 1`
|
|
272
|
+
// fallback is left unchecked because `d` is walk-bounded to `dMax + maxSteps` (≈190 under the
|
|
273
|
+
// default `maxSteps`), comfortably under coordD's 255 range.
|
|
274
|
+
// NOTE: `maxSteps` is operator-configurable — a value above ~195 plus an adversarial chain of
|
|
275
|
+
// no-`targetTier` `promoted` replies (each bumps `d` by +1) could push the `d + 1` fallback
|
|
276
|
+
// past coordD's range and reintroduce the RangeError. If maxSteps is ever raised that high,
|
|
277
|
+
// bound the fallback here too.
|
|
278
|
+
if (reply.targetTier !== undefined && !isValidTreeTier(targetTier)) {
|
|
279
|
+
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
280
|
+
}
|
|
281
|
+
// Following a (fresh) redirect: mark it, and reset the follow-on latch so a cold child at
|
|
282
|
+
// THIS target gets its own single follow-on re-issue. The honest flow re-registers the
|
|
283
|
+
// child with a PLAIN frame first (followOn false) and only escalates to followOn on its
|
|
284
|
+
// NoState — so clear the flag here; the NoState branch re-arms it.
|
|
285
|
+
followedPromoted = true;
|
|
286
|
+
followOn = false;
|
|
287
|
+
followOnReissued = false;
|
|
288
|
+
if (!this.followPromoted) {
|
|
289
|
+
return { kind: "promoted", targetTier };
|
|
290
|
+
}
|
|
291
|
+
d = targetTier; // the one outward move — recompute coord at the redirect target
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
case "unwilling_member": {
|
|
295
|
+
const candidates = reply.candidateMembers ?? [];
|
|
296
|
+
// Consume this (possibly fresh) list from its best (index-0) member: pick the FIRST
|
|
297
|
+
// candidate not already dialed on this walk. A positional `memberAttempts % len` offset
|
|
298
|
+
// would skip index 0 of every list after the first, permanently starving the best member.
|
|
299
|
+
const next = candidates.find((c) => !triedMembers.has(c));
|
|
300
|
+
if (next === undefined || triedMembers.size >= this.maxMemberRetries) {
|
|
301
|
+
// No untried candidate offered (or the retry cap is spent) → fall through to a
|
|
302
|
+
// cohort-level temporal back-off, restarting at d_max on the caller's retry.
|
|
303
|
+
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
304
|
+
}
|
|
305
|
+
// Retry the SAME coord at a named alternative member (spatial move within the cohort).
|
|
306
|
+
triedMembers.add(next);
|
|
307
|
+
dialTarget = { id: b64urlToBytes(next) };
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
case "unwilling_cohort": {
|
|
311
|
+
// Back off in TIME, no spatial move; the caller restarts at d_max after the delay.
|
|
312
|
+
return { kind: "retry_later", afterMs: reply.retryAfterMs ?? backoffRetryMs(0) };
|
|
313
|
+
}
|
|
314
|
+
default: {
|
|
315
|
+
// Exhaustive over RegisterResult; an unknown result is treated as a temporal decline.
|
|
316
|
+
return { kind: "retry_later", afterMs: backoffRetryMs(0) };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Build a {@link WalkEngine} over the injected router, addressing, `d_max`, and message factory. */
|
|
324
|
+
export function createWalkEngine(deps: WalkEngineDeps): WalkEngine {
|
|
325
|
+
return new RouterWalkEngine(deps);
|
|
326
|
+
}
|