@optimystic/db-core 0.22.0 → 0.24.0
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,405 +1,405 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic substrate — promotion / demotion lifecycle.
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/cohort-topic.md` §Promotion and demotion lifecycle and folded back from the
|
|
5
|
-
* simulator-validated `packages/substrate-simulator/src/topic-tree.ts`. A forwarder cohort grows
|
|
6
|
-
* (promotes) and shrinks (demotes) for a topic `T` based on its per-topic direct-participant count
|
|
7
|
-
* and the per-tier load barometer; both transitions are **threshold-signed** (via the gossip ticket's
|
|
8
|
-
* {@link CohortSigner}) so participants can verify them.
|
|
9
|
-
*
|
|
10
|
-
* **Promote** when, for a quorum (the quorum is enforced by the threshold signature — `minSigs`
|
|
11
|
-
* signers must agree to the notice):
|
|
12
|
-
* - `directParticipants(T) ≥ cap_promote` (64), OR
|
|
13
|
-
* - `loadBucket[tier(T)] ≥ bucket_overload` (6) AND `directParticipants(T) ≥ cap_promote_fast` (32)
|
|
14
|
-
* — the hot-load fast path from the capacity-barometer ticket, OR
|
|
15
|
-
* - the growth **slope** predicts crossing `cap_promote` within `T_promote_lookahead` (30s) — fires
|
|
16
|
-
* early to avoid the gossip-lag overshoot (`§Promotion`: pre-promotion on slope).
|
|
17
|
-
*
|
|
18
|
-
* Once promoted, the state is **sticky** for ≥ `T_promote_sticky` (60s) before it can be reconsidered
|
|
19
|
-
* for demotion, so transient count drops don't flap a cohort back to accepting.
|
|
20
|
-
*
|
|
21
|
-
* **Demote** when, for a quorum: `directParticipants(T) ≤ cap_demote` (16) AND that has held for
|
|
22
|
-
* ≥ `T_demote` (5min) AND the cohort has no live child cohorts AND it has a parent (the root, tree
|
|
23
|
-
* tier 0, never demotes — it has nowhere to hand off). The `4×` gap between `cap_promote` and
|
|
24
|
-
* `cap_demote` plus `T_demote` is the hysteresis that prevents thrash.
|
|
25
|
-
*
|
|
26
|
-
* **Deviation from the doc's interface sketch (documented).** `docs/cohort-topic.md` sketches
|
|
27
|
-
* `onParticipantCountChange` as returning `void` and "may emit `PromotionNoticeV1`". Because
|
|
28
|
-
* threshold signing is asynchronous ({@link CohortSigner.thresholdSign} resolves a Promise), the
|
|
29
|
-
* methods here are `async` and **return** the signed notice (or `undefined`) instead of emitting it
|
|
30
|
-
* through a side channel — the caller broadcasts whatever it gets back. This keeps the module pure
|
|
31
|
-
* and unit-testable and avoids an awkward async callback. The op-tier `tier(T)` the barometer is
|
|
32
|
-
* indexed by is supplied through the injected {@link PromotionDeps.loadBucket} resolver, so this
|
|
33
|
-
* module never needs to know a topic's tier.
|
|
34
|
-
*
|
|
35
|
-
* **Remote apply path.** `promote()` / `demote()` set this cohort's per-topic state as a side effect of
|
|
36
|
-
* the **local** signing path — only the member that originates a notice adopts the new state that way. A
|
|
37
|
-
* member that *learns* of a promotion/demotion via gossip or a broadcast notice adopts it through
|
|
38
|
-
* {@link PromotionLifecycle.applyPromotionNotice} / {@link PromotionLifecycle.applyDemotionNotice},
|
|
39
|
-
* which set the same {@link PromotionState} **without re-signing** (the notice is already a verified
|
|
40
|
-
* quorum decision). **Precondition: the caller has already verified the notice's threshold signature**
|
|
41
|
-
* (db-p2p's `MembershipVerifier`); this module is crypto-free and trusts that gate. The apply path is
|
|
42
|
-
* idempotent and `effectiveAt`-ordered via the monotonic {@link PromotionState.lastEffectiveAt}
|
|
43
|
-
* high-water mark, so re-applying a notice (including this member's own echoed broadcast) or a replayed
|
|
44
|
-
* older notice is a no-op — a stale promotion can never un-demote a cohort that has since demoted.
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
import { b64urlToBytes, bytesToB64url } from "./wire/codec.js";
|
|
48
|
-
import type { CohortSigner } from "./sig/threshold.js";
|
|
49
|
-
import { demotionNoticeSigningPayload, promotionNoticeSigningPayload } from "./sig/payloads.js";
|
|
50
|
-
import type { DemotionNoticeV1, PromotionNoticeV1 } from "./wire/types.js";
|
|
51
|
-
import type { RegistrationStore } from "./registration/types.js";
|
|
52
|
-
import { bytesKey } from "./registration/bytes.js";
|
|
53
|
-
|
|
54
|
-
// --- defaults (docs/cohort-topic.md §Configuration; simulator-confirmed) ---
|
|
55
|
-
|
|
56
|
-
/** Direct-participant cap before promotion. */
|
|
57
|
-
export const DEFAULT_CAP_PROMOTE = 64;
|
|
58
|
-
/** Direct-participant cap when the load barometer is hot (fast path). */
|
|
59
|
-
export const DEFAULT_CAP_PROMOTE_FAST = 32;
|
|
60
|
-
/** Load-barometer bucket at/above which the fast promote path is armed. */
|
|
61
|
-
export const DEFAULT_BUCKET_OVERLOAD = 6;
|
|
62
|
-
/** Direct-participant floor for demotion (= `cap_promote / 4`). */
|
|
63
|
-
export const DEFAULT_CAP_DEMOTE = 16;
|
|
64
|
-
/** Hysteresis window the low-load condition must hold before demotion (ms). */
|
|
65
|
-
export const DEFAULT_T_DEMOTE_MS = 300_000;
|
|
66
|
-
/** Pre-promotion slope lookahead (ms). */
|
|
67
|
-
export const DEFAULT_T_PROMOTE_LOOKAHEAD_MS = 30_000;
|
|
68
|
-
/** Minimum time a cohort stays promoted before it can be reconsidered for demotion (ms). */
|
|
69
|
-
export const DEFAULT_T_PROMOTE_STICKY_MS = 60_000;
|
|
70
|
-
/**
|
|
71
|
-
* Slope window over which the growth rate is measured for pre-promotion. **Not pinned by
|
|
72
|
-
* `docs/cohort-topic.md`** — the simulator (`topic-tree.ts` `DEFAULT_LIFECYCLE_CONFIG`) uses 10s and
|
|
73
|
-
* this matches it.
|
|
74
|
-
*/
|
|
75
|
-
export const DEFAULT_GROWTH_WINDOW_MS = 10_000;
|
|
76
|
-
|
|
77
|
-
export interface PromotionConfig {
|
|
78
|
-
capPromote?: number;
|
|
79
|
-
capPromoteFast?: number;
|
|
80
|
-
bucketOverload?: number;
|
|
81
|
-
capDemote?: number;
|
|
82
|
-
tDemoteMs?: number;
|
|
83
|
-
tPromoteLookaheadMs?: number;
|
|
84
|
-
tPromoteStickyMs?: number;
|
|
85
|
-
growthWindowMs?: number;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** A sampled `(time, count)` growth point, for slope-based pre-promotion. */
|
|
89
|
-
interface GrowthSample {
|
|
90
|
-
readonly t: number;
|
|
91
|
-
readonly count: number;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Per-topic promotion bookkeeping held by this cohort. */
|
|
95
|
-
interface PromotionState {
|
|
96
|
-
promoted: boolean;
|
|
97
|
-
/** When the cohort last entered promoted mode (sticky-window anchor). */
|
|
98
|
-
promotedAt?: number;
|
|
99
|
-
/** When `directParticipants` last dropped to ≤ `cap_demote` (demotion hysteresis anchor). */
|
|
100
|
-
lowLoadSince?: number;
|
|
101
|
-
/** Recent growth samples within `growthWindowMs`, for slope extrapolation. */
|
|
102
|
-
samples: GrowthSample[];
|
|
103
|
-
/**
|
|
104
|
-
* `effectiveAt` of the most recent promotion/demotion this cohort has adopted — locally originated
|
|
105
|
-
* (`promote()` / `demote()`) or remotely applied (`applyPromotionNotice` / `applyDemotionNotice`).
|
|
106
|
-
* Monotonic and **never cleared** (a demotion clears `promoted`/`promotedAt` but keeps this), so it
|
|
107
|
-
* is the high-water mark the remote-apply path orders against: a notice whose `effectiveAt` is not
|
|
108
|
-
* strictly newer is a no-op — re-applied (incl. this member's own echoed broadcast) or stale-replayed.
|
|
109
|
-
*/
|
|
110
|
-
lastEffectiveAt?: number;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface PromotionDeps {
|
|
114
|
-
/** Replicated registration store — supplies the per-topic `directParticipants` stock count. */
|
|
115
|
-
store: Pick<RegistrationStore, "directParticipants">;
|
|
116
|
-
/**
|
|
117
|
-
* Current load-barometer bucket (0..7) for the topic's op tier `tier(T)`. The caller resolves the
|
|
118
|
-
* topic→tier mapping and the barometer lookup, keeping this module tier-agnostic and FRET-free.
|
|
119
|
-
*/
|
|
120
|
-
loadBucket: (topicId: Uint8Array) => number;
|
|
121
|
-
/** Live child-cohort count for the topic (0 if none); `> 0` blocks demotion. */
|
|
122
|
-
childCohortCount: (topicId: Uint8Array) => number;
|
|
123
|
-
/** Tree tier `d` this cohort serves the topic at — `fromTier` on a notice; `toTier = d + 1`. */
|
|
124
|
-
treeTier: (topicId: Uint8Array) => number;
|
|
125
|
-
/** The tier-`(d − 1)` parent cohort coord, target of a {@link DemotionNoticeV1}. */
|
|
126
|
-
parentCoord: (topicId: Uint8Array) => Uint8Array;
|
|
127
|
-
/**
|
|
128
|
-
* The served coord `coord_d(participantCoord, topicId)` this cohort sits at (raw bytes) — stamped on
|
|
129
|
-
* every notice as `cohortCoord` and covered by its threshold signature, so a receiver routes the notice
|
|
130
|
-
* to exactly this cohort's engine and verifies it against exactly this cohort's cert. Constant for the
|
|
131
|
-
* engine's lifetime (the engine is instantiated at one served coord).
|
|
132
|
-
*/
|
|
133
|
-
cohortCoord: () => Uint8Array;
|
|
134
|
-
/** Cohort epoch (raw bytes) the notices are signed under. */
|
|
135
|
-
cohortEpoch: () => Uint8Array;
|
|
136
|
-
/** Threshold signer (the gossip ticket's `k − x` cohort signer). */
|
|
137
|
-
signer: CohortSigner;
|
|
138
|
-
config?: PromotionConfig;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Promotion / demotion lifecycle for one cohort across all the topics it serves. Keyed by `topicId`;
|
|
143
|
-
* each topic carries its own promoted/low-load/sticky state.
|
|
144
|
-
*/
|
|
145
|
-
export interface PromotionLifecycle {
|
|
146
|
-
/**
|
|
147
|
-
* React to a change in `topicId`'s direct-participant count (call after every arrival/eviction).
|
|
148
|
-
* Refreshes the growth + hysteresis clocks and, if a promote trigger fires and the cohort is not
|
|
149
|
-
* already promoted, threshold-signs and returns a {@link PromotionNoticeV1}. Returns `undefined`
|
|
150
|
-
* when no promotion fires.
|
|
151
|
-
*/
|
|
152
|
-
onParticipantCountChange(topicId: Uint8Array, now: number): Promise<PromotionNoticeV1 | undefined>;
|
|
153
|
-
/**
|
|
154
|
-
* Time-driven demotion check (call on the gossip tick). Returns a threshold-signed
|
|
155
|
-
* {@link DemotionNoticeV1} for the parent when every demotion condition holds, else `undefined`.
|
|
156
|
-
*/
|
|
157
|
-
maybeDemote(topicId: Uint8Array, now: number): Promise<DemotionNoticeV1 | undefined>;
|
|
158
|
-
/** Whether `topicId` is currently in promoted mode (new registrations get `Promoted(d+1)`). */
|
|
159
|
-
isPromoted(topicId: Uint8Array): boolean;
|
|
160
|
-
/**
|
|
161
|
-
* Adopt a promotion this member did **not** originate (learned via a verified broadcast notice).
|
|
162
|
-
* Sets `promoted = true` for `n.topicId` without re-signing. **Precondition: the caller has verified
|
|
163
|
-
* `n`'s threshold signature** — this module performs no crypto. Idempotent and `effectiveAt`-ordered
|
|
164
|
-
* (see {@link PromotionState.lastEffectiveAt}): a notice not strictly newer than the last adopted
|
|
165
|
-
* transition is a no-op, so this member's own echoed broadcast and stale replays are absorbed.
|
|
166
|
-
*/
|
|
167
|
-
applyPromotionNotice(n: PromotionNoticeV1, now: number): void;
|
|
168
|
-
/**
|
|
169
|
-
* Adopt a demotion this member did **not** originate (learned via a verified broadcast notice).
|
|
170
|
-
* Clears `promoted` state for `n.topicId` without re-signing. Same precondition, idempotency, and
|
|
171
|
-
* `effectiveAt` ordering as {@link applyPromotionNotice} — a stale promotion can never un-demote.
|
|
172
|
-
*/
|
|
173
|
-
applyDemotionNotice(n: DemotionNoticeV1, now: number): void;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
177
|
-
private readonly states = new Map<string, PromotionState>();
|
|
178
|
-
private readonly capPromote: number;
|
|
179
|
-
private readonly capPromoteFast: number;
|
|
180
|
-
private readonly bucketOverload: number;
|
|
181
|
-
private readonly capDemote: number;
|
|
182
|
-
private readonly tDemoteMs: number;
|
|
183
|
-
private readonly tPromoteLookaheadMs: number;
|
|
184
|
-
private readonly tPromoteStickyMs: number;
|
|
185
|
-
private readonly growthWindowMs: number;
|
|
186
|
-
|
|
187
|
-
constructor(private readonly deps: PromotionDeps) {
|
|
188
|
-
const cfg = deps.config ?? {};
|
|
189
|
-
this.capPromote = cfg.capPromote ?? DEFAULT_CAP_PROMOTE;
|
|
190
|
-
this.capPromoteFast = cfg.capPromoteFast ?? DEFAULT_CAP_PROMOTE_FAST;
|
|
191
|
-
this.bucketOverload = cfg.bucketOverload ?? DEFAULT_BUCKET_OVERLOAD;
|
|
192
|
-
this.capDemote = cfg.capDemote ?? DEFAULT_CAP_DEMOTE;
|
|
193
|
-
this.tDemoteMs = cfg.tDemoteMs ?? DEFAULT_T_DEMOTE_MS;
|
|
194
|
-
this.tPromoteLookaheadMs = cfg.tPromoteLookaheadMs ?? DEFAULT_T_PROMOTE_LOOKAHEAD_MS;
|
|
195
|
-
this.tPromoteStickyMs = cfg.tPromoteStickyMs ?? DEFAULT_T_PROMOTE_STICKY_MS;
|
|
196
|
-
this.growthWindowMs = cfg.growthWindowMs ?? DEFAULT_GROWTH_WINDOW_MS;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async onParticipantCountChange(topicId: Uint8Array, now: number): Promise<PromotionNoticeV1 | undefined> {
|
|
200
|
-
const state = this.stateFor(topicId);
|
|
201
|
-
const count = this.deps.store.directParticipants(topicId);
|
|
202
|
-
this.pushGrowthSample(state, count, now);
|
|
203
|
-
this.refreshLowLoadClock(state, count, now);
|
|
204
|
-
if (state.promoted) {
|
|
205
|
-
return undefined; // already promoted — sticky until a demotion clears it
|
|
206
|
-
}
|
|
207
|
-
if (!this.promotionTriggered(topicId, state, count, now)) {
|
|
208
|
-
return undefined;
|
|
209
|
-
}
|
|
210
|
-
return this.promote(topicId, state, now);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async maybeDemote(topicId: Uint8Array, now: number): Promise<DemotionNoticeV1 | undefined> {
|
|
214
|
-
const state = this.states.get(bytesKey(topicId));
|
|
215
|
-
if (state === undefined) {
|
|
216
|
-
return undefined;
|
|
217
|
-
}
|
|
218
|
-
if (!this.demotionTriggered(topicId, state, now)) {
|
|
219
|
-
return undefined;
|
|
220
|
-
}
|
|
221
|
-
return this.demote(topicId, state, now);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
isPromoted(topicId: Uint8Array): boolean {
|
|
225
|
-
return this.states.get(bytesKey(topicId))?.promoted ?? false;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// --- remote apply (verified notices this member did not originate) ---
|
|
229
|
-
|
|
230
|
-
applyPromotionNotice(n: PromotionNoticeV1, now: number): void {
|
|
231
|
-
const state = this.stateFor(b64urlToBytes(n.topicId));
|
|
232
|
-
if (!this.isNewerTransition(state, n.effectiveAt)) {
|
|
233
|
-
return; // already adopted, our own echoed broadcast, or a stale replay
|
|
234
|
-
}
|
|
235
|
-
state.promoted = true;
|
|
236
|
-
state.promotedAt = now;
|
|
237
|
-
state.lastEffectiveAt = n.effectiveAt;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
applyDemotionNotice(n: DemotionNoticeV1, _now: number): void {
|
|
241
|
-
const state = this.stateFor(b64urlToBytes(n.topicId));
|
|
242
|
-
if (!this.isNewerTransition(state, n.effectiveAt)) {
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
// Mirror the local demote() release so a later re-growth re-evaluates cleanly.
|
|
246
|
-
state.promoted = false;
|
|
247
|
-
state.promotedAt = undefined;
|
|
248
|
-
state.lowLoadSince = undefined;
|
|
249
|
-
state.lastEffectiveAt = n.effectiveAt;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** A notice is adopted only if strictly newer than the last transition we adopted (the high-water mark). */
|
|
253
|
-
private isNewerTransition(state: PromotionState, effectiveAt: number): boolean {
|
|
254
|
-
return state.lastEffectiveAt === undefined || effectiveAt > state.lastEffectiveAt;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// --- promotion ---
|
|
258
|
-
|
|
259
|
-
private promotionTriggered(topicId: Uint8Array, state: PromotionState, count: number, now: number): boolean {
|
|
260
|
-
if (count >= this.capPromote) {
|
|
261
|
-
return true;
|
|
262
|
-
}
|
|
263
|
-
if (this.deps.loadBucket(topicId) >= this.bucketOverload && count >= this.capPromoteFast) {
|
|
264
|
-
return true;
|
|
265
|
-
}
|
|
266
|
-
return this.slopePredictsCrossing(state, count, now);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** Linear extrapolation over the growth window: will `directParticipants` cross `cap_promote` within lookahead? */
|
|
270
|
-
private slopePredictsCrossing(state: PromotionState, count: number, now: number): boolean {
|
|
271
|
-
const samples = state.samples;
|
|
272
|
-
if (samples.length < 2) {
|
|
273
|
-
return false;
|
|
274
|
-
}
|
|
275
|
-
const first = samples[0]!;
|
|
276
|
-
const last = samples[samples.length - 1]!;
|
|
277
|
-
const span = last.t - first.t;
|
|
278
|
-
if (span <= 0) {
|
|
279
|
-
return false;
|
|
280
|
-
}
|
|
281
|
-
const slope = (last.count - first.count) / span; // participants per ms
|
|
282
|
-
if (slope <= 0) {
|
|
283
|
-
return false;
|
|
284
|
-
}
|
|
285
|
-
const predicted = count + slope * this.tPromoteLookaheadMs;
|
|
286
|
-
return predicted >= this.capPromote;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
private async promote(topicId: Uint8Array, state: PromotionState, now: number): Promise<PromotionNoticeV1> {
|
|
290
|
-
const fromTier = this.deps.treeTier(topicId);
|
|
291
|
-
// NOTE: no guard here against fromTier === DEFAULT_D_MAX_CAP (60). At that depth toTier = 61 exceeds the
|
|
292
|
-
// tree-tier ceiling, so validatePromotionNoticeV1 rejects the notice on every receiver. Unreachable today
|
|
293
|
-
// (tree tier 60 is pathological); if the tree can ever reach the cap, gate promotion below the cap here.
|
|
294
|
-
const topicB64 = bytesToB64url(topicId);
|
|
295
|
-
const epochB64 = bytesToB64url(this.deps.cohortEpoch());
|
|
296
|
-
const cohortB64 = bytesToB64url(this.deps.cohortCoord());
|
|
297
|
-
const signable = { topicId: topicB64, fromTier, toTier: fromTier + 1, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
|
|
298
|
-
const { thresholdSig, signers } = await this.deps.signer.thresholdSign(promotionNoticeSigningPayload(signable));
|
|
299
|
-
state.promoted = true;
|
|
300
|
-
state.promotedAt = now;
|
|
301
|
-
state.lastEffectiveAt = now; // local effectiveAt — so our own echoed broadcast applies as a no-op
|
|
302
|
-
return {
|
|
303
|
-
v: 1,
|
|
304
|
-
topicId: topicB64,
|
|
305
|
-
fromTier,
|
|
306
|
-
toTier: fromTier + 1,
|
|
307
|
-
cohortCoord: cohortB64,
|
|
308
|
-
effectiveAt: now,
|
|
309
|
-
thresholdSig: bytesToB64url(thresholdSig),
|
|
310
|
-
signers: signers.map(bytesToB64url),
|
|
311
|
-
cohortEpoch: epochB64,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// --- demotion ---
|
|
316
|
-
|
|
317
|
-
private demotionTriggered(topicId: Uint8Array, state: PromotionState, now: number): boolean {
|
|
318
|
-
// The root (tree tier 0) has no parent to hand off to — it never demotes.
|
|
319
|
-
if (this.deps.treeTier(topicId) <= 0) {
|
|
320
|
-
return false;
|
|
321
|
-
}
|
|
322
|
-
// Sticky: a freshly-promoted cohort holds promoted mode through transient drops.
|
|
323
|
-
if (state.promoted && state.promotedAt !== undefined && now - state.promotedAt < this.tPromoteStickyMs) {
|
|
324
|
-
return false;
|
|
325
|
-
}
|
|
326
|
-
// Never collapse a cohort that still has live children beneath it.
|
|
327
|
-
if (this.deps.childCohortCount(topicId) > 0) {
|
|
328
|
-
return false;
|
|
329
|
-
}
|
|
330
|
-
if (this.deps.store.directParticipants(topicId) > this.capDemote) {
|
|
331
|
-
return false;
|
|
332
|
-
}
|
|
333
|
-
if (state.lowLoadSince === undefined) {
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
return now - state.lowLoadSince >= this.tDemoteMs;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
private async demote(topicId: Uint8Array, state: PromotionState, now: number): Promise<DemotionNoticeV1> {
|
|
340
|
-
const tier = this.deps.treeTier(topicId);
|
|
341
|
-
const topicB64 = bytesToB64url(topicId);
|
|
342
|
-
const epochB64 = bytesToB64url(this.deps.cohortEpoch());
|
|
343
|
-
const parentB64 = bytesToB64url(this.deps.parentCoord(topicId));
|
|
344
|
-
const cohortB64 = bytesToB64url(this.deps.cohortCoord());
|
|
345
|
-
const signable = { topicId: topicB64, tier, parentCohortCoord: parentB64, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
|
|
346
|
-
const { thresholdSig, signers } = await this.deps.signer.thresholdSign(demotionNoticeSigningPayload(signable));
|
|
347
|
-
// Release forwarder state: a demoted cohort leaves promoted mode and resets its clocks so a
|
|
348
|
-
// later re-growth re-evaluates cleanly.
|
|
349
|
-
// NOTE: demotion currently resets only the promoted-bounce clocks; it does NOT stop the node serving
|
|
350
|
-
// the topic — the cold-start forwarder, the direct-participant records, and the budget slot all stay,
|
|
351
|
-
// and the cohort keeps serving at tier d. If demotion is ever made to actually collapse local tier
|
|
352
|
-
// state, it must reassign or drain the topic's records FIRST, then `coldStart.remove` + release the
|
|
353
|
-
// budget slot + `traffic.forget`, in that order — removing the forwarder while records remain re-creates
|
|
354
|
-
// the off-budget-serving drift fixed in `cohort-topic-coldstart-forwarder-reconcile` (the budget stops
|
|
355
|
-
// bounding the served-topic set). The eviction path already reconciles all three via `TopicBudget.onEvict`.
|
|
356
|
-
state.promoted = false;
|
|
357
|
-
state.promotedAt = undefined;
|
|
358
|
-
state.lowLoadSince = undefined;
|
|
359
|
-
state.lastEffectiveAt = now; // local effectiveAt — so our own echoed broadcast applies as a no-op
|
|
360
|
-
return {
|
|
361
|
-
v: 1,
|
|
362
|
-
topicId: topicB64,
|
|
363
|
-
tier,
|
|
364
|
-
parentCohortCoord: parentB64,
|
|
365
|
-
cohortCoord: cohortB64,
|
|
366
|
-
effectiveAt: now,
|
|
367
|
-
thresholdSig: bytesToB64url(thresholdSig),
|
|
368
|
-
signers: signers.map(bytesToB64url),
|
|
369
|
-
cohortEpoch: epochB64,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// --- bookkeeping ---
|
|
374
|
-
|
|
375
|
-
private stateFor(topicId: Uint8Array): PromotionState {
|
|
376
|
-
const key = bytesKey(topicId);
|
|
377
|
-
let state = this.states.get(key);
|
|
378
|
-
if (state === undefined) {
|
|
379
|
-
state = { promoted: false, samples: [] };
|
|
380
|
-
this.states.set(key, state);
|
|
381
|
-
}
|
|
382
|
-
return state;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
private pushGrowthSample(state: PromotionState, count: number, now: number): void {
|
|
386
|
-
state.samples.push({ t: now, count });
|
|
387
|
-
const cutoff = now - this.growthWindowMs;
|
|
388
|
-
while (state.samples.length > 1 && state.samples[0]!.t < cutoff) {
|
|
389
|
-
state.samples.shift();
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
private refreshLowLoadClock(state: PromotionState, count: number, now: number): void {
|
|
394
|
-
if (count <= this.capDemote) {
|
|
395
|
-
state.lowLoadSince ??= now;
|
|
396
|
-
} else {
|
|
397
|
-
state.lowLoadSince = undefined;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** Build a {@link PromotionLifecycle} over the injected store, barometer resolver, and signer. */
|
|
403
|
-
export function createPromotionLifecycle(deps: PromotionDeps): PromotionLifecycle {
|
|
404
|
-
return new CohortPromotionLifecycle(deps);
|
|
405
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic substrate — promotion / demotion lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/cohort-topic.md` §Promotion and demotion lifecycle and folded back from the
|
|
5
|
+
* simulator-validated `packages/substrate-simulator/src/topic-tree.ts`. A forwarder cohort grows
|
|
6
|
+
* (promotes) and shrinks (demotes) for a topic `T` based on its per-topic direct-participant count
|
|
7
|
+
* and the per-tier load barometer; both transitions are **threshold-signed** (via the gossip ticket's
|
|
8
|
+
* {@link CohortSigner}) so participants can verify them.
|
|
9
|
+
*
|
|
10
|
+
* **Promote** when, for a quorum (the quorum is enforced by the threshold signature — `minSigs`
|
|
11
|
+
* signers must agree to the notice):
|
|
12
|
+
* - `directParticipants(T) ≥ cap_promote` (64), OR
|
|
13
|
+
* - `loadBucket[tier(T)] ≥ bucket_overload` (6) AND `directParticipants(T) ≥ cap_promote_fast` (32)
|
|
14
|
+
* — the hot-load fast path from the capacity-barometer ticket, OR
|
|
15
|
+
* - the growth **slope** predicts crossing `cap_promote` within `T_promote_lookahead` (30s) — fires
|
|
16
|
+
* early to avoid the gossip-lag overshoot (`§Promotion`: pre-promotion on slope).
|
|
17
|
+
*
|
|
18
|
+
* Once promoted, the state is **sticky** for ≥ `T_promote_sticky` (60s) before it can be reconsidered
|
|
19
|
+
* for demotion, so transient count drops don't flap a cohort back to accepting.
|
|
20
|
+
*
|
|
21
|
+
* **Demote** when, for a quorum: `directParticipants(T) ≤ cap_demote` (16) AND that has held for
|
|
22
|
+
* ≥ `T_demote` (5min) AND the cohort has no live child cohorts AND it has a parent (the root, tree
|
|
23
|
+
* tier 0, never demotes — it has nowhere to hand off). The `4×` gap between `cap_promote` and
|
|
24
|
+
* `cap_demote` plus `T_demote` is the hysteresis that prevents thrash.
|
|
25
|
+
*
|
|
26
|
+
* **Deviation from the doc's interface sketch (documented).** `docs/cohort-topic.md` sketches
|
|
27
|
+
* `onParticipantCountChange` as returning `void` and "may emit `PromotionNoticeV1`". Because
|
|
28
|
+
* threshold signing is asynchronous ({@link CohortSigner.thresholdSign} resolves a Promise), the
|
|
29
|
+
* methods here are `async` and **return** the signed notice (or `undefined`) instead of emitting it
|
|
30
|
+
* through a side channel — the caller broadcasts whatever it gets back. This keeps the module pure
|
|
31
|
+
* and unit-testable and avoids an awkward async callback. The op-tier `tier(T)` the barometer is
|
|
32
|
+
* indexed by is supplied through the injected {@link PromotionDeps.loadBucket} resolver, so this
|
|
33
|
+
* module never needs to know a topic's tier.
|
|
34
|
+
*
|
|
35
|
+
* **Remote apply path.** `promote()` / `demote()` set this cohort's per-topic state as a side effect of
|
|
36
|
+
* the **local** signing path — only the member that originates a notice adopts the new state that way. A
|
|
37
|
+
* member that *learns* of a promotion/demotion via gossip or a broadcast notice adopts it through
|
|
38
|
+
* {@link PromotionLifecycle.applyPromotionNotice} / {@link PromotionLifecycle.applyDemotionNotice},
|
|
39
|
+
* which set the same {@link PromotionState} **without re-signing** (the notice is already a verified
|
|
40
|
+
* quorum decision). **Precondition: the caller has already verified the notice's threshold signature**
|
|
41
|
+
* (db-p2p's `MembershipVerifier`); this module is crypto-free and trusts that gate. The apply path is
|
|
42
|
+
* idempotent and `effectiveAt`-ordered via the monotonic {@link PromotionState.lastEffectiveAt}
|
|
43
|
+
* high-water mark, so re-applying a notice (including this member's own echoed broadcast) or a replayed
|
|
44
|
+
* older notice is a no-op — a stale promotion can never un-demote a cohort that has since demoted.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { b64urlToBytes, bytesToB64url } from "./wire/codec.js";
|
|
48
|
+
import type { CohortSigner } from "./sig/threshold.js";
|
|
49
|
+
import { demotionNoticeSigningPayload, promotionNoticeSigningPayload } from "./sig/payloads.js";
|
|
50
|
+
import type { DemotionNoticeV1, PromotionNoticeV1 } from "./wire/types.js";
|
|
51
|
+
import type { RegistrationStore } from "./registration/types.js";
|
|
52
|
+
import { bytesKey } from "./registration/bytes.js";
|
|
53
|
+
|
|
54
|
+
// --- defaults (docs/cohort-topic.md §Configuration; simulator-confirmed) ---
|
|
55
|
+
|
|
56
|
+
/** Direct-participant cap before promotion. */
|
|
57
|
+
export const DEFAULT_CAP_PROMOTE = 64;
|
|
58
|
+
/** Direct-participant cap when the load barometer is hot (fast path). */
|
|
59
|
+
export const DEFAULT_CAP_PROMOTE_FAST = 32;
|
|
60
|
+
/** Load-barometer bucket at/above which the fast promote path is armed. */
|
|
61
|
+
export const DEFAULT_BUCKET_OVERLOAD = 6;
|
|
62
|
+
/** Direct-participant floor for demotion (= `cap_promote / 4`). */
|
|
63
|
+
export const DEFAULT_CAP_DEMOTE = 16;
|
|
64
|
+
/** Hysteresis window the low-load condition must hold before demotion (ms). */
|
|
65
|
+
export const DEFAULT_T_DEMOTE_MS = 300_000;
|
|
66
|
+
/** Pre-promotion slope lookahead (ms). */
|
|
67
|
+
export const DEFAULT_T_PROMOTE_LOOKAHEAD_MS = 30_000;
|
|
68
|
+
/** Minimum time a cohort stays promoted before it can be reconsidered for demotion (ms). */
|
|
69
|
+
export const DEFAULT_T_PROMOTE_STICKY_MS = 60_000;
|
|
70
|
+
/**
|
|
71
|
+
* Slope window over which the growth rate is measured for pre-promotion. **Not pinned by
|
|
72
|
+
* `docs/cohort-topic.md`** — the simulator (`topic-tree.ts` `DEFAULT_LIFECYCLE_CONFIG`) uses 10s and
|
|
73
|
+
* this matches it.
|
|
74
|
+
*/
|
|
75
|
+
export const DEFAULT_GROWTH_WINDOW_MS = 10_000;
|
|
76
|
+
|
|
77
|
+
export interface PromotionConfig {
|
|
78
|
+
capPromote?: number;
|
|
79
|
+
capPromoteFast?: number;
|
|
80
|
+
bucketOverload?: number;
|
|
81
|
+
capDemote?: number;
|
|
82
|
+
tDemoteMs?: number;
|
|
83
|
+
tPromoteLookaheadMs?: number;
|
|
84
|
+
tPromoteStickyMs?: number;
|
|
85
|
+
growthWindowMs?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A sampled `(time, count)` growth point, for slope-based pre-promotion. */
|
|
89
|
+
interface GrowthSample {
|
|
90
|
+
readonly t: number;
|
|
91
|
+
readonly count: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Per-topic promotion bookkeeping held by this cohort. */
|
|
95
|
+
interface PromotionState {
|
|
96
|
+
promoted: boolean;
|
|
97
|
+
/** When the cohort last entered promoted mode (sticky-window anchor). */
|
|
98
|
+
promotedAt?: number;
|
|
99
|
+
/** When `directParticipants` last dropped to ≤ `cap_demote` (demotion hysteresis anchor). */
|
|
100
|
+
lowLoadSince?: number;
|
|
101
|
+
/** Recent growth samples within `growthWindowMs`, for slope extrapolation. */
|
|
102
|
+
samples: GrowthSample[];
|
|
103
|
+
/**
|
|
104
|
+
* `effectiveAt` of the most recent promotion/demotion this cohort has adopted — locally originated
|
|
105
|
+
* (`promote()` / `demote()`) or remotely applied (`applyPromotionNotice` / `applyDemotionNotice`).
|
|
106
|
+
* Monotonic and **never cleared** (a demotion clears `promoted`/`promotedAt` but keeps this), so it
|
|
107
|
+
* is the high-water mark the remote-apply path orders against: a notice whose `effectiveAt` is not
|
|
108
|
+
* strictly newer is a no-op — re-applied (incl. this member's own echoed broadcast) or stale-replayed.
|
|
109
|
+
*/
|
|
110
|
+
lastEffectiveAt?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PromotionDeps {
|
|
114
|
+
/** Replicated registration store — supplies the per-topic `directParticipants` stock count. */
|
|
115
|
+
store: Pick<RegistrationStore, "directParticipants">;
|
|
116
|
+
/**
|
|
117
|
+
* Current load-barometer bucket (0..7) for the topic's op tier `tier(T)`. The caller resolves the
|
|
118
|
+
* topic→tier mapping and the barometer lookup, keeping this module tier-agnostic and FRET-free.
|
|
119
|
+
*/
|
|
120
|
+
loadBucket: (topicId: Uint8Array) => number;
|
|
121
|
+
/** Live child-cohort count for the topic (0 if none); `> 0` blocks demotion. */
|
|
122
|
+
childCohortCount: (topicId: Uint8Array) => number;
|
|
123
|
+
/** Tree tier `d` this cohort serves the topic at — `fromTier` on a notice; `toTier = d + 1`. */
|
|
124
|
+
treeTier: (topicId: Uint8Array) => number;
|
|
125
|
+
/** The tier-`(d − 1)` parent cohort coord, target of a {@link DemotionNoticeV1}. */
|
|
126
|
+
parentCoord: (topicId: Uint8Array) => Uint8Array;
|
|
127
|
+
/**
|
|
128
|
+
* The served coord `coord_d(participantCoord, topicId)` this cohort sits at (raw bytes) — stamped on
|
|
129
|
+
* every notice as `cohortCoord` and covered by its threshold signature, so a receiver routes the notice
|
|
130
|
+
* to exactly this cohort's engine and verifies it against exactly this cohort's cert. Constant for the
|
|
131
|
+
* engine's lifetime (the engine is instantiated at one served coord).
|
|
132
|
+
*/
|
|
133
|
+
cohortCoord: () => Uint8Array;
|
|
134
|
+
/** Cohort epoch (raw bytes) the notices are signed under. */
|
|
135
|
+
cohortEpoch: () => Uint8Array;
|
|
136
|
+
/** Threshold signer (the gossip ticket's `k − x` cohort signer). */
|
|
137
|
+
signer: CohortSigner;
|
|
138
|
+
config?: PromotionConfig;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Promotion / demotion lifecycle for one cohort across all the topics it serves. Keyed by `topicId`;
|
|
143
|
+
* each topic carries its own promoted/low-load/sticky state.
|
|
144
|
+
*/
|
|
145
|
+
export interface PromotionLifecycle {
|
|
146
|
+
/**
|
|
147
|
+
* React to a change in `topicId`'s direct-participant count (call after every arrival/eviction).
|
|
148
|
+
* Refreshes the growth + hysteresis clocks and, if a promote trigger fires and the cohort is not
|
|
149
|
+
* already promoted, threshold-signs and returns a {@link PromotionNoticeV1}. Returns `undefined`
|
|
150
|
+
* when no promotion fires.
|
|
151
|
+
*/
|
|
152
|
+
onParticipantCountChange(topicId: Uint8Array, now: number): Promise<PromotionNoticeV1 | undefined>;
|
|
153
|
+
/**
|
|
154
|
+
* Time-driven demotion check (call on the gossip tick). Returns a threshold-signed
|
|
155
|
+
* {@link DemotionNoticeV1} for the parent when every demotion condition holds, else `undefined`.
|
|
156
|
+
*/
|
|
157
|
+
maybeDemote(topicId: Uint8Array, now: number): Promise<DemotionNoticeV1 | undefined>;
|
|
158
|
+
/** Whether `topicId` is currently in promoted mode (new registrations get `Promoted(d+1)`). */
|
|
159
|
+
isPromoted(topicId: Uint8Array): boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Adopt a promotion this member did **not** originate (learned via a verified broadcast notice).
|
|
162
|
+
* Sets `promoted = true` for `n.topicId` without re-signing. **Precondition: the caller has verified
|
|
163
|
+
* `n`'s threshold signature** — this module performs no crypto. Idempotent and `effectiveAt`-ordered
|
|
164
|
+
* (see {@link PromotionState.lastEffectiveAt}): a notice not strictly newer than the last adopted
|
|
165
|
+
* transition is a no-op, so this member's own echoed broadcast and stale replays are absorbed.
|
|
166
|
+
*/
|
|
167
|
+
applyPromotionNotice(n: PromotionNoticeV1, now: number): void;
|
|
168
|
+
/**
|
|
169
|
+
* Adopt a demotion this member did **not** originate (learned via a verified broadcast notice).
|
|
170
|
+
* Clears `promoted` state for `n.topicId` without re-signing. Same precondition, idempotency, and
|
|
171
|
+
* `effectiveAt` ordering as {@link applyPromotionNotice} — a stale promotion can never un-demote.
|
|
172
|
+
*/
|
|
173
|
+
applyDemotionNotice(n: DemotionNoticeV1, now: number): void;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
class CohortPromotionLifecycle implements PromotionLifecycle {
|
|
177
|
+
private readonly states = new Map<string, PromotionState>();
|
|
178
|
+
private readonly capPromote: number;
|
|
179
|
+
private readonly capPromoteFast: number;
|
|
180
|
+
private readonly bucketOverload: number;
|
|
181
|
+
private readonly capDemote: number;
|
|
182
|
+
private readonly tDemoteMs: number;
|
|
183
|
+
private readonly tPromoteLookaheadMs: number;
|
|
184
|
+
private readonly tPromoteStickyMs: number;
|
|
185
|
+
private readonly growthWindowMs: number;
|
|
186
|
+
|
|
187
|
+
constructor(private readonly deps: PromotionDeps) {
|
|
188
|
+
const cfg = deps.config ?? {};
|
|
189
|
+
this.capPromote = cfg.capPromote ?? DEFAULT_CAP_PROMOTE;
|
|
190
|
+
this.capPromoteFast = cfg.capPromoteFast ?? DEFAULT_CAP_PROMOTE_FAST;
|
|
191
|
+
this.bucketOverload = cfg.bucketOverload ?? DEFAULT_BUCKET_OVERLOAD;
|
|
192
|
+
this.capDemote = cfg.capDemote ?? DEFAULT_CAP_DEMOTE;
|
|
193
|
+
this.tDemoteMs = cfg.tDemoteMs ?? DEFAULT_T_DEMOTE_MS;
|
|
194
|
+
this.tPromoteLookaheadMs = cfg.tPromoteLookaheadMs ?? DEFAULT_T_PROMOTE_LOOKAHEAD_MS;
|
|
195
|
+
this.tPromoteStickyMs = cfg.tPromoteStickyMs ?? DEFAULT_T_PROMOTE_STICKY_MS;
|
|
196
|
+
this.growthWindowMs = cfg.growthWindowMs ?? DEFAULT_GROWTH_WINDOW_MS;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async onParticipantCountChange(topicId: Uint8Array, now: number): Promise<PromotionNoticeV1 | undefined> {
|
|
200
|
+
const state = this.stateFor(topicId);
|
|
201
|
+
const count = this.deps.store.directParticipants(topicId);
|
|
202
|
+
this.pushGrowthSample(state, count, now);
|
|
203
|
+
this.refreshLowLoadClock(state, count, now);
|
|
204
|
+
if (state.promoted) {
|
|
205
|
+
return undefined; // already promoted — sticky until a demotion clears it
|
|
206
|
+
}
|
|
207
|
+
if (!this.promotionTriggered(topicId, state, count, now)) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
return this.promote(topicId, state, now);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async maybeDemote(topicId: Uint8Array, now: number): Promise<DemotionNoticeV1 | undefined> {
|
|
214
|
+
const state = this.states.get(bytesKey(topicId));
|
|
215
|
+
if (state === undefined) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
if (!this.demotionTriggered(topicId, state, now)) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
return this.demote(topicId, state, now);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
isPromoted(topicId: Uint8Array): boolean {
|
|
225
|
+
return this.states.get(bytesKey(topicId))?.promoted ?? false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- remote apply (verified notices this member did not originate) ---
|
|
229
|
+
|
|
230
|
+
applyPromotionNotice(n: PromotionNoticeV1, now: number): void {
|
|
231
|
+
const state = this.stateFor(b64urlToBytes(n.topicId));
|
|
232
|
+
if (!this.isNewerTransition(state, n.effectiveAt)) {
|
|
233
|
+
return; // already adopted, our own echoed broadcast, or a stale replay
|
|
234
|
+
}
|
|
235
|
+
state.promoted = true;
|
|
236
|
+
state.promotedAt = now;
|
|
237
|
+
state.lastEffectiveAt = n.effectiveAt;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
applyDemotionNotice(n: DemotionNoticeV1, _now: number): void {
|
|
241
|
+
const state = this.stateFor(b64urlToBytes(n.topicId));
|
|
242
|
+
if (!this.isNewerTransition(state, n.effectiveAt)) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// Mirror the local demote() release so a later re-growth re-evaluates cleanly.
|
|
246
|
+
state.promoted = false;
|
|
247
|
+
state.promotedAt = undefined;
|
|
248
|
+
state.lowLoadSince = undefined;
|
|
249
|
+
state.lastEffectiveAt = n.effectiveAt;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A notice is adopted only if strictly newer than the last transition we adopted (the high-water mark). */
|
|
253
|
+
private isNewerTransition(state: PromotionState, effectiveAt: number): boolean {
|
|
254
|
+
return state.lastEffectiveAt === undefined || effectiveAt > state.lastEffectiveAt;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- promotion ---
|
|
258
|
+
|
|
259
|
+
private promotionTriggered(topicId: Uint8Array, state: PromotionState, count: number, now: number): boolean {
|
|
260
|
+
if (count >= this.capPromote) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
if (this.deps.loadBucket(topicId) >= this.bucketOverload && count >= this.capPromoteFast) {
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
return this.slopePredictsCrossing(state, count, now);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Linear extrapolation over the growth window: will `directParticipants` cross `cap_promote` within lookahead? */
|
|
270
|
+
private slopePredictsCrossing(state: PromotionState, count: number, now: number): boolean {
|
|
271
|
+
const samples = state.samples;
|
|
272
|
+
if (samples.length < 2) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
const first = samples[0]!;
|
|
276
|
+
const last = samples[samples.length - 1]!;
|
|
277
|
+
const span = last.t - first.t;
|
|
278
|
+
if (span <= 0) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
const slope = (last.count - first.count) / span; // participants per ms
|
|
282
|
+
if (slope <= 0) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
const predicted = count + slope * this.tPromoteLookaheadMs;
|
|
286
|
+
return predicted >= this.capPromote;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async promote(topicId: Uint8Array, state: PromotionState, now: number): Promise<PromotionNoticeV1> {
|
|
290
|
+
const fromTier = this.deps.treeTier(topicId);
|
|
291
|
+
// NOTE: no guard here against fromTier === DEFAULT_D_MAX_CAP (60). At that depth toTier = 61 exceeds the
|
|
292
|
+
// tree-tier ceiling, so validatePromotionNoticeV1 rejects the notice on every receiver. Unreachable today
|
|
293
|
+
// (tree tier 60 is pathological); if the tree can ever reach the cap, gate promotion below the cap here.
|
|
294
|
+
const topicB64 = bytesToB64url(topicId);
|
|
295
|
+
const epochB64 = bytesToB64url(this.deps.cohortEpoch());
|
|
296
|
+
const cohortB64 = bytesToB64url(this.deps.cohortCoord());
|
|
297
|
+
const signable = { topicId: topicB64, fromTier, toTier: fromTier + 1, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
|
|
298
|
+
const { thresholdSig, signers } = await this.deps.signer.thresholdSign(promotionNoticeSigningPayload(signable));
|
|
299
|
+
state.promoted = true;
|
|
300
|
+
state.promotedAt = now;
|
|
301
|
+
state.lastEffectiveAt = now; // local effectiveAt — so our own echoed broadcast applies as a no-op
|
|
302
|
+
return {
|
|
303
|
+
v: 1,
|
|
304
|
+
topicId: topicB64,
|
|
305
|
+
fromTier,
|
|
306
|
+
toTier: fromTier + 1,
|
|
307
|
+
cohortCoord: cohortB64,
|
|
308
|
+
effectiveAt: now,
|
|
309
|
+
thresholdSig: bytesToB64url(thresholdSig),
|
|
310
|
+
signers: signers.map(bytesToB64url),
|
|
311
|
+
cohortEpoch: epochB64,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- demotion ---
|
|
316
|
+
|
|
317
|
+
private demotionTriggered(topicId: Uint8Array, state: PromotionState, now: number): boolean {
|
|
318
|
+
// The root (tree tier 0) has no parent to hand off to — it never demotes.
|
|
319
|
+
if (this.deps.treeTier(topicId) <= 0) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
// Sticky: a freshly-promoted cohort holds promoted mode through transient drops.
|
|
323
|
+
if (state.promoted && state.promotedAt !== undefined && now - state.promotedAt < this.tPromoteStickyMs) {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
// Never collapse a cohort that still has live children beneath it.
|
|
327
|
+
if (this.deps.childCohortCount(topicId) > 0) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
if (this.deps.store.directParticipants(topicId) > this.capDemote) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
if (state.lowLoadSince === undefined) {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
return now - state.lowLoadSince >= this.tDemoteMs;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private async demote(topicId: Uint8Array, state: PromotionState, now: number): Promise<DemotionNoticeV1> {
|
|
340
|
+
const tier = this.deps.treeTier(topicId);
|
|
341
|
+
const topicB64 = bytesToB64url(topicId);
|
|
342
|
+
const epochB64 = bytesToB64url(this.deps.cohortEpoch());
|
|
343
|
+
const parentB64 = bytesToB64url(this.deps.parentCoord(topicId));
|
|
344
|
+
const cohortB64 = bytesToB64url(this.deps.cohortCoord());
|
|
345
|
+
const signable = { topicId: topicB64, tier, parentCohortCoord: parentB64, effectiveAt: now, cohortEpoch: epochB64, cohortCoord: cohortB64 };
|
|
346
|
+
const { thresholdSig, signers } = await this.deps.signer.thresholdSign(demotionNoticeSigningPayload(signable));
|
|
347
|
+
// Release forwarder state: a demoted cohort leaves promoted mode and resets its clocks so a
|
|
348
|
+
// later re-growth re-evaluates cleanly.
|
|
349
|
+
// NOTE: demotion currently resets only the promoted-bounce clocks; it does NOT stop the node serving
|
|
350
|
+
// the topic — the cold-start forwarder, the direct-participant records, and the budget slot all stay,
|
|
351
|
+
// and the cohort keeps serving at tier d. If demotion is ever made to actually collapse local tier
|
|
352
|
+
// state, it must reassign or drain the topic's records FIRST, then `coldStart.remove` + release the
|
|
353
|
+
// budget slot + `traffic.forget`, in that order — removing the forwarder while records remain re-creates
|
|
354
|
+
// the off-budget-serving drift fixed in `cohort-topic-coldstart-forwarder-reconcile` (the budget stops
|
|
355
|
+
// bounding the served-topic set). The eviction path already reconciles all three via `TopicBudget.onEvict`.
|
|
356
|
+
state.promoted = false;
|
|
357
|
+
state.promotedAt = undefined;
|
|
358
|
+
state.lowLoadSince = undefined;
|
|
359
|
+
state.lastEffectiveAt = now; // local effectiveAt — so our own echoed broadcast applies as a no-op
|
|
360
|
+
return {
|
|
361
|
+
v: 1,
|
|
362
|
+
topicId: topicB64,
|
|
363
|
+
tier,
|
|
364
|
+
parentCohortCoord: parentB64,
|
|
365
|
+
cohortCoord: cohortB64,
|
|
366
|
+
effectiveAt: now,
|
|
367
|
+
thresholdSig: bytesToB64url(thresholdSig),
|
|
368
|
+
signers: signers.map(bytesToB64url),
|
|
369
|
+
cohortEpoch: epochB64,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// --- bookkeeping ---
|
|
374
|
+
|
|
375
|
+
private stateFor(topicId: Uint8Array): PromotionState {
|
|
376
|
+
const key = bytesKey(topicId);
|
|
377
|
+
let state = this.states.get(key);
|
|
378
|
+
if (state === undefined) {
|
|
379
|
+
state = { promoted: false, samples: [] };
|
|
380
|
+
this.states.set(key, state);
|
|
381
|
+
}
|
|
382
|
+
return state;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private pushGrowthSample(state: PromotionState, count: number, now: number): void {
|
|
386
|
+
state.samples.push({ t: now, count });
|
|
387
|
+
const cutoff = now - this.growthWindowMs;
|
|
388
|
+
while (state.samples.length > 1 && state.samples[0]!.t < cutoff) {
|
|
389
|
+
state.samples.shift();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private refreshLowLoadClock(state: PromotionState, count: number, now: number): void {
|
|
394
|
+
if (count <= this.capDemote) {
|
|
395
|
+
state.lowLoadSince ??= now;
|
|
396
|
+
} else {
|
|
397
|
+
state.lowLoadSince = undefined;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Build a {@link PromotionLifecycle} over the injected store, barometer resolver, and signer. */
|
|
403
|
+
export function createPromotionLifecycle(deps: PromotionDeps): PromotionLifecycle {
|
|
404
|
+
return new CohortPromotionLifecycle(deps);
|
|
405
|
+
}
|