//! Reads of the runtime-tunable `config` table, fronted by an in-memory cache. //! //! Each handler used to keep a small local copy of these helpers; consolidating them //! here means one place to add a parser, one place to mock for tests, and one place to //! find when a key changes. New keys do not require code changes — they're picked up //! the next time the cache reloads. //! //! ## Why a cache //! //! The `config` table is effectively static during an event, yet it was the busiest //! query in the system: every request re-read each key with its own `SELECT` //! (an upload touched it ~8 times). Against the small connection pool that was the //! throughput ceiling. [`ConfigCache`] loads the whole table once and serves reads //! from memory. //! //! ## Consistency contract //! //! Correctness comes from **synchronous invalidation on every write**, not from the //! TTL. The two runtime write paths — the admin `PATCH /admin/config` handler and the //! test-mode truncate/reseed — both call [`ConfigCache::invalidate`] after committing, //! so the *next* read reloads from the DB and sees the new value immediately. The //! [`RELOAD_TTL`] is only a safety net for out-of-band changes (e.g. a migration or a //! manual DB edit); it is deliberately short but never the primary mechanism. //! //! Values are read with a default fallback so the app still starts if a key is missing //! (e.g. during a migration window). Production seeds keys via migrations 005 and 009. use std::collections::HashMap; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; use sqlx::PgPool; /// How long a loaded snapshot is trusted before the next read reloads it. This is a /// backstop for out-of-band DB changes only — every in-process write invalidates the /// cache synchronously, so tests that PATCH-then-assert never depend on this expiring. const RELOAD_TTL: Duration = Duration::from_secs(30); struct Snapshot { values: HashMap, loaded_at: Instant, } /// In-memory cache of the entire `config` table. Cheap to `clone` (shares the pool and /// the `Arc`), so it lives in `AppState` and every handler reads through it. #[derive(Clone)] pub struct ConfigCache { pool: PgPool, inner: Arc>>, } impl ConfigCache { pub fn new(pool: PgPool) -> Self { Self { pool, inner: Arc::new(RwLock::new(None)), } } /// Drop the cached snapshot so the next read reloads the whole table from the DB. /// Call this after any write to the `config` table (admin PATCH, test reseed). pub fn invalidate(&self) { *self.inner.write().unwrap() = None; } /// Return the fresh snapshot if one is loaded and still within [`RELOAD_TTL`]. fn fresh_snapshot(&self) -> Option> { let guard = self.inner.read().unwrap(); match guard.as_ref() { Some(snap) if snap.loaded_at.elapsed() < RELOAD_TTL => Some(snap.values.clone()), _ => None, } } /// Read one key, loading the whole table into the cache on a miss/expiry. On a DB /// error we return `None` (callers fall back to their default) without poisoning /// the cache. async fn get_raw(&self, key: &str) -> Option { if let Some(values) = self.fresh_snapshot() { return values.get(key).cloned(); } // Cache miss or stale — reload the entire table in one query. let rows: Vec<(String, String)> = match sqlx::query_as::<_, (String, String)>( "SELECT key, value FROM config", ) .fetch_all(&self.pool) .await { Ok(rows) => rows, Err(e) => { tracing::warn!(error = ?e, "config reload failed; using defaults for this read"); return None; } }; let values: HashMap = rows.into_iter().collect(); let result = values.get(key).cloned(); *self.inner.write().unwrap() = Some(Snapshot { values, loaded_at: Instant::now(), }); result } } pub async fn get_str(cache: &ConfigCache, key: &str, default: &str) -> String { cache .get_raw(key) .await .unwrap_or_else(|| default.to_string()) } pub async fn get_i64(cache: &ConfigCache, key: &str, default: i64) -> i64 { cache .get_raw(key) .await .and_then(|v| v.parse().ok()) .unwrap_or(default) } pub async fn get_usize(cache: &ConfigCache, key: &str, default: usize) -> usize { cache .get_raw(key) .await .and_then(|v| v.parse().ok()) .unwrap_or(default) } pub async fn get_f64(cache: &ConfigCache, key: &str, default: f64) -> f64 { cache .get_raw(key) .await .and_then(|v| v.parse().ok()) .unwrap_or(default) } /// Parses common truthy spellings used by both the migration seeds and the admin form. /// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else /// returns `default`. pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool { let Some(raw) = cache.get_raw(key).await else { return default; }; match raw.trim().to_ascii_lowercase().as_str() { "true" | "1" | "yes" | "on" => true, "false" | "0" | "no" | "off" => false, _ => default, } }