Skip to main content

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}