@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
package/src/reactivity/config.ts
CHANGED
|
@@ -1,172 +1,172 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reactivity — configuration defaults (single source for the hot path).
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/reactivity.md` §Configuration. Every tunable the reactivity hot path reads
|
|
5
|
-
* is sourced from {@link DEFAULT_REACTIVITY_CONFIG} — never hard-coded at a call site — so the
|
|
6
|
-
* simulator fold-back ([fold-simulator-findings-into-design-docs]) can retune `W` / `dedupe_window`
|
|
7
|
-
* without touching origination, forwarding, the replay buffer, or delivery.
|
|
8
|
-
*
|
|
9
|
-
* **Simulator-validated-pending.** `W` (replay buffer depth), `dedupe_window`, `T_drain`, `queue_max`,
|
|
10
|
-
* and `block_fill_size` are *provisional* pending the design simulator ([simulator-reactivity-replay],
|
|
11
|
-
* folded back by [fold-simulator-findings-into-design-docs]). The simulator's REVISED guidance is that `W`
|
|
12
|
-
* SHOULD become adaptive per measured commit-rate on hot collections (`W ≈ ⌈min_coverage × cps⌉`); the
|
|
13
|
-
* static `W = 256` is kept as the Edge/low-rate default. {@link resolveW} exposes that hook; the
|
|
14
|
-
* simulator's tail-rotation scenario also confirms the re-registration burst stays inside
|
|
15
|
-
* `cap_promote_fast` within `T_drain`, and flags whether `queue_max` should scale with cohort size/tier
|
|
16
|
-
* ({@link resolveQueueMax} is the parallel hook for that fold-back). Sourcing every tunable from this
|
|
17
|
-
* single table is what lets the fold-back revise the values without touching the protocol code.
|
|
18
|
-
*
|
|
19
|
-
* Ownership: `W` / `dedupe_window` ← [reactivity-origination-replay-delivery]; `W_checkpoint` ←
|
|
20
|
-
* [reactivity-backfill-resume-checkpoints]; `queue_max` / `delta_max` / `T_drain` / `warm_threshold` /
|
|
21
|
-
* `block_fill_size` ← [reactivity-rotation-backpressure-policy] (this ticket). `T_rejoin_jitter`, TTL, and
|
|
22
|
-
* ping are inherited from the cohort-topic defaults.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import type { NodeProfile } from "../cohort-topic/tiers.js";
|
|
26
|
-
import { DEFAULT_T_REJOIN_JITTER_MS } from "../cohort-topic/antiflood/jitter.js";
|
|
27
|
-
|
|
28
|
-
/** Replay buffer depth (revisions per cohort, per collection). Simulator-validated-pending. */
|
|
29
|
-
export const W_DEFAULT = 256;
|
|
30
|
-
/** Sliding dedupe-window size (revisions). Simulator-validated-pending. */
|
|
31
|
-
export const DEDUPE_WINDOW_DEFAULT = 64;
|
|
32
|
-
/** Parent-checkpoint span (revisions). Owned by the backfill/resume ticket; default carried here. */
|
|
33
|
-
export const W_CHECKPOINT_DEFAULT = 4096;
|
|
34
|
-
/** Per-subscriber bounded queue depth at a forwarder. Simulator-validated-pending (may scale with cohort/tier). */
|
|
35
|
-
export const QUEUE_MAX_DEFAULT = 32;
|
|
36
|
-
/** Max delta payload size on a Core node (bytes). */
|
|
37
|
-
export const DELTA_MAX_CORE_BYTES = 4096;
|
|
38
|
-
/** Max delta payload size on an Edge node (bytes) — Edge declines deltas (`0` ⇒ omit `delta`). */
|
|
39
|
-
export const DELTA_MAX_EDGE_BYTES = 0;
|
|
40
|
-
/** Subscriber registration TTL on a Core node (ms), inherited from cohort-topic. */
|
|
41
|
-
export const SUBSCRIBER_TTL_CORE_MS = 90_000;
|
|
42
|
-
/** Subscriber registration TTL on an Edge node (ms), inherited from cohort-topic. */
|
|
43
|
-
export const SUBSCRIBER_TTL_EDGE_MS = 60_000;
|
|
44
|
-
/** Transactions per block — drives tail rotation. Simulator-validated-pending. */
|
|
45
|
-
export const BLOCK_FILL_SIZE_DEFAULT = 64;
|
|
46
|
-
/** Old-tail drain time after rotation (ms). Simulator-validated-pending. */
|
|
47
|
-
export const T_DRAIN_MS = 60_000;
|
|
48
|
-
/** Transactions remaining in tail before anticipatory warm-up. Owned by the rotation ticket. */
|
|
49
|
-
export const WARM_THRESHOLD_DEFAULT = 8;
|
|
50
|
-
/** Subscriber re-registration jitter span after a rotation hint (ms). Inherited from cohort-topic `T_rejoin_jitter`. */
|
|
51
|
-
export const T_REJOIN_JITTER_MS = DEFAULT_T_REJOIN_JITTER_MS;
|
|
52
|
-
|
|
53
|
-
/** The full reactivity config, with the documented defaults. */
|
|
54
|
-
export interface ReactivityConfig {
|
|
55
|
-
/** Replay buffer depth `W`. Simulator-validated-pending. */
|
|
56
|
-
readonly w: number;
|
|
57
|
-
/** Sliding dedupe-window size. Simulator-validated-pending. */
|
|
58
|
-
readonly dedupeWindow: number;
|
|
59
|
-
/** Parent-checkpoint span (reserved; backfill/resume ticket). */
|
|
60
|
-
readonly wCheckpoint: number;
|
|
61
|
-
/** Per-subscriber bounded queue depth (reserved; backpressure ticket). */
|
|
62
|
-
readonly queueMax: number;
|
|
63
|
-
/** Max delta payload size on a Core node (bytes). */
|
|
64
|
-
readonly deltaMaxCoreBytes: number;
|
|
65
|
-
/** Max delta payload size on an Edge node (bytes). */
|
|
66
|
-
readonly deltaMaxEdgeBytes: number;
|
|
67
|
-
/** Subscriber TTL on a Core node (ms). */
|
|
68
|
-
readonly subscriberTtlCoreMs: number;
|
|
69
|
-
/** Subscriber TTL on an Edge node (ms). */
|
|
70
|
-
readonly subscriberTtlEdgeMs: number;
|
|
71
|
-
/** Transactions per block — drives tail rotation. Simulator-validated-pending. */
|
|
72
|
-
readonly blockFillSize: number;
|
|
73
|
-
/** Old-tail drain time after rotation (ms). Simulator-validated-pending. */
|
|
74
|
-
readonly tDrainMs: number;
|
|
75
|
-
/** Transactions remaining before anticipatory warm-up. */
|
|
76
|
-
readonly warmThreshold: number;
|
|
77
|
-
/** Subscriber re-registration jitter span after a rotation (ms); inherited from cohort-topic. */
|
|
78
|
-
readonly tRejoinJitterMs: number;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** The default reactivity config (`docs/reactivity.md` §Configuration). */
|
|
82
|
-
export const DEFAULT_REACTIVITY_CONFIG: ReactivityConfig = {
|
|
83
|
-
w: W_DEFAULT,
|
|
84
|
-
dedupeWindow: DEDUPE_WINDOW_DEFAULT,
|
|
85
|
-
wCheckpoint: W_CHECKPOINT_DEFAULT,
|
|
86
|
-
queueMax: QUEUE_MAX_DEFAULT,
|
|
87
|
-
deltaMaxCoreBytes: DELTA_MAX_CORE_BYTES,
|
|
88
|
-
deltaMaxEdgeBytes: DELTA_MAX_EDGE_BYTES,
|
|
89
|
-
subscriberTtlCoreMs: SUBSCRIBER_TTL_CORE_MS,
|
|
90
|
-
subscriberTtlEdgeMs: SUBSCRIBER_TTL_EDGE_MS,
|
|
91
|
-
blockFillSize: BLOCK_FILL_SIZE_DEFAULT,
|
|
92
|
-
tDrainMs: T_DRAIN_MS,
|
|
93
|
-
warmThreshold: WARM_THRESHOLD_DEFAULT,
|
|
94
|
-
tRejoinJitterMs: T_REJOIN_JITTER_MS,
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
/** `delta_max` for a node profile: Core admits deltas up to `delta_max`, Edge declines them (`0`). */
|
|
98
|
-
export function deltaMaxForProfile(profile: NodeProfile, config: ReactivityConfig = DEFAULT_REACTIVITY_CONFIG): number {
|
|
99
|
-
return profile.kind === "edge" ? config.deltaMaxEdgeBytes : config.deltaMaxCoreBytes;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Subscriber TTL for a node profile (`docs/reactivity.md` §Subscription): Core 90 s / Edge 60 s. */
|
|
103
|
-
export function subscriberTtlForProfile(profile: NodeProfile, config: ReactivityConfig = DEFAULT_REACTIVITY_CONFIG): number {
|
|
104
|
-
return profile.kind === "edge" ? config.subscriberTtlEdgeMs : config.subscriberTtlCoreMs;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Resolve the replay-buffer depth `W` for a collection.
|
|
109
|
-
*
|
|
110
|
-
* Static default (no `cps`): returns the configured `config.w` unchanged — the simulator-confirmed
|
|
111
|
-
* Edge/low-rate default. Adaptive (a measured `cps` and `minCoverageSeconds`): returns
|
|
112
|
-
* `⌈minCoverageSeconds × cps⌉` clamped to `[config.w, maxW]`, the simulator's REVISED guidance for hot
|
|
113
|
-
* collections. This is the single hook the backfill/resume ticket extends; the hot path here only ever
|
|
114
|
-
* calls the static form, so behavior is unchanged until a `cps` is wired through.
|
|
115
|
-
*/
|
|
116
|
-
export function resolveW(opts: { cps?: number; minCoverageSeconds?: number; maxW?: number; config?: ReactivityConfig } = {}): number {
|
|
117
|
-
const config = opts.config ?? DEFAULT_REACTIVITY_CONFIG;
|
|
118
|
-
if (opts.cps === undefined || opts.minCoverageSeconds === undefined) {
|
|
119
|
-
return config.w;
|
|
120
|
-
}
|
|
121
|
-
if (!Number.isFinite(opts.cps) || opts.cps <= 0 || !Number.isFinite(opts.minCoverageSeconds) || opts.minCoverageSeconds <= 0) {
|
|
122
|
-
return config.w;
|
|
123
|
-
}
|
|
124
|
-
const adaptive = Math.ceil(opts.minCoverageSeconds * opts.cps);
|
|
125
|
-
const maxW = opts.maxW ?? Number.POSITIVE_INFINITY;
|
|
126
|
-
return Math.min(Math.max(config.w, adaptive), maxW);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Default `W_checkpoint`-to-`W` ratio (`docs/reactivity.md` §Parent checkpoint summaries: 16×). */
|
|
130
|
-
export const W_CHECKPOINT_RATIO = 16;
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Resolve the parent-checkpoint span `W_checkpoint` for a collection.
|
|
134
|
-
*
|
|
135
|
-
* Static default (no `cps`): the configured `config.wCheckpoint` (4096). Adaptive: `W_checkpoint`
|
|
136
|
-
* "scales the same way and may stay a fixed 16× multiple of the resolved `W`" (`docs/reactivity.md`
|
|
137
|
-
* §Configuration), so when a `cps` is supplied it tracks `W_CHECKPOINT_RATIO × resolveW(opts)`. This is
|
|
138
|
-
* the single hook the simulator fold-back ([fold-simulator-findings-into-design-docs]) retunes; the
|
|
139
|
-
* resume classifier reads it so a hot collection's stacked recovery range scales with the replay depth.
|
|
140
|
-
*/
|
|
141
|
-
export function resolveWCheckpoint(opts: { cps?: number; minCoverageSeconds?: number; maxW?: number; ratio?: number; config?: ReactivityConfig } = {}): number {
|
|
142
|
-
const config = opts.config ?? DEFAULT_REACTIVITY_CONFIG;
|
|
143
|
-
if (opts.cps === undefined || opts.minCoverageSeconds === undefined) {
|
|
144
|
-
return config.wCheckpoint;
|
|
145
|
-
}
|
|
146
|
-
const ratio = opts.ratio ?? W_CHECKPOINT_RATIO;
|
|
147
|
-
return resolveW(opts) * ratio;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Resolve the per-subscriber bounded-queue depth `queue_max` for a cohort.
|
|
152
|
-
*
|
|
153
|
-
* Static default (no scaling input): the configured `config.queueMax` (32). The simulator flags whether
|
|
154
|
-
* `queue_max` should scale with cohort size / tier on hot collections; this is the single hook the
|
|
155
|
-
* fold-back ([fold-simulator-findings-into-design-docs]) retunes. When a `cohortSubscribers` count is
|
|
156
|
-
* supplied, the depth scales as `⌈queue_max × subscribers / scaleBaseline⌉` clamped to `[queueMax, maxQueue]`
|
|
157
|
-
* — never below the static default, optionally capped at a per-cohort memory budget. With no scaling input
|
|
158
|
-
* the hot path is unchanged (constant `queue_max`).
|
|
159
|
-
*/
|
|
160
|
-
export function resolveQueueMax(opts: { cohortSubscribers?: number; scaleBaseline?: number; maxQueue?: number; config?: ReactivityConfig } = {}): number {
|
|
161
|
-
const config = opts.config ?? DEFAULT_REACTIVITY_CONFIG;
|
|
162
|
-
if (opts.cohortSubscribers === undefined) {
|
|
163
|
-
return config.queueMax;
|
|
164
|
-
}
|
|
165
|
-
const baseline = opts.scaleBaseline ?? 1;
|
|
166
|
-
if (!Number.isFinite(opts.cohortSubscribers) || opts.cohortSubscribers <= 0 || !(baseline > 0)) {
|
|
167
|
-
return config.queueMax;
|
|
168
|
-
}
|
|
169
|
-
const scaled = Math.ceil((config.queueMax * opts.cohortSubscribers) / baseline);
|
|
170
|
-
const maxQueue = opts.maxQueue ?? Number.POSITIVE_INFINITY;
|
|
171
|
-
return Math.min(Math.max(config.queueMax, scaled), maxQueue);
|
|
172
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reactivity — configuration defaults (single source for the hot path).
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/reactivity.md` §Configuration. Every tunable the reactivity hot path reads
|
|
5
|
+
* is sourced from {@link DEFAULT_REACTIVITY_CONFIG} — never hard-coded at a call site — so the
|
|
6
|
+
* simulator fold-back ([fold-simulator-findings-into-design-docs]) can retune `W` / `dedupe_window`
|
|
7
|
+
* without touching origination, forwarding, the replay buffer, or delivery.
|
|
8
|
+
*
|
|
9
|
+
* **Simulator-validated-pending.** `W` (replay buffer depth), `dedupe_window`, `T_drain`, `queue_max`,
|
|
10
|
+
* and `block_fill_size` are *provisional* pending the design simulator ([simulator-reactivity-replay],
|
|
11
|
+
* folded back by [fold-simulator-findings-into-design-docs]). The simulator's REVISED guidance is that `W`
|
|
12
|
+
* SHOULD become adaptive per measured commit-rate on hot collections (`W ≈ ⌈min_coverage × cps⌉`); the
|
|
13
|
+
* static `W = 256` is kept as the Edge/low-rate default. {@link resolveW} exposes that hook; the
|
|
14
|
+
* simulator's tail-rotation scenario also confirms the re-registration burst stays inside
|
|
15
|
+
* `cap_promote_fast` within `T_drain`, and flags whether `queue_max` should scale with cohort size/tier
|
|
16
|
+
* ({@link resolveQueueMax} is the parallel hook for that fold-back). Sourcing every tunable from this
|
|
17
|
+
* single table is what lets the fold-back revise the values without touching the protocol code.
|
|
18
|
+
*
|
|
19
|
+
* Ownership: `W` / `dedupe_window` ← [reactivity-origination-replay-delivery]; `W_checkpoint` ←
|
|
20
|
+
* [reactivity-backfill-resume-checkpoints]; `queue_max` / `delta_max` / `T_drain` / `warm_threshold` /
|
|
21
|
+
* `block_fill_size` ← [reactivity-rotation-backpressure-policy] (this ticket). `T_rejoin_jitter`, TTL, and
|
|
22
|
+
* ping are inherited from the cohort-topic defaults.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { NodeProfile } from "../cohort-topic/tiers.js";
|
|
26
|
+
import { DEFAULT_T_REJOIN_JITTER_MS } from "../cohort-topic/antiflood/jitter.js";
|
|
27
|
+
|
|
28
|
+
/** Replay buffer depth (revisions per cohort, per collection). Simulator-validated-pending. */
|
|
29
|
+
export const W_DEFAULT = 256;
|
|
30
|
+
/** Sliding dedupe-window size (revisions). Simulator-validated-pending. */
|
|
31
|
+
export const DEDUPE_WINDOW_DEFAULT = 64;
|
|
32
|
+
/** Parent-checkpoint span (revisions). Owned by the backfill/resume ticket; default carried here. */
|
|
33
|
+
export const W_CHECKPOINT_DEFAULT = 4096;
|
|
34
|
+
/** Per-subscriber bounded queue depth at a forwarder. Simulator-validated-pending (may scale with cohort/tier). */
|
|
35
|
+
export const QUEUE_MAX_DEFAULT = 32;
|
|
36
|
+
/** Max delta payload size on a Core node (bytes). */
|
|
37
|
+
export const DELTA_MAX_CORE_BYTES = 4096;
|
|
38
|
+
/** Max delta payload size on an Edge node (bytes) — Edge declines deltas (`0` ⇒ omit `delta`). */
|
|
39
|
+
export const DELTA_MAX_EDGE_BYTES = 0;
|
|
40
|
+
/** Subscriber registration TTL on a Core node (ms), inherited from cohort-topic. */
|
|
41
|
+
export const SUBSCRIBER_TTL_CORE_MS = 90_000;
|
|
42
|
+
/** Subscriber registration TTL on an Edge node (ms), inherited from cohort-topic. */
|
|
43
|
+
export const SUBSCRIBER_TTL_EDGE_MS = 60_000;
|
|
44
|
+
/** Transactions per block — drives tail rotation. Simulator-validated-pending. */
|
|
45
|
+
export const BLOCK_FILL_SIZE_DEFAULT = 64;
|
|
46
|
+
/** Old-tail drain time after rotation (ms). Simulator-validated-pending. */
|
|
47
|
+
export const T_DRAIN_MS = 60_000;
|
|
48
|
+
/** Transactions remaining in tail before anticipatory warm-up. Owned by the rotation ticket. */
|
|
49
|
+
export const WARM_THRESHOLD_DEFAULT = 8;
|
|
50
|
+
/** Subscriber re-registration jitter span after a rotation hint (ms). Inherited from cohort-topic `T_rejoin_jitter`. */
|
|
51
|
+
export const T_REJOIN_JITTER_MS = DEFAULT_T_REJOIN_JITTER_MS;
|
|
52
|
+
|
|
53
|
+
/** The full reactivity config, with the documented defaults. */
|
|
54
|
+
export interface ReactivityConfig {
|
|
55
|
+
/** Replay buffer depth `W`. Simulator-validated-pending. */
|
|
56
|
+
readonly w: number;
|
|
57
|
+
/** Sliding dedupe-window size. Simulator-validated-pending. */
|
|
58
|
+
readonly dedupeWindow: number;
|
|
59
|
+
/** Parent-checkpoint span (reserved; backfill/resume ticket). */
|
|
60
|
+
readonly wCheckpoint: number;
|
|
61
|
+
/** Per-subscriber bounded queue depth (reserved; backpressure ticket). */
|
|
62
|
+
readonly queueMax: number;
|
|
63
|
+
/** Max delta payload size on a Core node (bytes). */
|
|
64
|
+
readonly deltaMaxCoreBytes: number;
|
|
65
|
+
/** Max delta payload size on an Edge node (bytes). */
|
|
66
|
+
readonly deltaMaxEdgeBytes: number;
|
|
67
|
+
/** Subscriber TTL on a Core node (ms). */
|
|
68
|
+
readonly subscriberTtlCoreMs: number;
|
|
69
|
+
/** Subscriber TTL on an Edge node (ms). */
|
|
70
|
+
readonly subscriberTtlEdgeMs: number;
|
|
71
|
+
/** Transactions per block — drives tail rotation. Simulator-validated-pending. */
|
|
72
|
+
readonly blockFillSize: number;
|
|
73
|
+
/** Old-tail drain time after rotation (ms). Simulator-validated-pending. */
|
|
74
|
+
readonly tDrainMs: number;
|
|
75
|
+
/** Transactions remaining before anticipatory warm-up. */
|
|
76
|
+
readonly warmThreshold: number;
|
|
77
|
+
/** Subscriber re-registration jitter span after a rotation (ms); inherited from cohort-topic. */
|
|
78
|
+
readonly tRejoinJitterMs: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The default reactivity config (`docs/reactivity.md` §Configuration). */
|
|
82
|
+
export const DEFAULT_REACTIVITY_CONFIG: ReactivityConfig = {
|
|
83
|
+
w: W_DEFAULT,
|
|
84
|
+
dedupeWindow: DEDUPE_WINDOW_DEFAULT,
|
|
85
|
+
wCheckpoint: W_CHECKPOINT_DEFAULT,
|
|
86
|
+
queueMax: QUEUE_MAX_DEFAULT,
|
|
87
|
+
deltaMaxCoreBytes: DELTA_MAX_CORE_BYTES,
|
|
88
|
+
deltaMaxEdgeBytes: DELTA_MAX_EDGE_BYTES,
|
|
89
|
+
subscriberTtlCoreMs: SUBSCRIBER_TTL_CORE_MS,
|
|
90
|
+
subscriberTtlEdgeMs: SUBSCRIBER_TTL_EDGE_MS,
|
|
91
|
+
blockFillSize: BLOCK_FILL_SIZE_DEFAULT,
|
|
92
|
+
tDrainMs: T_DRAIN_MS,
|
|
93
|
+
warmThreshold: WARM_THRESHOLD_DEFAULT,
|
|
94
|
+
tRejoinJitterMs: T_REJOIN_JITTER_MS,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** `delta_max` for a node profile: Core admits deltas up to `delta_max`, Edge declines them (`0`). */
|
|
98
|
+
export function deltaMaxForProfile(profile: NodeProfile, config: ReactivityConfig = DEFAULT_REACTIVITY_CONFIG): number {
|
|
99
|
+
return profile.kind === "edge" ? config.deltaMaxEdgeBytes : config.deltaMaxCoreBytes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Subscriber TTL for a node profile (`docs/reactivity.md` §Subscription): Core 90 s / Edge 60 s. */
|
|
103
|
+
export function subscriberTtlForProfile(profile: NodeProfile, config: ReactivityConfig = DEFAULT_REACTIVITY_CONFIG): number {
|
|
104
|
+
return profile.kind === "edge" ? config.subscriberTtlEdgeMs : config.subscriberTtlCoreMs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the replay-buffer depth `W` for a collection.
|
|
109
|
+
*
|
|
110
|
+
* Static default (no `cps`): returns the configured `config.w` unchanged — the simulator-confirmed
|
|
111
|
+
* Edge/low-rate default. Adaptive (a measured `cps` and `minCoverageSeconds`): returns
|
|
112
|
+
* `⌈minCoverageSeconds × cps⌉` clamped to `[config.w, maxW]`, the simulator's REVISED guidance for hot
|
|
113
|
+
* collections. This is the single hook the backfill/resume ticket extends; the hot path here only ever
|
|
114
|
+
* calls the static form, so behavior is unchanged until a `cps` is wired through.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveW(opts: { cps?: number; minCoverageSeconds?: number; maxW?: number; config?: ReactivityConfig } = {}): number {
|
|
117
|
+
const config = opts.config ?? DEFAULT_REACTIVITY_CONFIG;
|
|
118
|
+
if (opts.cps === undefined || opts.minCoverageSeconds === undefined) {
|
|
119
|
+
return config.w;
|
|
120
|
+
}
|
|
121
|
+
if (!Number.isFinite(opts.cps) || opts.cps <= 0 || !Number.isFinite(opts.minCoverageSeconds) || opts.minCoverageSeconds <= 0) {
|
|
122
|
+
return config.w;
|
|
123
|
+
}
|
|
124
|
+
const adaptive = Math.ceil(opts.minCoverageSeconds * opts.cps);
|
|
125
|
+
const maxW = opts.maxW ?? Number.POSITIVE_INFINITY;
|
|
126
|
+
return Math.min(Math.max(config.w, adaptive), maxW);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Default `W_checkpoint`-to-`W` ratio (`docs/reactivity.md` §Parent checkpoint summaries: 16×). */
|
|
130
|
+
export const W_CHECKPOINT_RATIO = 16;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the parent-checkpoint span `W_checkpoint` for a collection.
|
|
134
|
+
*
|
|
135
|
+
* Static default (no `cps`): the configured `config.wCheckpoint` (4096). Adaptive: `W_checkpoint`
|
|
136
|
+
* "scales the same way and may stay a fixed 16× multiple of the resolved `W`" (`docs/reactivity.md`
|
|
137
|
+
* §Configuration), so when a `cps` is supplied it tracks `W_CHECKPOINT_RATIO × resolveW(opts)`. This is
|
|
138
|
+
* the single hook the simulator fold-back ([fold-simulator-findings-into-design-docs]) retunes; the
|
|
139
|
+
* resume classifier reads it so a hot collection's stacked recovery range scales with the replay depth.
|
|
140
|
+
*/
|
|
141
|
+
export function resolveWCheckpoint(opts: { cps?: number; minCoverageSeconds?: number; maxW?: number; ratio?: number; config?: ReactivityConfig } = {}): number {
|
|
142
|
+
const config = opts.config ?? DEFAULT_REACTIVITY_CONFIG;
|
|
143
|
+
if (opts.cps === undefined || opts.minCoverageSeconds === undefined) {
|
|
144
|
+
return config.wCheckpoint;
|
|
145
|
+
}
|
|
146
|
+
const ratio = opts.ratio ?? W_CHECKPOINT_RATIO;
|
|
147
|
+
return resolveW(opts) * ratio;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the per-subscriber bounded-queue depth `queue_max` for a cohort.
|
|
152
|
+
*
|
|
153
|
+
* Static default (no scaling input): the configured `config.queueMax` (32). The simulator flags whether
|
|
154
|
+
* `queue_max` should scale with cohort size / tier on hot collections; this is the single hook the
|
|
155
|
+
* fold-back ([fold-simulator-findings-into-design-docs]) retunes. When a `cohortSubscribers` count is
|
|
156
|
+
* supplied, the depth scales as `⌈queue_max × subscribers / scaleBaseline⌉` clamped to `[queueMax, maxQueue]`
|
|
157
|
+
* — never below the static default, optionally capped at a per-cohort memory budget. With no scaling input
|
|
158
|
+
* the hot path is unchanged (constant `queue_max`).
|
|
159
|
+
*/
|
|
160
|
+
export function resolveQueueMax(opts: { cohortSubscribers?: number; scaleBaseline?: number; maxQueue?: number; config?: ReactivityConfig } = {}): number {
|
|
161
|
+
const config = opts.config ?? DEFAULT_REACTIVITY_CONFIG;
|
|
162
|
+
if (opts.cohortSubscribers === undefined) {
|
|
163
|
+
return config.queueMax;
|
|
164
|
+
}
|
|
165
|
+
const baseline = opts.scaleBaseline ?? 1;
|
|
166
|
+
if (!Number.isFinite(opts.cohortSubscribers) || opts.cohortSubscribers <= 0 || !(baseline > 0)) {
|
|
167
|
+
return config.queueMax;
|
|
168
|
+
}
|
|
169
|
+
const scaled = Math.ceil((config.queueMax * opts.cohortSubscribers) / baseline);
|
|
170
|
+
const maxQueue = opts.maxQueue ?? Number.POSITIVE_INFINITY;
|
|
171
|
+
return Math.min(Math.max(config.queueMax, scaled), maxQueue);
|
|
172
|
+
}
|
package/src/reactivity/dedupe.ts
CHANGED
|
@@ -1,132 +1,132 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reactivity — per-revision dedupe (sliding-window set), `docs/reactivity.md` §Per-revision dedupe.
|
|
3
|
-
*
|
|
4
|
-
* A scalar `lastRevision` is insufficient under partition healing: the same revision may legitimately
|
|
5
|
-
* arrive from multiple parents during a merge, and dropping all but the first on `revision > lastRevision`
|
|
6
|
-
* would discard honest retransmits exactly when a subscriber needs them. Instead each forwarder keeps a
|
|
7
|
-
* sliding set of `(revision, sigDigest)` pairs for the last `dedupe_window` revisions (default 64).
|
|
8
|
-
*
|
|
9
|
-
* A notification is forwarded if:
|
|
10
|
-
* - it is for the *highest revision* seen in the window (normal case), OR
|
|
11
|
-
* - it is for an earlier revision, its `(revision, sigDigest)` is not already in the set, and it passes
|
|
12
|
-
* verification (recovery: a retransmit closing a gap).
|
|
13
|
-
*
|
|
14
|
-
* Both reduce to: **forward iff the `(revision, sigDigest)` key is not already present** (verification is
|
|
15
|
-
* the forwarder's prior step, applied to every inbound). Keys already in the set are dropped silently.
|
|
16
|
-
* The set is gossiped within the cohort ({@link DedupeWindow.serialize} / {@link DedupeWindow.merge}) so
|
|
17
|
-
* all members agree on what has been seen.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { DEDUPE_WINDOW_DEFAULT } from "./config.js";
|
|
21
|
-
|
|
22
|
-
/** The dedupe decision for one inbound notification. */
|
|
23
|
-
export type DedupeOutcome = "forward" | "duplicate";
|
|
24
|
-
|
|
25
|
-
/** Serializable dedupe state for intra-cohort gossip convergence. */
|
|
26
|
-
export interface DedupeStateV1 {
|
|
27
|
-
/** Highest revision observed (anchors the sliding window's high edge). */
|
|
28
|
-
readonly highestRevision: number;
|
|
29
|
-
/** Seen `(revision, sigDigest)` keys with their revision, for window-bounded merge. */
|
|
30
|
-
readonly entries: ReadonlyArray<{ readonly key: string; readonly revision: number }>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** A sliding-window dedupe set over `(revision, sigDigest)` pairs. */
|
|
34
|
-
export interface DedupeWindow {
|
|
35
|
-
/** Highest revision observed so far (`-1` before any observation). */
|
|
36
|
-
readonly highestRevision: number;
|
|
37
|
-
/** Number of retained keys. */
|
|
38
|
-
readonly size: number;
|
|
39
|
-
/** True iff `(revision, sigDigest)` is already in the set. */
|
|
40
|
-
has(revision: number, sigDigest: string): boolean;
|
|
41
|
-
/**
|
|
42
|
-
* Record `(revision, sigDigest)` and report whether it should be forwarded. Returns `"duplicate"`
|
|
43
|
-
* (no state change) if already seen, else records it, advances the window, and returns `"forward"`.
|
|
44
|
-
*/
|
|
45
|
-
observe(revision: number, sigDigest: string): DedupeOutcome;
|
|
46
|
-
/** Snapshot for gossip. */
|
|
47
|
-
serialize(): DedupeStateV1;
|
|
48
|
-
/** Union another member's gossiped state into this one (idempotent, commutative within the window). */
|
|
49
|
-
merge(state: DedupeStateV1): void;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
class SlidingDedupeWindow implements DedupeWindow {
|
|
53
|
-
private readonly windowSize: number;
|
|
54
|
-
/** key → revision, for window-bounded eviction. */
|
|
55
|
-
private readonly seen = new Map<string, number>();
|
|
56
|
-
private highest = -1;
|
|
57
|
-
|
|
58
|
-
constructor(windowSize: number) {
|
|
59
|
-
if (!Number.isInteger(windowSize) || windowSize < 1) {
|
|
60
|
-
throw new RangeError(`reactivity dedupe: windowSize must be an integer >= 1, got ${windowSize}`);
|
|
61
|
-
}
|
|
62
|
-
this.windowSize = windowSize;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
get highestRevision(): number {
|
|
66
|
-
return this.highest;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
get size(): number {
|
|
70
|
-
return this.seen.size;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
has(revision: number, sigDigest: string): boolean {
|
|
74
|
-
return this.seen.has(keyOf(revision, sigDigest));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
observe(revision: number, sigDigest: string): DedupeOutcome {
|
|
78
|
-
const key = keyOf(revision, sigDigest);
|
|
79
|
-
if (this.seen.has(key)) {
|
|
80
|
-
return "duplicate";
|
|
81
|
-
}
|
|
82
|
-
this.insert(key, revision);
|
|
83
|
-
return "forward";
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
serialize(): DedupeStateV1 {
|
|
87
|
-
return {
|
|
88
|
-
highestRevision: this.highest,
|
|
89
|
-
entries: [...this.seen].map(([key, rev]) => ({ key, revision: rev })),
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
merge(state: DedupeStateV1): void {
|
|
94
|
-
if (state.highestRevision > this.highest) {
|
|
95
|
-
this.highest = state.highestRevision;
|
|
96
|
-
}
|
|
97
|
-
for (const { key, revision } of state.entries) {
|
|
98
|
-
if (!this.seen.has(key)) {
|
|
99
|
-
this.seen.set(key, revision);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
this.evictBelowWindow();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
private insert(key: string, revision: number): void {
|
|
106
|
-
if (revision > this.highest) {
|
|
107
|
-
this.highest = revision;
|
|
108
|
-
}
|
|
109
|
-
this.seen.set(key, revision);
|
|
110
|
-
this.evictBelowWindow();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Retain only revisions within `[highest - windowSize + 1, highest]`. */
|
|
114
|
-
private evictBelowWindow(): void {
|
|
115
|
-
const low = this.highest - this.windowSize + 1;
|
|
116
|
-
for (const [key, revision] of this.seen) {
|
|
117
|
-
if (revision < low) {
|
|
118
|
-
this.seen.delete(key);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** `${revision}:${sigDigest}` — the dedupe-set key. */
|
|
125
|
-
function keyOf(revision: number, sigDigest: string): string {
|
|
126
|
-
return `${revision}:${sigDigest}`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Build a {@link DedupeWindow} with the configured (default {@link DEDUPE_WINDOW_DEFAULT}) span. */
|
|
130
|
-
export function createDedupeWindow(windowSize: number = DEDUPE_WINDOW_DEFAULT): DedupeWindow {
|
|
131
|
-
return new SlidingDedupeWindow(windowSize);
|
|
132
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reactivity — per-revision dedupe (sliding-window set), `docs/reactivity.md` §Per-revision dedupe.
|
|
3
|
+
*
|
|
4
|
+
* A scalar `lastRevision` is insufficient under partition healing: the same revision may legitimately
|
|
5
|
+
* arrive from multiple parents during a merge, and dropping all but the first on `revision > lastRevision`
|
|
6
|
+
* would discard honest retransmits exactly when a subscriber needs them. Instead each forwarder keeps a
|
|
7
|
+
* sliding set of `(revision, sigDigest)` pairs for the last `dedupe_window` revisions (default 64).
|
|
8
|
+
*
|
|
9
|
+
* A notification is forwarded if:
|
|
10
|
+
* - it is for the *highest revision* seen in the window (normal case), OR
|
|
11
|
+
* - it is for an earlier revision, its `(revision, sigDigest)` is not already in the set, and it passes
|
|
12
|
+
* verification (recovery: a retransmit closing a gap).
|
|
13
|
+
*
|
|
14
|
+
* Both reduce to: **forward iff the `(revision, sigDigest)` key is not already present** (verification is
|
|
15
|
+
* the forwarder's prior step, applied to every inbound). Keys already in the set are dropped silently.
|
|
16
|
+
* The set is gossiped within the cohort ({@link DedupeWindow.serialize} / {@link DedupeWindow.merge}) so
|
|
17
|
+
* all members agree on what has been seen.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { DEDUPE_WINDOW_DEFAULT } from "./config.js";
|
|
21
|
+
|
|
22
|
+
/** The dedupe decision for one inbound notification. */
|
|
23
|
+
export type DedupeOutcome = "forward" | "duplicate";
|
|
24
|
+
|
|
25
|
+
/** Serializable dedupe state for intra-cohort gossip convergence. */
|
|
26
|
+
export interface DedupeStateV1 {
|
|
27
|
+
/** Highest revision observed (anchors the sliding window's high edge). */
|
|
28
|
+
readonly highestRevision: number;
|
|
29
|
+
/** Seen `(revision, sigDigest)` keys with their revision, for window-bounded merge. */
|
|
30
|
+
readonly entries: ReadonlyArray<{ readonly key: string; readonly revision: number }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A sliding-window dedupe set over `(revision, sigDigest)` pairs. */
|
|
34
|
+
export interface DedupeWindow {
|
|
35
|
+
/** Highest revision observed so far (`-1` before any observation). */
|
|
36
|
+
readonly highestRevision: number;
|
|
37
|
+
/** Number of retained keys. */
|
|
38
|
+
readonly size: number;
|
|
39
|
+
/** True iff `(revision, sigDigest)` is already in the set. */
|
|
40
|
+
has(revision: number, sigDigest: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Record `(revision, sigDigest)` and report whether it should be forwarded. Returns `"duplicate"`
|
|
43
|
+
* (no state change) if already seen, else records it, advances the window, and returns `"forward"`.
|
|
44
|
+
*/
|
|
45
|
+
observe(revision: number, sigDigest: string): DedupeOutcome;
|
|
46
|
+
/** Snapshot for gossip. */
|
|
47
|
+
serialize(): DedupeStateV1;
|
|
48
|
+
/** Union another member's gossiped state into this one (idempotent, commutative within the window). */
|
|
49
|
+
merge(state: DedupeStateV1): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class SlidingDedupeWindow implements DedupeWindow {
|
|
53
|
+
private readonly windowSize: number;
|
|
54
|
+
/** key → revision, for window-bounded eviction. */
|
|
55
|
+
private readonly seen = new Map<string, number>();
|
|
56
|
+
private highest = -1;
|
|
57
|
+
|
|
58
|
+
constructor(windowSize: number) {
|
|
59
|
+
if (!Number.isInteger(windowSize) || windowSize < 1) {
|
|
60
|
+
throw new RangeError(`reactivity dedupe: windowSize must be an integer >= 1, got ${windowSize}`);
|
|
61
|
+
}
|
|
62
|
+
this.windowSize = windowSize;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get highestRevision(): number {
|
|
66
|
+
return this.highest;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get size(): number {
|
|
70
|
+
return this.seen.size;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
has(revision: number, sigDigest: string): boolean {
|
|
74
|
+
return this.seen.has(keyOf(revision, sigDigest));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
observe(revision: number, sigDigest: string): DedupeOutcome {
|
|
78
|
+
const key = keyOf(revision, sigDigest);
|
|
79
|
+
if (this.seen.has(key)) {
|
|
80
|
+
return "duplicate";
|
|
81
|
+
}
|
|
82
|
+
this.insert(key, revision);
|
|
83
|
+
return "forward";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
serialize(): DedupeStateV1 {
|
|
87
|
+
return {
|
|
88
|
+
highestRevision: this.highest,
|
|
89
|
+
entries: [...this.seen].map(([key, rev]) => ({ key, revision: rev })),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
merge(state: DedupeStateV1): void {
|
|
94
|
+
if (state.highestRevision > this.highest) {
|
|
95
|
+
this.highest = state.highestRevision;
|
|
96
|
+
}
|
|
97
|
+
for (const { key, revision } of state.entries) {
|
|
98
|
+
if (!this.seen.has(key)) {
|
|
99
|
+
this.seen.set(key, revision);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.evictBelowWindow();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private insert(key: string, revision: number): void {
|
|
106
|
+
if (revision > this.highest) {
|
|
107
|
+
this.highest = revision;
|
|
108
|
+
}
|
|
109
|
+
this.seen.set(key, revision);
|
|
110
|
+
this.evictBelowWindow();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Retain only revisions within `[highest - windowSize + 1, highest]`. */
|
|
114
|
+
private evictBelowWindow(): void {
|
|
115
|
+
const low = this.highest - this.windowSize + 1;
|
|
116
|
+
for (const [key, revision] of this.seen) {
|
|
117
|
+
if (revision < low) {
|
|
118
|
+
this.seen.delete(key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `${revision}:${sigDigest}` — the dedupe-set key. */
|
|
125
|
+
function keyOf(revision: number, sigDigest: string): string {
|
|
126
|
+
return `${revision}:${sigDigest}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Build a {@link DedupeWindow} with the configured (default {@link DEDUPE_WINDOW_DEFAULT}) span. */
|
|
130
|
+
export function createDedupeWindow(windowSize: number = DEDUPE_WINDOW_DEFAULT): DedupeWindow {
|
|
131
|
+
return new SlidingDedupeWindow(windowSize);
|
|
132
|
+
}
|