@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,237 +1,237 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic substrate — per-member willingness / admission control.
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/cohort-topic.md` §Willingness and §Tier ladder, folded back from the
|
|
5
|
-
* simulator-validated `packages/substrate-simulator/src/{willingness,backoff}.ts`. When FRET's
|
|
6
|
-
* `RouteAndMaybeAct` lands a `RegisterV1` on a member, that member runs this check and returns one
|
|
7
|
-
* of three outcomes:
|
|
8
|
-
*
|
|
9
|
-
* - **{@link AcceptedOutcome}** — the routed member is willing; it becomes `primary`.
|
|
10
|
-
* - **{@link UnwillingMemberOutcome}** — the routed member is personally unwilling, but the gossiped
|
|
11
|
-
* willingness vector says siblings will serve this tier; the caller retries a named sibling at the
|
|
12
|
-
* *same* coord (spatial move within the cohort).
|
|
13
|
-
* - **{@link UnwillingCohortOutcome}** — fewer than a quorum of members are willing to serve the
|
|
14
|
-
* tier, so the cohort declines the tier entirely; the caller backs off in *time* (no spatial
|
|
15
|
-
* move — see §Why the caller doesn't walk on UnwillingCohort). The `retryAfterMs` follows the
|
|
16
|
-
* capped-doubling curve in {@link backoffRetryMs}.
|
|
17
|
-
*
|
|
18
|
-
* A member's *live* willingness for a tier is: its profile serves the tier, the tier's load bucket
|
|
19
|
-
* is below `overloadBucket`, **and** it is under its per-tier primary-topic budget. The coarse 1-bit
|
|
20
|
-
* gossiped willingness vector ({@link selfWillingnessBits}) carries only profile-∧-load — the budget
|
|
21
|
-
* is a per-registration gate applied live at the routed member. (Resolved open question: willingness
|
|
22
|
-
* stays at **1 bit per tier** — no finer T3 gradations; the load bucket already supplies coarse load.)
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { createLoadBarometer, DEFAULT_OVERLOAD_BUCKET, type LoadBarometerState } from "./load/barometer.js";
|
|
26
|
-
import type { CohortView } from "./gossip/view.js";
|
|
27
|
-
import { b64urlToBytes } from "./wire/codec.js";
|
|
28
|
-
import type { RegisterV1 } from "./wire/types.js";
|
|
29
|
-
import { ALL_TIERS, type NodeProfile, type Tier } from "./tiers.js";
|
|
30
|
-
|
|
31
|
-
/** The routed member is willing and will serve as `primary`. */
|
|
32
|
-
export interface AcceptedOutcome {
|
|
33
|
-
readonly kind: "accepted";
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Routed member unwilling; these siblings (raw peer-id bytes) will serve — retry one at the same coord. */
|
|
37
|
-
export interface UnwillingMemberOutcome {
|
|
38
|
-
readonly kind: "unwilling_member";
|
|
39
|
-
readonly candidateMembers: Uint8Array[];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** No quorum of willing members; back off `retryAfterMs` in time and retry from `d_max`. */
|
|
43
|
-
export interface UnwillingCohortOutcome {
|
|
44
|
-
readonly kind: "unwilling_cohort";
|
|
45
|
-
readonly retryAfterMs: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export type WillingnessOutcome = AcceptedOutcome | UnwillingMemberOutcome | UnwillingCohortOutcome;
|
|
49
|
-
|
|
50
|
-
/** Per-member willingness / admission check. */
|
|
51
|
-
export interface WillingnessCheck {
|
|
52
|
-
/** Classify `reg` routed onto this member, given the member's static profile and the clock. */
|
|
53
|
-
evaluate(reg: RegisterV1, self: NodeProfile, now: number): WillingnessOutcome;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// --- willingness-vector bit packing (folded back from simulator `willingnessBits`) ---
|
|
57
|
-
|
|
58
|
-
/** Test bit `tier` (T0 = bit 0 … T3 = bit 3) of a packed 4-bit willingness vector. */
|
|
59
|
-
export function tierBit(bits: number, tier: Tier): boolean {
|
|
60
|
-
return (bits & (1 << tier)) !== 0;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Pack a per-tier predicate into a 4-bit willingness vector (bit `t` set iff `willing(t)`). */
|
|
64
|
-
export function packWillingnessBits(willing: (tier: Tier) => boolean): number {
|
|
65
|
-
let bits = 0;
|
|
66
|
-
for (const t of ALL_TIERS) {
|
|
67
|
-
if (willing(t)) bits |= 1 << t;
|
|
68
|
-
}
|
|
69
|
-
return bits;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** The packed vector as a single hex nibble — the `CohortGossipV1.willingnessBits` wire form. */
|
|
73
|
-
export function willingnessBitsHex(bits: number): string {
|
|
74
|
-
return (bits & 0xf).toString(16);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* This member's gossiped (coarse) willingness vector: bit `t` set iff the profile serves `t` and the
|
|
79
|
-
* tier is not shed under load. The per-tier primary-topic budget is deliberately **not** folded in
|
|
80
|
-
* — it is a live per-registration gate, not a gossiped signal (§Willingness: the gossiped vector is
|
|
81
|
-
* "one bit per tier per member… refreshed every gossip round").
|
|
82
|
-
*/
|
|
83
|
-
export function selfWillingnessBits(self: NodeProfile, barometer: LoadBarometerState): number {
|
|
84
|
-
return packWillingnessBits((t) => self.willingTiers.has(t) && barometer.loadWilling(t));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// --- exponential UnwillingCohort back-off (settled params, docs §Willingness L260-263) ---
|
|
88
|
-
|
|
89
|
-
export interface BackoffConfig {
|
|
90
|
-
/** First-rejection delay (ms). Default 1000. */
|
|
91
|
-
readonly baseMs: number;
|
|
92
|
-
/** Geometric growth per rejection. Default 2 (doubling). */
|
|
93
|
-
readonly factor: number;
|
|
94
|
-
/** Hard ceiling on a single delay (ms). Default 60000. */
|
|
95
|
-
readonly capMs: number;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Settled back-off curve (`docs/cohort-topic.md` §Willingness): `base = 1 s`, `factor = 2`, `cap = 60 s`. */
|
|
99
|
-
export const DEFAULT_BACKOFF_CONFIG: BackoffConfig = {
|
|
100
|
-
baseMs: 1000,
|
|
101
|
-
factor: 2,
|
|
102
|
-
capMs: 60_000,
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* `retryAfter` for the `attempt`-th rejection (0-based): `⌊base · factor^attempt⌋` capped at `capMs`.
|
|
107
|
-
* The capped doubling bounds the rejections a participant suffers across an overload window at
|
|
108
|
-
* `O(log(window/base))` (≤ ~6 to span 60 s) rather than the `window/base` a fixed interval incurs.
|
|
109
|
-
* As survivors of a burst back off geometrically, offered load sheds and accepted/sec holds at the
|
|
110
|
-
* willing-quorum capacity without a cascade.
|
|
111
|
-
*/
|
|
112
|
-
export function backoffRetryMs(attempt: number, config: BackoffConfig = DEFAULT_BACKOFF_CONFIG): number {
|
|
113
|
-
if (!Number.isInteger(attempt) || attempt < 0) {
|
|
114
|
-
throw new RangeError(`attempt must be a non-negative integer, got ${attempt}`);
|
|
115
|
-
}
|
|
116
|
-
const raw = config.baseMs * Math.pow(config.factor, attempt);
|
|
117
|
-
return Math.min(Math.floor(raw), config.capMs);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// --- willingness check ---
|
|
121
|
-
|
|
122
|
-
export interface WillingnessConfig {
|
|
123
|
-
/** Cohort size `k` (drives the default quorum). Default 16. */
|
|
124
|
-
cohortSize?: number;
|
|
125
|
-
/**
|
|
126
|
-
* Members that must be willing to serve a tier for the cohort to take it on. Default: strict
|
|
127
|
-
* majority of `cohortSize` (`⌊k/2⌋ + 1`). Not pinned by `docs/cohort-topic.md` — §Tier ladder
|
|
128
|
-
* only requires "a quorum"; configurable here, revisitable by the Edge/Core policy ticket.
|
|
129
|
-
*/
|
|
130
|
-
quorum?: number;
|
|
131
|
-
/** Load bucket at/above which a tier is shed. Default {@link DEFAULT_OVERLOAD_BUCKET} (6). */
|
|
132
|
-
overloadBucket?: number;
|
|
133
|
-
/**
|
|
134
|
-
* Max topics this member may be `primary` for at a single tier. Default 2048 (`topics_max`).
|
|
135
|
-
* The doc names the per-member per-tier budget but pins no number; configurable here.
|
|
136
|
-
*/
|
|
137
|
-
maxPrimaryTopicsPerTier?: number;
|
|
138
|
-
/** Back-off curve for `unwilling_cohort` retries. Default {@link DEFAULT_BACKOFF_CONFIG}. */
|
|
139
|
-
backoff?: BackoffConfig;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export interface WillingnessDeps {
|
|
143
|
-
/** This member's own per-tier load barometer (live willingness uses its buckets). */
|
|
144
|
-
barometer: LoadBarometerState;
|
|
145
|
-
/** Merged per-member gossip view — the sibling willingness vectors. */
|
|
146
|
-
view: CohortView;
|
|
147
|
-
/** This member's own id, base64url (the `fromMember` key) — excluded from the sibling scan. */
|
|
148
|
-
selfMember: string;
|
|
149
|
-
/** Count of topics this member is already `primary` for at `tier` (the budget gate input). */
|
|
150
|
-
primaryTopicCount: (tier: Tier) => number;
|
|
151
|
-
/**
|
|
152
|
-
* Rejection-attempt count for `reg`, driving the `retryAfter` curve. The anti-DoS rate limiter
|
|
153
|
-
* owns the counter; the *curve* lives here. Default `() => 0` (first-rejection delay).
|
|
154
|
-
*/
|
|
155
|
-
attempts?: (reg: RegisterV1) => number;
|
|
156
|
-
config?: WillingnessConfig;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Default quorum: strict majority of the cohort. */
|
|
160
|
-
export function defaultQuorum(cohortSize: number): number {
|
|
161
|
-
return Math.floor(cohortSize / 2) + 1;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
class GossipWillingnessCheck implements WillingnessCheck {
|
|
165
|
-
private readonly quorum: number;
|
|
166
|
-
private readonly overloadBucket: number;
|
|
167
|
-
private readonly maxPrimaryTopicsPerTier: number;
|
|
168
|
-
private readonly backoff: BackoffConfig;
|
|
169
|
-
private readonly attempts: (reg: RegisterV1) => number;
|
|
170
|
-
|
|
171
|
-
constructor(private readonly deps: WillingnessDeps) {
|
|
172
|
-
const cfg = deps.config ?? {};
|
|
173
|
-
const cohortSize = cfg.cohortSize ?? 16;
|
|
174
|
-
this.quorum = cfg.quorum ?? defaultQuorum(cohortSize);
|
|
175
|
-
this.overloadBucket = cfg.overloadBucket ?? DEFAULT_OVERLOAD_BUCKET;
|
|
176
|
-
this.maxPrimaryTopicsPerTier = cfg.maxPrimaryTopicsPerTier ?? 2048;
|
|
177
|
-
this.backoff = cfg.backoff ?? DEFAULT_BACKOFF_CONFIG;
|
|
178
|
-
this.attempts = deps.attempts ?? ((): number => 0);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
evaluate(reg: RegisterV1, self: NodeProfile, now: number): WillingnessOutcome {
|
|
182
|
-
const tier = reg.tier as Tier;
|
|
183
|
-
if (!ALL_TIERS.includes(tier)) {
|
|
184
|
-
// An op tier outside T0..T3 is not serviceable; treat as a cohort-level decline.
|
|
185
|
-
return { kind: "unwilling_cohort", retryAfterMs: backoffRetryMs(this.attempts(reg), this.backoff) };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const selfWilling = this.selfLiveWilling(self, tier);
|
|
189
|
-
const siblings = this.willingSiblings(tier);
|
|
190
|
-
const willingCount = siblings.length + (selfWilling ? 1 : 0);
|
|
191
|
-
|
|
192
|
-
// Quorum gate (§Tier ladder): the cohort takes on tier-T duties only if a quorum of members
|
|
193
|
-
// is willing; otherwise registrations get UnwillingCohort and the caller backs off in time.
|
|
194
|
-
if (willingCount < this.quorum) {
|
|
195
|
-
return { kind: "unwilling_cohort", retryAfterMs: backoffRetryMs(this.attempts(reg), this.backoff) };
|
|
196
|
-
}
|
|
197
|
-
if (selfWilling) {
|
|
198
|
-
return { kind: "accepted" };
|
|
199
|
-
}
|
|
200
|
-
// Quorum holds and self is unwilling → some sibling will serve.
|
|
201
|
-
return { kind: "unwilling_member", candidateMembers: siblings.map((m) => b64urlToBytes(m)) };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Live willingness of *this* member for `tier`: profile ∧ load-under-threshold ∧ under budget. */
|
|
205
|
-
private selfLiveWilling(self: NodeProfile, tier: Tier): boolean {
|
|
206
|
-
return (
|
|
207
|
-
self.willingTiers.has(tier) &&
|
|
208
|
-
this.deps.barometer.bucket(tier) < this.overloadBucket &&
|
|
209
|
-
this.deps.primaryTopicCount(tier) < this.maxPrimaryTopicsPerTier
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Sibling member keys (base64url) whose *gossiped* willingness vector serves `tier`. */
|
|
214
|
-
private willingSiblings(tier: Tier): string[] {
|
|
215
|
-
const out: string[] = [];
|
|
216
|
-
for (const [member, contribution] of this.deps.view.all()) {
|
|
217
|
-
if (member === this.deps.selfMember) continue; // self counted via live willingness
|
|
218
|
-
if (tierBit(contribution.willingness, tier)) {
|
|
219
|
-
out.push(member);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return out;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Build a {@link WillingnessCheck} over the injected barometer + gossip view + budget source. */
|
|
227
|
-
export function createWillingnessCheck(deps: WillingnessDeps): WillingnessCheck {
|
|
228
|
-
return new GossipWillingnessCheck(deps);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Convenience: a willingness check with a fresh idle barometer (tests / single-tier callers). */
|
|
232
|
-
export function createWillingnessCheckWithIdleBarometer(
|
|
233
|
-
deps: Omit<WillingnessDeps, "barometer">,
|
|
234
|
-
): { check: WillingnessCheck; barometer: LoadBarometerState } {
|
|
235
|
-
const barometer = createLoadBarometer({ overloadBucket: deps.config?.overloadBucket });
|
|
236
|
-
return { check: createWillingnessCheck({ ...deps, barometer }), barometer };
|
|
237
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic substrate — per-member willingness / admission control.
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/cohort-topic.md` §Willingness and §Tier ladder, folded back from the
|
|
5
|
+
* simulator-validated `packages/substrate-simulator/src/{willingness,backoff}.ts`. When FRET's
|
|
6
|
+
* `RouteAndMaybeAct` lands a `RegisterV1` on a member, that member runs this check and returns one
|
|
7
|
+
* of three outcomes:
|
|
8
|
+
*
|
|
9
|
+
* - **{@link AcceptedOutcome}** — the routed member is willing; it becomes `primary`.
|
|
10
|
+
* - **{@link UnwillingMemberOutcome}** — the routed member is personally unwilling, but the gossiped
|
|
11
|
+
* willingness vector says siblings will serve this tier; the caller retries a named sibling at the
|
|
12
|
+
* *same* coord (spatial move within the cohort).
|
|
13
|
+
* - **{@link UnwillingCohortOutcome}** — fewer than a quorum of members are willing to serve the
|
|
14
|
+
* tier, so the cohort declines the tier entirely; the caller backs off in *time* (no spatial
|
|
15
|
+
* move — see §Why the caller doesn't walk on UnwillingCohort). The `retryAfterMs` follows the
|
|
16
|
+
* capped-doubling curve in {@link backoffRetryMs}.
|
|
17
|
+
*
|
|
18
|
+
* A member's *live* willingness for a tier is: its profile serves the tier, the tier's load bucket
|
|
19
|
+
* is below `overloadBucket`, **and** it is under its per-tier primary-topic budget. The coarse 1-bit
|
|
20
|
+
* gossiped willingness vector ({@link selfWillingnessBits}) carries only profile-∧-load — the budget
|
|
21
|
+
* is a per-registration gate applied live at the routed member. (Resolved open question: willingness
|
|
22
|
+
* stays at **1 bit per tier** — no finer T3 gradations; the load bucket already supplies coarse load.)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createLoadBarometer, DEFAULT_OVERLOAD_BUCKET, type LoadBarometerState } from "./load/barometer.js";
|
|
26
|
+
import type { CohortView } from "./gossip/view.js";
|
|
27
|
+
import { b64urlToBytes } from "./wire/codec.js";
|
|
28
|
+
import type { RegisterV1 } from "./wire/types.js";
|
|
29
|
+
import { ALL_TIERS, type NodeProfile, type Tier } from "./tiers.js";
|
|
30
|
+
|
|
31
|
+
/** The routed member is willing and will serve as `primary`. */
|
|
32
|
+
export interface AcceptedOutcome {
|
|
33
|
+
readonly kind: "accepted";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Routed member unwilling; these siblings (raw peer-id bytes) will serve — retry one at the same coord. */
|
|
37
|
+
export interface UnwillingMemberOutcome {
|
|
38
|
+
readonly kind: "unwilling_member";
|
|
39
|
+
readonly candidateMembers: Uint8Array[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** No quorum of willing members; back off `retryAfterMs` in time and retry from `d_max`. */
|
|
43
|
+
export interface UnwillingCohortOutcome {
|
|
44
|
+
readonly kind: "unwilling_cohort";
|
|
45
|
+
readonly retryAfterMs: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type WillingnessOutcome = AcceptedOutcome | UnwillingMemberOutcome | UnwillingCohortOutcome;
|
|
49
|
+
|
|
50
|
+
/** Per-member willingness / admission check. */
|
|
51
|
+
export interface WillingnessCheck {
|
|
52
|
+
/** Classify `reg` routed onto this member, given the member's static profile and the clock. */
|
|
53
|
+
evaluate(reg: RegisterV1, self: NodeProfile, now: number): WillingnessOutcome;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- willingness-vector bit packing (folded back from simulator `willingnessBits`) ---
|
|
57
|
+
|
|
58
|
+
/** Test bit `tier` (T0 = bit 0 … T3 = bit 3) of a packed 4-bit willingness vector. */
|
|
59
|
+
export function tierBit(bits: number, tier: Tier): boolean {
|
|
60
|
+
return (bits & (1 << tier)) !== 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Pack a per-tier predicate into a 4-bit willingness vector (bit `t` set iff `willing(t)`). */
|
|
64
|
+
export function packWillingnessBits(willing: (tier: Tier) => boolean): number {
|
|
65
|
+
let bits = 0;
|
|
66
|
+
for (const t of ALL_TIERS) {
|
|
67
|
+
if (willing(t)) bits |= 1 << t;
|
|
68
|
+
}
|
|
69
|
+
return bits;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The packed vector as a single hex nibble — the `CohortGossipV1.willingnessBits` wire form. */
|
|
73
|
+
export function willingnessBitsHex(bits: number): string {
|
|
74
|
+
return (bits & 0xf).toString(16);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* This member's gossiped (coarse) willingness vector: bit `t` set iff the profile serves `t` and the
|
|
79
|
+
* tier is not shed under load. The per-tier primary-topic budget is deliberately **not** folded in
|
|
80
|
+
* — it is a live per-registration gate, not a gossiped signal (§Willingness: the gossiped vector is
|
|
81
|
+
* "one bit per tier per member… refreshed every gossip round").
|
|
82
|
+
*/
|
|
83
|
+
export function selfWillingnessBits(self: NodeProfile, barometer: LoadBarometerState): number {
|
|
84
|
+
return packWillingnessBits((t) => self.willingTiers.has(t) && barometer.loadWilling(t));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- exponential UnwillingCohort back-off (settled params, docs §Willingness L260-263) ---
|
|
88
|
+
|
|
89
|
+
export interface BackoffConfig {
|
|
90
|
+
/** First-rejection delay (ms). Default 1000. */
|
|
91
|
+
readonly baseMs: number;
|
|
92
|
+
/** Geometric growth per rejection. Default 2 (doubling). */
|
|
93
|
+
readonly factor: number;
|
|
94
|
+
/** Hard ceiling on a single delay (ms). Default 60000. */
|
|
95
|
+
readonly capMs: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Settled back-off curve (`docs/cohort-topic.md` §Willingness): `base = 1 s`, `factor = 2`, `cap = 60 s`. */
|
|
99
|
+
export const DEFAULT_BACKOFF_CONFIG: BackoffConfig = {
|
|
100
|
+
baseMs: 1000,
|
|
101
|
+
factor: 2,
|
|
102
|
+
capMs: 60_000,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `retryAfter` for the `attempt`-th rejection (0-based): `⌊base · factor^attempt⌋` capped at `capMs`.
|
|
107
|
+
* The capped doubling bounds the rejections a participant suffers across an overload window at
|
|
108
|
+
* `O(log(window/base))` (≤ ~6 to span 60 s) rather than the `window/base` a fixed interval incurs.
|
|
109
|
+
* As survivors of a burst back off geometrically, offered load sheds and accepted/sec holds at the
|
|
110
|
+
* willing-quorum capacity without a cascade.
|
|
111
|
+
*/
|
|
112
|
+
export function backoffRetryMs(attempt: number, config: BackoffConfig = DEFAULT_BACKOFF_CONFIG): number {
|
|
113
|
+
if (!Number.isInteger(attempt) || attempt < 0) {
|
|
114
|
+
throw new RangeError(`attempt must be a non-negative integer, got ${attempt}`);
|
|
115
|
+
}
|
|
116
|
+
const raw = config.baseMs * Math.pow(config.factor, attempt);
|
|
117
|
+
return Math.min(Math.floor(raw), config.capMs);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- willingness check ---
|
|
121
|
+
|
|
122
|
+
export interface WillingnessConfig {
|
|
123
|
+
/** Cohort size `k` (drives the default quorum). Default 16. */
|
|
124
|
+
cohortSize?: number;
|
|
125
|
+
/**
|
|
126
|
+
* Members that must be willing to serve a tier for the cohort to take it on. Default: strict
|
|
127
|
+
* majority of `cohortSize` (`⌊k/2⌋ + 1`). Not pinned by `docs/cohort-topic.md` — §Tier ladder
|
|
128
|
+
* only requires "a quorum"; configurable here, revisitable by the Edge/Core policy ticket.
|
|
129
|
+
*/
|
|
130
|
+
quorum?: number;
|
|
131
|
+
/** Load bucket at/above which a tier is shed. Default {@link DEFAULT_OVERLOAD_BUCKET} (6). */
|
|
132
|
+
overloadBucket?: number;
|
|
133
|
+
/**
|
|
134
|
+
* Max topics this member may be `primary` for at a single tier. Default 2048 (`topics_max`).
|
|
135
|
+
* The doc names the per-member per-tier budget but pins no number; configurable here.
|
|
136
|
+
*/
|
|
137
|
+
maxPrimaryTopicsPerTier?: number;
|
|
138
|
+
/** Back-off curve for `unwilling_cohort` retries. Default {@link DEFAULT_BACKOFF_CONFIG}. */
|
|
139
|
+
backoff?: BackoffConfig;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface WillingnessDeps {
|
|
143
|
+
/** This member's own per-tier load barometer (live willingness uses its buckets). */
|
|
144
|
+
barometer: LoadBarometerState;
|
|
145
|
+
/** Merged per-member gossip view — the sibling willingness vectors. */
|
|
146
|
+
view: CohortView;
|
|
147
|
+
/** This member's own id, base64url (the `fromMember` key) — excluded from the sibling scan. */
|
|
148
|
+
selfMember: string;
|
|
149
|
+
/** Count of topics this member is already `primary` for at `tier` (the budget gate input). */
|
|
150
|
+
primaryTopicCount: (tier: Tier) => number;
|
|
151
|
+
/**
|
|
152
|
+
* Rejection-attempt count for `reg`, driving the `retryAfter` curve. The anti-DoS rate limiter
|
|
153
|
+
* owns the counter; the *curve* lives here. Default `() => 0` (first-rejection delay).
|
|
154
|
+
*/
|
|
155
|
+
attempts?: (reg: RegisterV1) => number;
|
|
156
|
+
config?: WillingnessConfig;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Default quorum: strict majority of the cohort. */
|
|
160
|
+
export function defaultQuorum(cohortSize: number): number {
|
|
161
|
+
return Math.floor(cohortSize / 2) + 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
class GossipWillingnessCheck implements WillingnessCheck {
|
|
165
|
+
private readonly quorum: number;
|
|
166
|
+
private readonly overloadBucket: number;
|
|
167
|
+
private readonly maxPrimaryTopicsPerTier: number;
|
|
168
|
+
private readonly backoff: BackoffConfig;
|
|
169
|
+
private readonly attempts: (reg: RegisterV1) => number;
|
|
170
|
+
|
|
171
|
+
constructor(private readonly deps: WillingnessDeps) {
|
|
172
|
+
const cfg = deps.config ?? {};
|
|
173
|
+
const cohortSize = cfg.cohortSize ?? 16;
|
|
174
|
+
this.quorum = cfg.quorum ?? defaultQuorum(cohortSize);
|
|
175
|
+
this.overloadBucket = cfg.overloadBucket ?? DEFAULT_OVERLOAD_BUCKET;
|
|
176
|
+
this.maxPrimaryTopicsPerTier = cfg.maxPrimaryTopicsPerTier ?? 2048;
|
|
177
|
+
this.backoff = cfg.backoff ?? DEFAULT_BACKOFF_CONFIG;
|
|
178
|
+
this.attempts = deps.attempts ?? ((): number => 0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
evaluate(reg: RegisterV1, self: NodeProfile, now: number): WillingnessOutcome {
|
|
182
|
+
const tier = reg.tier as Tier;
|
|
183
|
+
if (!ALL_TIERS.includes(tier)) {
|
|
184
|
+
// An op tier outside T0..T3 is not serviceable; treat as a cohort-level decline.
|
|
185
|
+
return { kind: "unwilling_cohort", retryAfterMs: backoffRetryMs(this.attempts(reg), this.backoff) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const selfWilling = this.selfLiveWilling(self, tier);
|
|
189
|
+
const siblings = this.willingSiblings(tier);
|
|
190
|
+
const willingCount = siblings.length + (selfWilling ? 1 : 0);
|
|
191
|
+
|
|
192
|
+
// Quorum gate (§Tier ladder): the cohort takes on tier-T duties only if a quorum of members
|
|
193
|
+
// is willing; otherwise registrations get UnwillingCohort and the caller backs off in time.
|
|
194
|
+
if (willingCount < this.quorum) {
|
|
195
|
+
return { kind: "unwilling_cohort", retryAfterMs: backoffRetryMs(this.attempts(reg), this.backoff) };
|
|
196
|
+
}
|
|
197
|
+
if (selfWilling) {
|
|
198
|
+
return { kind: "accepted" };
|
|
199
|
+
}
|
|
200
|
+
// Quorum holds and self is unwilling → some sibling will serve.
|
|
201
|
+
return { kind: "unwilling_member", candidateMembers: siblings.map((m) => b64urlToBytes(m)) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Live willingness of *this* member for `tier`: profile ∧ load-under-threshold ∧ under budget. */
|
|
205
|
+
private selfLiveWilling(self: NodeProfile, tier: Tier): boolean {
|
|
206
|
+
return (
|
|
207
|
+
self.willingTiers.has(tier) &&
|
|
208
|
+
this.deps.barometer.bucket(tier) < this.overloadBucket &&
|
|
209
|
+
this.deps.primaryTopicCount(tier) < this.maxPrimaryTopicsPerTier
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Sibling member keys (base64url) whose *gossiped* willingness vector serves `tier`. */
|
|
214
|
+
private willingSiblings(tier: Tier): string[] {
|
|
215
|
+
const out: string[] = [];
|
|
216
|
+
for (const [member, contribution] of this.deps.view.all()) {
|
|
217
|
+
if (member === this.deps.selfMember) continue; // self counted via live willingness
|
|
218
|
+
if (tierBit(contribution.willingness, tier)) {
|
|
219
|
+
out.push(member);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Build a {@link WillingnessCheck} over the injected barometer + gossip view + budget source. */
|
|
227
|
+
export function createWillingnessCheck(deps: WillingnessDeps): WillingnessCheck {
|
|
228
|
+
return new GossipWillingnessCheck(deps);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Convenience: a willingness check with a fresh idle barometer (tests / single-tier callers). */
|
|
232
|
+
export function createWillingnessCheckWithIdleBarometer(
|
|
233
|
+
deps: Omit<WillingnessDeps, "barometer">,
|
|
234
|
+
): { check: WillingnessCheck; barometer: LoadBarometerState } {
|
|
235
|
+
const barometer = createLoadBarometer({ overloadBucket: deps.config?.overloadBucket });
|
|
236
|
+
return { check: createWillingnessCheck({ ...deps, barometer }), barometer };
|
|
237
|
+
}
|