@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,146 +1,146 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic substrate — correlation-id replay guard (anti-DoS).
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 3. Every `RegisterV1` carries a
|
|
5
|
-
* `correlationId` (16 random bytes) and a signature over `(topicId, tier, correlationId, timestamp)`.
|
|
6
|
-
* The signature (verified elsewhere — the participant-key check) proves authorship; this guard adds
|
|
7
|
-
* **freshness**: it drops a registration whose `timestamp` is outside the accepted skew window
|
|
8
|
-
* (stale, or implausibly far in the future) and drops a `correlationId` it has already seen inside the
|
|
9
|
-
* window (a captured-and-replayed registration).
|
|
10
|
-
*
|
|
11
|
-
* Because a stale registration is rejected outright, the guard only needs to remember correlation ids
|
|
12
|
-
* for one `maxAgeMs` window: an id older than that would be rejected on timestamp alone, so its record
|
|
13
|
-
* can be pruned. Pruning runs on access, bounding memory to the live window's worth of registrations.
|
|
14
|
-
*
|
|
15
|
-
* On top of the age-based prune, the map carries a **hard LRU `maxKeys` cap** (mirroring the sibling
|
|
16
|
-
* {@link import("./rate-limiter.js").RegisterRateLimiter}) so a flood of genuinely-fresh, admitted
|
|
17
|
-
* correlationIds cannot grow `seen` without bound before the age prune fires. When a new id would
|
|
18
|
-
* exceed the cap, the **oldest-inserted** entries are evicted until within cap. That victim is the
|
|
19
|
-
* least-bad one: replay entries are inserted once and never refreshed, so `Map` insertion order tracks
|
|
20
|
-
* timestamp order — the oldest entry is the one nearest to aging out of the window and being pruned as
|
|
21
|
-
* stale anyway. Evicting it forgives at most that entry's remaining replay-protection window: a bounded,
|
|
22
|
-
* documented tradeoff (unlike the rate limiter's fully penalty-free eviction), and one that only
|
|
23
|
-
* triggers under a flood of admitted ids — which, in the register pipeline, must also have passed the
|
|
24
|
-
* signature, rate, and bootstrap gates before ever reaching this guard.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { bytesKey } from "../registration/bytes.js";
|
|
28
|
-
import { createLogger } from "../../logger.js";
|
|
29
|
-
|
|
30
|
-
const log = createLogger("cohort-topic:antidos");
|
|
31
|
-
|
|
32
|
-
/** Default acceptance window: a registration timestamped older than this (relative to `now`) is stale. */
|
|
33
|
-
export const DEFAULT_REPLAY_MAX_AGE_MS = 60_000;
|
|
34
|
-
/** Default tolerated forward clock skew: a timestamp this far past `now` is rejected as implausible. */
|
|
35
|
-
export const DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS = 5_000;
|
|
36
|
-
/** Default hard cap on remembered correlationIds; the oldest-inserted are evicted beyond this. */
|
|
37
|
-
export const DEFAULT_REPLAY_GUARD_MAX_KEYS = 100_000;
|
|
38
|
-
|
|
39
|
-
export interface CorrelationReplayGuardConfig {
|
|
40
|
-
/** A timestamp older than `now − maxAgeMs` is stale. Default {@link DEFAULT_REPLAY_MAX_AGE_MS}. */
|
|
41
|
-
maxAgeMs?: number;
|
|
42
|
-
/** A timestamp newer than `now + maxFutureSkewMs` is rejected. Default {@link DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS}. */
|
|
43
|
-
maxFutureSkewMs?: number;
|
|
44
|
-
/** Hard LRU cap on remembered correlationIds; oldest-inserted (≈ oldest-timestamp) evicted beyond this. Default {@link DEFAULT_REPLAY_GUARD_MAX_KEYS}. */
|
|
45
|
-
maxKeys?: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Freshness + anti-replay gate over registration `correlationId`s and timestamps. */
|
|
49
|
-
export interface CorrelationReplayGuard {
|
|
50
|
-
/**
|
|
51
|
-
* Accept `correlationId` (from `peerId`, stamped `timestamp`) evaluated at `now`. Returns `false`
|
|
52
|
-
* — and records nothing — when the timestamp is stale or implausibly future, or when this
|
|
53
|
-
* `correlationId` was already accepted inside the window (replay). Returns `true` and remembers the
|
|
54
|
-
* id on first sight of a fresh registration.
|
|
55
|
-
*/
|
|
56
|
-
accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean;
|
|
57
|
-
/** Remembered correlationId count (test/diagnostic introspection). */
|
|
58
|
-
readonly size: number;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** A remembered acceptance, kept until its timestamp ages out of the window. */
|
|
62
|
-
interface SeenEntry {
|
|
63
|
-
timestamp: number;
|
|
64
|
-
peer: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
class WindowedReplayGuard implements CorrelationReplayGuard {
|
|
68
|
-
private readonly seen = new Map<string, SeenEntry>();
|
|
69
|
-
private readonly maxAgeMs: number;
|
|
70
|
-
private readonly maxFutureSkewMs: number;
|
|
71
|
-
private readonly maxKeys: number;
|
|
72
|
-
/** `now` of the last prune, so pruning amortizes rather than scanning on every call. */
|
|
73
|
-
private lastPruneAt = -Infinity;
|
|
74
|
-
|
|
75
|
-
constructor(config: CorrelationReplayGuardConfig = {}) {
|
|
76
|
-
this.maxAgeMs = config.maxAgeMs ?? DEFAULT_REPLAY_MAX_AGE_MS;
|
|
77
|
-
if (!(this.maxAgeMs > 0)) {
|
|
78
|
-
throw new RangeError(`maxAgeMs must be > 0, got ${this.maxAgeMs}`);
|
|
79
|
-
}
|
|
80
|
-
this.maxFutureSkewMs = config.maxFutureSkewMs ?? DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS;
|
|
81
|
-
if (!(this.maxFutureSkewMs >= 0)) {
|
|
82
|
-
throw new RangeError(`maxFutureSkewMs must be >= 0, got ${this.maxFutureSkewMs}`);
|
|
83
|
-
}
|
|
84
|
-
this.maxKeys = config.maxKeys ?? DEFAULT_REPLAY_GUARD_MAX_KEYS;
|
|
85
|
-
if (!Number.isInteger(this.maxKeys) || this.maxKeys <= 0) {
|
|
86
|
-
throw new RangeError(`maxKeys must be a positive integer, got ${this.maxKeys}`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean {
|
|
91
|
-
if (timestamp < now - this.maxAgeMs) {
|
|
92
|
-
log("replay-guard reject: stale timestamp age=%d > maxAge=%d", now - timestamp, this.maxAgeMs);
|
|
93
|
-
return false; // stale
|
|
94
|
-
}
|
|
95
|
-
if (timestamp > now + this.maxFutureSkewMs) {
|
|
96
|
-
log("replay-guard reject: future timestamp skew=%d > maxSkew=%d", timestamp - now, this.maxFutureSkewMs);
|
|
97
|
-
return false; // implausibly future
|
|
98
|
-
}
|
|
99
|
-
this.maybePrune(now);
|
|
100
|
-
const key = bytesKey(correlationId);
|
|
101
|
-
const prior = this.seen.get(key);
|
|
102
|
-
if (prior !== undefined) {
|
|
103
|
-
// Attribute the replay to the *original* accepter: the correlationId (not the peer id) is the
|
|
104
|
-
// anti-replay key, so a replayer that spoofs a different source id is still caught here.
|
|
105
|
-
log("replay-guard reject: replayed correlationId (first seen from peer=%s)", prior.peer);
|
|
106
|
-
return false; // replay
|
|
107
|
-
}
|
|
108
|
-
// New id: enforce the hard cap by evicting the oldest-inserted entries (oldest by `Map` insertion
|
|
109
|
-
// order) until this insertion stays within `maxKeys`. Since entries are inserted once and never
|
|
110
|
-
// refreshed, insertion order closely tracks timestamp order, so the victim is ≈ the oldest-timestamp
|
|
111
|
-
// — nearest to aging out as stale. (It is only ≈: ids from distinct peers can arrive out of
|
|
112
|
-
// timestamp order within the skew window, so a victim may retain slightly more window than a
|
|
113
|
-
// strictly-oldest pick. The forgiveness bound — at most one entry's remaining window — holds either way.)
|
|
114
|
-
while (this.seen.size >= this.maxKeys) {
|
|
115
|
-
const oldest = this.seen.keys().next().value;
|
|
116
|
-
if (oldest === undefined) break;
|
|
117
|
-
this.seen.delete(oldest);
|
|
118
|
-
}
|
|
119
|
-
this.seen.set(key, { timestamp, peer: bytesKey(peerId) });
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Forget ids whose timestamp has aged past the window — they would be rejected as stale anyway. */
|
|
124
|
-
private maybePrune(now: number): void {
|
|
125
|
-
// Amortize: prune at most once per window rather than scanning on every accept.
|
|
126
|
-
if (now - this.lastPruneAt < this.maxAgeMs) {
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
this.lastPruneAt = now;
|
|
130
|
-
const cutoff = now - this.maxAgeMs;
|
|
131
|
-
for (const [key, entry] of this.seen) {
|
|
132
|
-
if (entry.timestamp < cutoff) {
|
|
133
|
-
this.seen.delete(key);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
get size(): number {
|
|
139
|
-
return this.seen.size;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Build a {@link CorrelationReplayGuard} over the configured staleness window and forward skew. */
|
|
144
|
-
export function createCorrelationReplayGuard(config: CorrelationReplayGuardConfig = {}): CorrelationReplayGuard {
|
|
145
|
-
return new WindowedReplayGuard(config);
|
|
146
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic substrate — correlation-id replay guard (anti-DoS).
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 3. Every `RegisterV1` carries a
|
|
5
|
+
* `correlationId` (16 random bytes) and a signature over `(topicId, tier, correlationId, timestamp)`.
|
|
6
|
+
* The signature (verified elsewhere — the participant-key check) proves authorship; this guard adds
|
|
7
|
+
* **freshness**: it drops a registration whose `timestamp` is outside the accepted skew window
|
|
8
|
+
* (stale, or implausibly far in the future) and drops a `correlationId` it has already seen inside the
|
|
9
|
+
* window (a captured-and-replayed registration).
|
|
10
|
+
*
|
|
11
|
+
* Because a stale registration is rejected outright, the guard only needs to remember correlation ids
|
|
12
|
+
* for one `maxAgeMs` window: an id older than that would be rejected on timestamp alone, so its record
|
|
13
|
+
* can be pruned. Pruning runs on access, bounding memory to the live window's worth of registrations.
|
|
14
|
+
*
|
|
15
|
+
* On top of the age-based prune, the map carries a **hard LRU `maxKeys` cap** (mirroring the sibling
|
|
16
|
+
* {@link import("./rate-limiter.js").RegisterRateLimiter}) so a flood of genuinely-fresh, admitted
|
|
17
|
+
* correlationIds cannot grow `seen` without bound before the age prune fires. When a new id would
|
|
18
|
+
* exceed the cap, the **oldest-inserted** entries are evicted until within cap. That victim is the
|
|
19
|
+
* least-bad one: replay entries are inserted once and never refreshed, so `Map` insertion order tracks
|
|
20
|
+
* timestamp order — the oldest entry is the one nearest to aging out of the window and being pruned as
|
|
21
|
+
* stale anyway. Evicting it forgives at most that entry's remaining replay-protection window: a bounded,
|
|
22
|
+
* documented tradeoff (unlike the rate limiter's fully penalty-free eviction), and one that only
|
|
23
|
+
* triggers under a flood of admitted ids — which, in the register pipeline, must also have passed the
|
|
24
|
+
* signature, rate, and bootstrap gates before ever reaching this guard.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { bytesKey } from "../registration/bytes.js";
|
|
28
|
+
import { createLogger } from "../../logger.js";
|
|
29
|
+
|
|
30
|
+
const log = createLogger("cohort-topic:antidos");
|
|
31
|
+
|
|
32
|
+
/** Default acceptance window: a registration timestamped older than this (relative to `now`) is stale. */
|
|
33
|
+
export const DEFAULT_REPLAY_MAX_AGE_MS = 60_000;
|
|
34
|
+
/** Default tolerated forward clock skew: a timestamp this far past `now` is rejected as implausible. */
|
|
35
|
+
export const DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS = 5_000;
|
|
36
|
+
/** Default hard cap on remembered correlationIds; the oldest-inserted are evicted beyond this. */
|
|
37
|
+
export const DEFAULT_REPLAY_GUARD_MAX_KEYS = 100_000;
|
|
38
|
+
|
|
39
|
+
export interface CorrelationReplayGuardConfig {
|
|
40
|
+
/** A timestamp older than `now − maxAgeMs` is stale. Default {@link DEFAULT_REPLAY_MAX_AGE_MS}. */
|
|
41
|
+
maxAgeMs?: number;
|
|
42
|
+
/** A timestamp newer than `now + maxFutureSkewMs` is rejected. Default {@link DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS}. */
|
|
43
|
+
maxFutureSkewMs?: number;
|
|
44
|
+
/** Hard LRU cap on remembered correlationIds; oldest-inserted (≈ oldest-timestamp) evicted beyond this. Default {@link DEFAULT_REPLAY_GUARD_MAX_KEYS}. */
|
|
45
|
+
maxKeys?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Freshness + anti-replay gate over registration `correlationId`s and timestamps. */
|
|
49
|
+
export interface CorrelationReplayGuard {
|
|
50
|
+
/**
|
|
51
|
+
* Accept `correlationId` (from `peerId`, stamped `timestamp`) evaluated at `now`. Returns `false`
|
|
52
|
+
* — and records nothing — when the timestamp is stale or implausibly future, or when this
|
|
53
|
+
* `correlationId` was already accepted inside the window (replay). Returns `true` and remembers the
|
|
54
|
+
* id on first sight of a fresh registration.
|
|
55
|
+
*/
|
|
56
|
+
accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean;
|
|
57
|
+
/** Remembered correlationId count (test/diagnostic introspection). */
|
|
58
|
+
readonly size: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A remembered acceptance, kept until its timestamp ages out of the window. */
|
|
62
|
+
interface SeenEntry {
|
|
63
|
+
timestamp: number;
|
|
64
|
+
peer: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class WindowedReplayGuard implements CorrelationReplayGuard {
|
|
68
|
+
private readonly seen = new Map<string, SeenEntry>();
|
|
69
|
+
private readonly maxAgeMs: number;
|
|
70
|
+
private readonly maxFutureSkewMs: number;
|
|
71
|
+
private readonly maxKeys: number;
|
|
72
|
+
/** `now` of the last prune, so pruning amortizes rather than scanning on every call. */
|
|
73
|
+
private lastPruneAt = -Infinity;
|
|
74
|
+
|
|
75
|
+
constructor(config: CorrelationReplayGuardConfig = {}) {
|
|
76
|
+
this.maxAgeMs = config.maxAgeMs ?? DEFAULT_REPLAY_MAX_AGE_MS;
|
|
77
|
+
if (!(this.maxAgeMs > 0)) {
|
|
78
|
+
throw new RangeError(`maxAgeMs must be > 0, got ${this.maxAgeMs}`);
|
|
79
|
+
}
|
|
80
|
+
this.maxFutureSkewMs = config.maxFutureSkewMs ?? DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS;
|
|
81
|
+
if (!(this.maxFutureSkewMs >= 0)) {
|
|
82
|
+
throw new RangeError(`maxFutureSkewMs must be >= 0, got ${this.maxFutureSkewMs}`);
|
|
83
|
+
}
|
|
84
|
+
this.maxKeys = config.maxKeys ?? DEFAULT_REPLAY_GUARD_MAX_KEYS;
|
|
85
|
+
if (!Number.isInteger(this.maxKeys) || this.maxKeys <= 0) {
|
|
86
|
+
throw new RangeError(`maxKeys must be a positive integer, got ${this.maxKeys}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean {
|
|
91
|
+
if (timestamp < now - this.maxAgeMs) {
|
|
92
|
+
log("replay-guard reject: stale timestamp age=%d > maxAge=%d", now - timestamp, this.maxAgeMs);
|
|
93
|
+
return false; // stale
|
|
94
|
+
}
|
|
95
|
+
if (timestamp > now + this.maxFutureSkewMs) {
|
|
96
|
+
log("replay-guard reject: future timestamp skew=%d > maxSkew=%d", timestamp - now, this.maxFutureSkewMs);
|
|
97
|
+
return false; // implausibly future
|
|
98
|
+
}
|
|
99
|
+
this.maybePrune(now);
|
|
100
|
+
const key = bytesKey(correlationId);
|
|
101
|
+
const prior = this.seen.get(key);
|
|
102
|
+
if (prior !== undefined) {
|
|
103
|
+
// Attribute the replay to the *original* accepter: the correlationId (not the peer id) is the
|
|
104
|
+
// anti-replay key, so a replayer that spoofs a different source id is still caught here.
|
|
105
|
+
log("replay-guard reject: replayed correlationId (first seen from peer=%s)", prior.peer);
|
|
106
|
+
return false; // replay
|
|
107
|
+
}
|
|
108
|
+
// New id: enforce the hard cap by evicting the oldest-inserted entries (oldest by `Map` insertion
|
|
109
|
+
// order) until this insertion stays within `maxKeys`. Since entries are inserted once and never
|
|
110
|
+
// refreshed, insertion order closely tracks timestamp order, so the victim is ≈ the oldest-timestamp
|
|
111
|
+
// — nearest to aging out as stale. (It is only ≈: ids from distinct peers can arrive out of
|
|
112
|
+
// timestamp order within the skew window, so a victim may retain slightly more window than a
|
|
113
|
+
// strictly-oldest pick. The forgiveness bound — at most one entry's remaining window — holds either way.)
|
|
114
|
+
while (this.seen.size >= this.maxKeys) {
|
|
115
|
+
const oldest = this.seen.keys().next().value;
|
|
116
|
+
if (oldest === undefined) break;
|
|
117
|
+
this.seen.delete(oldest);
|
|
118
|
+
}
|
|
119
|
+
this.seen.set(key, { timestamp, peer: bytesKey(peerId) });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Forget ids whose timestamp has aged past the window — they would be rejected as stale anyway. */
|
|
124
|
+
private maybePrune(now: number): void {
|
|
125
|
+
// Amortize: prune at most once per window rather than scanning on every accept.
|
|
126
|
+
if (now - this.lastPruneAt < this.maxAgeMs) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.lastPruneAt = now;
|
|
130
|
+
const cutoff = now - this.maxAgeMs;
|
|
131
|
+
for (const [key, entry] of this.seen) {
|
|
132
|
+
if (entry.timestamp < cutoff) {
|
|
133
|
+
this.seen.delete(key);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get size(): number {
|
|
139
|
+
return this.seen.size;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Build a {@link CorrelationReplayGuard} over the configured staleness window and forward skew. */
|
|
144
|
+
export function createCorrelationReplayGuard(config: CorrelationReplayGuardConfig = {}): CorrelationReplayGuard {
|
|
145
|
+
return new WindowedReplayGuard(config);
|
|
146
|
+
}
|
|
@@ -1,160 +1,160 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic substrate — per-cohort topic budget (anti-DoS).
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 2. A cohort holds forwarder state for at
|
|
5
|
-
* most `topics_max` (default 2048) topics. Once the budget is full a request to instantiate a **new**
|
|
6
|
-
* topic is refused (the caller answers `UnwillingCohort`); topics already in the budget continue to
|
|
7
|
-
* serve. To keep room for genuinely active topics, the budget evicts cold ones: eviction order is
|
|
8
|
-
* **LRU by participant count**, with **zero-recent-registration topics dropped first** — a topic that
|
|
9
|
-
* carries no direct participants is the cheapest to shed and is shed before any populated topic.
|
|
10
|
-
*
|
|
11
|
-
* `admit` only ever evicts a *zero-participant* topic. If every resident topic still carries
|
|
12
|
-
* participants the budget is genuinely saturated and the new instantiation is refused — an attacker
|
|
13
|
-
* cannot, by flooding new topic ids, evict a topic that real participants are using. LRU ordering
|
|
14
|
-
* uses a monotonic access counter bumped on every `admit`/`touch`, so it is deterministic and needs
|
|
15
|
-
* no wall clock.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { bytesKey } from "../registration/bytes.js";
|
|
19
|
-
import { createLogger } from "../../logger.js";
|
|
20
|
-
|
|
21
|
-
const log = createLogger("cohort-topic:antidos");
|
|
22
|
-
|
|
23
|
-
/** Default per-cohort forwarder-topic ceiling — `topics_max`. */
|
|
24
|
-
export const DEFAULT_TOPICS_MAX = 2048;
|
|
25
|
-
|
|
26
|
-
export interface TopicBudgetConfig {
|
|
27
|
-
/** Max topics with forwarder state. Default {@link DEFAULT_TOPICS_MAX}. */
|
|
28
|
-
topicsMax?: number;
|
|
29
|
-
/**
|
|
30
|
-
* Called with the evicted topic's id just before its slot is freed in {@link TopicBudget.admit}, so
|
|
31
|
-
* the caller can tear down the now-unbacked forwarder + traffic window that outlives the slot. Fires
|
|
32
|
-
* only for a genuine eviction (a zero-participant victim), never for a plain admit, an already-resident
|
|
33
|
-
* re-admit, or a refusal. Absent → no teardown hook.
|
|
34
|
-
*/
|
|
35
|
-
onEvict?: (topicId: Uint8Array) => void;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Per-cohort cap on the number of topics with forwarder state, with LRU eviction of cold topics. */
|
|
39
|
-
export interface TopicBudget {
|
|
40
|
-
/**
|
|
41
|
-
* Admit `topicId` to the forwarder-state budget. Returns `true` if it is already resident or there
|
|
42
|
-
* is (or can be freed) room; `false` when the budget is full of populated topics, in which case the
|
|
43
|
-
* caller answers `UnwillingCohort` for the new instantiation. Admitting a *new* topic when full
|
|
44
|
-
* evicts the coldest zero-participant resident first.
|
|
45
|
-
*/
|
|
46
|
-
admit(topicId: Uint8Array): boolean;
|
|
47
|
-
/**
|
|
48
|
-
* Record the current direct-participant count for a resident topic (LRU bookkeeping). Updates the
|
|
49
|
-
* eviction key and bumps the topic's recency; a no-op for a topic that is not resident.
|
|
50
|
-
*/
|
|
51
|
-
touch(topicId: Uint8Array, participantCount: number): void;
|
|
52
|
-
/** Whether `topicId` currently holds forwarder state. */
|
|
53
|
-
has(topicId: Uint8Array): boolean;
|
|
54
|
-
/**
|
|
55
|
-
* The direct-participant count last reported for a resident `topicId` (the eviction key), or
|
|
56
|
-
* `undefined` when the topic is not resident. A resident reporting `0` is cold/evictable. Read-only
|
|
57
|
-
* introspection for tests/diagnostics — eviction policy reads the same field internally.
|
|
58
|
-
*/
|
|
59
|
-
participantCount(topicId: Uint8Array): number | undefined;
|
|
60
|
-
/** Number of resident topics. */
|
|
61
|
-
readonly size: number;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Per-resident eviction bookkeeping. */
|
|
65
|
-
interface ResidentState {
|
|
66
|
-
/** Direct-participant count last reported via `touch` (eviction primary key; ascending = colder). */
|
|
67
|
-
participantCount: number;
|
|
68
|
-
/** Monotonic access sequence (LRU tiebreaker; lower = least-recently used). */
|
|
69
|
-
seq: number;
|
|
70
|
-
/** The original topic-id bytes, captured on admit so `onEvict` can be handed real bytes (residents key by string). */
|
|
71
|
-
topicId: Uint8Array;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
class LruTopicBudget implements TopicBudget {
|
|
75
|
-
private readonly residents = new Map<string, ResidentState>();
|
|
76
|
-
private readonly topicsMax: number;
|
|
77
|
-
private readonly onEvict?: (topicId: Uint8Array) => void;
|
|
78
|
-
private seqCounter = 0;
|
|
79
|
-
|
|
80
|
-
constructor(config: TopicBudgetConfig = {}) {
|
|
81
|
-
this.topicsMax = config.topicsMax ?? DEFAULT_TOPICS_MAX;
|
|
82
|
-
if (!Number.isInteger(this.topicsMax) || this.topicsMax <= 0) {
|
|
83
|
-
throw new RangeError(`topicsMax must be a positive integer, got ${this.topicsMax}`);
|
|
84
|
-
}
|
|
85
|
-
this.onEvict = config.onEvict;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
get size(): number {
|
|
89
|
-
return this.residents.size;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
has(topicId: Uint8Array): boolean {
|
|
93
|
-
return this.residents.has(bytesKey(topicId));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
participantCount(topicId: Uint8Array): number | undefined {
|
|
97
|
-
return this.residents.get(bytesKey(topicId))?.participantCount;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
admit(topicId: Uint8Array): boolean {
|
|
101
|
-
const key = bytesKey(topicId);
|
|
102
|
-
const existing = this.residents.get(key);
|
|
103
|
-
if (existing !== undefined) {
|
|
104
|
-
existing.seq = ++this.seqCounter; // already resident — refresh recency, always allowed
|
|
105
|
-
return true;
|
|
106
|
-
}
|
|
107
|
-
if (this.residents.size < this.topicsMax) {
|
|
108
|
-
this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
|
|
109
|
-
return true;
|
|
110
|
-
}
|
|
111
|
-
// Full: only a zero-participant resident may be evicted to make room for a new topic.
|
|
112
|
-
const victim = this.coldestEvictable();
|
|
113
|
-
if (victim === undefined) {
|
|
114
|
-
log("topic-budget full size=%d max=%d — refuse new topic", this.residents.size, this.topicsMax);
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
// Fire the teardown hook with the victim's real bytes BEFORE freeing the slot, so the callback
|
|
118
|
-
// (drop the now-unbacked forwarder + traffic window) sees a coherent state. Only a genuine
|
|
119
|
-
// eviction reaches here — `coldestEvictable()` already refused a full-of-populated budget above.
|
|
120
|
-
this.onEvict?.(this.residents.get(victim)!.topicId);
|
|
121
|
-
this.residents.delete(victim);
|
|
122
|
-
this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
|
|
123
|
-
log("topic-budget evicted cold topic to admit new (size=%d max=%d)", this.residents.size, this.topicsMax);
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
touch(topicId: Uint8Array, participantCount: number): void {
|
|
128
|
-
const key = bytesKey(topicId);
|
|
129
|
-
const state = this.residents.get(key);
|
|
130
|
-
if (state === undefined) {
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
state.participantCount = participantCount;
|
|
134
|
-
state.seq = ++this.seqCounter;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* The coldest evictable resident key: among zero-participant topics, the least-recently used.
|
|
139
|
-
* `undefined` when every resident still carries participants (nothing may be evicted for a new topic).
|
|
140
|
-
*/
|
|
141
|
-
private coldestEvictable(): string | undefined {
|
|
142
|
-
let victim: string | undefined;
|
|
143
|
-
let victimSeq = Infinity;
|
|
144
|
-
for (const [key, state] of this.residents) {
|
|
145
|
-
if (state.participantCount > 0) {
|
|
146
|
-
continue; // populated topics continue — never evicted for a new instantiation
|
|
147
|
-
}
|
|
148
|
-
if (state.seq < victimSeq) {
|
|
149
|
-
victim = key;
|
|
150
|
-
victimSeq = state.seq;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return victim;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Build a {@link TopicBudget} over the configured `topics_max`. */
|
|
158
|
-
export function createTopicBudget(config: TopicBudgetConfig = {}): TopicBudget {
|
|
159
|
-
return new LruTopicBudget(config);
|
|
160
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic substrate — per-cohort topic budget (anti-DoS).
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 2. A cohort holds forwarder state for at
|
|
5
|
+
* most `topics_max` (default 2048) topics. Once the budget is full a request to instantiate a **new**
|
|
6
|
+
* topic is refused (the caller answers `UnwillingCohort`); topics already in the budget continue to
|
|
7
|
+
* serve. To keep room for genuinely active topics, the budget evicts cold ones: eviction order is
|
|
8
|
+
* **LRU by participant count**, with **zero-recent-registration topics dropped first** — a topic that
|
|
9
|
+
* carries no direct participants is the cheapest to shed and is shed before any populated topic.
|
|
10
|
+
*
|
|
11
|
+
* `admit` only ever evicts a *zero-participant* topic. If every resident topic still carries
|
|
12
|
+
* participants the budget is genuinely saturated and the new instantiation is refused — an attacker
|
|
13
|
+
* cannot, by flooding new topic ids, evict a topic that real participants are using. LRU ordering
|
|
14
|
+
* uses a monotonic access counter bumped on every `admit`/`touch`, so it is deterministic and needs
|
|
15
|
+
* no wall clock.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { bytesKey } from "../registration/bytes.js";
|
|
19
|
+
import { createLogger } from "../../logger.js";
|
|
20
|
+
|
|
21
|
+
const log = createLogger("cohort-topic:antidos");
|
|
22
|
+
|
|
23
|
+
/** Default per-cohort forwarder-topic ceiling — `topics_max`. */
|
|
24
|
+
export const DEFAULT_TOPICS_MAX = 2048;
|
|
25
|
+
|
|
26
|
+
export interface TopicBudgetConfig {
|
|
27
|
+
/** Max topics with forwarder state. Default {@link DEFAULT_TOPICS_MAX}. */
|
|
28
|
+
topicsMax?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Called with the evicted topic's id just before its slot is freed in {@link TopicBudget.admit}, so
|
|
31
|
+
* the caller can tear down the now-unbacked forwarder + traffic window that outlives the slot. Fires
|
|
32
|
+
* only for a genuine eviction (a zero-participant victim), never for a plain admit, an already-resident
|
|
33
|
+
* re-admit, or a refusal. Absent → no teardown hook.
|
|
34
|
+
*/
|
|
35
|
+
onEvict?: (topicId: Uint8Array) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Per-cohort cap on the number of topics with forwarder state, with LRU eviction of cold topics. */
|
|
39
|
+
export interface TopicBudget {
|
|
40
|
+
/**
|
|
41
|
+
* Admit `topicId` to the forwarder-state budget. Returns `true` if it is already resident or there
|
|
42
|
+
* is (or can be freed) room; `false` when the budget is full of populated topics, in which case the
|
|
43
|
+
* caller answers `UnwillingCohort` for the new instantiation. Admitting a *new* topic when full
|
|
44
|
+
* evicts the coldest zero-participant resident first.
|
|
45
|
+
*/
|
|
46
|
+
admit(topicId: Uint8Array): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Record the current direct-participant count for a resident topic (LRU bookkeeping). Updates the
|
|
49
|
+
* eviction key and bumps the topic's recency; a no-op for a topic that is not resident.
|
|
50
|
+
*/
|
|
51
|
+
touch(topicId: Uint8Array, participantCount: number): void;
|
|
52
|
+
/** Whether `topicId` currently holds forwarder state. */
|
|
53
|
+
has(topicId: Uint8Array): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* The direct-participant count last reported for a resident `topicId` (the eviction key), or
|
|
56
|
+
* `undefined` when the topic is not resident. A resident reporting `0` is cold/evictable. Read-only
|
|
57
|
+
* introspection for tests/diagnostics — eviction policy reads the same field internally.
|
|
58
|
+
*/
|
|
59
|
+
participantCount(topicId: Uint8Array): number | undefined;
|
|
60
|
+
/** Number of resident topics. */
|
|
61
|
+
readonly size: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-resident eviction bookkeeping. */
|
|
65
|
+
interface ResidentState {
|
|
66
|
+
/** Direct-participant count last reported via `touch` (eviction primary key; ascending = colder). */
|
|
67
|
+
participantCount: number;
|
|
68
|
+
/** Monotonic access sequence (LRU tiebreaker; lower = least-recently used). */
|
|
69
|
+
seq: number;
|
|
70
|
+
/** The original topic-id bytes, captured on admit so `onEvict` can be handed real bytes (residents key by string). */
|
|
71
|
+
topicId: Uint8Array;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class LruTopicBudget implements TopicBudget {
|
|
75
|
+
private readonly residents = new Map<string, ResidentState>();
|
|
76
|
+
private readonly topicsMax: number;
|
|
77
|
+
private readonly onEvict?: (topicId: Uint8Array) => void;
|
|
78
|
+
private seqCounter = 0;
|
|
79
|
+
|
|
80
|
+
constructor(config: TopicBudgetConfig = {}) {
|
|
81
|
+
this.topicsMax = config.topicsMax ?? DEFAULT_TOPICS_MAX;
|
|
82
|
+
if (!Number.isInteger(this.topicsMax) || this.topicsMax <= 0) {
|
|
83
|
+
throw new RangeError(`topicsMax must be a positive integer, got ${this.topicsMax}`);
|
|
84
|
+
}
|
|
85
|
+
this.onEvict = config.onEvict;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get size(): number {
|
|
89
|
+
return this.residents.size;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
has(topicId: Uint8Array): boolean {
|
|
93
|
+
return this.residents.has(bytesKey(topicId));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
participantCount(topicId: Uint8Array): number | undefined {
|
|
97
|
+
return this.residents.get(bytesKey(topicId))?.participantCount;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
admit(topicId: Uint8Array): boolean {
|
|
101
|
+
const key = bytesKey(topicId);
|
|
102
|
+
const existing = this.residents.get(key);
|
|
103
|
+
if (existing !== undefined) {
|
|
104
|
+
existing.seq = ++this.seqCounter; // already resident — refresh recency, always allowed
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
if (this.residents.size < this.topicsMax) {
|
|
108
|
+
this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
// Full: only a zero-participant resident may be evicted to make room for a new topic.
|
|
112
|
+
const victim = this.coldestEvictable();
|
|
113
|
+
if (victim === undefined) {
|
|
114
|
+
log("topic-budget full size=%d max=%d — refuse new topic", this.residents.size, this.topicsMax);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
// Fire the teardown hook with the victim's real bytes BEFORE freeing the slot, so the callback
|
|
118
|
+
// (drop the now-unbacked forwarder + traffic window) sees a coherent state. Only a genuine
|
|
119
|
+
// eviction reaches here — `coldestEvictable()` already refused a full-of-populated budget above.
|
|
120
|
+
this.onEvict?.(this.residents.get(victim)!.topicId);
|
|
121
|
+
this.residents.delete(victim);
|
|
122
|
+
this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
|
|
123
|
+
log("topic-budget evicted cold topic to admit new (size=%d max=%d)", this.residents.size, this.topicsMax);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
touch(topicId: Uint8Array, participantCount: number): void {
|
|
128
|
+
const key = bytesKey(topicId);
|
|
129
|
+
const state = this.residents.get(key);
|
|
130
|
+
if (state === undefined) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
state.participantCount = participantCount;
|
|
134
|
+
state.seq = ++this.seqCounter;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The coldest evictable resident key: among zero-participant topics, the least-recently used.
|
|
139
|
+
* `undefined` when every resident still carries participants (nothing may be evicted for a new topic).
|
|
140
|
+
*/
|
|
141
|
+
private coldestEvictable(): string | undefined {
|
|
142
|
+
let victim: string | undefined;
|
|
143
|
+
let victimSeq = Infinity;
|
|
144
|
+
for (const [key, state] of this.residents) {
|
|
145
|
+
if (state.participantCount > 0) {
|
|
146
|
+
continue; // populated topics continue — never evicted for a new instantiation
|
|
147
|
+
}
|
|
148
|
+
if (state.seq < victimSeq) {
|
|
149
|
+
victim = key;
|
|
150
|
+
victimSeq = state.seq;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return victim;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Build a {@link TopicBudget} over the configured `topics_max`. */
|
|
158
|
+
export function createTopicBudget(config: TopicBudgetConfig = {}): TopicBudget {
|
|
159
|
+
return new LruTopicBudget(config);
|
|
160
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from "./jitter.js";
|
|
2
|
-
export * from "./invariants.js";
|
|
1
|
+
export * from "./jitter.js";
|
|
2
|
+
export * from "./invariants.js";
|