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}