Skip to main content

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}