Skip to main content

digdigdig3_station/
rest_cache.rs

1//! Generic TTL cache for REST responses (instrument metadata, exchange info, etc).
2//!
3//! Wraps any async fn, caches by key. Per-key TTL. Concurrent-safe via [`DashMap`].
4//!
5//! # Recommended TTLs
6//!
7//! | Data | TTL |
8//! |------|-----|
9//! | Instrument list / exchange info | 1 hour |
10//! | Symbol precision / lot size | 1 hour |
11//! | Server time | 30 sec |
12//! | Recent ticker (REST polling fallback) | 10 sec |
13//! | Funding rate | 5 min |
14//! | Open interest | 30 sec |
15//!
16//! These are suggestions — consumers choose their own TTL at construction time.
17//!
18//! # Example
19//!
20//! ```rust,no_run
21//! # use std::time::Duration;
22//! # use digdigdig3::core::rest_cache::RestCache;
23//! # async fn example() -> Result<(), String> {
24//! let cache: RestCache<String, String> = RestCache::new(Duration::from_secs(3600));
25//! let info = cache.get_or_fetch("binance".to_string(), || async {
26//!     Ok::<String, String>("exchange_info_payload".to_string())
27//! }).await?;
28//! # Ok(())
29//! # }
30//! ```
31//!
32//! # Single-flight note
33//!
34//! `get_or_fetch` is **not single-flight**: if two tasks call it concurrently on the same
35//! missing key, both will invoke the loader. The second result simply overwrites the first.
36//! For most REST metadata use-cases (slow changing, cheap on race) this is acceptable.
37//! If strict single-flight is required, layer a `tokio::sync::Mutex` per key on top.
38
39use dashmap::DashMap;
40use std::future::Future;
41use std::hash::Hash;
42use std::sync::Arc;
43use std::time::Duration;
44
45// Monotonic clock: std::time::Instant on native, instant::Instant on wasm32.
46// std::time::Instant panics at runtime on wasm32-unknown-unknown (no monotonic
47// clock without a shim). The `instant` crate provides a drop-in replacement
48// backed by js_sys::Date::now() on wasm32.
49#[cfg(not(target_arch = "wasm32"))]
50use std::time::Instant;
51#[cfg(target_arch = "wasm32")]
52use instant::Instant;
53
54#[derive(Debug, Clone)]
55struct Entry<V> {
56    value: V,
57    inserted_at: Instant,
58    ttl: Duration,
59}
60
61impl<V> Entry<V> {
62    fn is_fresh(&self) -> bool {
63        self.inserted_at.elapsed() < self.ttl
64    }
65}
66
67/// Concurrent TTL cache.
68///
69/// Use [`RestCache::get_or_fetch`] to look up a value, fetching from the loader if
70/// missing or expired. The loader is async and returns `Result<V, E>`.
71///
72/// Clone is cheap — the internal map is `Arc`-wrapped.
73pub struct RestCache<K, V>
74where
75    K: Eq + Hash + Clone,
76    V: Clone + Send + Sync,
77{
78    inner: Arc<DashMap<K, Entry<V>>>,
79    default_ttl: Duration,
80}
81
82impl<K, V> RestCache<K, V>
83where
84    K: Eq + Hash + Clone,
85    V: Clone + Send + Sync,
86{
87    /// Create a new cache with the given default TTL applied by [`RestCache::insert`].
88    pub fn new(default_ttl: Duration) -> Self {
89        Self {
90            inner: Arc::new(DashMap::new()),
91            default_ttl,
92        }
93    }
94
95    /// Get cached value if still fresh, else `None`.
96    pub fn get(&self, key: &K) -> Option<V> {
97        let entry = self.inner.get(key)?;
98        if entry.is_fresh() {
99            Some(entry.value.clone())
100        } else {
101            None
102        }
103    }
104
105    /// Insert with the default TTL configured at construction.
106    pub fn insert(&self, key: K, value: V) {
107        self.insert_with_ttl(key, value, self.default_ttl);
108    }
109
110    /// Insert with an explicit TTL.
111    pub fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) {
112        self.inner.insert(
113            key,
114            Entry {
115                value,
116                inserted_at: Instant::now(),
117                ttl,
118            },
119        );
120    }
121
122    /// Get or fetch.
123    ///
124    /// Returns the cached value if fresh. Otherwise calls `loader`, caches the result
125    /// with the default TTL, and returns it.
126    ///
127    /// See the module-level doc for single-flight semantics (short: not single-flight).
128    pub async fn get_or_fetch<F, Fut, E>(&self, key: K, loader: F) -> Result<V, E>
129    where
130        F: FnOnce() -> Fut,
131        Fut: Future<Output = Result<V, E>>,
132    {
133        if let Some(v) = self.get(&key) {
134            return Ok(v);
135        }
136        let value = loader().await?;
137        self.insert(key, value.clone());
138        Ok(value)
139    }
140
141    /// Manually expire one key (next `get` or `get_or_fetch` will reload).
142    pub fn invalidate(&self, key: &K) {
143        self.inner.remove(key);
144    }
145
146    /// Remove all entries.
147    pub fn clear(&self) {
148        self.inner.clear();
149    }
150
151    /// Number of entries (including expired ones not yet swept).
152    pub fn len(&self) -> usize {
153        self.inner.len()
154    }
155
156    /// Returns `true` if no entries are present.
157    pub fn is_empty(&self) -> bool {
158        self.inner.is_empty()
159    }
160
161    /// Eviction sweep — removes all expired entries.
162    ///
163    /// Returns the number of entries removed. Call periodically if the cache can
164    /// accumulate many distinct keys (e.g. per-symbol caches).
165    pub fn sweep_expired(&self) -> usize {
166        let before = self.inner.len();
167        self.inner.retain(|_, entry| entry.is_fresh());
168        before - self.inner.len()
169    }
170}
171
172impl<K, V> Clone for RestCache<K, V>
173where
174    K: Eq + Hash + Clone,
175    V: Clone + Send + Sync,
176{
177    fn clone(&self) -> Self {
178        Self {
179            inner: self.inner.clone(),
180            default_ttl: self.default_ttl,
181        }
182    }
183}