Skip to main content

whatsapp_rust/
cache_config.rs

1use std::fmt::Display;
2use std::sync::Arc;
3use std::time::Duration;
4
5use crate::cache::Cache;
6use serde::{Serialize, de::DeserializeOwned};
7
8use crate::cache_store::TypedCache;
9pub use wacore::store::cache::CacheStore;
10
11/// Configuration for a single cache instance.
12///
13/// Controls the expiry timeout and maximum capacity of a moka cache.
14/// The `timeout` field is used as either TTL (`build_with_ttl`) or TTI
15/// (`build_with_tti`) depending on which builder method is called.
16/// Set `timeout` to `None` to disable time-based expiry (entries stay until
17/// evicted by capacity).
18#[derive(Debug, Clone)]
19pub struct CacheEntryConfig {
20    /// Expiry timeout duration. `None` means no time-based expiry.
21    /// Interpreted as TTL or TTI depending on the builder method used.
22    pub timeout: Option<Duration>,
23    /// Maximum number of entries.
24    pub capacity: u64,
25}
26
27impl CacheEntryConfig {
28    pub fn new(timeout: Option<Duration>, capacity: u64) -> Self {
29        Self { timeout, capacity }
30    }
31
32    /// Build a Cache using time_to_live semantics.
33    pub(crate) fn build_with_ttl<K, V>(&self) -> Cache<K, V>
34    where
35        K: std::hash::Hash + Eq + Clone + Send + Sync + 'static,
36        V: Clone + Send + Sync + 'static,
37    {
38        let mut builder = Cache::builder().max_capacity(self.capacity);
39        if let Some(timeout) = self.timeout {
40            builder = builder.time_to_live(timeout);
41        }
42        builder.build()
43    }
44
45    /// Build a [`TypedCache`] with TTL semantics, using the custom store if
46    /// provided or falling back to an in-process cache.
47    pub(crate) fn build_typed_ttl<K, V>(
48        &self,
49        store: Option<Arc<dyn CacheStore>>,
50        namespace: &'static str,
51    ) -> TypedCache<K, V>
52    where
53        K: std::hash::Hash + Eq + Clone + Display + Send + Sync + 'static,
54        V: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
55    {
56        match store {
57            Some(s) => TypedCache::from_store(s, namespace, self.timeout),
58            None => TypedCache::from_moka(self.build_with_ttl()),
59        }
60    }
61
62    /// Build a Cache using time_to_idle semantics.
63    pub(crate) fn build_with_tti<K, V>(&self) -> Cache<K, V>
64    where
65        K: std::hash::Hash + Eq + Clone + Send + Sync + 'static,
66        V: Clone + Send + Sync + 'static,
67    {
68        let mut builder = Cache::builder().max_capacity(self.capacity);
69        if let Some(timeout) = self.timeout {
70            builder = builder.time_to_idle(timeout);
71        }
72        builder.build()
73    }
74}
75
76/// Per-cache custom store overrides.
77///
78/// Each field is an optional [`CacheStore`] for that specific cache. When
79/// `None`, the default in-process moka cache is used.
80///
81/// # Example — group and device registry on Redis
82///
83/// ```rust,ignore
84/// let redis = Arc::new(MyRedisCacheStore::new("redis://localhost:6379"));
85/// let config = CacheConfig {
86///     cache_stores: CacheStores {
87///         group_cache: Some(redis.clone()),
88///         device_registry_cache: Some(redis.clone()),
89///         ..Default::default()
90///     },
91///     ..Default::default()
92/// };
93/// ```
94#[derive(Default, Clone)]
95pub struct CacheStores {
96    /// Custom store for group metadata cache.
97    pub group_cache: Option<Arc<dyn CacheStore>>,
98    /// Custom store for device registry cache.
99    pub device_registry_cache: Option<Arc<dyn CacheStore>>,
100    /// Custom store for LID-PN bidirectional mapping cache.
101    pub lid_pn_cache: Option<Arc<dyn CacheStore>>,
102}
103
104impl CacheStores {
105    /// Set the same [`CacheStore`] for all pluggable caches at once.
106    ///
107    /// Coordination caches (`session_locks`, `chat_lanes`, etc.) and the
108    /// signal write-behind cache always remain in-process regardless of this
109    /// setting.
110    ///
111    /// # Example
112    ///
113    /// ```rust,ignore
114    /// let stores = CacheStores::all(Arc::new(MyRedisCacheStore::new("redis://localhost:6379")));
115    /// ```
116    pub fn all(store: Arc<dyn CacheStore>) -> Self {
117        Self {
118            group_cache: Some(store.clone()),
119            device_registry_cache: Some(store.clone()),
120            lid_pn_cache: Some(store),
121        }
122    }
123}
124
125/// Configuration for all client caches and resource pools.
126///
127/// All fields default to WhatsApp Web behavior. Use `..Default::default()` to
128/// override only specific settings.
129///
130/// # Example — tune TTL/capacity
131///
132/// ```rust,ignore
133/// use whatsapp_rust::{CacheConfig, CacheEntryConfig};
134/// use std::time::Duration;
135///
136/// let config = CacheConfig {
137///     group_cache: CacheEntryConfig::new(None, 1_000), // no TTL
138///     ..Default::default()
139/// };
140/// ```
141///
142/// # Example — Redis for group and device registry caches
143///
144/// ```rust,ignore
145/// use std::sync::Arc;
146/// use whatsapp_rust::{CacheConfig, CacheStores};
147///
148/// let redis = Arc::new(MyRedisCacheStore::new("redis://localhost:6379"));
149/// let config = CacheConfig {
150///     cache_stores: CacheStores {
151///         group_cache: Some(redis.clone()),
152///         device_registry_cache: Some(redis.clone()),
153///         ..Default::default()
154///     },
155///     ..Default::default()
156/// };
157/// ```
158#[derive(Clone)]
159pub struct CacheConfig {
160    /// Group metadata cache (time_to_live). Default: 1h TTL, 250 entries.
161    pub group_cache: CacheEntryConfig,
162    /// Device registry cache (time_to_live). Default: 1h TTL, 1000 entries.
163    pub device_registry_cache: CacheEntryConfig,
164    /// LID-to-phone cache. WAWebLidPnCache uses plain Maps with no expiry
165    /// and no size cap; evicting a still-valid mapping silently downgrades
166    /// Signal addresses to `@c.us`. Default: no timeout, capacity u64::MAX
167    /// (effectively unbounded — moka doesn't expose an `unbounded()` builder).
168    pub lid_pn_cache: CacheEntryConfig,
169    /// Optional L1 in-memory cache for sent messages (retry support).
170    /// Default: capacity 0 (disabled — DB-only, matching WA Web).
171    /// Set capacity > 0 to enable a fast in-memory cache in front of the DB.
172    pub recent_messages: CacheEntryConfig,
173    /// Message retry counts (time_to_live). Default: 5m TTL, 500 entries.
174    pub message_retry_counts: CacheEntryConfig,
175    /// Dedup key for `UndecryptableMessage` dispatch so a server resend of
176    /// the same id does not surface a second notification. Default: 5m TTL,
177    /// 1000 entries.
178    pub undecryptable_dispatched: CacheEntryConfig,
179    /// PDO pending requests (time_to_live). Default: 30s TTL, 200 entries.
180    pub pdo_pending_requests: CacheEntryConfig,
181    /// Sender key device tracking cache (time_to_idle). Default: 1h TTI, 500 entries.
182    /// Caches per-group SKDM distribution state to avoid DB reads on every group send.
183    pub sender_key_devices_cache: CacheEntryConfig,
184
185    // --- Coordination caches (capacity-only, no TTL) ---
186    /// Per-device Signal session lock capacity. Default: 10000.
187    pub session_locks_capacity: u64,
188    /// Per-chat lane capacity (combined lock + queue). Default: 5000.
189    pub chat_lanes_capacity: u64,
190
191    // --- Sent message DB cleanup ---
192    /// TTL in seconds for sent messages in DB before periodic cleanup.
193    /// 0 = no automatic cleanup. Default: 300 (5 minutes).
194    pub sent_message_ttl_secs: u64,
195
196    // --- Custom store overrides ---
197    /// Per-cache custom store overrides.
198    ///
199    /// For each field set to `Some(store)`, the corresponding cache uses that
200    /// backend instead of the default in-process moka cache. Fields left as
201    /// `None` keep the default moka behaviour.
202    ///
203    /// Coordination caches (`session_locks`, `chat_lanes`), the signal write-behind
204    /// cache, and `pdo_pending_requests` always stay in-process — they hold live Rust
205    /// objects (mutexes, channel senders, oneshot senders) that cannot be
206    /// serialised to an external store.
207    pub cache_stores: CacheStores,
208}
209
210impl std::fmt::Debug for CacheConfig {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        f.debug_struct("CacheConfig")
213            .field("group_cache", &self.group_cache)
214            .field("device_registry_cache", &self.device_registry_cache)
215            .field("lid_pn_cache", &self.lid_pn_cache)
216            .field("recent_messages", &self.recent_messages)
217            .field("message_retry_counts", &self.message_retry_counts)
218            .field("undecryptable_dispatched", &self.undecryptable_dispatched)
219            .field("pdo_pending_requests", &self.pdo_pending_requests)
220            .field("sender_key_devices_cache", &self.sender_key_devices_cache)
221            .field("session_locks_capacity", &self.session_locks_capacity)
222            .field("chat_lanes_capacity", &self.chat_lanes_capacity)
223            .field("sent_message_ttl_secs", &self.sent_message_ttl_secs)
224            .field(
225                "cache_stores.group_cache",
226                &self.cache_stores.group_cache.is_some(),
227            )
228            .field(
229                "cache_stores.device_registry_cache",
230                &self.cache_stores.device_registry_cache.is_some(),
231            )
232            .field(
233                "cache_stores.lid_pn_cache",
234                &self.cache_stores.lid_pn_cache.is_some(),
235            )
236            .finish()
237    }
238}
239
240impl Default for CacheConfig {
241    fn default() -> Self {
242        let one_hour = Some(Duration::from_secs(3600));
243        let five_min = Some(Duration::from_secs(300));
244
245        Self {
246            group_cache: CacheEntryConfig::new(one_hour, 250),
247            device_registry_cache: CacheEntryConfig::new(one_hour, 1_000),
248            lid_pn_cache: CacheEntryConfig::new(None, u64::MAX),
249            recent_messages: CacheEntryConfig::new(five_min, 0),
250            message_retry_counts: CacheEntryConfig::new(five_min, 500),
251            undecryptable_dispatched: CacheEntryConfig::new(five_min, 1_000),
252            pdo_pending_requests: CacheEntryConfig::new(Some(Duration::from_secs(30)), 200),
253            sender_key_devices_cache: CacheEntryConfig::new(one_hour, 500),
254            // Coordination caches hold live mutexes/senders; capacity eviction
255            // while a reference is held creates a second lock for the same key,
256            // breaking serialization. Size generously to avoid eviction pressure.
257            session_locks_capacity: 10_000,
258            chat_lanes_capacity: 5_000,
259            sent_message_ttl_secs: 300,
260            cache_stores: CacheStores::default(),
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn lid_pn_cache_default_is_effectively_unbounded() {
271        let cfg = CacheConfig::default();
272        assert_eq!(
273            cfg.lid_pn_cache.timeout, None,
274            "lid_pn_cache must not expire entries by time; WAWebLidPnCache uses plain Maps"
275        );
276        assert_eq!(
277            cfg.lid_pn_cache.capacity,
278            u64::MAX,
279            "lid_pn_cache must be effectively unbounded; capacity-LRU re-introduces the eviction bug at higher thresholds"
280        );
281    }
282}