whatsapp_rust/cache_store.rs
1//! Typed cache wrapper that dispatches to either the in-process
2//! [`Cache`] or a custom [`CacheStore`] backend (e.g., Redis).
3//!
4//! [`TypedCache`] presents the same interface regardless of the backing store.
5//! Keys are serialised via [`Display`]; values are serialised with `serde_json`
6//! only on the custom-store path — the in-process path has zero extra overhead.
7
8use std::borrow::Borrow;
9use std::fmt::Display;
10use std::marker::PhantomData;
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::cache::Cache;
15use serde::{Serialize, de::DeserializeOwned};
16
17pub use wacore::store::cache::CacheStore;
18
19// ── Internal storage variant ──────────────────────────────────────────────────
20
21enum Inner<K, V> {
22 Local(Cache<K, V>),
23 Custom {
24 store: Arc<dyn CacheStore>,
25 namespace: &'static str,
26 ttl: Option<Duration>,
27 _marker: PhantomData<fn(K, V)>,
28 },
29}
30
31// ── TypedCache ─────────────────────────────────────────────────────────────────
32
33/// A cache over `K → V` backed by either the in-process cache or any [`CacheStore`].
34///
35/// The in-process path has **zero extra overhead** — values are stored in
36/// memory without any serialisation. The custom-store path serialises values
37/// with `serde_json` and keys via [`Display`].
38pub struct TypedCache<K, V> {
39 inner: Inner<K, V>,
40}
41
42impl<K, V> TypedCache<K, V>
43where
44 K: std::hash::Hash + Eq + Clone + Send + Sync + 'static,
45 V: Clone + Send + Sync + 'static,
46{
47 /// Wrap an in-process [`Cache`] (zero overhead vs. using the cache directly).
48 pub fn from_local(cache: Cache<K, V>) -> Self {
49 Self {
50 inner: Inner::Local(cache),
51 }
52 }
53}
54
55impl<K, V> TypedCache<K, V>
56where
57 K: std::hash::Hash + Eq + Clone + Display + Send + Sync + 'static,
58 V: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
59{
60 /// Create a cache backed by a custom store.
61 ///
62 /// - `namespace` — unique string for this cache (e.g., `"group"`)
63 /// - `ttl` — forwarded to [`CacheStore::set`]; `None` means no expiry
64 pub fn from_store(
65 store: Arc<dyn CacheStore>,
66 namespace: &'static str,
67 ttl: Option<Duration>,
68 ) -> Self {
69 Self {
70 inner: Inner::Custom {
71 store,
72 namespace,
73 ttl,
74 _marker: PhantomData,
75 },
76 }
77 }
78
79 /// Look up a value.
80 ///
81 /// Accepts borrowed keys (`&str` for `String`, `&Jid` for `Jid`, etc.)
82 /// following the same pattern as [`std::collections::HashMap::get`].
83 ///
84 /// Cache misses and deserialisation failures both return `None`; the
85 /// caller re-fetches from the authoritative source.
86 pub async fn get<Q>(&self, key: &Q) -> Option<V>
87 where
88 K: Borrow<Q>,
89 Q: std::hash::Hash + Eq + Display + ?Sized,
90 {
91 match &self.inner {
92 Inner::Local(cache) => cache.get(key).await,
93 Inner::Custom {
94 store, namespace, ..
95 } => {
96 let key_str = key.to_string();
97 match store.get(namespace, &key_str).await {
98 Ok(Some(bytes)) => serde_json::from_slice(&bytes)
99 .inspect_err(|e| {
100 log::warn!(
101 "TypedCache[{namespace}]: deserialise failed for {key_str}: {e}"
102 );
103 })
104 .ok(),
105 Ok(None) => None,
106 Err(e) => {
107 log::warn!("TypedCache[{namespace}]: get({key_str}) error: {e}");
108 None
109 }
110 }
111 }
112 }
113 }
114
115 /// Insert or update a value (takes ownership of key and value).
116 pub async fn insert(&self, key: K, value: V) {
117 match &self.inner {
118 Inner::Local(cache) => cache.insert(key, value).await,
119 Inner::Custom {
120 store,
121 namespace,
122 ttl,
123 ..
124 } => {
125 let key_str = key.to_string();
126 match serde_json::to_vec(&value) {
127 Ok(bytes) => {
128 if let Err(e) = store.set(namespace, &key_str, &bytes, *ttl).await {
129 log::warn!("TypedCache[{namespace}]: set({key_str}) error: {e}");
130 }
131 }
132 Err(e) => {
133 log::warn!("TypedCache[{namespace}]: serialise failed for {key_str}: {e}");
134 }
135 }
136 }
137 }
138 }
139
140 /// Remove a single key.
141 ///
142 /// Accepts borrowed keys following the same pattern as `get`.
143 pub async fn invalidate<Q>(&self, key: &Q)
144 where
145 K: Borrow<Q>,
146 Q: std::hash::Hash + Eq + Display + ?Sized,
147 {
148 match &self.inner {
149 Inner::Local(cache) => cache.invalidate(key).await,
150 Inner::Custom {
151 store, namespace, ..
152 } => {
153 let key_str = key.to_string();
154 if let Err(e) = store.delete(namespace, &key_str).await {
155 log::warn!("TypedCache[{namespace}]: delete({key_str}) error: {e}");
156 }
157 }
158 }
159 }
160
161 /// Remove all entries.
162 ///
163 /// For the in-process backend this is synchronous.
164 /// For the custom backend this spawns a fire-and-forget task via
165 /// [`tokio::runtime::Handle::try_current`] (requires `tokio-runtime`
166 /// feature) to avoid panicking if called outside a Tokio runtime.
167 /// Without `tokio-runtime`, the clear is skipped with a warning.
168 pub fn invalidate_all(&self) {
169 match &self.inner {
170 Inner::Local(cache) => cache.invalidate_all(),
171 Inner::Custom {
172 store, namespace, ..
173 } => {
174 let _store = store.clone();
175 let _ns = *namespace;
176 #[cfg(all(not(target_arch = "wasm32"), feature = "tokio-runtime"))]
177 match tokio::runtime::Handle::try_current() {
178 Ok(handle) => {
179 handle.spawn(async move {
180 if let Err(e) = _store.clear(_ns).await {
181 log::warn!("TypedCache[{_ns}]: clear() error: {e}");
182 }
183 });
184 }
185 Err(_) => {
186 log::warn!("TypedCache[{_ns}]: clear() skipped: no runtime");
187 }
188 }
189 #[cfg(all(not(target_arch = "wasm32"), not(feature = "tokio-runtime")))]
190 log::warn!("TypedCache[{_ns}]: clear() skipped: tokio-runtime feature not enabled");
191 }
192 }
193 }
194
195 /// Remove all entries, awaiting completion for custom backends.
196 pub async fn clear(&self) {
197 match &self.inner {
198 Inner::Local(cache) => cache.clear().await,
199 Inner::Custom {
200 store, namespace, ..
201 } => {
202 if let Err(e) = store.clear(namespace).await {
203 log::warn!("TypedCache[{namespace}]: clear() error: {e}");
204 }
205 }
206 }
207 }
208
209 /// Run any pending internal housekeeping tasks (in-process backend only).
210 ///
211 /// For the in-process backend this evicts expired entries so a subsequent
212 /// [`entry_count`](Self::entry_count) reflects them. For custom backends
213 /// this is a no-op.
214 pub async fn run_pending_tasks(&self) {
215 if let Inner::Local(cache) = &self.inner {
216 cache.run_pending_tasks().await;
217 }
218 }
219
220 /// Iterate the in-process backend's entries. `None` for custom stores,
221 /// whose entries live outside this process (memory reports treat them as
222 /// zero retained bytes for the same reason).
223 pub fn iter_local(&self) -> Option<std::vec::IntoIter<(Arc<K>, V)>> {
224 match &self.inner {
225 Inner::Local(cache) => Some(cache.iter()),
226 Inner::Custom { .. } => None,
227 }
228 }
229
230 /// Entry count plus estimated retained bytes, summing `per_entry` over the
231 /// in-process backend (a reliable by-reference walk — no clones, cannot
232 /// degrade to an empty snapshot under write contention). Custom stores
233 /// report zero: their entries live outside this process.
234 pub async fn memory_stats(
235 &self,
236 per_entry: impl FnMut(&K, &V) -> usize,
237 ) -> wacore::stats::CollectionStats {
238 match &self.inner {
239 Inner::Local(cache) => cache.memory_stats(per_entry).await,
240 Inner::Custom { .. } => wacore::stats::CollectionStats::default(),
241 }
242 }
243
244 /// Approximate entry count (sync). Returns `0` for custom backends.
245 ///
246 /// For diagnostics that need custom backend counts, use
247 /// [`entry_count_async`](Self::entry_count_async) instead.
248 pub fn entry_count(&self) -> u64 {
249 match &self.inner {
250 Inner::Local(cache) => cache.entry_count(),
251 Inner::Custom { .. } => 0,
252 }
253 }
254
255 /// Approximate entry count, delegating to the custom backend if available.
256 pub async fn entry_count_async(&self) -> u64 {
257 match &self.inner {
258 Inner::Local(cache) => cache.entry_count(),
259 Inner::Custom {
260 store, namespace, ..
261 } => store.entry_count(namespace).await.unwrap_or(0),
262 }
263 }
264}