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, Instant};
44
45#[derive(Debug, Clone)]
46struct Entry<V> {
47    value: V,
48    inserted_at: Instant,
49    ttl: Duration,
50}
51
52impl<V> Entry<V> {
53    fn is_fresh(&self) -> bool {
54        self.inserted_at.elapsed() < self.ttl
55    }
56}
57
58/// Concurrent TTL cache.
59///
60/// Use [`RestCache::get_or_fetch`] to look up a value, fetching from the loader if
61/// missing or expired. The loader is async and returns `Result<V, E>`.
62///
63/// Clone is cheap — the internal map is `Arc`-wrapped.
64pub struct RestCache<K, V>
65where
66    K: Eq + Hash + Clone,
67    V: Clone + Send + Sync,
68{
69    inner: Arc<DashMap<K, Entry<V>>>,
70    default_ttl: Duration,
71}
72
73impl<K, V> RestCache<K, V>
74where
75    K: Eq + Hash + Clone,
76    V: Clone + Send + Sync,
77{
78    /// Create a new cache with the given default TTL applied by [`RestCache::insert`].
79    pub fn new(default_ttl: Duration) -> Self {
80        Self {
81            inner: Arc::new(DashMap::new()),
82            default_ttl,
83        }
84    }
85
86    /// Get cached value if still fresh, else `None`.
87    pub fn get(&self, key: &K) -> Option<V> {
88        let entry = self.inner.get(key)?;
89        if entry.is_fresh() {
90            Some(entry.value.clone())
91        } else {
92            None
93        }
94    }
95
96    /// Insert with the default TTL configured at construction.
97    pub fn insert(&self, key: K, value: V) {
98        self.insert_with_ttl(key, value, self.default_ttl);
99    }
100
101    /// Insert with an explicit TTL.
102    pub fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) {
103        self.inner.insert(
104            key,
105            Entry {
106                value,
107                inserted_at: Instant::now(),
108                ttl,
109            },
110        );
111    }
112
113    /// Get or fetch.
114    ///
115    /// Returns the cached value if fresh. Otherwise calls `loader`, caches the result
116    /// with the default TTL, and returns it.
117    ///
118    /// See the module-level doc for single-flight semantics (short: not single-flight).
119    pub async fn get_or_fetch<F, Fut, E>(&self, key: K, loader: F) -> Result<V, E>
120    where
121        F: FnOnce() -> Fut,
122        Fut: Future<Output = Result<V, E>>,
123    {
124        if let Some(v) = self.get(&key) {
125            return Ok(v);
126        }
127        let value = loader().await?;
128        self.insert(key, value.clone());
129        Ok(value)
130    }
131
132    /// Manually expire one key (next `get` or `get_or_fetch` will reload).
133    pub fn invalidate(&self, key: &K) {
134        self.inner.remove(key);
135    }
136
137    /// Remove all entries.
138    pub fn clear(&self) {
139        self.inner.clear();
140    }
141
142    /// Number of entries (including expired ones not yet swept).
143    pub fn len(&self) -> usize {
144        self.inner.len()
145    }
146
147    /// Returns `true` if no entries are present.
148    pub fn is_empty(&self) -> bool {
149        self.inner.is_empty()
150    }
151
152    /// Eviction sweep — removes all expired entries.
153    ///
154    /// Returns the number of entries removed. Call periodically if the cache can
155    /// accumulate many distinct keys (e.g. per-symbol caches).
156    pub fn sweep_expired(&self) -> usize {
157        let before = self.inner.len();
158        self.inner.retain(|_, entry| entry.is_fresh());
159        before - self.inner.len()
160    }
161}
162
163impl<K, V> Clone for RestCache<K, V>
164where
165    K: Eq + Hash + Clone,
166    V: Clone + Send + Sync,
167{
168    fn clone(&self) -> Self {
169        Self {
170            inner: self.inner.clone(),
171            default_ttl: self.default_ttl,
172        }
173    }
174}