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::msg_secret::{MsgSecretPolicy, MsgSecretRetention, OriginalMessageResolver};
10pub use wacore::store::cache::CacheStore;
11
12/// Configuration for a single cache instance.
13///
14/// Controls the expiry timeout and maximum capacity of an in-process cache.
15/// The `timeout` field is used as either TTL (`build_with_ttl`) or TTI
16/// (`build_with_tti`) depending on which builder method is called.
17/// Set `timeout` to `None` to disable time-based expiry (entries stay until
18/// evicted by capacity).
19#[derive(Debug, Clone)]
20pub struct CacheEntryConfig {
21    /// Expiry timeout duration. `None` means no time-based expiry.
22    /// Interpreted as TTL or TTI depending on the builder method used.
23    pub timeout: Option<Duration>,
24    /// Maximum number of entries.
25    pub capacity: u64,
26}
27
28impl CacheEntryConfig {
29    pub fn new(timeout: Option<Duration>, capacity: u64) -> Self {
30        Self { timeout, capacity }
31    }
32
33    /// Build a Cache using time_to_live semantics.
34    pub(crate) fn build_with_ttl<K, V>(&self) -> Cache<K, V>
35    where
36        K: std::hash::Hash + Eq + Clone + Send + Sync + 'static,
37        V: Clone + Send + Sync + 'static,
38    {
39        let mut builder = Cache::builder().max_capacity(self.capacity);
40        if let Some(timeout) = self.timeout {
41            builder = builder.time_to_live(timeout);
42        }
43        builder.build()
44    }
45
46    /// Build a [`TypedCache`] with TTL semantics, using the custom store if
47    /// provided or falling back to an in-process cache.
48    pub(crate) fn build_typed_ttl<K, V>(
49        &self,
50        store: Option<Arc<dyn CacheStore>>,
51        namespace: &'static str,
52    ) -> TypedCache<K, V>
53    where
54        K: std::hash::Hash + Eq + Clone + Display + Send + Sync + 'static,
55        V: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
56    {
57        match store {
58            Some(s) => TypedCache::from_store(s, namespace, self.timeout),
59            None => TypedCache::from_local(self.build_with_ttl()),
60        }
61    }
62
63    /// Build a Cache using time_to_idle semantics.
64    pub(crate) fn build_with_tti<K, V>(&self) -> Cache<K, V>
65    where
66        K: std::hash::Hash + Eq + Clone + Send + Sync + 'static,
67        V: Clone + Send + Sync + 'static,
68    {
69        let mut builder = Cache::builder().max_capacity(self.capacity);
70        if let Some(timeout) = self.timeout {
71            builder = builder.time_to_idle(timeout);
72        }
73        builder.build()
74    }
75}
76
77/// Per-cache custom store overrides.
78///
79/// Each field is an optional [`CacheStore`] for that specific cache. When
80/// `None`, the default in-process cache is used.
81///
82/// # Example — group and device registry on Redis
83///
84/// ```rust,ignore
85/// let redis = Arc::new(MyRedisCacheStore::new("redis://localhost:6379"));
86/// let config = CacheConfig {
87///     cache_stores: CacheStores {
88///         group_cache: Some(redis.clone()),
89///         device_registry_cache: Some(redis.clone()),
90///         ..Default::default()
91///     },
92///     ..Default::default()
93/// };
94/// ```
95#[derive(Default, Clone)]
96pub struct CacheStores {
97    /// Custom store for group metadata cache.
98    pub group_cache: Option<Arc<dyn CacheStore>>,
99    /// Custom store for device registry cache.
100    pub device_registry_cache: Option<Arc<dyn CacheStore>>,
101    /// Custom store for LID-PN bidirectional mapping cache.
102    pub lid_pn_cache: Option<Arc<dyn CacheStore>>,
103}
104
105impl CacheStores {
106    /// Set the same [`CacheStore`] for all pluggable caches at once.
107    ///
108    /// Coordination caches (`session_locks`, `chat_lanes`, etc.) and the
109    /// signal write-behind cache always remain in-process regardless of this
110    /// setting.
111    ///
112    /// # Example
113    ///
114    /// ```rust,ignore
115    /// let stores = CacheStores::all(Arc::new(MyRedisCacheStore::new("redis://localhost:6379")));
116    /// ```
117    pub fn all(store: Arc<dyn CacheStore>) -> Self {
118        Self {
119            group_cache: Some(store.clone()),
120            device_registry_cache: Some(store.clone()),
121            lid_pn_cache: Some(store),
122        }
123    }
124}
125
126/// Configuration for all client caches and resource pools.
127///
128/// All fields default to WhatsApp Web behavior. Use `..Default::default()` to
129/// override only specific settings.
130///
131/// # Example — tune TTL/capacity
132///
133/// ```rust,ignore
134/// use whatsapp_rust::{CacheConfig, CacheEntryConfig};
135/// use std::time::Duration;
136///
137/// let config = CacheConfig {
138///     group_cache: CacheEntryConfig::new(None, 1_000), // no TTL
139///     ..Default::default()
140/// };
141/// ```
142///
143/// # Example — Redis for group and device registry caches
144///
145/// ```rust,ignore
146/// use std::sync::Arc;
147/// use whatsapp_rust::{CacheConfig, CacheStores};
148///
149/// let redis = Arc::new(MyRedisCacheStore::new("redis://localhost:6379"));
150/// let config = CacheConfig {
151///     cache_stores: CacheStores {
152///         group_cache: Some(redis.clone()),
153///         device_registry_cache: Some(redis.clone()),
154///         ..Default::default()
155///     },
156///     ..Default::default()
157/// };
158/// ```
159#[derive(Clone)]
160pub struct CacheConfig {
161    /// Group metadata cache (time_to_live). Default: 1h TTL, 250 entries.
162    pub group_cache: CacheEntryConfig,
163    /// Device registry cache (time_to_live). Default: 1h TTL, 5000 entries
164    /// (holds a large group's per-member device set; a near-max group is ~1024).
165    pub device_registry_cache: CacheEntryConfig,
166    /// LID-to-phone cache. WAWebLidPnCache uses plain Maps with no expiry
167    /// and no size cap; evicting a still-valid mapping silently downgrades
168    /// Signal addresses to `@c.us`. Default: no timeout, capacity u64::MAX
169    /// (effectively unbounded — the cache has no dedicated `unbounded()` builder).
170    pub lid_pn_cache: CacheEntryConfig,
171    /// Optional L1 in-memory cache for sent messages (retry support).
172    /// Default: capacity 0 (disabled — DB-only, matching WA Web).
173    /// Set capacity > 0 to enable a fast in-memory cache in front of the DB.
174    pub recent_messages: CacheEntryConfig,
175    /// Message retry counts (time_to_live). Default: 1h TTL, 500 entries.
176    /// Long enough that the MAX_DECRYPT_RETRIES cap survives spaced redeliveries.
177    pub message_retry_counts: CacheEntryConfig,
178    /// Dedup key for `UndecryptableMessage` dispatch so a server resend of
179    /// the same id does not surface a second notification. Default: 5m TTL,
180    /// 1000 entries.
181    pub undecryptable_dispatched: CacheEntryConfig,
182    /// PDO pending requests (time_to_live). Default: 30s TTL, 200 entries.
183    pub pdo_pending_requests: CacheEntryConfig,
184    /// Messages already covered by a placeholder-resend PDO request
185    /// (time_to_live). WA Web keeps a session-lifetime set
186    /// (`WAWebNonMessageDataRequestPlaceholderMessageResendUtils`) so each
187    /// message triggers at most one request; without it, every redelivery of
188    /// an undecryptable message re-asks the phone (a stuck sender resending
189    /// every ~11s produced ~700 requests in 3h). The TTL stands in for
190    /// "session lifetime" with bounded memory. Default: 24h TTL, 512 entries.
191    pub pdo_requested: CacheEntryConfig,
192    /// Sender key device tracking cache (time_to_idle). Default: 1h TTI, 500 entries.
193    /// Caches per-group SKDM distribution state to avoid DB reads on every group send.
194    pub sender_key_devices_cache: CacheEntryConfig,
195    /// Session-recreate throttle history (time_to_live). Default: 1h TTL, 256
196    /// entries. Replaces a global `Mutex<HashMap>` scanned O(n) per retry receipt.
197    pub session_recreate_history: CacheEntryConfig,
198
199    // --- Coordination caches (capacity-only, no TTL) ---
200    /// Per-device Signal session lock capacity. Default: 10000. Soft cap: a lock a
201    /// task is actively holding is never evicted, so the map can briefly exceed this
202    /// under heavy concurrent fan-out (bounded by the concurrently-held count) rather
203    /// than evicting a live lock and letting two writers race the same session.
204    pub session_locks_capacity: u64,
205    /// Per-chat lane capacity (combined lock + queue). Default: 5000.
206    pub chat_lanes_capacity: u64,
207    /// Per-group cold sender-key distribution lock capacity. Default: 512.
208    /// Soft cap: a live lane is never evicted, so the map may briefly exceed
209    /// this under concurrent fan-out instead of breaking tracker ordering.
210    pub group_distribution_locks_capacity: u64,
211    /// Per-chat resend rate-limiter capacity: one token-bucket entry per group
212    /// recently driving retry resends. Keep above the count of concurrently
213    /// storming groups: eviction is FIFO and fail-open (an evicted bucket is
214    /// recreated full), so undersizing only forgives rate, never over-throttles.
215    /// Default: 4096.
216    pub resend_rate_limiter_capacity: u64,
217
218    // --- Sent message DB cleanup ---
219    /// TTL in seconds for sent messages in DB before periodic cleanup. Must
220    /// outlive retry receipts (which can arrive well after a send) or the retry
221    /// is dropped as "not found in cache". The periodic sweep keeps the table
222    /// bounded. 0 = no automatic cleanup. Default: 7200 (2 hours).
223    pub sent_message_ttl_secs: u64,
224
225    // --- MsgSecret retention ---
226    /// How the per-message `messageSecret` store is managed (capture / seed /
227    /// prune). Default [`MsgSecretPolicy::Managed`] bounds DB growth: it seeds
228    /// only the still-relevant slice of history and prunes by a per-add-on-kind
229    /// event-time horizon. Set [`MsgSecretPolicy::Full`] to keep everything
230    /// forever, or [`MsgSecretPolicy::Disabled`] to persist nothing and delegate
231    /// to [`original_message_resolver`].
232    ///
233    /// [`original_message_resolver`]: CacheConfig::original_message_resolver
234    pub msg_secret_policy: MsgSecretPolicy,
235    /// Per-add-on-kind retention horizons applied under `Managed`/`BotOnly`.
236    pub msg_secret_retention: MsgSecretRetention,
237    /// Whether to seed `messageSecret`s from history-sync blobs. Default `true`.
238    ///
239    /// Independent of live capture (which `msg_secret_policy` governs): seeding
240    /// only matters for add-ons that arrive live after connect yet reference a
241    /// parent delivered via history sync — edits of just-pre-pairing messages,
242    /// add-options/edits on still-open polls, or replays to a reconnecting
243    /// offline device. Headless consumers that only react to new messages can
244    /// set this to `false` to skip the pairing-time seed entirely. When `true`,
245    /// the policy still filters the seed (age/type under `Managed`, bot-only
246    /// under `BotOnly`, everything under `Full`).
247    pub seed_msg_secrets_from_history: bool,
248    /// Optional app-supplied fallback consulted when an add-on's parent secret
249    /// is absent from the store (and its LID/PN alternates). Lets an app that
250    /// keeps its own message store own secret retention; required for the
251    /// `Disabled` policy to decrypt anything beyond what it has seen live.
252    pub original_message_resolver: Option<Arc<dyn OriginalMessageResolver>>,
253    /// Bound on each [`original_message_resolver`] call. The resolver runs
254    /// inside the per-chat receive lane, so a slow callback would stall that
255    /// chat; on timeout the lookup degrades to a miss. Default: 5s.
256    ///
257    /// [`original_message_resolver`]: CacheConfig::original_message_resolver
258    pub msg_secret_resolver_timeout: Duration,
259
260    // --- Custom store overrides ---
261    /// Per-cache custom store overrides.
262    ///
263    /// For each field set to `Some(store)`, the corresponding cache uses that
264    /// backend instead of the default in-process cache. Fields left as
265    /// `None` keep the default in-process behaviour.
266    ///
267    /// Coordination caches (`session_locks`, `chat_lanes`), the signal write-behind
268    /// cache, and `pdo_pending_requests` always stay in-process — they hold live Rust
269    /// objects (mutexes, channel senders, oneshot senders) that cannot be
270    /// serialised to an external store.
271    pub cache_stores: CacheStores,
272}
273
274impl std::fmt::Debug for CacheConfig {
275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276        f.debug_struct("CacheConfig")
277            .field("group_cache", &self.group_cache)
278            .field("device_registry_cache", &self.device_registry_cache)
279            .field("lid_pn_cache", &self.lid_pn_cache)
280            .field("recent_messages", &self.recent_messages)
281            .field("message_retry_counts", &self.message_retry_counts)
282            .field("undecryptable_dispatched", &self.undecryptable_dispatched)
283            .field("pdo_pending_requests", &self.pdo_pending_requests)
284            .field("pdo_requested", &self.pdo_requested)
285            .field("sender_key_devices_cache", &self.sender_key_devices_cache)
286            .field("session_recreate_history", &self.session_recreate_history)
287            .field("session_locks_capacity", &self.session_locks_capacity)
288            .field("chat_lanes_capacity", &self.chat_lanes_capacity)
289            .field(
290                "group_distribution_locks_capacity",
291                &self.group_distribution_locks_capacity,
292            )
293            .field(
294                "resend_rate_limiter_capacity",
295                &self.resend_rate_limiter_capacity,
296            )
297            .field("sent_message_ttl_secs", &self.sent_message_ttl_secs)
298            .field("msg_secret_policy", &self.msg_secret_policy)
299            .field("msg_secret_retention", &self.msg_secret_retention)
300            .field(
301                "seed_msg_secrets_from_history",
302                &self.seed_msg_secrets_from_history,
303            )
304            .field(
305                "original_message_resolver",
306                &self.original_message_resolver.is_some(),
307            )
308            .field(
309                "msg_secret_resolver_timeout",
310                &self.msg_secret_resolver_timeout,
311            )
312            .field(
313                "cache_stores.group_cache",
314                &self.cache_stores.group_cache.is_some(),
315            )
316            .field(
317                "cache_stores.device_registry_cache",
318                &self.cache_stores.device_registry_cache.is_some(),
319            )
320            .field(
321                "cache_stores.lid_pn_cache",
322                &self.cache_stores.lid_pn_cache.is_some(),
323            )
324            .finish()
325    }
326}
327
328impl Default for CacheConfig {
329    fn default() -> Self {
330        let one_hour = Some(Duration::from_secs(3600));
331        let five_min = Some(Duration::from_secs(300));
332
333        Self {
334            group_cache: CacheEntryConfig::new(one_hour, 250),
335            // One entry per group member; 1000 was below a near-max (~1024) group,
336            // so large-group warm sends thrashed to the serial per-user DB path.
337            device_registry_cache: CacheEntryConfig::new(one_hour, 5_000),
338            lid_pn_cache: CacheEntryConfig::new(None, u64::MAX),
339            recent_messages: CacheEntryConfig::new(five_min, 0),
340            // 1h so the MAX_DECRYPT_RETRIES cap survives spaced redeliveries; a
341            // 5m TTL expired between reconnects so the count never reached the cap.
342            message_retry_counts: CacheEntryConfig::new(one_hour, 500),
343            undecryptable_dispatched: CacheEntryConfig::new(five_min, 1_000),
344            pdo_pending_requests: CacheEntryConfig::new(Some(Duration::from_secs(30)), 200),
345            pdo_requested: CacheEntryConfig::new(Some(Duration::from_secs(24 * 3600)), 512),
346            sender_key_devices_cache: CacheEntryConfig::new(one_hour, 500),
347            session_recreate_history: CacheEntryConfig::new(one_hour, 256),
348            // Coordination caches hold live mutexes/senders; capacity eviction
349            // while a reference is held creates a second lock for the same key,
350            // breaking serialization. Size generously to avoid eviction pressure.
351            session_locks_capacity: 10_000,
352            chat_lanes_capacity: 5_000,
353            group_distribution_locks_capacity: 512,
354            resend_rate_limiter_capacity: 4_096,
355            sent_message_ttl_secs: 7200,
356            // Bounded by default: seed only the still-relevant slice of history
357            // and prune by per-add-on-kind event-time horizons, so the store no
358            // longer accumulates a secret for every message forever.
359            msg_secret_policy: MsgSecretPolicy::default(),
360            msg_secret_retention: MsgSecretRetention::default(),
361            seed_msg_secrets_from_history: true,
362            original_message_resolver: None,
363            msg_secret_resolver_timeout: Duration::from_secs(5),
364            cache_stores: CacheStores::default(),
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn lid_pn_cache_default_is_effectively_unbounded() {
375        let cfg = CacheConfig::default();
376        assert_eq!(
377            cfg.lid_pn_cache.timeout, None,
378            "lid_pn_cache must not expire entries by time; WAWebLidPnCache uses plain Maps"
379        );
380        assert_eq!(
381            cfg.lid_pn_cache.capacity,
382            u64::MAX,
383            "lid_pn_cache must be effectively unbounded; capacity-LRU re-introduces the eviction bug at higher thresholds"
384        );
385    }
386}