The backend had never been run through rustfmt. Doing it in one mechanical pass (134 files) so no future functional diff is buried under formatting churn, then gating `cargo fmt --check` in checks.yml so it stays clean. Formatting only — no logic, SQL, or behaviour changed. Verified after the reformat: cargo test 56 passed, clippy --all-targets -D warnings clean, cargo fmt --check clean. This is the deferred cleanup noted when CI's Format step was first left out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
151 lines
5.3 KiB
Rust
151 lines
5.3 KiB
Rust
//! 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<String, String>,
|
|
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<RwLock<Option<Snapshot>>>,
|
|
}
|
|
|
|
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<HashMap<String, String>> {
|
|
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<String> {
|
|
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<String, String> = 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,
|
|
}
|
|
}
|