Skip to main content

archivist_core/
cache.rs

1//! Redis-backed pagination cache for list commands.
2//!
3//! FicHub shares its Redis instance with the bot. When a user runs `/recs`,
4//! `/search`, `/ask`, `/quote`, or `/requests`, the full result list is stored
5//! under a short-lived session key; `!next`/`!prev`/`!page X` then read from
6//! Redis instead of re-calling the API (no DB hammering).
7//!
8//! Key layout: `archivist:session:<user_id>:<session_id>` → JSON array of
9//! `PageEntry` (the whole list, so pagination is trivial).
10//!
11//! Response cache: `archivist:askcache:<sha256(q)>` → JSON `AskResponse`,
12//! and `archivist:searchcache:<sha256(qs)>` → JSON `SearchResponse`. These
13//! protect the public bot from hammering the LLM (`/ask`) and the API
14//! (`/search`). Only `Ok` responses are cached; a Redis failure never blocks
15//! the request (best-effort, like `log_search`).
16
17use redis::aio::ConnectionManager;
18use serde::{Deserialize, Serialize};
19
20use crate::config::BotConfig;
21use crate::error::Result;
22
23/// A single paginated-list entry: the raw item plus a stable ordering index.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PageEntry {
26    /// Stable index into the list (0-based).
27    pub index: usize,
28    /// The raw JSON object of the item (flexible — each command knows its shape).
29    pub item: serde_json::Value,
30}
31
32/// A paginated list session stored in Redis.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PageList {
35    /// Session id (uuid) for the list.
36    pub session_id: String,
37    /// Title of the list ("Recommendations", "Search: drarry", ...).
38    pub title: String,
39    /// All entries (whole list; may be large but Redis handles it fine).
40    pub entries: Vec<PageEntry>,
41    /// Optional footer/timestamp.
42    pub created_at: String,
43}
44
45impl PageList {
46    /// Empty placeholder (no Redis) used by tests and offline adapters.
47    pub fn empty() -> Self {
48        Self {
49            session_id: String::new(),
50            title: String::new(),
51            entries: Vec::new(),
52            created_at: String::new(),
53        }
54    }
55}
56
57/// Redis-backed cache for paginated lists.
58#[derive(Clone)]
59pub struct PageCache {
60    /// Backend: a live Redis connection manager, or the offline no-op.
61    backend: PageCacheBackend,
62    /// TTL for session keys.
63    ttl_secs: u64,
64}
65
66#[derive(Clone)]
67enum PageCacheBackend {
68    Redis(ConnectionManager),
69    /// In-memory no-op (CLI/offline adapters; every operation succeeds with
70    /// empty/throwaway data but never touches the network).
71    Offline,
72}
73
74impl PageCache {
75    /// In-memory placeholder (no Redis) for offline adapters/tests: every
76    /// operation is a no-op. Real adapters should use `connect()`.
77    pub fn offline() -> Self {
78        Self { backend: PageCacheBackend::Offline, ttl_secs: 300 }
79    }
80
81    /// Connect to Redis (same URL as FicHub).
82    pub async fn connect(config: &BotConfig) -> Result<Self> {
83        let client = redis::Client::open(config.redis_url.as_str())?;
84        let conn = ConnectionManager::new(client).await?;
85        Ok(Self {
86            backend: PageCacheBackend::Redis(conn),
87            ttl_secs: config.link_code_ttl_secs.max(300),
88        })
89    }
90
91    /// Store a new list, returning its session id.
92    pub async fn store(&self, title: &str, entries: Vec<PageEntry>) -> Result<String> {
93        let session_id = uuid::Uuid::new_v4().to_string();
94        let PageCacheBackend::Redis(conn) = &self.backend else {
95            // Offline: return a session id that will never resolve, so callers
96            // can paginate but always get "no results" on fetch.
97            return Ok(session_id);
98        };
99        let list = PageList {
100            session_id: session_id.clone(),
101            title: title.to_string(),
102            entries,
103            created_at: chrono::Utc::now().to_rfc3339(),
104        };
105        let key = format!("archivist:session:{session_id}");
106        let data = serde_json::to_vec(&list)?;
107        redis::cmd("SETEX")
108            .arg(&key)
109            .arg(self.ttl_secs)
110            .arg(data)
111            .exec_async(&mut conn.clone())
112            .await?;
113        Ok(session_id)
114    }
115
116    /// Store a new list for a user, remembering it as their "latest" session
117    /// (so `!next`/`!prev`/`!page X` know which list to paginate).
118    pub async fn store_for_user(
119        &self,
120        user_id: u64,
121        title: &str,
122        entries: Vec<PageEntry>,
123    ) -> Result<String> {
124        let session_id = self.store(title, entries).await?;
125        let PageCacheBackend::Redis(conn) = &self.backend else {
126            return Ok(session_id);
127        };
128        let key = format!("archivist:user:{user_id}:latest");
129        redis::cmd("SETEX")
130            .arg(&key)
131            .arg(self.ttl_secs)
132            .arg(&session_id)
133            .exec_async(&mut conn.clone())
134            .await?;
135        Ok(session_id)
136    }
137
138    /// The user's most recent list session id, if any.
139    pub async fn latest_for_user(&self, user_id: u64) -> Result<Option<String>> {
140        let PageCacheBackend::Redis(conn) = &self.backend else {
141            return Ok(None);
142        };
143        let key = format!("archivist:user:{user_id}:latest");
144        let val: Option<String> = redis::cmd("GET")
145            .arg(&key)
146            .query_async(&mut conn.clone())
147            .await?;
148        Ok(val)
149    }
150
151    /// Current page for a session (defaults to 1 when unset).
152    pub async fn current_page(&self, session_id: &str) -> Result<Option<usize>> {
153        let PageCacheBackend::Redis(conn) = &self.backend else {
154            return Ok(None);
155        };
156        let key = format!("archivist:page:{session_id}");
157        let val: Option<u64> = redis::cmd("GET")
158            .arg(&key)
159            .query_async(&mut conn.clone())
160            .await?;
161        Ok(val.map(|v| v as usize))
162    }
163
164    /// Record the current page for a session.
165    pub async fn set_current_page(&self, session_id: &str, page: usize) -> Result<()> {
166        let PageCacheBackend::Redis(conn) = &self.backend else {
167            return Ok(());
168        };
169        let key = format!("archivist:page:{session_id}");
170        redis::cmd("SETEX")
171            .arg(&key)
172            .arg(self.ttl_secs)
173            .arg(page as u64)
174            .exec_async(&mut conn.clone())
175            .await?;
176        Ok(())
177    }
178
179    /// Fetch a stored list by session id.
180    pub async fn fetch(&self, session_id: &str) -> Result<Option<PageList>> {
181        let PageCacheBackend::Redis(conn) = &self.backend else {
182            return Ok(None);
183        };
184        let key = format!("archivist:session:{session_id}");
185        let raw: Option<Vec<u8>> = redis::cmd("GET")
186            .arg(&key)
187            .query_async(&mut conn.clone())
188            .await?;
189        match raw {
190            Some(bytes) => {
191                let list: PageList = serde_json::from_slice(&bytes)?;
192                Ok(Some(list))
193            }
194            None => Ok(None),
195        }
196    }
197
198    /// Delete a stored list (call after a session ends).
199    pub async fn delete(&self, session_id: &str) -> Result<()> {
200        let PageCacheBackend::Redis(conn) = &self.backend else {
201            return Ok(());
202        };
203        let key = format!("archivist:session:{session_id}");
204        redis::cmd("DEL")
205            .arg(&key)
206            .exec_async(&mut conn.clone())
207            .await?;
208        Ok(())
209    }
210
211    /// Append a search-log entry to today's Redis list, trimming to the last
212    /// `SEARCH_LOG_CAP` entries and giving the key a 7-day TTL.
213    ///
214    /// Logging is best-effort: a Redis failure must never break the search
215    /// command, so the caller ignores the error. Keys look like
216    /// `archivist:searchlog:2026-08-15`.
217    pub async fn log_search(&self, entry: &SearchLogEntry) {
218        let PageCacheBackend::Redis(conn) = &self.backend else {
219            return;
220        };
221        let key = format!(
222            "archivist:searchlog:{}",
223            chrono::Utc::now().format("%Y-%m-%d")
224        );
225        let data = match serde_json::to_vec(entry) {
226            Ok(d) => d,
227            Err(e) => {
228                tracing::warn!("search log serialization failed: {e}");
229                return;
230            }
231        };
232        let mut conn = conn.clone();
233        let res: redis::RedisResult<i64> = redis::cmd("RPUSH")
234            .arg(&key)
235            .arg(&data)
236            .query_async(&mut conn)
237            .await;
238        match res {
239            Ok(_) => {
240                // Trim to cap and set TTL (best-effort, ignore failures).
241                let _ = redis::cmd("LTRIM")
242                    .arg(&key)
243                    .arg(-(SEARCH_LOG_CAP as isize))
244                    .arg(-1)
245                    .exec_async(&mut conn.clone())
246                    .await;
247                let _ = redis::cmd("EXPIRE")
248                    .arg(&key)
249                    .arg(7 * 24 * 60 * 60)
250                    .exec_async(&mut conn.clone())
251                    .await;
252            }
253            Err(e) => tracing::warn!("search log append failed: {e}"),
254        }
255    }
256
257    // ── Response cache (ask + search) ──────────────────────────────────
258
259    /// Look up a cached response value by kind + raw query.
260    ///
261    /// `kind` selects the key prefix + TTL: `"ask"` (`archivist:askcache:<h>`)
262    /// or `"search"` (`archivist:searchcache:<h>`). Only the raw JSON bytes
263    /// are cached; the caller deserializes into the typed response so this
264    /// method stays generic. Returns `Ok(None)` on miss or Redis failure
265    /// (best-effort — a broken cache must never break the command).
266    pub async fn cached_response(&self, kind: &str, query: &str) -> Option<serde_json::Value> {
267        let PageCacheBackend::Redis(conn) = &self.backend else {
268            return None;
269        };
270        let key = response_cache_key(kind, query);
271        let res: redis::RedisResult<Option<Vec<u8>>> = redis::cmd("GET")
272            .arg(&key)
273            .query_async(&mut conn.clone())
274            .await;
275        match res {
276            Ok(Some(bytes)) => match serde_json::from_slice(&bytes) {
277                Ok(v) => Some(v),
278                Err(e) => {
279                    tracing::warn!("response cache deserialize failed for {key}: {e}");
280                    None
281                }
282            },
283            Ok(None) => None,
284            Err(e) => {
285                tracing::warn!("response cache get failed for {key}: {e}");
286                None
287            }
288        }
289    }
290
291    /// Store a response value in the cache (best-effort, like `log_search`).
292    ///
293    /// `kind` selects the TTL: ask entries live longer (LLM cost is the
294    /// dominant expense); search entries get a short TTL so results do not go
295    /// stale as the archive grows.
296    pub async fn cache_response(&self, kind: &str, query: &str, value: &serde_json::Value) {
297        let PageCacheBackend::Redis(conn) = &self.backend else {
298            return;
299        };
300        let key = response_cache_key(kind, query);
301        let data = match serde_json::to_vec(value) {
302            Ok(d) => d,
303            Err(e) => {
304                tracing::warn!("response cache serialization failed for {key}: {e}");
305                return;
306            }
307        };
308        let ttl = match kind {
309            "ask" => ASK_CACHE_TTL_SECS,
310            _ => SEARCH_CACHE_TTL_SECS,
311        };
312        let res: redis::RedisResult<()> = redis::cmd("SETEX")
313            .arg(&key)
314            .arg(ttl)
315            .arg(data)
316            .exec_async(&mut conn.clone())
317            .await;
318        if let Err(e) = res {
319            tracing::warn!("response cache set failed for {key}: {e}");
320        }
321    }
322
323    /// Look up a raw cached value by an EXPLICIT key (e.g. the intent cache,
324    /// which uses its own TTL). Returns `Ok(None)` on miss or Redis failure.
325    pub async fn cached_raw(&self, key: &str) -> Option<serde_json::Value> {
326        let PageCacheBackend::Redis(conn) = &self.backend else {
327            return None;
328        };
329        let res: redis::RedisResult<Option<Vec<u8>>> = redis::cmd("GET")
330            .arg(key)
331            .query_async(&mut conn.clone())
332            .await;
333        match res {
334            Ok(Some(bytes)) => match serde_json::from_slice(&bytes) {
335                Ok(v) => Some(v),
336                Err(e) => {
337                    tracing::warn!("raw cache deserialize failed for {key}: {e}");
338                    None
339                }
340            },
341            Ok(None) => None,
342            Err(e) => {
343                tracing::warn!("raw cache get failed for {key}: {e}");
344                None
345            }
346        }
347    }
348
349    /// Store a raw JSON value under an EXPLICIT key + TTL (best-effort).
350    pub async fn cache_raw(&self, key: &str, ttl_secs: u64, value: &serde_json::Value) {
351        let PageCacheBackend::Redis(conn) = &self.backend else {
352            return;
353        };
354        let data = match serde_json::to_vec(value) {
355            Ok(d) => d,
356            Err(e) => {
357                tracing::warn!("raw cache serialization failed for {key}: {e}");
358                return;
359            }
360        };
361        let res: redis::RedisResult<()> = redis::cmd("SETEX")
362            .arg(key)
363            .arg(ttl_secs)
364            .arg(data)
365            .exec_async(&mut conn.clone())
366            .await;
367        if let Err(e) = res {
368            tracing::warn!("raw cache set failed for {key}: {e}");
369        }
370    }
371}
372
373/// How long cached `/ask` responses live (LLM generation is expensive — a
374/// popular public bot would otherwise hammer Ollama with identical queries).
375pub const ASK_CACHE_TTL_SECS: u64 = 24 * 60 * 60; // 24h
376
377/// How long successful `/search` responses live (cheap, but rate-limits /
378/// transient API errors should not force a re-fetch for every caller).
379pub const SEARCH_CACHE_TTL_SECS: u64 = 5 * 60; // 5 min
380
381/// Build a stable cache key for a response-cache entry.
382///
383/// Normalizes the query (trim + lowercase) so `"Drarry"` and `" drarry "`
384/// share one entry, then hashes with SHA-256 to keep the key bounded.
385fn response_cache_key(kind: &str, query: &str) -> String {
386    use sha2::{Digest, Sha256};
387    let norm = query.trim().to_lowercase();
388    let mut hasher = Sha256::new();
389    hasher.update(norm.as_bytes());
390    let hex = hasher.finalize();
391    let h = hex.iter().map(|b| format!("{b:02x}")).collect::<String>();
392    format!("archivist:{kind}cache:{h}")
393}
394
395/// Max entries kept per day in the search log.
396pub const SEARCH_LOG_CAP: usize = 500;
397
398/// One search-log row: everything needed to reproduce/debug a search request.
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct SearchLogEntry {
401    /// ISO-8601 UTC timestamp.
402    pub ts: String,
403    /// Discord user id.
404    pub user_id: u64,
405    /// Command that ran: "search" | "body" | "ask".
406    pub command: String,
407    /// The user's raw query.
408    pub query: String,
409    /// Full query string sent to the API (e.g. `q=...&page=1`).
410    pub api_query: String,
411    /// HTTP status (0 when transport failed).
412    pub status: u16,
413    /// Result count from the API (0 on error).
414    pub result_count: usize,
415    /// Wall-clock latency in milliseconds.
416    pub latency_ms: u64,
417    /// Error message if the attempt failed (empty when OK).
418    pub error: String,
419}
420
421/// Pagination helper: slice a list for a given page.
422pub fn slice_page<T>(items: &[T], page: usize, page_size: usize) -> Vec<&T> {
423    let page = page.max(1);
424    let start = (page - 1) * page_size;
425    if start >= items.len() {
426        return Vec::new();
427    }
428    let end = (start + page_size).min(items.len());
429    items[start..end].iter().collect()
430}
431
432/// Number of pages for a list.
433pub fn page_count(len: usize, page_size: usize) -> usize {
434    if len == 0 {
435        return 0;
436    }
437    (len + page_size - 1) / page_size
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn slice_page_first() {
446        let items = vec![1, 2, 3, 4, 5, 6, 7];
447        let page = slice_page(&items, 1, 3);
448        assert_eq!(page, vec![&1, &2, &3]);
449    }
450
451    #[test]
452    fn slice_page_middle() {
453        let items = vec![1, 2, 3, 4, 5, 6, 7];
454        let page = slice_page(&items, 2, 3);
455        assert_eq!(page, vec![&4, &5, &6]);
456    }
457
458    #[test]
459    fn slice_page_last_partial() {
460        let items = vec![1, 2, 3, 4, 5, 6, 7];
461        let page = slice_page(&items, 3, 3);
462        assert_eq!(page, vec![&7]);
463    }
464
465    #[test]
466    fn slice_page_out_of_range() {
467        let items = vec![1, 2, 3, 4, 5, 6, 7];
468        let page = slice_page(&items, 99, 3);
469        assert!(page.is_empty());
470    }
471
472    #[test]
473    fn page_count_calc() {
474        assert_eq!(page_count(0, 3), 0);
475        assert_eq!(page_count(7, 3), 3);
476        assert_eq!(page_count(9, 3), 3);
477        assert_eq!(page_count(10, 3), 4);
478        assert_eq!(page_count(5, 5), 1);
479    }
480
481    #[test]
482    fn response_cache_key_prefixes_by_kind() {
483        let ask = response_cache_key("ask", "dark harry");
484        let search = response_cache_key("search", "dark harry");
485        assert!(ask.starts_with("archivist:askcache:"));
486        assert!(search.starts_with("archivist:searchcache:"));
487        assert_ne!(ask, search);
488        // Hex sha256 = 64 chars.
489        assert_eq!(ask.len(), "archivist:askcache:".len() + 64);
490    }
491
492    #[test]
493    fn response_cache_key_normalizes_query() {
494        let a = response_cache_key("ask", "  Dark Harry  ");
495        let b = response_cache_key("ask", "dark harry");
496        let c = response_cache_key("ask", "DARK HARRY");
497        assert_eq!(a, b);
498        assert_eq!(b, c);
499    }
500
501    #[test]
502    fn response_cache_key_differs_for_diff_queries() {
503        let a = response_cache_key("ask", "drarry");
504        let b = response_cache_key("ask", "enemies to lovers");
505        assert_ne!(a, b);
506    }
507}