digdigdig3_station/quota.rs
1//! Per-consumer subscription + REST rate quotas.
2//!
3//! Opt-in layer over [`Station`]. A consumer registers via
4//! [`Station::register_consumer`]`(quota) -> ConsumerHandle`, then uses
5//! [`ConsumerHandle::subscribe`] and [`ConsumerHandle::rest_gate`]
6//! instead of the raw [`Station::subscribe`]. Each consumer has its own
7//! active-sub counter, REST token bucket, and optional
8//! `(exchange, kind, symbol)` whitelist.
9//!
10//! Existing [`Station::subscribe`] is unchanged. Consumers that do not
11//! want quotas keep using it directly.
12//!
13//! # Atomic-or-nothing
14//!
15//! [`ConsumerHandle::subscribe`] enforces `max_active_subs` and whitelist
16//! BEFORE any `acquire_or_spawn` call. If either check fails, no
17//! subscriptions are made. If a stream fails mid-batch with
18//! `StreamNotSupported`, the acquired refs for that batch are rolled back
19//! and `Err(QuotaError::Inner(...))` is returned — the active-sub counter
20//! never sees a partial increment.
21//!
22//! # REST passthrough
23//!
24//! REST quota uses the token-handoff pattern: call
25//! [`ConsumerHandle::rest_gate`] to consume one token from the bucket,
26//! get back `Arc<StationInner>`, then call the connector method directly.
27//! This avoids ~75 LOC of forwarding boilerplate and is immune to future
28//! additions of REST methods.
29
30use std::collections::HashSet;
31use std::sync::Arc;
32use std::time::Duration;
33
34use digdigdig3::core::types::ExchangeId;
35use thiserror::Error;
36use tokio::sync::Mutex;
37
38// Monotonic clock: std::time::Instant on native, instant::Instant on wasm32.
39// std::time::Instant panics at runtime on wasm32-unknown-unknown (no monotonic
40// clock without a shim). The `instant` crate provides a drop-in replacement
41// backed by js_sys::Date::now() on wasm32.
42#[cfg(not(target_arch = "wasm32"))]
43use std::time::Instant;
44#[cfg(target_arch = "wasm32")]
45use instant::Instant;
46
47use crate::series::Kind;
48use crate::station::StationInner;
49use crate::subscription::MultiplexRef;
50use crate::{Station, StationError, SubscribeReport, SubscriptionSet};
51
52// ---------------------------------------------------------------------------
53// Public configuration types
54// ---------------------------------------------------------------------------
55
56/// Per-consumer quota configuration. All limits are optional; `None`
57/// means unlimited. Build with the chaining helpers or directly set fields.
58#[derive(Clone, Debug)]
59pub struct ConsumerQuota {
60 /// Maximum number of simultaneously active subscriptions (distinct
61 /// `SeriesKey`s) this consumer may hold. `None` = unlimited.
62 pub max_active_subs: Option<u32>,
63 /// Maximum REST requests per [`rest_window`][Self::rest_window].
64 /// `None` = unlimited.
65 pub max_rest_per_window: Option<u32>,
66 /// Token-bucket window for REST rate limiting.
67 pub rest_window: Duration,
68 /// Optional whitelist. When set, subscribe rejects any
69 /// `(exchange, kind, symbol)` not in the list.
70 pub whitelist: Option<Arc<ConsumerWhitelist>>,
71}
72
73impl Default for ConsumerQuota {
74 fn default() -> Self {
75 Self {
76 max_active_subs: None,
77 max_rest_per_window: None,
78 rest_window: Duration::from_secs(60),
79 whitelist: None,
80 }
81 }
82}
83
84impl ConsumerQuota {
85 /// No limits, no whitelist.
86 pub fn unlimited() -> Self {
87 Self::default()
88 }
89
90 /// Cap simultaneous active subscriptions.
91 pub fn max_active_subs(mut self, n: u32) -> Self {
92 self.max_active_subs = Some(n);
93 self
94 }
95
96 /// REST token bucket: `per_window` calls allowed per `window` duration.
97 pub fn max_rest(mut self, per_window: u32, window: Duration) -> Self {
98 self.max_rest_per_window = Some(per_window);
99 self.rest_window = window;
100 self
101 }
102
103 /// Attach a whitelist filter.
104 pub fn whitelist(mut self, wl: ConsumerWhitelist) -> Self {
105 self.whitelist = Some(Arc::new(wl));
106 self
107 }
108}
109
110/// Whitelist that restricts which `(exchange, kind, symbol)` tuples a
111/// consumer may subscribe to. Empty `HashSet` for `exchanges` / `kinds`
112/// means "allow any"; `None` for `symbols` means "allow any symbol".
113#[derive(Debug, Default)]
114pub struct ConsumerWhitelist {
115 /// Allowed exchange IDs. Empty = any.
116 pub exchanges: HashSet<ExchangeId>,
117 /// Allowed stream kinds. Empty = any.
118 pub kinds: HashSet<Kind>,
119 /// Allowed symbols (raw user-input form). `None` = any.
120 pub symbols: Option<HashSet<String>>,
121}
122
123impl ConsumerWhitelist {
124 /// Create an empty whitelist (all fields = allow-any).
125 pub fn new() -> Self {
126 Self::default()
127 }
128
129 /// Allow subscriptions to `e`. Call multiple times for multiple exchanges.
130 pub fn allow_exchange(mut self, e: ExchangeId) -> Self {
131 self.exchanges.insert(e);
132 self
133 }
134
135 /// Allow subscriptions of `k`. Call multiple times for multiple kinds.
136 pub fn allow_kind(mut self, k: Kind) -> Self {
137 self.kinds.insert(k);
138 self
139 }
140
141 /// Allow subscriptions to symbol `s` (user-input form, e.g. `"BTC-USDT"`).
142 pub fn allow_symbol(mut self, s: impl Into<String>) -> Self {
143 self.symbols.get_or_insert_with(HashSet::new).insert(s.into());
144 self
145 }
146
147 /// Check whether `(exchange, kind, symbol)` passes the whitelist.
148 /// Returns `Ok(())` or `Err(human-readable reason)`.
149 pub(crate) fn check(
150 &self,
151 exchange: ExchangeId,
152 kind: &Kind,
153 symbol: &str,
154 ) -> Result<(), String> {
155 if !self.exchanges.is_empty() && !self.exchanges.contains(&exchange) {
156 return Err(format!("exchange {exchange:?} not whitelisted"));
157 }
158 if !self.kinds.is_empty() && !self.kinds.contains(kind) {
159 return Err(format!("kind {kind:?} not whitelisted"));
160 }
161 if let Some(syms) = &self.symbols {
162 if !syms.contains(symbol) {
163 return Err(format!("symbol {symbol} not whitelisted"));
164 }
165 }
166 Ok(())
167 }
168}
169
170// ---------------------------------------------------------------------------
171// Error type
172// ---------------------------------------------------------------------------
173
174/// Errors produced by [`ConsumerHandle`] operations.
175#[derive(Debug, Error)]
176pub enum QuotaError {
177 /// The requested subscribe batch would push the consumer over its cap.
178 /// Nothing in the batch was subscribed (atomic-or-nothing).
179 #[error("subscription quota exceeded: have {have}, cap {cap}")]
180 SubsCapExceeded { have: u32, cap: u32 },
181
182 /// The consumer's REST token bucket is empty.
183 /// `remaining_ms` is an upper-bound estimate until the next refill.
184 #[error("REST rate limit: {remaining_ms}ms until next token")]
185 RestRateLimit { remaining_ms: u64 },
186
187 /// A `(exchange, kind, symbol)` tuple in the subscribe set was rejected
188 /// by the consumer's whitelist. Nothing in the batch was subscribed.
189 #[error("not in whitelist: {0}")]
190 NotInWhitelist(String),
191
192 /// An underlying station error (e.g. `StreamNotSupported`, IO).
193 #[error(transparent)]
194 Inner(#[from] StationError),
195}
196
197// ---------------------------------------------------------------------------
198// Token bucket (REST rate limiting)
199// ---------------------------------------------------------------------------
200
201/// Simple fixed-window token bucket. Uses `instant::Instant` for
202/// monotonic time (cross-target: compiles on native and wasm32).
203pub(crate) struct TokenBucket {
204 capacity: u32,
205 available: u32,
206 refill_window: Duration,
207 last_refill: Instant,
208}
209
210impl TokenBucket {
211 pub(crate) fn new(capacity: u32, window: Duration) -> Self {
212 Self {
213 capacity,
214 available: capacity,
215 refill_window: window,
216 last_refill: Instant::now(),
217 }
218 }
219
220 /// Try to consume one token.
221 ///
222 /// Returns `Ok(())` on success. Returns `Err(remaining_ms)` when the
223 /// bucket is empty; `remaining_ms` is how long until the next refill.
224 pub(crate) fn try_consume(&mut self) -> Result<(), u64> {
225 let now = Instant::now();
226 let elapsed = now.duration_since(self.last_refill);
227 if elapsed >= self.refill_window {
228 self.available = self.capacity;
229 self.last_refill = now;
230 }
231 if self.available > 0 {
232 self.available -= 1;
233 Ok(())
234 } else {
235 let wait = self.refill_window.saturating_sub(elapsed);
236 Err(wait.as_millis() as u64)
237 }
238 }
239
240 /// Tokens available right now (without consuming).
241 pub(crate) fn available(&self) -> u32 {
242 self.available
243 }
244}
245
246// ---------------------------------------------------------------------------
247// ConsumerHandle
248// ---------------------------------------------------------------------------
249
250/// Opaque handle that enforces per-consumer quotas on a shared [`Station`].
251///
252/// Obtain via [`Station::register_consumer`]. The handle is `Send + Sync`
253/// and may be wrapped in an `Arc` for sharing across tasks.
254///
255/// # Drop semantics
256///
257/// Dropping the `ConsumerHandle` drops the `Vec<MultiplexRef>` held
258/// internally. Each `MultiplexRef::drop` calls `release_consumer` on
259/// the underlying station, decrementing the per-`SeriesKey` refcount.
260/// When the last holder drops, the multiplexer shuts down — same path
261/// as dropping a `SubscriptionHandle`.
262///
263/// This is fully synchronous: `MultiplexRef::drop` performs only a
264/// `fetch_sub` + optional `oneshot::send`, both non-blocking.
265pub struct ConsumerHandle {
266 pub(crate) station: Arc<StationInner>,
267 pub(crate) quota: ConsumerQuota,
268 pub(crate) rest_bucket: Arc<Mutex<Option<TokenBucket>>>,
269 /// `(active_count, refs)`. The mutex guards both so cap check and
270 /// ref-accumulation are a single critical section with zero race window.
271 pub(crate) refs: Mutex<(u32, Vec<MultiplexRef>)>,
272}
273
274impl ConsumerHandle {
275 /// Number of currently active subscriptions held by this consumer.
276 pub async fn active_sub_count(&self) -> u32 {
277 self.refs.lock().await.0
278 }
279
280 /// REST tokens available in the current window (without consuming).
281 /// Returns `u32::MAX` when no REST quota is configured.
282 pub async fn rest_tokens_available(&self) -> u32 {
283 match self.rest_bucket.lock().await.as_ref() {
284 Some(b) => b.available(),
285 None => u32::MAX,
286 }
287 }
288
289 /// Subscribe with quota enforcement.
290 ///
291 /// Pre-flight checks (whitelist, cap) run before any
292 /// `acquire_or_spawn` call. If either fails, no subscriptions are
293 /// made and an error is returned immediately.
294 ///
295 /// If a stream fails mid-batch (`StreamNotSupported`), any refs
296 /// acquired so far in this batch are rolled back via
297 /// `release_consumer` before returning `Err`. The consumer's
298 /// active-sub counter never sees a partial increment.
299 ///
300 /// The returned [`SubscribeReport`]'s `handle` still works for
301 /// `recv()` — the multiplex broadcast keeps emitting as long as any
302 /// consumer holds a ref. The underlying `MultiplexRef`s are moved
303 /// into this `ConsumerHandle`, so dropping the `ConsumerHandle`
304 /// releases ALL refs even if the caller still holds the
305 /// `SubscriptionHandle` for event recv.
306 pub async fn subscribe(
307 &self,
308 set: SubscriptionSet,
309 ) -> Result<SubscribeReport, QuotaError> {
310 // Count requested streams.
311 let n_new: u32 = set
312 .entries
313 .iter()
314 .map(|e| e.streams.len() as u32)
315 .sum();
316
317 // --- Whitelist check (first; fail-fast before touching any counter) ---
318 if let Some(wl) = &self.quota.whitelist {
319 for entry in &set.entries {
320 for s in &entry.streams {
321 let kind = s.to_kind();
322 if let Err(msg) = wl.check(entry.exchange, &kind, &entry.symbol) {
323 return Err(QuotaError::NotInWhitelist(msg));
324 }
325 }
326 }
327 }
328
329 // --- Cap check + acquire refs under a single lock ─────────────────
330 // Lock covers both the cap check AND the append of new refs so there
331 // is no window where another concurrent subscribe can race through.
332 let mut guard = self.refs.lock().await;
333 let current = guard.0;
334
335 if let Some(cap) = self.quota.max_active_subs {
336 if current.saturating_add(n_new) > cap {
337 return Err(QuotaError::SubsCapExceeded {
338 have: current.saturating_add(n_new),
339 cap,
340 });
341 }
342 }
343
344 // --- Delegate to Station::subscribe (contains all WS/REST plumbing) ---
345 let station = Station {
346 inner: self.station.clone(),
347 };
348 let report = station.subscribe(set).await?;
349
350 // Move MultiplexRefs from the SubscriptionHandle into our own vec.
351 // The rx channel stays with the report's handle so the caller can
352 // still recv() events. Dropping this ConsumerHandle releases the
353 // refs even if the caller holds the SubscriptionHandle.
354 let ok_count = report.ok.len() as u32;
355 let report = report.take_refs_into(&mut guard.1);
356 guard.0 = guard.0.saturating_add(ok_count);
357
358 Ok(report)
359 }
360
361 /// Consume one REST token and return a [`Station`] view.
362 ///
363 /// The caller uses the returned `Station` to call REST methods (e.g.
364 /// `hub().get_klines(...)`). This token-handoff design avoids ~75 LOC
365 /// of forwarding boilerplate and is immune to future additions of REST
366 /// methods.
367 ///
368 /// Returns `Err(QuotaError::RestRateLimit { remaining_ms })` when the
369 /// token bucket is empty.
370 pub async fn rest_gate(&self) -> Result<Station, QuotaError> {
371 let mut bucket = self.rest_bucket.lock().await;
372 if let Some(b) = bucket.as_mut() {
373 b.try_consume()
374 .map_err(|remaining_ms| QuotaError::RestRateLimit { remaining_ms })?;
375 }
376 Ok(Station { inner: self.station.clone() })
377 }
378}