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}