Skip to main content

gglib_core/services/
settings_cache.rs

1//! Short-lived snapshot of application settings.
2//!
3//! Every chat-completion request needs settings — global inference defaults,
4//! and now the configured inference profiles. Reading them per request meant a
5//! full `SELECT` over the `settings_kv` table on the hot path, twice on the
6//! upstream-death retry path. Profiles are needed *earlier* in the handler than
7//! the defaults were, so without a cache the cost would have doubled again.
8//!
9//! This wraps the repository in a snapshot that is reused for [`DEFAULT_TTL`]
10//! before being refreshed, turning a per-request query into roughly one query
11//! per TTL window. Callers hold the returned [`Arc`] for the life of the
12//! request and borrow from it, so nothing is cloned per request either.
13//!
14//! # Why a TTL rather than invalidation on write
15//!
16//! The obvious alternative — clear the cache whenever settings are saved —
17//! cannot work here. The CLI writes the same `SQLite` file from a **separate
18//! process**, so an in-process invalidation hook would never observe
19//! `gglib config profile set` while the proxy is running. A TTL bounds
20//! staleness uniformly no matter which process did the writing, at the cost of
21//! settings changes taking up to [`DEFAULT_TTL`] to take effect.
22//!
23//! # Failure behaviour
24//!
25//! A failed load never fails the request. The last good snapshot is served if
26//! there is one, and [`Settings::default`] otherwise — matching the previous
27//! `.ok().and_then(...)` behaviour at the call sites. A failure does not
28//! refresh the expiry, so the next request retries rather than serving a stale
29//! value for a whole TTL window; during a sustained outage that means one
30//! attempt per request, which is what the code did before this cache existed.
31
32use std::sync::Arc;
33use std::time::{Duration, Instant};
34
35use crate::Settings;
36use crate::ports::SettingsRepository;
37use tokio::sync::RwLock;
38use tracing::warn;
39
40/// How long a snapshot is served before it is refreshed.
41///
42/// Short enough that a settings change from the GUI or CLI shows up quickly
43/// enough to feel immediate, long enough that a burst of requests collapses to
44/// a single query.
45pub const DEFAULT_TTL: Duration = Duration::from_secs(5);
46
47/// A settings snapshot refreshed at most once per TTL window.
48pub struct SettingsCache {
49    repo: Arc<dyn SettingsRepository>,
50    /// The current snapshot and the instant it expires. `None` until the first
51    /// successful load.
52    snapshot: RwLock<Option<(Arc<Settings>, Instant)>>,
53    ttl: Duration,
54}
55
56/// Hand-written because [`SettingsRepository`] is not `Debug`; the repository
57/// is elided rather than dropping the impl, which `AppState` needs.
58impl std::fmt::Debug for SettingsCache {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("SettingsCache")
61            .field("ttl", &self.ttl)
62            .finish_non_exhaustive()
63    }
64}
65
66impl SettingsCache {
67    /// Wrap a repository with the default TTL.
68    #[must_use]
69    pub fn new(repo: Arc<dyn SettingsRepository>) -> Self {
70        Self::with_ttl(repo, DEFAULT_TTL)
71    }
72
73    /// Wrap a repository with an explicit TTL.
74    #[must_use]
75    pub fn with_ttl(repo: Arc<dyn SettingsRepository>, ttl: Duration) -> Self {
76        Self {
77            repo,
78            snapshot: RwLock::new(None),
79            ttl,
80        }
81    }
82
83    /// Get the current settings, refreshing if the snapshot has expired.
84    ///
85    /// Never fails: see the module docs for what happens when the repository
86    /// errors.
87    pub async fn get(&self) -> Arc<Settings> {
88        // Fast path: a live snapshot, taken under a read lock so concurrent
89        // requests do not serialise on each other.
90        if let Some((settings, expires_at)) = self.snapshot.read().await.as_ref()
91            && Instant::now() < *expires_at
92        {
93            return Arc::clone(settings);
94        }
95
96        // Refresh under the write lock. Holding it across the load is
97        // deliberate: it makes the refresh single-flight, so a burst of
98        // requests arriving on an expired snapshot issues one query rather
99        // than one each.
100        let mut guard = self.snapshot.write().await;
101
102        // Another task may have refreshed while this one waited for the lock.
103        if let Some((settings, expires_at)) = guard.as_ref()
104            && Instant::now() < *expires_at
105        {
106            return Arc::clone(settings);
107        }
108
109        match self.repo.load().await {
110            Ok(settings) => {
111                let settings = Arc::new(settings);
112                *guard = Some((Arc::clone(&settings), Instant::now() + self.ttl));
113                settings
114            }
115            Err(e) => {
116                warn!(error = %e, "failed to load settings; serving last known values");
117                // Leave the expiry untouched so the next request retries.
118                guard
119                    .as_ref()
120                    .map_or_else(|| Arc::new(Settings::default()), |(s, _)| Arc::clone(s))
121            }
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    use std::sync::atomic::{AtomicUsize, Ordering};
131
132    use crate::RepositoryError;
133    use async_trait::async_trait;
134
135    /// Repository that counts loads and can be switched to failing.
136    #[derive(Debug, Default)]
137    struct CountingRepo {
138        loads: AtomicUsize,
139        context_size: AtomicUsize,
140        fail: std::sync::atomic::AtomicBool,
141    }
142
143    impl CountingRepo {
144        fn loads(&self) -> usize {
145            self.loads.load(Ordering::SeqCst)
146        }
147    }
148
149    #[async_trait]
150    impl SettingsRepository for CountingRepo {
151        async fn load(&self) -> Result<Settings, RepositoryError> {
152            self.loads.fetch_add(1, Ordering::SeqCst);
153            if self.fail.load(Ordering::SeqCst) {
154                return Err(RepositoryError::Storage("boom".to_owned()));
155            }
156            Ok(Settings {
157                default_context_size: Some(self.context_size.load(Ordering::SeqCst) as u64),
158                ..Settings::default()
159            })
160        }
161
162        async fn save(&self, _settings: &Settings) -> Result<(), RepositoryError> {
163            Ok(())
164        }
165    }
166
167    /// The point of the cache: repeated requests inside one window must not
168    /// each hit the database.
169    #[tokio::test]
170    async fn repeated_reads_within_the_window_load_once() {
171        let repo = Arc::new(CountingRepo::default());
172        let cache = SettingsCache::with_ttl(Arc::clone(&repo) as _, Duration::from_mins(1));
173
174        for _ in 0..10 {
175            let _ = cache.get().await;
176        }
177
178        assert_eq!(repo.loads(), 1);
179    }
180
181    /// A write from another process is picked up once the window expires —
182    /// the property that makes a TTL the right mechanism here.
183    #[tokio::test]
184    async fn a_write_is_observed_after_the_window_expires() {
185        let repo = Arc::new(CountingRepo::default());
186        let cache = SettingsCache::with_ttl(Arc::clone(&repo) as _, Duration::from_millis(20));
187
188        assert_eq!(cache.get().await.default_context_size, Some(0));
189
190        // Simulate an out-of-process edit.
191        repo.context_size.store(4096, Ordering::SeqCst);
192        assert_eq!(
193            cache.get().await.default_context_size,
194            Some(0),
195            "still inside the window"
196        );
197
198        tokio::time::sleep(Duration::from_millis(30)).await;
199        assert_eq!(cache.get().await.default_context_size, Some(4096));
200    }
201
202    /// A repository failure must degrade to the last good snapshot rather than
203    /// dropping settings — losing them mid-flight would silently change the
204    /// sampling applied to a request.
205    #[tokio::test]
206    async fn a_failed_refresh_serves_the_last_good_snapshot() {
207        let repo = Arc::new(CountingRepo::default());
208        repo.context_size.store(4096, Ordering::SeqCst);
209        let cache = SettingsCache::with_ttl(Arc::clone(&repo) as _, Duration::from_millis(20));
210
211        assert_eq!(cache.get().await.default_context_size, Some(4096));
212
213        repo.fail.store(true, Ordering::SeqCst);
214        tokio::time::sleep(Duration::from_millis(30)).await;
215
216        assert_eq!(cache.get().await.default_context_size, Some(4096));
217    }
218
219    /// Failing before any successful load has nothing to fall back on, so it
220    /// yields defaults rather than panicking or erroring the request.
221    #[tokio::test]
222    async fn a_failure_with_no_prior_snapshot_yields_defaults() {
223        let repo = Arc::new(CountingRepo::default());
224        repo.fail.store(true, Ordering::SeqCst);
225        let cache = SettingsCache::new(Arc::clone(&repo) as _);
226
227        assert_eq!(*cache.get().await, Settings::default());
228    }
229
230    /// A burst arriving on an expired snapshot must collapse into one query,
231    /// not one per caller.
232    #[tokio::test]
233    async fn concurrent_reads_on_an_expired_snapshot_are_single_flight() {
234        let repo = Arc::new(CountingRepo::default());
235        let cache = Arc::new(SettingsCache::with_ttl(
236            Arc::clone(&repo) as _,
237            Duration::from_mins(1),
238        ));
239
240        let handles: Vec<_> = (0..16)
241            .map(|_| {
242                let cache = Arc::clone(&cache);
243                tokio::spawn(async move { cache.get().await })
244            })
245            .collect();
246        for handle in handles {
247            handle.await.expect("task completes");
248        }
249
250        assert_eq!(repo.loads(), 1);
251    }
252}