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}