@optimystic/db-core 0.21.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/btree/btree.d.ts +2 -1
- package/dist/src/btree/btree.d.ts.map +1 -1
- package/dist/src/btree/btree.js +1 -1
- package/dist/src/btree/btree.js.map +1 -1
- package/dist/src/chain/chain.d.ts +1 -1
- package/dist/src/chain/chain.d.ts.map +1 -1
- package/dist/src/chain/chain.js +1 -1
- package/dist/src/chain/chain.js.map +1 -1
- 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 +20 -1
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +31 -4
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/diary/diary.d.ts.map +1 -1
- package/dist/src/collections/diary/diary.js +2 -1
- package/dist/src/collections/diary/diary.js.map +1 -1
- package/dist/src/collections/diary/struct.js +1 -1
- package/dist/src/collections/diary/struct.js.map +1 -1
- package/dist/src/collections/tree/collection-trunk.js +1 -1
- package/dist/src/collections/tree/collection-trunk.js.map +1 -1
- package/dist/src/collections/tree/struct.d.ts +1 -1
- package/dist/src/collections/tree/struct.d.ts.map +1 -1
- package/dist/src/collections/tree/struct.js +2 -1
- package/dist/src/collections/tree/struct.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/log/log.d.ts +1 -1
- package/dist/src/log/log.d.ts.map +1 -1
- package/dist/src/log/log.js +2 -2
- package/dist/src/log/log.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 +133 -12
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +2 -1
- package/dist/src/transaction/coordinator.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 +57 -18
- 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/dist/src/transform/cache-source.js +1 -1
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/helpers.d.ts +6 -1
- package/dist/src/transform/helpers.d.ts.map +1 -1
- package/dist/src/transform/helpers.js +7 -6
- package/dist/src/transform/helpers.js.map +1 -1
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +2 -1
- package/dist/src/transform/tracker.js.map +1 -1
- package/package.json +1 -1
- package/src/btree/btree.ts +2 -1
- package/src/chain/chain.ts +2 -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 +32 -4
- package/src/collections/diary/diary.ts +68 -67
- package/src/collections/diary/struct.ts +1 -1
- package/src/collections/tree/collection-trunk.ts +1 -1
- package/src/collections/tree/readme.md +4 -0
- package/src/collections/tree/struct.ts +3 -1
- package/src/collections/tree/tree.ts +320 -312
- package/src/log/log.ts +2 -2
- 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 -489
- package/src/transaction/coordinator.ts +2 -1
- 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 +58 -19
- package/src/transactor/transactor-source.ts +25 -2
- package/src/transform/atomic-proxy.ts +92 -92
- package/src/transform/cache-source.ts +1 -1
- package/src/transform/helpers.ts +159 -158
- package/src/transform/tracker.ts +2 -1
- 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,233 +1,233 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic substrate — topic-traffic signal.
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/cohort-topic.md` §Topic traffic signal. A cohort tracks per-topic flow
|
|
5
|
-
* rates alongside the stock `directParticipants` count and returns them on registration replies so
|
|
6
|
-
* applications can decide whether the current tier is dense enough to settle on.
|
|
7
|
-
*
|
|
8
|
-
* Shape (`TopicTrafficV1` from the wire ticket):
|
|
9
|
-
* - `arrivalsPerMin` — **combines** fresh registrations and renewals into one scalar (the seeker
|
|
10
|
-
* uses renewals as a proxy for active matchable supply); the caller invokes {@link recordArrival}
|
|
11
|
-
* for both.
|
|
12
|
-
* - `queriesPerMin` — application-level query rate.
|
|
13
|
-
* - counts are **exact integers** over `windowSeconds` (default 60), *not* log-bucketed like the
|
|
14
|
-
* load barometer — the consumer-side matchmaking formulas are numeric.
|
|
15
|
-
*
|
|
16
|
-
* Pipeline: each member counts arrivals/queries locally ({@link recordArrival} / {@link recordQuery})
|
|
17
|
-
* over a sliding window; {@link TrafficCounters.publish} freezes the windowed counts into the
|
|
18
|
-
* member's gossiped per-topic summary each gossip round; {@link TrafficCounters.snapshot} returns the
|
|
19
|
-
* **gossip-derived** view (own last-published summary + siblings' last-gossiped summaries from the
|
|
20
|
-
* {@link CohortView}), so the reply lags by at most one round and never recomputes from raw counters
|
|
21
|
-
* at reply time.
|
|
22
|
-
*
|
|
23
|
-
* Resolved open question (GROUNDING): the signal is returned **only** on `accepted` and `promoted`
|
|
24
|
-
* replies (see {@link attachTopicTraffic}); a participant getting `Promoted(d+1)` still receives the
|
|
25
|
-
* outgoing cohort's traffic so it can estimate whether the redirect target is hot.
|
|
26
|
-
*
|
|
27
|
-
* Counters reset to zero on `cohortEpoch` change ({@link TrafficCounters.reset}); the first round
|
|
28
|
-
* after a rotation may under-report, and consumers tolerate a single zero reading.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import { bytesKey } from "./registration/bytes.js";
|
|
32
|
-
import type { CohortView } from "./gossip/view.js";
|
|
33
|
-
import type { RegistrationStore } from "./registration/types.js";
|
|
34
|
-
import { bytesToB64url } from "./wire/codec.js";
|
|
35
|
-
import type { CohortTopicSummary, RegisterReplyV1, TopicTrafficV1 } from "./wire/types.js";
|
|
36
|
-
|
|
37
|
-
/** Default observation window for the rate fields (seconds). */
|
|
38
|
-
export const DEFAULT_TRAFFIC_WINDOW_SECONDS = 60;
|
|
39
|
-
|
|
40
|
-
/** Frozen per-topic counts the member last published to gossip. */
|
|
41
|
-
interface PublishedTopic {
|
|
42
|
-
readonly arrivals: number;
|
|
43
|
-
readonly queries: number;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Local windowed event timestamps (unix ms) for one topic. */
|
|
47
|
-
interface TopicWindow {
|
|
48
|
-
arrivals: number[];
|
|
49
|
-
queries: number[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Per-topic traffic counters with a gossip-derived snapshot. */
|
|
53
|
-
export interface TrafficCounters {
|
|
54
|
-
/** Record a fresh registration **or** a renewal for `topicId` at `now` (combined arrivals). */
|
|
55
|
-
recordArrival(topicId: Uint8Array, now: number): void;
|
|
56
|
-
/** Record an application-level query against `topicId` at `now`. */
|
|
57
|
-
recordQuery(topicId: Uint8Array, now: number): void;
|
|
58
|
-
/**
|
|
59
|
-
* Freeze the current windowed counts into the member's gossiped summary (call once per gossip
|
|
60
|
-
* round, at `now`). The frozen values are what {@link snapshot} and the gossip frame read.
|
|
61
|
-
*/
|
|
62
|
-
publish(topicId: Uint8Array, now: number): PublishedTopic;
|
|
63
|
-
/** Own most-recent published `(arrivals, queries)` for `topicId`, or zeros if never published. */
|
|
64
|
-
published(topicId: Uint8Array): PublishedTopic;
|
|
65
|
-
/**
|
|
66
|
-
* Gossip-derived traffic for `topicId`: own last-published counts plus siblings' last-gossiped
|
|
67
|
-
* summaries (exact-integer sums), with `directParticipants` from the replicated store. Lags ≤ one
|
|
68
|
-
* round; never recomputes from raw counters.
|
|
69
|
-
*/
|
|
70
|
-
snapshot(topicId: Uint8Array): TopicTrafficV1;
|
|
71
|
-
/** Reset all counters to zero (call on `cohortEpoch` change). */
|
|
72
|
-
reset(): void;
|
|
73
|
-
/**
|
|
74
|
-
* Drop all local windowed counts and the last-published summary for `topicId` (the cohort no longer
|
|
75
|
-
* serves it — budget eviction / teardown). Idempotent; a no-op if never observed. Siblings' gossiped
|
|
76
|
-
* contributions age out of {@link snapshot} on their own as their summaries stop naming the topic.
|
|
77
|
-
*/
|
|
78
|
-
forget(topicId: Uint8Array): void;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface TrafficCountersDeps {
|
|
82
|
-
/** Merged per-member gossip view — siblings' last-gossiped per-topic summaries. */
|
|
83
|
-
view: CohortView;
|
|
84
|
-
/** Replicated registration store — supplies the cohort-wide `directParticipants` stock count. */
|
|
85
|
-
store: Pick<RegistrationStore, "directParticipants">;
|
|
86
|
-
/** This member's own id, base64url — excluded from the sibling summary scan (counted via own published). */
|
|
87
|
-
selfMember: string;
|
|
88
|
-
/** Observation window (seconds). Default {@link DEFAULT_TRAFFIC_WINDOW_SECONDS}. */
|
|
89
|
-
windowSeconds?: number;
|
|
90
|
-
/** Tier-(d+1) cohort count for a topic (0 if not promoted); the promotion ticket owns this. */
|
|
91
|
-
childCohortCount?: (topicId: Uint8Array) => number;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
class WindowedTrafficCounters implements TrafficCounters {
|
|
95
|
-
private readonly windows = new Map<string, TopicWindow>();
|
|
96
|
-
private readonly lastPublished = new Map<string, PublishedTopic>();
|
|
97
|
-
private readonly windowMs: number;
|
|
98
|
-
|
|
99
|
-
constructor(private readonly deps: TrafficCountersDeps) {
|
|
100
|
-
const ws = deps.windowSeconds ?? DEFAULT_TRAFFIC_WINDOW_SECONDS;
|
|
101
|
-
if (!(ws > 0)) {
|
|
102
|
-
throw new RangeError(`windowSeconds must be > 0, got ${ws}`);
|
|
103
|
-
}
|
|
104
|
-
this.windowMs = ws * 1000;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
recordArrival(topicId: Uint8Array, now: number): void {
|
|
108
|
-
this.windowFor(topicId).arrivals.push(now);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
recordQuery(topicId: Uint8Array, now: number): void {
|
|
112
|
-
this.windowFor(topicId).queries.push(now);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
publish(topicId: Uint8Array, now: number): PublishedTopic {
|
|
116
|
-
const w = this.windows.get(bytesKey(topicId));
|
|
117
|
-
const frozen: PublishedTopic = {
|
|
118
|
-
arrivals: w === undefined ? 0 : countWithin(w.arrivals, now, this.windowMs),
|
|
119
|
-
queries: w === undefined ? 0 : countWithin(w.queries, now, this.windowMs),
|
|
120
|
-
};
|
|
121
|
-
this.lastPublished.set(bytesKey(topicId), frozen);
|
|
122
|
-
return frozen;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
published(topicId: Uint8Array): PublishedTopic {
|
|
126
|
-
return this.lastPublished.get(bytesKey(topicId)) ?? { arrivals: 0, queries: 0 };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
snapshot(topicId: Uint8Array): TopicTrafficV1 {
|
|
130
|
-
const topicB64 = bytesToB64url(topicId);
|
|
131
|
-
const own = this.published(topicId);
|
|
132
|
-
let arrivals = own.arrivals;
|
|
133
|
-
let queries = own.queries;
|
|
134
|
-
let childCohortCount = 0;
|
|
135
|
-
for (const [member, contribution] of this.deps.view.all()) {
|
|
136
|
-
if (member === this.deps.selfMember) continue; // own contribution comes from lastPublished
|
|
137
|
-
// O(1) per-topic lookup via the merge-time index; fall back to a scan only for a contribution
|
|
138
|
-
// not built through `merge` (which populates `topicIndex`). Net: snapshot is O(members + summaries).
|
|
139
|
-
const summary = contribution.topicIndex !== undefined
|
|
140
|
-
? contribution.topicIndex.get(topicB64)
|
|
141
|
-
: contribution.topicSummaries.find((s) => s.topicId === topicB64);
|
|
142
|
-
if (summary === undefined) continue;
|
|
143
|
-
arrivals += summary.arrivalsPerMin;
|
|
144
|
-
queries += summary.queriesPerMin;
|
|
145
|
-
if (summary.childCohortCount > childCohortCount) {
|
|
146
|
-
childCohortCount = summary.childCohortCount;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
// NOTE: `childCohortCount` (the max of siblings' gossiped counts above) is dormant whenever the
|
|
150
|
-
// registry override is wired — the override returns a number (0 included), so `?? childCohortCount`
|
|
151
|
-
// never falls through. It becomes the effective value only if the override is unwired; the child-set
|
|
152
|
-
// replication follow-on converges siblings by populating each engine's registry, not via this max.
|
|
153
|
-
const childOverride = this.deps.childCohortCount?.(topicId);
|
|
154
|
-
return {
|
|
155
|
-
windowSeconds: this.windowMs / 1000,
|
|
156
|
-
arrivalsPerMin: arrivals,
|
|
157
|
-
queriesPerMin: queries,
|
|
158
|
-
directParticipants: this.deps.store.directParticipants(topicId),
|
|
159
|
-
childCohortCount: childOverride ?? childCohortCount,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
reset(): void {
|
|
164
|
-
this.windows.clear();
|
|
165
|
-
this.lastPublished.clear();
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
forget(topicId: Uint8Array): void {
|
|
169
|
-
// Idempotent: `Map.delete` on an absent key is a safe no-op. Clears both the raw windowed events
|
|
170
|
-
// and the frozen last-published summary so a re-instantiated topic starts from zero.
|
|
171
|
-
const key = bytesKey(topicId);
|
|
172
|
-
this.windows.delete(key);
|
|
173
|
-
this.lastPublished.delete(key);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
private windowFor(topicId: Uint8Array): TopicWindow {
|
|
177
|
-
const key = bytesKey(topicId);
|
|
178
|
-
let w = this.windows.get(key);
|
|
179
|
-
if (w === undefined) {
|
|
180
|
-
w = { arrivals: [], queries: [] };
|
|
181
|
-
this.windows.set(key, w);
|
|
182
|
-
}
|
|
183
|
-
return w;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Count timestamps within `[now - windowMs, now]`, pruning anything older from `events` in place. */
|
|
188
|
-
function countWithin(events: number[], now: number, windowMs: number): number {
|
|
189
|
-
const cutoff = now - windowMs;
|
|
190
|
-
// Events accumulate in arrival order; drop the stale prefix so the array stays bounded.
|
|
191
|
-
let drop = 0;
|
|
192
|
-
while (drop < events.length && events[drop]! < cutoff) {
|
|
193
|
-
drop++;
|
|
194
|
-
}
|
|
195
|
-
if (drop > 0) {
|
|
196
|
-
events.splice(0, drop);
|
|
197
|
-
}
|
|
198
|
-
return events.length;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** Construct empty {@link TrafficCounters}. */
|
|
202
|
-
export function createTrafficCounters(deps: TrafficCountersDeps): TrafficCounters {
|
|
203
|
-
return new WindowedTrafficCounters(deps);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Attach the topic-traffic signal to a reply **only** when the result is `accepted` or `promoted`
|
|
208
|
-
* (GROUNDING-resolved: absent on `no_state`, `unwilling_member`, `unwilling_cohort`). Mutates and
|
|
209
|
-
* returns `reply`. A no-op for non-traffic-bearing results, so callers can pipe every reply through.
|
|
210
|
-
*/
|
|
211
|
-
export function attachTopicTraffic(reply: RegisterReplyV1, traffic: TopicTrafficV1): RegisterReplyV1 {
|
|
212
|
-
if (reply.result === "accepted" || reply.result === "promoted") {
|
|
213
|
-
reply.topicTraffic = traffic;
|
|
214
|
-
}
|
|
215
|
-
return reply;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** The own published counts as a `CohortTopicSummary` for the gossip frame. Callers supply tier/promotion fields. */
|
|
219
|
-
export function toCohortTopicSummary(
|
|
220
|
-
topicId: Uint8Array,
|
|
221
|
-
published: PublishedTopic,
|
|
222
|
-
fields: { tier: number; directParticipants: number; promoted: boolean; childCohortCount: number },
|
|
223
|
-
): CohortTopicSummary {
|
|
224
|
-
return {
|
|
225
|
-
topicId: bytesToB64url(topicId),
|
|
226
|
-
tier: fields.tier,
|
|
227
|
-
directParticipants: fields.directParticipants,
|
|
228
|
-
arrivalsPerMin: published.arrivals,
|
|
229
|
-
queriesPerMin: published.queries,
|
|
230
|
-
promoted: fields.promoted,
|
|
231
|
-
childCohortCount: fields.childCohortCount,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic substrate — topic-traffic signal.
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/cohort-topic.md` §Topic traffic signal. A cohort tracks per-topic flow
|
|
5
|
+
* rates alongside the stock `directParticipants` count and returns them on registration replies so
|
|
6
|
+
* applications can decide whether the current tier is dense enough to settle on.
|
|
7
|
+
*
|
|
8
|
+
* Shape (`TopicTrafficV1` from the wire ticket):
|
|
9
|
+
* - `arrivalsPerMin` — **combines** fresh registrations and renewals into one scalar (the seeker
|
|
10
|
+
* uses renewals as a proxy for active matchable supply); the caller invokes {@link recordArrival}
|
|
11
|
+
* for both.
|
|
12
|
+
* - `queriesPerMin` — application-level query rate.
|
|
13
|
+
* - counts are **exact integers** over `windowSeconds` (default 60), *not* log-bucketed like the
|
|
14
|
+
* load barometer — the consumer-side matchmaking formulas are numeric.
|
|
15
|
+
*
|
|
16
|
+
* Pipeline: each member counts arrivals/queries locally ({@link recordArrival} / {@link recordQuery})
|
|
17
|
+
* over a sliding window; {@link TrafficCounters.publish} freezes the windowed counts into the
|
|
18
|
+
* member's gossiped per-topic summary each gossip round; {@link TrafficCounters.snapshot} returns the
|
|
19
|
+
* **gossip-derived** view (own last-published summary + siblings' last-gossiped summaries from the
|
|
20
|
+
* {@link CohortView}), so the reply lags by at most one round and never recomputes from raw counters
|
|
21
|
+
* at reply time.
|
|
22
|
+
*
|
|
23
|
+
* Resolved open question (GROUNDING): the signal is returned **only** on `accepted` and `promoted`
|
|
24
|
+
* replies (see {@link attachTopicTraffic}); a participant getting `Promoted(d+1)` still receives the
|
|
25
|
+
* outgoing cohort's traffic so it can estimate whether the redirect target is hot.
|
|
26
|
+
*
|
|
27
|
+
* Counters reset to zero on `cohortEpoch` change ({@link TrafficCounters.reset}); the first round
|
|
28
|
+
* after a rotation may under-report, and consumers tolerate a single zero reading.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { bytesKey } from "./registration/bytes.js";
|
|
32
|
+
import type { CohortView } from "./gossip/view.js";
|
|
33
|
+
import type { RegistrationStore } from "./registration/types.js";
|
|
34
|
+
import { bytesToB64url } from "./wire/codec.js";
|
|
35
|
+
import type { CohortTopicSummary, RegisterReplyV1, TopicTrafficV1 } from "./wire/types.js";
|
|
36
|
+
|
|
37
|
+
/** Default observation window for the rate fields (seconds). */
|
|
38
|
+
export const DEFAULT_TRAFFIC_WINDOW_SECONDS = 60;
|
|
39
|
+
|
|
40
|
+
/** Frozen per-topic counts the member last published to gossip. */
|
|
41
|
+
interface PublishedTopic {
|
|
42
|
+
readonly arrivals: number;
|
|
43
|
+
readonly queries: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Local windowed event timestamps (unix ms) for one topic. */
|
|
47
|
+
interface TopicWindow {
|
|
48
|
+
arrivals: number[];
|
|
49
|
+
queries: number[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Per-topic traffic counters with a gossip-derived snapshot. */
|
|
53
|
+
export interface TrafficCounters {
|
|
54
|
+
/** Record a fresh registration **or** a renewal for `topicId` at `now` (combined arrivals). */
|
|
55
|
+
recordArrival(topicId: Uint8Array, now: number): void;
|
|
56
|
+
/** Record an application-level query against `topicId` at `now`. */
|
|
57
|
+
recordQuery(topicId: Uint8Array, now: number): void;
|
|
58
|
+
/**
|
|
59
|
+
* Freeze the current windowed counts into the member's gossiped summary (call once per gossip
|
|
60
|
+
* round, at `now`). The frozen values are what {@link snapshot} and the gossip frame read.
|
|
61
|
+
*/
|
|
62
|
+
publish(topicId: Uint8Array, now: number): PublishedTopic;
|
|
63
|
+
/** Own most-recent published `(arrivals, queries)` for `topicId`, or zeros if never published. */
|
|
64
|
+
published(topicId: Uint8Array): PublishedTopic;
|
|
65
|
+
/**
|
|
66
|
+
* Gossip-derived traffic for `topicId`: own last-published counts plus siblings' last-gossiped
|
|
67
|
+
* summaries (exact-integer sums), with `directParticipants` from the replicated store. Lags ≤ one
|
|
68
|
+
* round; never recomputes from raw counters.
|
|
69
|
+
*/
|
|
70
|
+
snapshot(topicId: Uint8Array): TopicTrafficV1;
|
|
71
|
+
/** Reset all counters to zero (call on `cohortEpoch` change). */
|
|
72
|
+
reset(): void;
|
|
73
|
+
/**
|
|
74
|
+
* Drop all local windowed counts and the last-published summary for `topicId` (the cohort no longer
|
|
75
|
+
* serves it — budget eviction / teardown). Idempotent; a no-op if never observed. Siblings' gossiped
|
|
76
|
+
* contributions age out of {@link snapshot} on their own as their summaries stop naming the topic.
|
|
77
|
+
*/
|
|
78
|
+
forget(topicId: Uint8Array): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface TrafficCountersDeps {
|
|
82
|
+
/** Merged per-member gossip view — siblings' last-gossiped per-topic summaries. */
|
|
83
|
+
view: CohortView;
|
|
84
|
+
/** Replicated registration store — supplies the cohort-wide `directParticipants` stock count. */
|
|
85
|
+
store: Pick<RegistrationStore, "directParticipants">;
|
|
86
|
+
/** This member's own id, base64url — excluded from the sibling summary scan (counted via own published). */
|
|
87
|
+
selfMember: string;
|
|
88
|
+
/** Observation window (seconds). Default {@link DEFAULT_TRAFFIC_WINDOW_SECONDS}. */
|
|
89
|
+
windowSeconds?: number;
|
|
90
|
+
/** Tier-(d+1) cohort count for a topic (0 if not promoted); the promotion ticket owns this. */
|
|
91
|
+
childCohortCount?: (topicId: Uint8Array) => number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
class WindowedTrafficCounters implements TrafficCounters {
|
|
95
|
+
private readonly windows = new Map<string, TopicWindow>();
|
|
96
|
+
private readonly lastPublished = new Map<string, PublishedTopic>();
|
|
97
|
+
private readonly windowMs: number;
|
|
98
|
+
|
|
99
|
+
constructor(private readonly deps: TrafficCountersDeps) {
|
|
100
|
+
const ws = deps.windowSeconds ?? DEFAULT_TRAFFIC_WINDOW_SECONDS;
|
|
101
|
+
if (!(ws > 0)) {
|
|
102
|
+
throw new RangeError(`windowSeconds must be > 0, got ${ws}`);
|
|
103
|
+
}
|
|
104
|
+
this.windowMs = ws * 1000;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
recordArrival(topicId: Uint8Array, now: number): void {
|
|
108
|
+
this.windowFor(topicId).arrivals.push(now);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
recordQuery(topicId: Uint8Array, now: number): void {
|
|
112
|
+
this.windowFor(topicId).queries.push(now);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
publish(topicId: Uint8Array, now: number): PublishedTopic {
|
|
116
|
+
const w = this.windows.get(bytesKey(topicId));
|
|
117
|
+
const frozen: PublishedTopic = {
|
|
118
|
+
arrivals: w === undefined ? 0 : countWithin(w.arrivals, now, this.windowMs),
|
|
119
|
+
queries: w === undefined ? 0 : countWithin(w.queries, now, this.windowMs),
|
|
120
|
+
};
|
|
121
|
+
this.lastPublished.set(bytesKey(topicId), frozen);
|
|
122
|
+
return frozen;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
published(topicId: Uint8Array): PublishedTopic {
|
|
126
|
+
return this.lastPublished.get(bytesKey(topicId)) ?? { arrivals: 0, queries: 0 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
snapshot(topicId: Uint8Array): TopicTrafficV1 {
|
|
130
|
+
const topicB64 = bytesToB64url(topicId);
|
|
131
|
+
const own = this.published(topicId);
|
|
132
|
+
let arrivals = own.arrivals;
|
|
133
|
+
let queries = own.queries;
|
|
134
|
+
let childCohortCount = 0;
|
|
135
|
+
for (const [member, contribution] of this.deps.view.all()) {
|
|
136
|
+
if (member === this.deps.selfMember) continue; // own contribution comes from lastPublished
|
|
137
|
+
// O(1) per-topic lookup via the merge-time index; fall back to a scan only for a contribution
|
|
138
|
+
// not built through `merge` (which populates `topicIndex`). Net: snapshot is O(members + summaries).
|
|
139
|
+
const summary = contribution.topicIndex !== undefined
|
|
140
|
+
? contribution.topicIndex.get(topicB64)
|
|
141
|
+
: contribution.topicSummaries.find((s) => s.topicId === topicB64);
|
|
142
|
+
if (summary === undefined) continue;
|
|
143
|
+
arrivals += summary.arrivalsPerMin;
|
|
144
|
+
queries += summary.queriesPerMin;
|
|
145
|
+
if (summary.childCohortCount > childCohortCount) {
|
|
146
|
+
childCohortCount = summary.childCohortCount;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// NOTE: `childCohortCount` (the max of siblings' gossiped counts above) is dormant whenever the
|
|
150
|
+
// registry override is wired — the override returns a number (0 included), so `?? childCohortCount`
|
|
151
|
+
// never falls through. It becomes the effective value only if the override is unwired; the child-set
|
|
152
|
+
// replication follow-on converges siblings by populating each engine's registry, not via this max.
|
|
153
|
+
const childOverride = this.deps.childCohortCount?.(topicId);
|
|
154
|
+
return {
|
|
155
|
+
windowSeconds: this.windowMs / 1000,
|
|
156
|
+
arrivalsPerMin: arrivals,
|
|
157
|
+
queriesPerMin: queries,
|
|
158
|
+
directParticipants: this.deps.store.directParticipants(topicId),
|
|
159
|
+
childCohortCount: childOverride ?? childCohortCount,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
reset(): void {
|
|
164
|
+
this.windows.clear();
|
|
165
|
+
this.lastPublished.clear();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
forget(topicId: Uint8Array): void {
|
|
169
|
+
// Idempotent: `Map.delete` on an absent key is a safe no-op. Clears both the raw windowed events
|
|
170
|
+
// and the frozen last-published summary so a re-instantiated topic starts from zero.
|
|
171
|
+
const key = bytesKey(topicId);
|
|
172
|
+
this.windows.delete(key);
|
|
173
|
+
this.lastPublished.delete(key);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private windowFor(topicId: Uint8Array): TopicWindow {
|
|
177
|
+
const key = bytesKey(topicId);
|
|
178
|
+
let w = this.windows.get(key);
|
|
179
|
+
if (w === undefined) {
|
|
180
|
+
w = { arrivals: [], queries: [] };
|
|
181
|
+
this.windows.set(key, w);
|
|
182
|
+
}
|
|
183
|
+
return w;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Count timestamps within `[now - windowMs, now]`, pruning anything older from `events` in place. */
|
|
188
|
+
function countWithin(events: number[], now: number, windowMs: number): number {
|
|
189
|
+
const cutoff = now - windowMs;
|
|
190
|
+
// Events accumulate in arrival order; drop the stale prefix so the array stays bounded.
|
|
191
|
+
let drop = 0;
|
|
192
|
+
while (drop < events.length && events[drop]! < cutoff) {
|
|
193
|
+
drop++;
|
|
194
|
+
}
|
|
195
|
+
if (drop > 0) {
|
|
196
|
+
events.splice(0, drop);
|
|
197
|
+
}
|
|
198
|
+
return events.length;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Construct empty {@link TrafficCounters}. */
|
|
202
|
+
export function createTrafficCounters(deps: TrafficCountersDeps): TrafficCounters {
|
|
203
|
+
return new WindowedTrafficCounters(deps);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Attach the topic-traffic signal to a reply **only** when the result is `accepted` or `promoted`
|
|
208
|
+
* (GROUNDING-resolved: absent on `no_state`, `unwilling_member`, `unwilling_cohort`). Mutates and
|
|
209
|
+
* returns `reply`. A no-op for non-traffic-bearing results, so callers can pipe every reply through.
|
|
210
|
+
*/
|
|
211
|
+
export function attachTopicTraffic(reply: RegisterReplyV1, traffic: TopicTrafficV1): RegisterReplyV1 {
|
|
212
|
+
if (reply.result === "accepted" || reply.result === "promoted") {
|
|
213
|
+
reply.topicTraffic = traffic;
|
|
214
|
+
}
|
|
215
|
+
return reply;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The own published counts as a `CohortTopicSummary` for the gossip frame. Callers supply tier/promotion fields. */
|
|
219
|
+
export function toCohortTopicSummary(
|
|
220
|
+
topicId: Uint8Array,
|
|
221
|
+
published: PublishedTopic,
|
|
222
|
+
fields: { tier: number; directParticipants: number; promoted: boolean; childCohortCount: number },
|
|
223
|
+
): CohortTopicSummary {
|
|
224
|
+
return {
|
|
225
|
+
topicId: bytesToB64url(topicId),
|
|
226
|
+
tier: fields.tier,
|
|
227
|
+
directParticipants: fields.directParticipants,
|
|
228
|
+
arrivalsPerMin: published.arrivals,
|
|
229
|
+
queriesPerMin: published.queries,
|
|
230
|
+
promoted: fields.promoted,
|
|
231
|
+
childCohortCount: fields.childCohortCount,
|
|
232
|
+
};
|
|
233
|
+
}
|