micromegas_auth/db_snapshot.rs
1//! Generic whole-table snapshot cache, extracted from what used to be
2//! `db_audience_grants.rs`'s entire cache-mechanics implementation. [`crate::db_snapshot::SnapshotLoader`] is the one
3//! thing each store (audience grants, groups) supplies: how to fetch a fresh snapshot and which
4//! metric/name to report on a refresh failure. [`crate::db_snapshot::SnapshotSource`] is everything else -- cold-start
5//! throttling, last-good serving after the first success, `ProviderUnavailable` wrapping -- copied
6//! unchanged from the original `DbAudienceGrantsSource`.
7//!
8//! Modeled on `db_api_key.rs`'s config/pool conventions, but a single cached value rather than a
9//! per-key `moka` cache: the whole table is small enough to hold as one snapshot, so `moka`'s
10//! eviction/LRU machinery has nothing to do here.
11
12use crate::types::ProviderUnavailable;
13use anyhow::{Result, anyhow};
14use async_trait::async_trait;
15use sqlx::PgPool;
16use std::sync::Arc;
17use std::sync::atomic::{AtomicI64, Ordering};
18use std::time::{Duration, Instant};
19
20/// What a [`SnapshotSource`] needs from a specific store: how to fetch a fresh snapshot from the
21/// pool, and the name/metric to report on a refresh failure.
22#[async_trait]
23pub trait SnapshotLoader: Send + Sync + 'static {
24 /// The loaded, ready-to-serve snapshot type (e.g. `AudienceGrants`, `GroupGraph`).
25 type Snapshot: Send + Sync;
26
27 /// Human-readable store name used in error/log text (e.g. "audience grant store", "group
28 /// store").
29 const NAME: &'static str;
30
31 /// Queries the whole table(s) and builds a fresh snapshot.
32 async fn fetch(pool: &PgPool) -> Result<Self::Snapshot>;
33
34 /// Emits this store's own refresh-error metric (`imetric!()` with its own literal name) --
35 /// called by [`SnapshotSource::current`] at both failure sites.
36 fn count_refresh_error();
37}
38
39#[derive(Debug)]
40struct Snapshot<T> {
41 value: Arc<T>,
42 /// Time of the last *successful* load -- never advanced on a failed refresh. Feeds the age
43 /// reported in the refresh-failure log/error context ("how stale is the view right now"),
44 /// independent of `fetched_at`; it is not itself emitted as a metric.
45 loaded_at: Instant,
46 /// Time of the last refresh *attempt*, successful or not -- gates how often a failing DB is
47 /// re-queried once at least one load has succeeded, so a post-first-success outage costs one
48 /// query per TTL, not one per request.
49 fetched_at: Instant,
50}
51
52/// The whole-table snapshot cache described in the module doc comment, generic over a
53/// [`SnapshotLoader`].
54pub struct SnapshotSource<L: SnapshotLoader> {
55 pool: PgPool,
56 ttl: Duration,
57 snapshot: tokio::sync::RwLock<Option<Snapshot<L::Snapshot>>>,
58 /// Process-start baseline every `last_attempt_at` reading is measured from -- captured once
59 /// here, not `Instant::now()` at each read, so `last_attempt_at` values are comparable across
60 /// calls the same way a wall-clock timestamp would be, but monotonic: unlike
61 /// `Utc::now().timestamp()`, a backwards clock step (NTP correction, VM migration, manual
62 /// clock set) can never make a later reading smaller than an earlier one.
63 start: Instant,
64 /// Milliseconds since `start` of the last refresh *attempt*, recorded outside `Snapshot` so
65 /// it exists even before any load has ever succeeded -- mirrors `db_api_key.rs`'s
66 /// `last_logged_at`, but monotonic rather than a Unix-epoch-seconds value (see [`Self::start`]).
67 /// This is what gates cold-start retries: with no `Snapshot` yet, there is nowhere else to
68 /// remember "we just tried and failed a moment ago", so without this field every `current()`
69 /// call during process startup against a still-coming-up DB would re-query with no throttling
70 /// at all, unlike the post-success path which `fetched_at` already gates. Initialized to
71 /// `i64::MIN` -- not `0`, which (being only milliseconds after `start`) would be indistinguishable
72 /// from a real attempt made moments after construction and would incorrectly throttle the very
73 /// first cold-start call -- so the first real attempt's `saturating_sub` against it is always
74 /// far past `ttl` regardless of how soon after construction it lands.
75 last_attempt_at: AtomicI64,
76}
77
78impl<L: SnapshotLoader> std::fmt::Debug for SnapshotSource<L> {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.debug_struct("SnapshotSource")
81 .field("name", &L::NAME)
82 .field("ttl", &self.ttl)
83 .finish()
84 }
85}
86
87impl<L: SnapshotLoader> SnapshotSource<L> {
88 /// Builds a source over `pool` (expected to be a [`crate::db_api_key::dedicated_key_store_pool`],
89 /// not the caller's lake pool directly), with no snapshot loaded yet -- the first `current()`
90 /// call performs the first query.
91 pub fn new(pool: PgPool, ttl: Duration) -> Self {
92 Self {
93 pool,
94 ttl,
95 snapshot: tokio::sync::RwLock::new(None),
96 start: Instant::now(),
97 last_attempt_at: AtomicI64::new(i64::MIN),
98 }
99 }
100
101 /// Milliseconds elapsed since `self.start` -- the monotonic clock `last_attempt_at` is
102 /// measured against.
103 fn elapsed_millis(&self) -> i64 {
104 i64::try_from(self.start.elapsed().as_millis()).unwrap_or(i64::MAX)
105 }
106
107 /// The synthesized error for a throttled cold-start window (see [`Self::current`]'s doc
108 /// comment) -- never a stored, re-handed-out `anyhow::Error` from a prior attempt, since that
109 /// type isn't `Clone` and this store deliberately carries none of `db_api_key.rs`'s
110 /// moka/`Arc<E>` sharing machinery to make it so.
111 ///
112 /// `last_attempt_millis` is measured against `self.start` (see that field's doc comment), not
113 /// a wall-clock instant, so there is no absolute timestamp left to report here -- the message
114 /// reports the throttle window's remaining duration instead, which stays meaningful (and never
115 /// goes backwards) regardless of the system clock.
116 fn throttled_error(&self, last_attempt_millis: i64) -> anyhow::Error {
117 let since_last_attempt = Duration::from_millis(
118 self.elapsed_millis()
119 .saturating_sub(last_attempt_millis)
120 .max(0) as u64,
121 );
122 let retry_in = self.ttl.saturating_sub(since_last_attempt);
123 // Wrapped in `ProviderUnavailable` (not a bare `anyhow!`) so `caller_context`
124 // (`flight_sql_service_impl.rs`) downcasts this to `Status::unavailable` rather than
125 // `Status::permission_denied` -- a throttled cold start is a store-outage symptom, not an
126 // authorization decision.
127 ProviderUnavailable(anyhow!(
128 "{} unavailable; cold-start retry throttled, retry available in {} seconds",
129 L::NAME,
130 retry_in.as_secs()
131 ))
132 .into()
133 }
134
135 /// Returns the current snapshot, refreshing it first if stale.
136 ///
137 /// Both the cold-start path (no `Snapshot` yet) and the post-success path are throttled to at
138 /// most one DB query per TTL window: cold-start via `last_attempt_at` (checked-and-set the
139 /// same compare-exchange way `db_api_key.rs::maybe_log_error` rate-limits its own log line),
140 /// the post-success path via `Snapshot::fetched_at` as before. A `current()` call that lands
141 /// inside an already-throttled window returns the last snapshot if one exists; if not, it
142 /// returns a freshly synthesized error describing the throttled cold-start state rather than
143 /// storing and re-handing-out the prior attempt's `anyhow::Error`. A concurrent caller that
144 /// loses the `last_attempt_at` compare-exchange while the very first cold-start attempt is
145 /// still in flight re-checks `self.snapshot` once more before giving up: if the winning
146 /// caller has since populated it, the loser serves that snapshot instead of failing; only if
147 /// it is still empty does it get the synthesized throttled-state error. It never blocks on
148 /// another caller's still-in-flight query, and it never skips the query silently with nothing
149 /// to show for it.
150 ///
151 /// `Err` only when there has never been one successful load -- a fresh process whose first
152 /// query hits a down DB has no "last good" to serve, so it fails closed like everything else
153 /// on this seam, at a rate capped by `last_attempt_at` rather than once per request. Once any
154 /// load has succeeded, a later refresh failure is logged + counted (via
155 /// [`SnapshotLoader::count_refresh_error`]) and the last good snapshot keeps serving,
156 /// unbounded -- this store has no per-item TTL eviction to fall back on the way
157 /// `db_api_key.rs`'s cache does, so an outage degrades to staleness for as long as it lasts,
158 /// not just one TTL window.
159 pub async fn current(&self) -> Result<Arc<L::Snapshot>> {
160 // Fast path: an unexpired snapshot needs no refresh attempt at all.
161 {
162 let guard = self.snapshot.read().await;
163 if let Some(snap) = guard.as_ref()
164 && snap.fetched_at.elapsed() < self.ttl
165 {
166 return Ok(Arc::clone(&snap.value));
167 }
168 }
169
170 let had_snapshot = self.snapshot.read().await.is_some();
171
172 if had_snapshot {
173 // Post-success path: no single-flight/dedup lock -- a plain `SELECT` with no side
174 // effect to de-duplicate, gated only by `fetched_at` above. A few concurrent callers
175 // landing right at the TTL boundary can each re-run this; strictly simpler and still
176 // cheap (the whole point of "the table is small").
177 match L::fetch(&self.pool).await {
178 Ok(value) => {
179 let value = Arc::new(value);
180 let mut guard = self.snapshot.write().await;
181 *guard = Some(Snapshot {
182 value: Arc::clone(&value),
183 loaded_at: Instant::now(),
184 fetched_at: Instant::now(),
185 });
186 Ok(value)
187 }
188 Err(err) => {
189 L::count_refresh_error();
190 let mut guard = self.snapshot.write().await;
191 match guard.as_mut() {
192 Some(snap) => {
193 let age_secs = snap.loaded_at.elapsed().as_secs();
194 micromegas_tracing::error!(
195 "{} refresh failed, serving stale snapshot (age={age_secs}s): \
196 {err:#}",
197 L::NAME
198 );
199 snap.fetched_at = Instant::now();
200 Ok(Arc::clone(&snap.value))
201 }
202 // Snapshot vanished between the check above and now -- this store never
203 // clears a snapshot once set, so this is unreachable in practice; treat
204 // it as a cold-start failure rather than panicking. Wrapped in
205 // `ProviderUnavailable` for the same reason `throttled_error` is: a real
206 // DB failure here must surface to `caller_context` as
207 // `Status::unavailable`, not `Status::permission_denied`.
208 None => Err(ProviderUnavailable(err).into()),
209 }
210 }
211 }
212 } else {
213 // Cold-start path: `last_attempt_at`'s compare-exchange *is* the dedup mechanism, so
214 // only one caller per TTL window fires the query and the rest get the synthesized
215 // throttled-state error instead of racing in. Measured against `self.start`
216 // (monotonic), not `Utc::now()` -- a backwards wall-clock step can never widen this
217 // window the way it could with an epoch-seconds comparison.
218 let now = self.elapsed_millis();
219 let prev = self.last_attempt_at.load(Ordering::Relaxed);
220 let ttl_millis = self.ttl.as_millis().max(1) as i64;
221 // `throttled_millis`, when set, is the timestamp that actually explains the
222 // remaining wait: on the throttle-hit branch that's `prev` (still accurate, no
223 // exchange attempted); on the CAS-loss branch it's the value the failed
224 // `compare_exchange` reports as *currently* stored, not the stale `prev` this
225 // caller read before attempting the exchange -- using `prev` there would report
226 // the wrong (often ancient/sentinel) retry window instead of the window the
227 // winning caller just started.
228 let throttled_millis = if now.saturating_sub(prev) < ttl_millis {
229 Some(prev)
230 } else {
231 self.last_attempt_at
232 .compare_exchange(prev, now, Ordering::Relaxed, Ordering::Relaxed)
233 .err()
234 };
235 if let Some(last_attempt_millis) = throttled_millis {
236 // The winner of the compare-exchange may have already finished its first query
237 // and populated the snapshot by the time we lost the race -- re-check before
238 // treating this as a throttled cold start, so concurrent callers during the very
239 // first load don't get denied purely from contention on a healthy DB. A cheap
240 // re-check, not a full single-flight/wait: if the winner hasn't finished yet,
241 // this still falls through to the throttled error below.
242 if let Some(snap) = self.snapshot.read().await.as_ref() {
243 return Ok(Arc::clone(&snap.value));
244 }
245 return Err(self.throttled_error(last_attempt_millis));
246 }
247
248 match L::fetch(&self.pool).await {
249 Ok(value) => {
250 let value = Arc::new(value);
251 let mut guard = self.snapshot.write().await;
252 *guard = Some(Snapshot {
253 value: Arc::clone(&value),
254 loaded_at: Instant::now(),
255 fetched_at: Instant::now(),
256 });
257 Ok(value)
258 }
259 Err(err) => {
260 L::count_refresh_error();
261 micromegas_tracing::error!("{} cold-start load failed: {err:#}", L::NAME);
262 // Wrapped in `ProviderUnavailable` for the same reason `throttled_error` is:
263 // a real DB failure here must surface to `caller_context` as
264 // `Status::unavailable`, not `Status::permission_denied`.
265 Err(ProviderUnavailable(err).into())
266 }
267 }
268 }
269 }
270}