Skip to main content

autumn_web/
feature_flags.rs

1//! First-class feature flags with per-actor rollouts and kill switches.
2//!
3//! Provides a typed, pluggable flag system that supports global on/off,
4//! percent rollouts (stable per `(flag_name, actor_id)`), explicit actor
5//! allowlists, and named group membership checks — without requiring a
6//! redeploy to toggle any gate.
7//!
8//! # Quick start
9//!
10//! ```rust
11//! use autumn_web::feature_flags::{FeatureFlagService, InMemoryFlagStore, FlagConfig};
12//! use std::sync::Arc;
13//!
14//! // 1. Build a service backed by the in-memory store (perfect for tests).
15//! let store = Arc::new(InMemoryFlagStore::new());
16//! let svc = FeatureFlagService::new(store);
17//!
18//! // 2. Enable a flag for everyone.
19//! svc.enable("dark_mode", None).unwrap();
20//! assert!(svc.is_enabled("dark_mode", Some("user:1")));
21//!
22//! // 3. Disable it. With the Postgres store, all replicas see the change on
23//! //    their next refresh (about one poll interval).
24//! svc.disable("dark_mode", None).unwrap();
25//! assert!(!svc.is_enabled("dark_mode", Some("user:1")));
26//! ```
27//!
28//! # Evaluation order
29//!
30//! For a given `(flag, actor)` pair, rules are checked in this order:
31//!
32//! 1. **Kill switch**: if `enabled = false`, return `false` immediately.
33//!    Call `disable()` for an instant kill-switch that overrides rollout and allowlists.
34//! 2. **Global on**: if `rollout_pct >= 100`, return `true` for all actors.
35//!    Call `enable()` to globally enable a flag.
36//! 3. **Actor allowlist**: if the actor ID is in the explicit allowlist, return `true`.
37//! 4. **Group membership**: if the actor belongs to any allowed group, return `true`.
38//! 5. **Percent rollout**: if `rollout_pct > 0` and the deterministic hash bucket
39//!    of `(flag_name, actor_id)` falls below the threshold, return `true`.
40//! 6. Otherwise return `false`.
41//!
42//! Calling `enable()` sets `rollout_pct = 100` (globally on for all actors).
43//! Calling `disable()` sets `enabled = false` — a hard kill-switch that overrides
44//! rollout and allowlists — while preserving the rollout/allowlist configuration.
45//!
46//! Percent-rollout buckets are computed with a FNV-1a hash over the UTF-8
47//! encoding of `"<flag_name>:<actor_id>"` and are therefore stable across
48//! restarts and replicas.
49
50// autumn-determinism-gate: production code in this module must read time and
51// mint identifiers through the framework's injected seams (ClockSource /
52// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
53// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
54// (issue #1797). Justify exceptions with
55// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
56#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
57
58use std::collections::HashMap;
59use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
60use std::sync::{Arc, Mutex, PoisonError, RwLock};
61
62use serde::{Deserialize, Serialize};
63
64// ── Change log ───────────────────────────────────────────────────────────────
65
66/// A single mutation recorded in the flag change log.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct FlagChangeRecord {
69    /// The flag key that was changed.
70    pub key: String,
71    /// Human-readable description of the mutation (e.g. `"enabled"`, `"rollout=25"`).
72    pub mutation: String,
73    /// Actor identifier supplied by the caller (username, principal, `"cli"`, etc.).
74    pub actor: Option<String>,
75    /// Wall-clock time of the change in seconds since UNIX epoch.
76    pub timestamp_secs: u64,
77}
78
79impl FlagChangeRecord {
80    fn now(key: &str, mutation: impl Into<String>, actor: Option<&str>) -> Self {
81        let timestamp_secs = crate::time::clock_unix_secs(&crate::time::AmbientClock);
82        Self {
83            key: key.to_owned(),
84            mutation: mutation.into(),
85            actor: actor.map(str::to_owned),
86            timestamp_secs,
87        }
88    }
89}
90
91// ── Flag configuration ───────────────────────────────────────────────────────
92
93/// The full configuration of a single feature flag.
94///
95/// A flag is enabled for a given actor when **any** of the following holds:
96///
97/// - `enabled` is `true` (global gate — fastest path).
98/// - The actor's ID appears in `actor_allowlist`.
99/// - The actor belongs to any group in `group_allowlist`.
100/// - `rollout_pct > 0` and the actor's deterministic bucket falls below the
101///   threshold (see module-level documentation for the hash algorithm).
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct FlagConfig {
104    /// Unique flag key in `snake_case`.
105    pub key: String,
106    /// Optional human-readable description.
107    pub description: Option<String>,
108    /// Global gate: when `true` every actor sees the flag as enabled.
109    pub enabled: bool,
110    /// Percent rollout (0 = off, 1–100 = percentage of actors).
111    pub rollout_pct: u8,
112    /// Explicit list of actor IDs that always see the flag as enabled.
113    pub actor_allowlist: Vec<String>,
114    /// Named groups whose members always see the flag as enabled.
115    pub group_allowlist: Vec<String>,
116}
117
118impl FlagConfig {
119    /// Create a new disabled flag with no gates set.
120    #[must_use]
121    pub fn new(key: impl Into<String>) -> Self {
122        Self {
123            key: key.into(),
124            description: None,
125            enabled: false,
126            rollout_pct: 0,
127            actor_allowlist: Vec::new(),
128            group_allowlist: Vec::new(),
129        }
130    }
131}
132
133// ── Group resolver ──────────────────────────────────────────────────────────
134
135/// A hook that checks whether `actor_id` belongs to `group`.
136///
137/// Register a resolver with [`FeatureFlagService::with_group_resolver`] to
138/// enable the named-group evaluation gate.
139pub type GroupResolver = Arc<dyn Fn(&str, &str) -> bool + Send + Sync + 'static>;
140
141// ── FlagStore trait ──────────────────────────────────────────────────────────
142
143/// Error from a [`FlagStore`] backend.
144#[derive(Debug, thiserror::Error)]
145pub enum FlagStoreError {
146    /// The backend reported an I/O or connection failure.
147    #[error("flag store backend error: {0}")]
148    Backend(String),
149}
150
151/// Pluggable storage backend for feature flags.
152///
153/// All mutation methods (`enable`, `disable`, `set_rollout`, `allow_actor`,
154/// `add_group`) record a [`FlagChangeRecord`] in the change log.
155pub trait FlagStore: Send + Sync + 'static {
156    /// Return the current configuration for `key`, or `None` if unknown.
157    ///
158    /// # Errors
159    ///
160    /// Returns a [`FlagStoreError`] on backend failure.
161    fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError>;
162
163    /// Return all known flags, sorted by key.
164    ///
165    /// # Errors
166    ///
167    /// Returns a [`FlagStoreError`] on backend failure.
168    fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError>;
169
170    /// Globally enable `key` for all actors (`enabled = true`, `rollout_pct = 100`).
171    ///
172    /// Creates the flag if absent. Clears any prior `disable()` kill-switch.
173    ///
174    /// # Errors
175    ///
176    /// Returns a [`FlagStoreError`] on backend failure.
177    fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError>;
178
179    /// Kill-switch `key` for all actors (`enabled = false`).
180    ///
181    /// Overrides rollout and allowlists while preserving their configuration.
182    /// Call `enable()` or `set_rollout()` to restore access.
183    ///
184    /// # Errors
185    ///
186    /// Returns a [`FlagStoreError`] on backend failure.
187    fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError>;
188
189    /// Set the percent-rollout gate for `key` to `pct` (0–100).
190    ///
191    /// Also clears any prior `disable()` kill-switch (`enabled = true`).
192    /// Use `disable()` for an instant kill-switch that overrides rollout.
193    ///
194    /// # Errors
195    ///
196    /// Returns a [`FlagStoreError`] on backend failure.
197    fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError>;
198
199    /// Add `actor_id` to the explicit allowlist for `key`.
200    ///
201    /// # Errors
202    ///
203    /// Returns a [`FlagStoreError`] on backend failure.
204    fn allow_actor(
205        &self,
206        key: &str,
207        actor_id: &str,
208        actor: Option<&str>,
209    ) -> Result<(), FlagStoreError>;
210
211    /// Add `group` to the named-group allowlist for `key`.
212    ///
213    /// # Errors
214    ///
215    /// Returns a [`FlagStoreError`] on backend failure.
216    fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError>;
217
218    /// Return the most recent `limit` change records for `key`.
219    ///
220    /// # Errors
221    ///
222    /// Returns a [`FlagStoreError`] on backend failure.
223    fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError>;
224
225    /// Load the store before the first read. This can block.
226    ///
227    /// [`AppBuilder::with_flag_store`](crate::app::AppBuilder::with_flag_store)
228    /// calls it once, on its own thread, when the app installs the store. The
229    /// default does nothing.
230    ///
231    /// # Errors
232    ///
233    /// Returns a [`FlagStoreError`] on backend failure.
234    fn preload(&self) -> Result<(), FlagStoreError> {
235        Ok(())
236    }
237}
238
239// Blanket delegation so `Box<dyn FlagStore>` can be passed to `with_flag_store`.
240impl FlagStore for Box<dyn FlagStore> {
241    fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
242        (**self).get(key)
243    }
244    fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
245        (**self).list()
246    }
247    fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
248        (**self).enable(key, actor)
249    }
250    fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
251        (**self).disable(key, actor)
252    }
253    fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError> {
254        (**self).set_rollout(key, pct, actor)
255    }
256    fn allow_actor(
257        &self,
258        key: &str,
259        actor_id: &str,
260        actor: Option<&str>,
261    ) -> Result<(), FlagStoreError> {
262        (**self).allow_actor(key, actor_id, actor)
263    }
264    fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
265        (**self).add_group(key, group, actor)
266    }
267    fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
268        (**self).history(key, limit)
269    }
270    fn preload(&self) -> Result<(), FlagStoreError> {
271        (**self).preload()
272    }
273}
274
275/// `Arc<T>` delegates every method to the inner `T`.
276///
277/// This allows sharing the **same** store instance — and therefore the same
278/// snapshot — between `with_flag_store` and `PgFlagStore::spawn_poll_listener`:
279///
280/// ```rust,ignore
281/// use std::sync::Arc;
282/// use autumn_web::feature_flags::pg::PgFlagStore;
283///
284/// let store = Arc::new(PgFlagStore::new(&db_url));
285/// // Listener and app service share the same Arc → same snapshot.
286/// PgFlagStore::spawn_poll_listener(Arc::clone(&store), Duration::from_secs(1));
287/// app.with_flag_store(Arc::clone(&store)).run().await;
288/// ```
289impl<T: FlagStore + ?Sized> FlagStore for Arc<T> {
290    fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
291        (**self).get(key)
292    }
293    fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
294        (**self).list()
295    }
296    fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
297        (**self).enable(key, actor)
298    }
299    fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
300        (**self).disable(key, actor)
301    }
302    fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError> {
303        (**self).set_rollout(key, pct, actor)
304    }
305    fn allow_actor(
306        &self,
307        key: &str,
308        actor_id: &str,
309        actor: Option<&str>,
310    ) -> Result<(), FlagStoreError> {
311        (**self).allow_actor(key, actor_id, actor)
312    }
313    fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
314        (**self).add_group(key, group, actor)
315    }
316    fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
317        (**self).history(key, limit)
318    }
319    fn preload(&self) -> Result<(), FlagStoreError> {
320        (**self).preload()
321    }
322}
323
324// ── InMemoryFlagStore ────────────────────────────────────────────────────────
325
326/// A thread-safe in-memory [`FlagStore`] suitable for tests and development.
327///
328/// State is **not** shared across processes or replicas. For production use
329/// the Postgres-backed store from `autumn_web::feature_flags::pg`.
330#[derive(Debug, Default)]
331pub struct InMemoryFlagStore {
332    flags: RwLock<HashMap<String, FlagConfig>>,
333    history: RwLock<HashMap<String, Vec<FlagChangeRecord>>>,
334}
335
336impl InMemoryFlagStore {
337    /// Create an empty in-memory store.
338    #[must_use]
339    pub fn new() -> Self {
340        Self::default()
341    }
342
343    fn upsert(&self, key: &str, f: impl FnOnce(&mut FlagConfig)) {
344        let mut flags = self.flags.write().unwrap();
345        let flag = flags
346            .entry(key.to_owned())
347            .or_insert_with(|| FlagConfig::new(key));
348        f(flag);
349        drop(flags);
350    }
351
352    fn record(&self, record: FlagChangeRecord) {
353        self.history
354            .write()
355            .unwrap()
356            .entry(record.key.clone())
357            .or_default()
358            .push(record);
359    }
360}
361
362impl FlagStore for InMemoryFlagStore {
363    fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
364        Ok(self.flags.read().unwrap().get(key).cloned())
365    }
366
367    fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
368        let mut flags: Vec<FlagConfig> = self.flags.read().unwrap().values().cloned().collect();
369        flags.sort_by(|a, b| a.key.cmp(&b.key));
370        Ok(flags)
371    }
372
373    fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
374        self.upsert(key, |f| {
375            f.enabled = true;
376            f.rollout_pct = 100;
377        });
378        self.record(FlagChangeRecord::now(key, "enabled", actor));
379        Ok(())
380    }
381
382    fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
383        self.upsert(key, |f| {
384            f.enabled = false;
385        });
386        self.record(FlagChangeRecord::now(key, "disabled", actor));
387        Ok(())
388    }
389
390    fn set_rollout(&self, key: &str, pct: u8, actor: Option<&str>) -> Result<(), FlagStoreError> {
391        let pct = pct.min(100);
392        self.upsert(key, |f| {
393            f.enabled = true;
394            f.rollout_pct = pct;
395        });
396        self.record(FlagChangeRecord::now(key, format!("rollout={pct}"), actor));
397        Ok(())
398    }
399
400    fn allow_actor(
401        &self,
402        key: &str,
403        actor_id: &str,
404        actor: Option<&str>,
405    ) -> Result<(), FlagStoreError> {
406        self.upsert(key, |f| {
407            if !f.enabled {
408                // Re-enabling from a kill-switch via allowlist: reset rollout to 0
409                // so only the explicitly listed actors gain access, not everyone
410                // who happened to be in the previous (e.g. 100%) rollout cohort.
411                f.rollout_pct = 0;
412            }
413            f.enabled = true;
414            if !f.actor_allowlist.contains(&actor_id.to_owned()) {
415                f.actor_allowlist.push(actor_id.to_owned());
416            }
417        });
418        self.record(FlagChangeRecord::now(
419            key,
420            format!("allowed_actor={actor_id}"),
421            actor,
422        ));
423        Ok(())
424    }
425
426    fn add_group(&self, key: &str, group: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
427        self.upsert(key, |f| {
428            if !f.enabled {
429                // Same targeted-enable semantics as allow_actor.
430                f.rollout_pct = 0;
431            }
432            f.enabled = true;
433            if !f.group_allowlist.contains(&group.to_owned()) {
434                f.group_allowlist.push(group.to_owned());
435            }
436        });
437        self.record(FlagChangeRecord::now(
438            key,
439            format!("added_group={group}"),
440            actor,
441        ));
442        Ok(())
443    }
444
445    fn history(&self, key: &str, limit: usize) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
446        Ok(self
447            .history
448            .read()
449            .unwrap()
450            .get(key)
451            .map(|records| records.iter().rev().take(limit).cloned().collect())
452            .unwrap_or_default())
453    }
454}
455
456// ── Postgres FlagStore ───────────────────────────────────────────────────────
457
458/// Postgres-backed flag storage.
459///
460/// Uses the framework-owned `autumn_feature_flags` and `feature_flag_changes`
461/// tables managed by the `create_feature_flags` migration. Reads come from an
462/// in-memory snapshot of all flags. A refresh loads the snapshot off the
463/// request path. Replicas see a remote write on their next refresh. Each write
464/// also sends `NOTIFY autumn_flags`; Autumn does not `LISTEN` for it.
465#[cfg(feature = "postgres")]
466pub mod pg {
467    use super::{FlagChangeRecord, FlagConfig, FlagStore, FlagStoreError};
468    use diesel::connection::SimpleConnection;
469    use diesel::prelude::*;
470    use std::collections::HashMap;
471    use std::sync::atomic::AtomicU32;
472    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
473    use std::sync::{Arc, Mutex, PoisonError, RwLock};
474    use std::time::{Duration, Instant};
475
476    /// Columns that `FlagRow` reads.
477    const FLAG_COLUMNS: &str =
478        "key, description, enabled, rollout_pct, actor_allowlist, group_allowlist";
479
480    /// Connect timeout that the store adds when the URL sets none.
481    const CONNECT_TIMEOUT_SECS: u64 = 5;
482
483    /// Statement timeout on each store connection.
484    pub(crate) const STATEMENT_TIMEOUT_MS: u64 = 5_000;
485
486    /// Longest wait between refresh attempts while refreshes fail.
487    const MAX_RETRY_DELAY: Duration = Duration::from_secs(30);
488
489    /// Add `connect_timeout` to `url` when it sets none, so that a refresh
490    /// cannot hang on a connect.
491    pub(crate) fn with_connect_timeout(url: &str) -> String {
492        if has_connect_timeout(url) {
493            url.to_owned()
494        } else if url.starts_with("postgres://") || url.starts_with("postgresql://") {
495            let separator = if url.contains('?') { '&' } else { '?' };
496            format!("{url}{separator}connect_timeout={CONNECT_TIMEOUT_SECS}")
497        } else {
498            // A `key=value` connection string.
499            format!("{url} connect_timeout={CONNECT_TIMEOUT_SECS}")
500        }
501    }
502
503    /// `true` when `url` sets the `connect_timeout` parameter.
504    fn has_connect_timeout(url: &str) -> bool {
505        if url.starts_with("postgres://") || url.starts_with("postgresql://") {
506            // Only a query parameter counts, not text in the user or path.
507            let query = url.split_once('?').map_or("", |(_, query)| query);
508            let query = query.split_once('#').map_or(query, |(query, _)| query);
509            return query
510                .split('&')
511                .any(|pair| pair.split('=').next() == Some("connect_timeout"));
512        }
513        conninfo_keys(url)
514            .iter()
515            .any(|key| key == "connect_timeout")
516    }
517
518    /// The keys of a libpq `key=value` connection string. A value can be in
519    /// single quotes, and `\` escapes the next character.
520    fn conninfo_keys(conninfo: &str) -> Vec<String> {
521        let mut keys = Vec::new();
522        let mut chars = conninfo.chars().peekable();
523        loop {
524            while chars.next_if(|c| c.is_whitespace()).is_some() {}
525            let mut key = String::new();
526            while let Some(c) = chars.next_if(|c| *c != '=' && !c.is_whitespace()) {
527                key.push(c);
528            }
529            if key.is_empty() {
530                return keys;
531            }
532            while chars.next_if(|c| c.is_whitespace()).is_some() {}
533            if chars.next_if_eq(&'=').is_none() {
534                // Not a valid pair: libpq refuses the string.
535                return keys;
536            }
537            keys.push(key);
538            while chars.next_if(|c| c.is_whitespace()).is_some() {}
539            // Skip the value.
540            if chars.next_if_eq(&'\'').is_some() {
541                while let Some(c) = chars.next() {
542                    match c {
543                        '\\' => {
544                            chars.next();
545                        }
546                        '\'' => break,
547                        _ => {}
548                    }
549                }
550            } else {
551                while let Some(c) = chars.next_if(|c| !c.is_whitespace()) {
552                    if c == '\\' {
553                        chars.next();
554                    }
555                }
556            }
557        }
558    }
559
560    /// Wait before the next refresh attempt after `failures` failed attempts
561    /// in a row: `base`, doubled for each failure, up to `MAX_RETRY_DELAY`.
562    pub(crate) fn retry_delay(base: Duration, failures: u32) -> Duration {
563        if failures == 0 {
564            return base;
565        }
566        base.saturating_mul(2_u32.saturating_pow(failures.min(16)))
567            .min(base.max(MAX_RETRY_DELAY))
568    }
569
570    /// All flags, as the last refresh and local writes left them.
571    #[derive(Debug, Default)]
572    struct Snapshot {
573        flags: HashMap<String, FlagConfig>,
574        /// `true` after the first full load.
575        loaded: bool,
576        /// Start of the last refresh attempt, good or bad.
577        checked_at: Option<Instant>,
578    }
579
580    /// State that a background refresh shares with the store.
581    struct Shared {
582        database_url: String,
583        /// `database_url` with a connect timeout.
584        connect_url: String,
585        cache_ttl: Duration,
586        snapshot: RwLock<Snapshot>,
587        /// Increments on each local write. A refresh that started before a
588        /// write does not replace the snapshot.
589        generation: AtomicU64,
590        /// `true` while a background refresh runs. Only one runs at a time.
591        refreshing: AtomicBool,
592        /// The refresh connection, kept between refreshes. Loads hold this
593        /// lock, so they run and install in order.
594        refresh_conn: Mutex<Option<diesel::PgConnection>>,
595        /// Held across a write and its snapshot update, so local writes
596        /// reach the snapshot in commit order.
597        write_lock: Mutex<()>,
598        refresh_errors: AtomicU64,
599        /// Failed refreshes in a row. Zero after a good refresh.
600        failures: AtomicU32,
601        #[cfg(test)]
602        connect_threads: Mutex<Vec<std::thread::ThreadId>>,
603    }
604
605    impl std::fmt::Debug for Shared {
606        /// Leaves out the URL: it can hold a password.
607        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608            f.debug_struct("PgFlagStore")
609                .field("cache_ttl", &self.cache_ttl)
610                .field("refresh_errors", &self.refresh_errors)
611                .finish_non_exhaustive()
612        }
613    }
614
615    /// Holds the single refresh slot. Drop releases it, also when a spawned
616    /// refresh never runs.
617    struct RefreshSlot(Arc<Shared>);
618
619    impl Drop for RefreshSlot {
620        fn drop(&mut self) {
621            self.0.refreshing.store(false, Ordering::Release);
622        }
623    }
624
625    impl Shared {
626        fn connect(&self) -> Result<diesel::PgConnection, FlagStoreError> {
627            #[cfg(test)]
628            self.connect_threads
629                .lock()
630                .unwrap_or_else(PoisonError::into_inner)
631                .push(std::thread::current().id());
632            crate::db_url::require_postgres_target(&self.database_url, "PgFlagStore")
633                .map_err(FlagStoreError::Backend)?;
634            // libpq quotes the target in its error, so redact it.
635            let mut conn = diesel::PgConnection::establish(&self.connect_url).map_err(|e| {
636                FlagStoreError::Backend(crate::db_url::redact_driver_error(
637                    &e.to_string(),
638                    &self.database_url,
639                ))
640            })?;
641            conn.batch_execute(&format!("SET statement_timeout = {STATEMENT_TIMEOUT_MS}"))
642                .map_err(|e| FlagStoreError::Backend(e.to_string()))?;
643            Ok(conn)
644        }
645
646        /// `true` when the last refresh attempt is older than the TTL, or
647        /// older than the retry delay while refreshes fail.
648        fn is_stale(&self) -> bool {
649            let wait = retry_delay(self.cache_ttl, self.failures.load(Ordering::Relaxed));
650            let snapshot = self.snapshot.read().unwrap_or_else(PoisonError::into_inner);
651            snapshot.checked_at.is_none_or(|checked| {
652                checked
653                    .checked_add(wait)
654                    .is_some_and(|due| crate::time::system_instant() >= due)
655            })
656        }
657
658        fn read(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
659            let snapshot = self.snapshot.read().unwrap_or_else(PoisonError::into_inner);
660            match snapshot.flags.get(key) {
661                Some(flag) => Ok(Some(flag.clone())),
662                None if snapshot.loaded => Ok(None),
663                None => Err(FlagStoreError::Backend(
664                    "flag snapshot not loaded yet; call PgFlagStore::refresh at startup".to_owned(),
665                )),
666            }
667        }
668
669        /// Take the refresh slot, or `None` when a refresh already runs.
670        fn begin_refresh(self: &Arc<Self>) -> Option<RefreshSlot> {
671            self.refreshing
672                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
673                .ok()
674                .map(|_| RefreshSlot(Arc::clone(self)))
675        }
676
677        /// Load all flags on this thread and install them. This blocks.
678        fn reload(&self) -> Result<(), FlagStoreError> {
679            self.load().map(drop)
680        }
681
682        /// Load all flags on this thread, install them, and return them.
683        /// This blocks.
684        fn load(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
685            let mut conn = self
686                .refresh_conn
687                .lock()
688                .unwrap_or_else(PoisonError::into_inner);
689            let started = self.generation.load(Ordering::Acquire);
690            self.snapshot
691                .write()
692                .unwrap_or_else(PoisonError::into_inner)
693                .checked_at = Some(crate::time::system_instant());
694            let loaded = conn
695                .take()
696                .map_or_else(|| self.connect(), Ok)
697                .and_then(|mut open| {
698                    let flags = load_all(&mut open)?;
699                    // Keep the connection only after a good load.
700                    *conn = Some(open);
701                    Ok(flags)
702                });
703            if let Ok(flags) = &loaded {
704                // Install before the lock drops, so loads install in order.
705                self.install(flags.clone(), started);
706            }
707            drop(conn);
708            match loaded {
709                Ok(flags) => {
710                    if self.failures.swap(0, Ordering::AcqRel) > 0 {
711                        tracing::info!("feature flag refresh recovered");
712                    }
713                    Ok(flags)
714                }
715                Err(error) => {
716                    self.refresh_errors.fetch_add(1, Ordering::Relaxed);
717                    if self.failures.fetch_add(1, Ordering::AcqRel) == 0 {
718                        tracing::warn!(
719                            %error,
720                            "feature flag refresh failed; serving the last-known snapshot"
721                        );
722                    } else {
723                        tracing::debug!(%error, "feature flag refresh failed again");
724                    }
725                    Err(error)
726                }
727            }
728        }
729
730        /// Replace the snapshot with `flags`, unless a local write came after
731        /// generation `started`. Return `true` when it replaced the snapshot.
732        fn install(&self, flags: Vec<FlagConfig>, started: u64) -> bool {
733            let mut snapshot = self
734                .snapshot
735                .write()
736                .unwrap_or_else(PoisonError::into_inner);
737            if self.generation.load(Ordering::Acquire) != started {
738                // The loaded data can be older than the write. Refresh again soon.
739                snapshot.checked_at = None;
740                return false;
741            }
742            snapshot.flags = flags.into_iter().map(|f| (f.key.clone(), f)).collect();
743            snapshot.loaded = true;
744            true
745        }
746
747        /// Put a flag that this process wrote into the snapshot.
748        ///
749        /// Two writes to one key can apply out of order, so this also marks
750        /// the snapshot stale: the next read starts a refresh.
751        fn apply_local(&self, flag: FlagConfig) {
752            let mut snapshot = self
753                .snapshot
754                .write()
755                .unwrap_or_else(PoisonError::into_inner);
756            self.generation.fetch_add(1, Ordering::AcqRel);
757            snapshot.flags.insert(flag.key.clone(), flag);
758            snapshot.checked_at = None;
759        }
760    }
761
762    fn load_all(conn: &mut diesel::PgConnection) -> Result<Vec<FlagConfig>, FlagStoreError> {
763        diesel::sql_query(format!(
764            "SELECT {FLAG_COLUMNS} FROM autumn_feature_flags ORDER BY key"
765        ))
766        .load::<FlagRow>(conn)
767        .map(|rows| rows.into_iter().map(FlagRow::into_config).collect())
768        .map_err(|e| FlagStoreError::Backend(e.to_string()))
769    }
770
771    /// Postgres-backed [`FlagStore`] that serves reads from memory.
772    ///
773    /// `get` reads an in-memory snapshot of all flags. It does not connect on
774    /// the calling thread when a Tokio runtime is present. When the snapshot is
775    /// older than the cache TTL, `get` starts one refresh on the blocking pool
776    /// and returns the current value at once. With no runtime, `get` refreshes
777    /// on the calling thread.
778    ///
779    /// When a refresh fails, the store keeps the last-known snapshot, logs a
780    /// warning and counts the error ([`refresh_errors`](Self::refresh_errors)).
781    /// Before the first load, `get` returns an error.
782    ///
783    /// Load the snapshot at startup: [`AppBuilder::with_flag_store`] calls
784    /// [`FlagStore::preload`], and [`spawn_poll_listener`](Self::spawn_poll_listener)
785    /// loads at once and then on each interval.
786    ///
787    /// [`AppBuilder::with_flag_store`]: crate::app::AppBuilder::with_flag_store
788    ///
789    /// Clone class (jscpd, `--min-tokens 50 --format rust`): this
790    /// `Shared`/snapshot-and-poll shape also serves `PgExperimentStore`
791    /// (`autumn/src/experiments.rs`, ported in #3262/#3273) and, in a
792    /// deliberately unported form, `PgConfigStore`
793    /// (`autumn/src/runtime_config.rs` — see its own doc for why). `retry_delay`,
794    /// `with_connect_timeout` and `STATEMENT_TIMEOUT_MS` really are shared
795    /// code, not copies: `experiments::pg` imports them from here instead of
796    /// redefining them. The rest stays duplicated per store because each
797    /// closes over a different row/config type; unifying it would need
798    /// generics over that type or a trait for two implementors, and this
799    /// clone class is 2 non-identical instances, not the 3 identical ones
800    /// Echo's bar needs. One deliberate difference, not a missed fix: this
801    /// store applies a local write straight into the snapshot
802    /// (`Shared::apply_local`) and relies on `generation`/`write_lock` to
803    /// keep that race-free against a concurrent background refresh;
804    /// `PgExperimentStore` instead reloads from Postgres after every write
805    /// and carries no matching `generation`/`write_lock` pair.
806    #[derive(Debug)]
807    pub struct PgFlagStore {
808        shared: Arc<Shared>,
809    }
810
811    impl Clone for PgFlagStore {
812        /// Make a new store with the same URL and TTL and an empty snapshot.
813        fn clone(&self) -> Self {
814            Self::with_cache_ttl(self.shared.database_url.clone(), self.shared.cache_ttl)
815        }
816    }
817
818    impl PgFlagStore {
819        /// Default snapshot lifetime before `get` starts a refresh.
820        pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(1);
821
822        /// Create a store with the default 1 s snapshot TTL.
823        #[must_use]
824        pub fn new(database_url: impl Into<String>) -> Self {
825            Self::with_cache_ttl(database_url, Self::DEFAULT_CACHE_TTL)
826        }
827
828        /// Create a store with an explicit snapshot TTL. With `Duration::ZERO`,
829        /// each `get` starts a refresh.
830        #[must_use]
831        pub fn with_cache_ttl(database_url: impl Into<String>, cache_ttl: Duration) -> Self {
832            let database_url = database_url.into();
833            Self {
834                shared: Arc::new(Shared {
835                    connect_url: with_connect_timeout(&database_url),
836                    database_url,
837                    cache_ttl,
838                    snapshot: RwLock::new(Snapshot::default()),
839                    generation: AtomicU64::new(0),
840                    refreshing: AtomicBool::new(false),
841                    refresh_conn: Mutex::new(None),
842                    write_lock: Mutex::new(()),
843                    refresh_errors: AtomicU64::new(0),
844                    failures: AtomicU32::new(0),
845                    #[cfg(test)]
846                    connect_threads: Mutex::new(Vec::new()),
847                }),
848            }
849        }
850
851        /// Create a store from Autumn's primary database configuration.
852        ///
853        /// Returns `None` when no primary URL is configured, and — since it
854        /// opens a `diesel::PgConnection` and notifies through `pg_notify` —
855        /// when the configured target does not name Postgres. Screening here
856        /// turns "a driver-level connection error on the first flag read" into
857        /// "this backend has no Postgres flag store", which is what the
858        /// operator can act on.
859        #[must_use]
860        pub fn from_database_config(config: &crate::config::DatabaseConfig) -> Option<Self> {
861            config.effective_primary_postgres_url().map(Self::new)
862        }
863
864        /// Load all flags into the snapshot now. This blocks: call it at
865        /// startup or from `spawn_blocking`.
866        ///
867        /// # Errors
868        ///
869        /// Returns [`FlagStoreError::Backend`] when the load fails. The last-known
870        /// snapshot stays in use.
871        pub fn refresh(&self) -> Result<(), FlagStoreError> {
872            self.shared.reload()
873        }
874
875        /// Number of failed refreshes since the store was made.
876        #[must_use]
877        pub fn refresh_errors(&self) -> u64 {
878            self.shared.refresh_errors.load(Ordering::Relaxed)
879        }
880
881        fn upsert_flag(
882            conn: &mut diesel::PgConnection,
883            key: &str,
884        ) -> Result<(), diesel::result::Error> {
885            diesel::sql_query(
886                "INSERT INTO autumn_feature_flags (key) VALUES ($1) \
887                 ON CONFLICT (key) DO NOTHING",
888            )
889            .bind::<diesel::sql_types::Text, _>(key)
890            .execute(conn)?;
891            Ok(())
892        }
893
894        fn notify(conn: &mut diesel::PgConnection, key: &str) -> Result<(), diesel::result::Error> {
895            diesel::sql_query("SELECT pg_notify('autumn_flags', $1)")
896                .bind::<diesel::sql_types::Text, _>(key)
897                .execute(conn)?;
898            Ok(())
899        }
900
901        /// Run one flag mutation in a transaction, record it in the change log,
902        /// and put the new row into the snapshot.
903        ///
904        /// `update` must end with `RETURNING {FLAG_COLUMNS}`.
905        fn write(
906            &self,
907            key: &str,
908            mutation: &str,
909            actor: Option<&str>,
910            update: impl FnOnce(&mut diesel::PgConnection) -> QueryResult<FlagRow>,
911        ) -> Result<(), FlagStoreError> {
912            let _ordered = self
913                .shared
914                .write_lock
915                .lock()
916                .unwrap_or_else(PoisonError::into_inner);
917            let mut conn = self.shared.connect()?;
918            let row = conn
919                .transaction::<FlagRow, diesel::result::Error, _>(|conn| {
920                    Self::upsert_flag(conn, key)?;
921                    let row = update(conn)?;
922                    diesel::sql_query(
923                        "INSERT INTO feature_flag_changes (key, mutation, actor) \
924                         VALUES ($1, $2, $3)",
925                    )
926                    .bind::<diesel::sql_types::Text, _>(key)
927                    .bind::<diesel::sql_types::Text, _>(mutation)
928                    .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
929                        actor.map(str::to_owned),
930                    )
931                    .execute(conn)?;
932                    Self::notify(conn, key)?;
933                    Ok(row)
934                })
935                .map_err(|e| FlagStoreError::Backend(e.to_string()))?;
936            self.shared.apply_local(row.into_config());
937            Ok(())
938        }
939
940        /// Spawn a thread that refreshes the snapshot at once and then on each
941        /// `poll_interval`.
942        ///
943        /// Replicas see remote writes on the next refresh. Call this once at
944        /// startup and share the `Arc` with the app:
945        ///
946        /// ```rust,ignore
947        /// let store = Arc::new(PgFlagStore::new(db_url));
948        /// PgFlagStore::spawn_poll_listener(Arc::clone(&store), Duration::from_secs(1));
949        /// ```
950        ///
951        /// The thread stops after all clones of the `Arc` drop.
952        #[allow(
953            clippy::must_use_candidate,
954            reason = "the thread runs detached; callers drop the handle"
955        )]
956        pub fn spawn_poll_listener(
957            store: Arc<Self>,
958            poll_interval: Duration,
959        ) -> std::thread::JoinHandle<()> {
960            let shared = Arc::downgrade(&store.shared);
961            drop(store);
962            std::thread::spawn(move || {
963                while let Some(shared) = shared.upgrade() {
964                    if let Some(slot) = shared.begin_refresh() {
965                        // `reload` logs and counts its errors. A panic must
966                        // not stop the polls.
967                        let reload = std::panic::AssertUnwindSafe(|| slot.0.reload());
968                        if std::panic::catch_unwind(reload).is_err() {
969                            tracing::warn!("feature flag refresh panicked");
970                        }
971                    }
972                    let wait = retry_delay(poll_interval, shared.failures.load(Ordering::Relaxed));
973                    drop(shared);
974                    std::thread::sleep(wait);
975                }
976            })
977        }
978
979        #[cfg(test)]
980        fn install_for_test(&self, flags: Vec<FlagConfig>) {
981            assert!(self.install(flags, self.write_generation()));
982            self.shared
983                .snapshot
984                .write()
985                .unwrap_or_else(PoisonError::into_inner)
986                .checked_at = Some(crate::time::system_instant());
987        }
988
989        #[cfg(test)]
990        fn connect_threads_for_test(&self) -> Vec<std::thread::ThreadId> {
991            self.shared
992                .connect_threads
993                .lock()
994                .unwrap_or_else(PoisonError::into_inner)
995                .clone()
996        }
997
998        #[cfg(test)]
999        fn refreshing_for_test(&self) -> bool {
1000            self.shared.refreshing.load(Ordering::Acquire)
1001        }
1002
1003        #[cfg(test)]
1004        fn write_generation(&self) -> u64 {
1005            self.shared.generation.load(Ordering::Acquire)
1006        }
1007
1008        #[cfg(test)]
1009        fn apply_local(&self, flag: FlagConfig) {
1010            self.shared.apply_local(flag);
1011        }
1012
1013        #[cfg(test)]
1014        fn install(&self, flags: Vec<FlagConfig>, generation: u64) -> bool {
1015            self.shared.install(flags, generation)
1016        }
1017    }
1018
1019    #[derive(diesel::QueryableByName)]
1020    struct FlagRow {
1021        #[diesel(sql_type = diesel::sql_types::Text)]
1022        key: String,
1023        #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1024        description: Option<String>,
1025        #[diesel(sql_type = diesel::sql_types::Bool)]
1026        enabled: bool,
1027        #[diesel(sql_type = diesel::sql_types::SmallInt)]
1028        rollout_pct: i16,
1029        #[diesel(sql_type = diesel::sql_types::Text)]
1030        actor_allowlist: String,
1031        #[diesel(sql_type = diesel::sql_types::Text)]
1032        group_allowlist: String,
1033    }
1034
1035    impl FlagRow {
1036        fn into_config(self) -> FlagConfig {
1037            let actor_allowlist: Vec<String> =
1038                serde_json::from_str(&self.actor_allowlist).unwrap_or_default();
1039            let group_allowlist: Vec<String> =
1040                serde_json::from_str(&self.group_allowlist).unwrap_or_default();
1041            FlagConfig {
1042                key: self.key,
1043                description: self.description,
1044                enabled: self.enabled,
1045                rollout_pct: u8::try_from(self.rollout_pct.clamp(0, 100)).unwrap_or(0),
1046                actor_allowlist,
1047                group_allowlist,
1048            }
1049        }
1050    }
1051
1052    #[derive(diesel::QueryableByName)]
1053    struct HistoryRow {
1054        #[diesel(sql_type = diesel::sql_types::Text)]
1055        key: String,
1056        #[diesel(sql_type = diesel::sql_types::Text)]
1057        mutation: String,
1058        #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1059        actor: Option<String>,
1060        #[diesel(sql_type = diesel::sql_types::BigInt)]
1061        timestamp_secs: i64,
1062    }
1063
1064    impl FlagStore for PgFlagStore {
1065        fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
1066            if self.shared.is_stale()
1067                && let Some(slot) = self.shared.begin_refresh()
1068            {
1069                match tokio::runtime::Handle::try_current() {
1070                    // On a runtime: refresh on the blocking pool, never here.
1071                    Ok(_) => {
1072                        drop(crate::time::spawn_blocking(move || {
1073                            // `reload` logs and counts its errors.
1074                            let _ = slot.0.reload();
1075                        }));
1076                    }
1077                    // No runtime: this thread can block.
1078                    Err(_) => {
1079                        let _ = slot.0.reload();
1080                    }
1081                }
1082            }
1083            self.shared.read(key)
1084        }
1085
1086        fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
1087            self.shared.load()
1088        }
1089
1090        fn preload(&self) -> Result<(), FlagStoreError> {
1091            self.refresh()
1092        }
1093
1094        fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
1095            self.write(key, "enabled", actor, |conn| {
1096                diesel::sql_query(format!(
1097                    "UPDATE autumn_feature_flags \
1098                     SET enabled = true, rollout_pct = 100, updated_at = NOW() \
1099                     WHERE key = $1 RETURNING {FLAG_COLUMNS}"
1100                ))
1101                .bind::<diesel::sql_types::Text, _>(key)
1102                .get_result(conn)
1103            })
1104        }
1105
1106        fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
1107            self.write(key, "disabled", actor, |conn| {
1108                diesel::sql_query(format!(
1109                    "UPDATE autumn_feature_flags SET enabled = false, updated_at = NOW() \
1110                     WHERE key = $1 RETURNING {FLAG_COLUMNS}"
1111                ))
1112                .bind::<diesel::sql_types::Text, _>(key)
1113                .get_result(conn)
1114            })
1115        }
1116
1117        fn set_rollout(
1118            &self,
1119            key: &str,
1120            pct: u8,
1121            actor: Option<&str>,
1122        ) -> Result<(), FlagStoreError> {
1123            let pct = i16::from(pct.min(100));
1124            self.write(key, &format!("rollout={pct}"), actor, |conn| {
1125                diesel::sql_query(format!(
1126                    "UPDATE autumn_feature_flags \
1127                     SET enabled = true, rollout_pct = $2, updated_at = NOW() \
1128                     WHERE key = $1 RETURNING {FLAG_COLUMNS}"
1129                ))
1130                .bind::<diesel::sql_types::Text, _>(key)
1131                .bind::<diesel::sql_types::SmallInt, _>(pct)
1132                .get_result(conn)
1133            })
1134        }
1135
1136        fn allow_actor(
1137            &self,
1138            key: &str,
1139            actor_id: &str,
1140            actor: Option<&str>,
1141        ) -> Result<(), FlagStoreError> {
1142            self.write(key, &format!("allowed_actor={actor_id}"), actor, |conn| {
1143                diesel::sql_query(format!(
1144                    // Re-enabling from kill-switch via allowlist resets rollout_pct to 0
1145                    // so only listed actors gain access, not the previous global cohort.
1146                    "UPDATE autumn_feature_flags \
1147                     SET enabled = true, \
1148                         rollout_pct = CASE WHEN NOT enabled THEN 0 ELSE rollout_pct END, \
1149                         actor_allowlist = (
1150                             SELECT json_agg(DISTINCT elem) \
1151                             FROM (
1152                                 SELECT jsonb_array_elements_text(actor_allowlist::jsonb) AS elem \
1153                                 UNION SELECT $2
1154                             ) t \
1155                         )::text, \
1156                         updated_at = NOW() \
1157                     WHERE key = $1 RETURNING {FLAG_COLUMNS}"
1158                ))
1159                .bind::<diesel::sql_types::Text, _>(key)
1160                .bind::<diesel::sql_types::Text, _>(actor_id)
1161                .get_result(conn)
1162            })
1163        }
1164
1165        fn add_group(
1166            &self,
1167            key: &str,
1168            group: &str,
1169            actor: Option<&str>,
1170        ) -> Result<(), FlagStoreError> {
1171            self.write(key, &format!("added_group={group}"), actor, |conn| {
1172                diesel::sql_query(format!(
1173                    // Re-enabling from kill-switch via group allowlist resets rollout_pct.
1174                    "UPDATE autumn_feature_flags \
1175                     SET enabled = true, \
1176                         rollout_pct = CASE WHEN NOT enabled THEN 0 ELSE rollout_pct END, \
1177                         group_allowlist = (
1178                             SELECT json_agg(DISTINCT elem) \
1179                             FROM (
1180                                 SELECT jsonb_array_elements_text(group_allowlist::jsonb) AS elem \
1181                                 UNION SELECT $2
1182                             ) t \
1183                         )::text, \
1184                         updated_at = NOW() \
1185                     WHERE key = $1 RETURNING {FLAG_COLUMNS}"
1186                ))
1187                .bind::<diesel::sql_types::Text, _>(key)
1188                .bind::<diesel::sql_types::Text, _>(group)
1189                .get_result(conn)
1190            })
1191        }
1192
1193        fn history(
1194            &self,
1195            key: &str,
1196            limit: usize,
1197        ) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
1198            let limit = i64::try_from(limit).unwrap_or(i64::MAX);
1199            let mut conn = self.shared.connect()?;
1200            diesel::sql_query(
1201                "SELECT key, mutation, actor, \
1202                        EXTRACT(EPOCH FROM changed_at)::bigint AS timestamp_secs \
1203                 FROM feature_flag_changes \
1204                 WHERE key = $1 \
1205                 ORDER BY changed_at DESC LIMIT $2",
1206            )
1207            .bind::<diesel::sql_types::Text, _>(key)
1208            .bind::<diesel::sql_types::BigInt, _>(limit)
1209            .load::<HistoryRow>(&mut conn)
1210            .map(|rows| {
1211                rows.into_iter()
1212                    .map(|r| FlagChangeRecord {
1213                        key: r.key,
1214                        mutation: r.mutation,
1215                        actor: r.actor,
1216                        timestamp_secs: u64::try_from(r.timestamp_secs).unwrap_or(0),
1217                    })
1218                    .collect()
1219            })
1220            .map_err(|e| FlagStoreError::Backend(e.to_string()))
1221        }
1222    }
1223
1224    #[cfg(test)]
1225    mod pg_tests {
1226        use super::*;
1227
1228        /// Nothing listens on port 1, so a connect fails at once.
1229        const DEAD_URL: &str = "postgres://autumn@127.0.0.1:1/autumn?connect_timeout=2";
1230
1231        fn enabled(key: &str) -> FlagConfig {
1232            let mut flag = FlagConfig::new(key);
1233            flag.enabled = true;
1234            flag.rollout_pct = 100;
1235            flag
1236        }
1237
1238        fn current_thread_runtime() -> tokio::runtime::Runtime {
1239            tokio::runtime::Builder::new_current_thread()
1240                .enable_all()
1241                .build()
1242                .unwrap()
1243        }
1244
1245        /// Wait (on the runtime) until no background refresh runs.
1246        async fn settle(store: &PgFlagStore) {
1247            for _ in 0..1000 {
1248                if !store.refreshing_for_test() {
1249                    return;
1250                }
1251                tokio::time::sleep(Duration::from_millis(10)).await;
1252            }
1253            panic!("background refresh did not finish");
1254        }
1255
1256        #[test]
1257        fn pg_get_serves_snapshot_when_refresh_fails() {
1258            let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
1259            store.install_for_test(vec![enabled("beta")]);
1260
1261            // No runtime here, so `get` refreshes on this thread. It fails.
1262            assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
1263            assert_eq!(store.get("absent").unwrap(), None);
1264            assert_eq!(store.refresh_errors(), 2);
1265        }
1266
1267        #[test]
1268        fn pg_get_within_ttl_does_not_connect() {
1269            let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
1270            store.install_for_test(vec![enabled("beta")]);
1271            assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
1272            assert!(store.connect_threads_for_test().is_empty());
1273        }
1274
1275        #[test]
1276        fn pg_get_on_current_thread_runtime_never_connects_on_the_worker() {
1277            let store = std::sync::Arc::new(PgFlagStore::with_cache_ttl(DEAD_URL, Duration::ZERO));
1278            store.install_for_test(vec![enabled("beta")]);
1279            let svc = crate::feature_flags::FeatureFlagService::new(store.clone());
1280
1281            current_thread_runtime().block_on(async {
1282                let worker = std::thread::current().id();
1283                assert!(svc.is_enabled("beta", Some("user:1")));
1284                settle(&store).await;
1285
1286                let threads = store.connect_threads_for_test();
1287                assert!(!threads.is_empty(), "the stale snapshot is refreshed");
1288                assert!(
1289                    !threads.contains(&worker),
1290                    "flag evaluation must not connect on the Tokio worker"
1291                );
1292            });
1293            assert!(store.refresh_errors() >= 1);
1294        }
1295
1296        #[test]
1297        fn pg_cold_get_on_runtime_errors_and_loads_in_background() {
1298            let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
1299            current_thread_runtime().block_on(async {
1300                let worker = std::thread::current().id();
1301                assert!(store.get("beta").is_err(), "no snapshot yet");
1302                settle(&store).await;
1303                let threads = store.connect_threads_for_test();
1304                assert!(!threads.is_empty());
1305                assert!(!threads.contains(&worker));
1306            });
1307        }
1308
1309        #[test]
1310        fn pg_refresh_older_than_a_local_write_is_discarded() {
1311            let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
1312            store.install_for_test(vec![]);
1313
1314            let started = store.write_generation();
1315            store.apply_local(enabled("beta"));
1316            // A refresh that read the database before the write ends now.
1317            assert!(!store.install(vec![FlagConfig::new("beta")], started));
1318            assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
1319
1320            assert!(store.install(vec![FlagConfig::new("beta")], store.write_generation()));
1321            assert_eq!(store.get("beta").unwrap(), Some(FlagConfig::new("beta")));
1322        }
1323
1324        #[test]
1325        fn pg_refresh_reports_backend_error() {
1326            let store = PgFlagStore::new(DEAD_URL);
1327            assert!(store.refresh().is_err());
1328            assert_eq!(store.refresh_errors(), 1);
1329            assert!(store.preload().is_err());
1330        }
1331
1332        #[test]
1333        fn pg_poll_listener_stops_when_the_store_drops() {
1334            let store = std::sync::Arc::new(PgFlagStore::new(DEAD_URL));
1335            let handle = PgFlagStore::spawn_poll_listener(
1336                std::sync::Arc::clone(&store),
1337                Duration::from_millis(10),
1338            );
1339            drop(store);
1340            for _ in 0..1000 {
1341                if handle.is_finished() {
1342                    return;
1343                }
1344                std::thread::sleep(Duration::from_millis(10));
1345            }
1346            panic!("poll listener outlived its store");
1347        }
1348
1349        #[test]
1350        fn connect_url_gets_a_connect_timeout() {
1351            assert_eq!(
1352                with_connect_timeout("postgres://h/db"),
1353                "postgres://h/db?connect_timeout=5"
1354            );
1355            assert_eq!(
1356                with_connect_timeout("postgresql://h/db?sslmode=require"),
1357                "postgresql://h/db?sslmode=require&connect_timeout=5"
1358            );
1359            assert_eq!(
1360                with_connect_timeout("host=db dbname=app"),
1361                "host=db dbname=app connect_timeout=5"
1362            );
1363            // A timeout in the URL stays as it is.
1364            assert_eq!(DEAD_URL, with_connect_timeout(DEAD_URL));
1365            assert_eq!(
1366                with_connect_timeout("host=db connect_timeout = 3"),
1367                "host=db connect_timeout = 3"
1368            );
1369            // The text elsewhere is not the parameter.
1370            assert_eq!(
1371                with_connect_timeout("postgres://u:connect_timeout@h/db"),
1372                "postgres://u:connect_timeout@h/db?connect_timeout=5"
1373            );
1374            assert_eq!(
1375                with_connect_timeout("postgres://h/db?application_name=connect_timeout"),
1376                "postgres://h/db?application_name=connect_timeout&connect_timeout=5"
1377            );
1378            assert_eq!(
1379                with_connect_timeout("host=db application_name=connect_timeout"),
1380                "host=db application_name=connect_timeout connect_timeout=5"
1381            );
1382            // Text in a quoted value is not a parameter.
1383            assert_eq!(
1384                with_connect_timeout("host=db application_name='foo connect_timeout=999'"),
1385                "host=db application_name='foo connect_timeout=999' connect_timeout=5"
1386            );
1387            assert_eq!(
1388                with_connect_timeout(r"host=db password='it\'s connect_timeout=1'"),
1389                r"host=db password='it\'s connect_timeout=1' connect_timeout=5"
1390            );
1391            assert_eq!(
1392                with_connect_timeout("host=db connect_timeout='7'"),
1393                "host=db connect_timeout='7'"
1394            );
1395        }
1396
1397        #[test]
1398        fn retry_delay_doubles_up_to_the_cap() {
1399            let second = Duration::from_secs(1);
1400            assert_eq!(retry_delay(second, 0), second);
1401            assert_eq!(retry_delay(second, 1), Duration::from_secs(2));
1402            assert_eq!(retry_delay(second, 3), Duration::from_secs(8));
1403            assert_eq!(retry_delay(second, 40), MAX_RETRY_DELAY);
1404            assert_eq!(retry_delay(Duration::ZERO, 5), Duration::ZERO);
1405            // A base longer than the cap stays the base.
1406            let minute = Duration::from_secs(60);
1407            assert_eq!(retry_delay(minute, 4), minute);
1408        }
1409
1410        #[test]
1411        fn pg_store_debug_hides_the_database_password() {
1412            let store = PgFlagStore::new("postgres://app:s3cret@db/app");
1413            assert!(!format!("{store:?}").contains("s3cret"));
1414        }
1415
1416        #[test]
1417        fn pg_list_serves_nothing_new_when_the_database_fails() {
1418            let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
1419            store.install_for_test(vec![enabled("beta")]);
1420            assert!(store.list().is_err());
1421            assert_eq!(store.get("beta").unwrap(), Some(enabled("beta")));
1422        }
1423
1424        #[test]
1425        fn pg_store_exposes_database_url() {
1426            let store = PgFlagStore::new("postgres://localhost/myapp");
1427            assert_eq!(store.shared.database_url, "postgres://localhost/myapp");
1428        }
1429
1430        #[test]
1431        fn pg_store_default_cache_ttl_is_one_second() {
1432            let store = PgFlagStore::new("postgres://localhost/myapp");
1433            assert_eq!(store.shared.cache_ttl, PgFlagStore::DEFAULT_CACHE_TTL);
1434        }
1435
1436        #[test]
1437        fn pg_store_with_cache_ttl_sets_custom_ttl() {
1438            let ttl = Duration::from_secs(30);
1439            let store = PgFlagStore::with_cache_ttl("postgres://localhost/myapp", ttl);
1440            assert_eq!(store.shared.cache_ttl, ttl);
1441        }
1442
1443        #[test]
1444        fn pg_store_clone_has_independent_snapshot() {
1445            // Only `Arc<PgFlagStore>` shares one snapshot.
1446            let store = PgFlagStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
1447            store.install_for_test(vec![enabled("cached")]);
1448            let cloned = store.clone();
1449            assert!(cloned.get("cached").is_err(), "a clone starts empty");
1450            assert_eq!(store.get("cached").unwrap(), Some(enabled("cached")));
1451        }
1452    }
1453}
1454
1455// ── Hash helpers ─────────────────────────────────────────────────────────────
1456
1457/// FNV-1a 64-bit hash of `parts` laid end to end.
1458///
1459/// FNV-1a folds one byte at a time, so this is exactly the hash of the parts'
1460/// concatenation, without building it. Used for stable, deterministic
1461/// percent-rollout bucket assignment. No external dependency — the algorithm
1462/// is specified by the FNV standard.
1463fn fnv1a_64(parts: &[&[u8]]) -> u64 {
1464    const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
1465    const FNV_PRIME: u64 = 1_099_511_628_211;
1466    let mut hash = FNV_OFFSET;
1467    for &byte in parts.iter().copied().flatten() {
1468        hash ^= u64(byte);
1469        hash = hash.wrapping_mul(FNV_PRIME);
1470    }
1471    hash
1472}
1473
1474#[allow(clippy::cast_lossless)]
1475const fn u64(v: u8) -> u64 {
1476    v as u64
1477}
1478
1479/// Compute the percent-rollout bucket for `(flag_key, actor_id)`.
1480///
1481/// Returns a value in `[0, 100)`. If the flag's `rollout_pct` is greater
1482/// than this value the actor is in the rollout cohort.
1483#[must_use]
1484pub fn rollout_bucket(flag_key: &str, actor_id: &str) -> u8 {
1485    // The bucket of `"<flag_key>:<actor_id>"` (see the module docs), hashed
1486    // part by part so a rollout check builds no joined key.
1487    let hash = fnv1a_64(&[flag_key.as_bytes(), b":".as_slice(), actor_id.as_bytes()]);
1488    u8::try_from(hash % 100).unwrap_or(0)
1489}
1490
1491// ── FeatureFlagService ───────────────────────────────────────────────────────
1492
1493/// The main feature-flag service.
1494///
1495/// Wrap a [`FlagStore`] (for persistence) and an optional [`GroupResolver`]
1496/// (for named-group membership). The service is cheaply clone-able and
1497/// intended to be stored as an `AppState` extension:
1498///
1499/// ```rust,ignore
1500/// state.insert_extension(FeatureFlagService::new(Arc::new(InMemoryFlagStore::new())));
1501/// ```
1502///
1503/// # Store failures
1504///
1505/// When the store returns an error, the service uses the last value that it
1506/// read for that flag (the last-known value). With no last-known value, it
1507/// uses the declared default ([`with_default`](Self::with_default)), or
1508/// `false`. Clones share the last-known values and the error count.
1509#[derive(Clone)]
1510pub struct FeatureFlagService {
1511    store: Arc<dyn FlagStore>,
1512    group_resolver: Option<GroupResolver>,
1513    defaults: Arc<HashMap<String, bool>>,
1514    health: Arc<StoreHealth>,
1515}
1516
1517/// Last-known flag values and store error state, shared by service clones.
1518#[derive(Default)]
1519struct StoreHealth {
1520    known: RwLock<Known>,
1521    errors: AtomicU64,
1522    /// `true` after a failed read, until a read succeeds.
1523    failing: AtomicBool,
1524    /// Increments on each write through the service.
1525    writes: AtomicU64,
1526    /// Held across a service write and its read-back, so generations follow
1527    /// the order of the writes.
1528    write_order: Mutex<()>,
1529}
1530
1531/// Values and write generations, under one lock.
1532#[derive(Default)]
1533struct Known {
1534    values: HashMap<String, FlagConfig>,
1535    /// The generation of the latest write to each key.
1536    last_write: HashMap<String, u64>,
1537}
1538
1539impl Known {
1540    /// `true` when a write to `key` began after generation `started`.
1541    fn written_after(&self, key: &str, started: u64) -> bool {
1542        self.last_write
1543            .get(key)
1544            .is_some_and(|write| *write > started)
1545    }
1546}
1547
1548impl StoreHealth {
1549    /// The generation to pass to [`remember`](Self::remember). Take it
1550    /// before the read.
1551    fn read_started(&self) -> u64 {
1552        self.writes.load(Ordering::Acquire)
1553    }
1554
1555    /// Start a write's read-back of `key`. Reads of `key` that started
1556    /// earlier can no longer replace its last-known value. Returns the
1557    /// write's generation.
1558    fn begin_write(&self, key: &str) -> u64 {
1559        let mut known = self.known.write().unwrap_or_else(PoisonError::into_inner);
1560        let write = self.writes.fetch_add(1, Ordering::AcqRel) + 1;
1561        known.last_write.insert(key.to_owned(), write);
1562        write
1563    }
1564
1565    /// Keep the value that the store returned for `key`, unless a write to
1566    /// `key` began after generation `started`: the read can be older than
1567    /// the write.
1568    ///
1569    /// The hot path takes no write lock when the value is the same.
1570    fn remember(&self, key: &str, flag: Option<&FlagConfig>, started: u64) {
1571        if self.failing.load(Ordering::Relaxed) && self.failing.swap(false, Ordering::AcqRel) {
1572            tracing::info!("feature flag store recovered");
1573        }
1574        let known = self.known.read().unwrap_or_else(PoisonError::into_inner);
1575        if known.values.get(key) == flag {
1576            return;
1577        }
1578        drop(known);
1579        let mut known = self.known.write().unwrap_or_else(PoisonError::into_inner);
1580        if known.written_after(key, started) {
1581            return;
1582        }
1583        match flag {
1584            Some(flag) => {
1585                known.values.insert(key.to_owned(), flag.clone());
1586            }
1587            None => {
1588                known.values.remove(key);
1589            }
1590        }
1591    }
1592
1593    /// After write `write` to `key` that the store cannot read back: a
1594    /// disable keeps the flag off, and other writes drop the last-known
1595    /// value. A newer write to `key` wins.
1596    fn forget(&self, key: &str, disabled: bool, write: u64) {
1597        let mut known = self.known.write().unwrap_or_else(PoisonError::into_inner);
1598        if known.written_after(key, write) {
1599            return;
1600        }
1601        if disabled {
1602            known
1603                .values
1604                .entry(key.to_owned())
1605                .or_insert_with(|| FlagConfig::new(key))
1606                .enabled = false;
1607        } else {
1608            known.values.remove(key);
1609        }
1610    }
1611
1612    /// Count a failed read and return the last-known value for `key`.
1613    fn recall(&self, key: &str, error: &FlagStoreError) -> Option<FlagConfig> {
1614        self.errors.fetch_add(1, Ordering::Relaxed);
1615        if self.failing.swap(true, Ordering::AcqRel) {
1616            tracing::debug!(flag = key, %error, "feature flag store read failed again");
1617        } else {
1618            tracing::warn!(
1619                flag = key,
1620                %error,
1621                "feature flag store read failed; serving last-known values or declared defaults"
1622            );
1623        }
1624        self.known
1625            .read()
1626            .unwrap_or_else(PoisonError::into_inner)
1627            .values
1628            .get(key)
1629            .cloned()
1630    }
1631}
1632
1633impl std::fmt::Debug for FeatureFlagService {
1634    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1635        f.debug_struct("FeatureFlagService").finish_non_exhaustive()
1636    }
1637}
1638
1639impl FeatureFlagService {
1640    /// Create a new service wrapping the given store.
1641    #[must_use]
1642    pub fn new(store: Arc<dyn FlagStore>) -> Self {
1643        Self {
1644            store,
1645            group_resolver: None,
1646            defaults: Arc::default(),
1647            health: Arc::default(),
1648        }
1649    }
1650
1651    /// Declare the value of `flag_key` when the store does not hold the flag,
1652    /// or cannot read it and no last-known value exists.
1653    ///
1654    /// Without a declared default, that value is `false`.
1655    #[must_use]
1656    pub fn with_default(mut self, flag_key: impl Into<String>, default: bool) -> Self {
1657        Arc::make_mut(&mut self.defaults).insert(flag_key.into(), default);
1658        self
1659    }
1660
1661    /// Number of failed store reads since the service was made.
1662    #[must_use]
1663    pub fn store_errors(&self) -> u64 {
1664        self.health.errors.load(Ordering::Relaxed)
1665    }
1666
1667    /// Load the store before the first read. This can block.
1668    ///
1669    /// # Errors
1670    ///
1671    /// Propagates [`FlagStoreError`] from the backing store.
1672    pub fn preload(&self) -> Result<(), FlagStoreError> {
1673        self.store.preload()
1674    }
1675
1676    /// Attach a group resolver so named-group gates are evaluated.
1677    #[must_use]
1678    pub fn with_group_resolver(mut self, resolver: GroupResolver) -> Self {
1679        self.group_resolver = Some(resolver);
1680        self
1681    }
1682
1683    /// Return `true` if `flag_key` is enabled for `actor_id`.
1684    ///
1685    /// An unknown flag gets its declared default, or `false`. A store error
1686    /// gets the last-known value (see [Store failures](Self#store-failures)).
1687    #[must_use]
1688    pub fn is_enabled(&self, flag_key: &str, actor_id: Option<&str>) -> bool {
1689        let started = self.health.read_started();
1690        let flag = match self.store.get(flag_key) {
1691            Ok(flag) => {
1692                self.health.remember(flag_key, flag.as_ref(), started);
1693                flag
1694            }
1695            Err(error) => self.health.recall(flag_key, &error),
1696        };
1697        flag.map_or_else(
1698            || self.defaults.get(flag_key).copied().unwrap_or(false),
1699            |flag| self.evaluate(&flag, actor_id),
1700        )
1701    }
1702
1703    fn evaluate(&self, flag: &FlagConfig, actor_id: Option<&str>) -> bool {
1704        // Kill switch: enabled=false overrides all other gates.
1705        if !flag.enabled {
1706            return false;
1707        }
1708
1709        // Globally on: rollout_pct=100 enables everyone without per-actor check.
1710        if flag.rollout_pct >= 100 {
1711            return true;
1712        }
1713
1714        // Actor allowlist.
1715        if let Some(actor) = actor_id
1716            && flag.actor_allowlist.iter().any(|a| a.as_str() == actor)
1717        {
1718            return true;
1719        }
1720
1721        // Named groups.
1722        if let (Some(actor), Some(resolver)) = (actor_id, &self.group_resolver) {
1723            for group in &flag.group_allowlist {
1724                if resolver(actor, group) {
1725                    return true;
1726                }
1727            }
1728        }
1729
1730        // Percent rollout (1–99%).
1731        if flag.rollout_pct > 0
1732            && let Some(actor) = actor_id
1733        {
1734            let bucket = rollout_bucket(&flag.key, actor);
1735            return bucket < flag.rollout_pct;
1736        }
1737
1738        false
1739    }
1740
1741    /// Run one store write, then update the last-known value of `flag_key`.
1742    ///
1743    /// Service writes run one at a time, through their read-back. When the
1744    /// store cannot read the flag back, a disable keeps the flag off, and any
1745    /// other write drops the last-known value.
1746    fn write(
1747        &self,
1748        flag_key: &str,
1749        disabled: bool,
1750        op: impl FnOnce(&dyn FlagStore) -> Result<(), FlagStoreError>,
1751    ) -> Result<(), FlagStoreError> {
1752        let _ordered = self
1753            .health
1754            .write_order
1755            .lock()
1756            .unwrap_or_else(PoisonError::into_inner);
1757        op(self.store.as_ref())?;
1758        let started = self.health.begin_write(flag_key);
1759        match self.store.get(flag_key) {
1760            Ok(flag) => self.health.remember(flag_key, flag.as_ref(), started),
1761            Err(_) => self.health.forget(flag_key, disabled, started),
1762        }
1763        Ok(())
1764    }
1765
1766    /// Enable `flag_key` for all actors.
1767    ///
1768    /// # Errors
1769    ///
1770    /// Propagates [`FlagStoreError`] from the backing store.
1771    pub fn enable(&self, flag_key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
1772        self.write(flag_key, false, |store| store.enable(flag_key, actor))
1773    }
1774
1775    /// Disable `flag_key` globally.
1776    ///
1777    /// # Errors
1778    ///
1779    /// Propagates [`FlagStoreError`] from the backing store.
1780    pub fn disable(&self, flag_key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
1781        self.write(flag_key, true, |store| store.disable(flag_key, actor))
1782    }
1783
1784    /// Set the percent-rollout gate for `flag_key` to `pct` (0–100).
1785    ///
1786    /// # Errors
1787    ///
1788    /// Propagates [`FlagStoreError`] from the backing store.
1789    pub fn set_rollout(
1790        &self,
1791        flag_key: &str,
1792        pct: u8,
1793        actor: Option<&str>,
1794    ) -> Result<(), FlagStoreError> {
1795        self.write(flag_key, false, |store| {
1796            store.set_rollout(flag_key, pct, actor)
1797        })
1798    }
1799
1800    /// Add `actor_id` to the explicit allowlist for `flag_key`.
1801    ///
1802    /// # Errors
1803    ///
1804    /// Propagates [`FlagStoreError`] from the backing store.
1805    pub fn allow_actor(
1806        &self,
1807        flag_key: &str,
1808        actor_id: &str,
1809        actor: Option<&str>,
1810    ) -> Result<(), FlagStoreError> {
1811        self.write(flag_key, false, |store| {
1812            store.allow_actor(flag_key, actor_id, actor)
1813        })
1814    }
1815
1816    /// Add `group` to the named-group allowlist for `flag_key`.
1817    ///
1818    /// # Errors
1819    ///
1820    /// Propagates [`FlagStoreError`] from the backing store.
1821    pub fn add_group(
1822        &self,
1823        flag_key: &str,
1824        group: &str,
1825        actor: Option<&str>,
1826    ) -> Result<(), FlagStoreError> {
1827        self.write(flag_key, false, |store| {
1828            store.add_group(flag_key, group, actor)
1829        })
1830    }
1831
1832    /// Return all known flags, sorted by key.
1833    ///
1834    /// # Errors
1835    ///
1836    /// Propagates [`FlagStoreError`] from the backing store.
1837    pub fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
1838        self.store.list()
1839    }
1840
1841    /// Return the most recent `limit` change records for `flag_key`.
1842    ///
1843    /// # Errors
1844    ///
1845    /// Propagates [`FlagStoreError`] from the backing store.
1846    pub fn history(
1847        &self,
1848        flag_key: &str,
1849        limit: usize,
1850    ) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
1851        self.store.history(flag_key, limit)
1852    }
1853}
1854
1855/// Longest time that the app waits for [`FlagStore::preload`]. It covers
1856/// the Postgres store's connect and statement timeouts.
1857pub(crate) const PRELOAD_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
1858
1859/// Preload the store of `service` on its own thread, and wait up to `wait`.
1860///
1861/// The app calls it when it installs the service, in every mode. So later
1862/// state initializers, job workers, startup hooks, routes, a static build, a
1863/// one-off task and a replay all see stored flags. After `wait`, the load
1864/// goes on and the app goes on. A failure logs a warning; it does not stop
1865/// the app.
1866pub(crate) fn preload_blocking(service: &FeatureFlagService, wait: std::time::Duration) {
1867    let service = service.clone();
1868    preload_with(
1869        "feature flag",
1870        "flags use declared defaults",
1871        wait,
1872        move || service.preload(),
1873    );
1874}
1875
1876/// Run `load` on its own thread and wait up to `wait`. `what` names the
1877/// store in logs. `fallback` says what the app does without the load.
1878pub(crate) fn preload_with<E: std::fmt::Display + Send + 'static>(
1879    what: &'static str,
1880    fallback: &'static str,
1881    wait: std::time::Duration,
1882    load: impl FnOnce() -> Result<(), E> + Send + 'static,
1883) {
1884    let (done, loaded) = std::sync::mpsc::channel();
1885    let spawned = std::thread::Builder::new()
1886        .name(format!("autumn-{}-preload", what.replace(' ', "-")))
1887        .spawn(move || {
1888            // The receiver can be gone after a timeout.
1889            let _ = done.send(load());
1890        });
1891    if let Err(error) = spawned {
1892        tracing::warn!(%error, "{what} preload thread did not start");
1893        return;
1894    }
1895    match loaded.recv_timeout(wait) {
1896        Ok(Ok(())) => {}
1897        Ok(Err(error)) => {
1898            tracing::warn!(%error, "{what} preload failed; {fallback}");
1899        }
1900        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
1901            tracing::warn!(
1902                ?wait,
1903                "{what} preload is slow; the app continues: {fallback}"
1904            );
1905        }
1906        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
1907            tracing::warn!("{what} preload panicked");
1908        }
1909    }
1910}
1911
1912// ── AppState extractor ───────────────────────────────────────────────────────
1913
1914/// Request extractor that resolves the current user's flag service handle.
1915///
1916/// Extracts [`FeatureFlagService`] from the `AppState` extension slot. If no
1917/// service is registered the extraction fails with `500 Internal Server Error`.
1918///
1919/// ```rust,ignore
1920/// use autumn_web::prelude::*;
1921/// use autumn_web::feature_flags::Flags;
1922///
1923/// #[get("/dashboard")]
1924/// async fn dashboard(flags: Flags) -> Markup {
1925///     html! {
1926///         @if flags.enabled("beta_inbox") {
1927///             (render_beta_inbox())
1928///         }
1929///     }
1930/// }
1931/// ```
1932pub struct Flags {
1933    service: FeatureFlagService,
1934    actor_id: Option<String>,
1935}
1936
1937impl Flags {
1938    /// Return `true` if `flag_key` is enabled for the current actor.
1939    #[must_use]
1940    pub fn enabled(&self, flag_key: &str) -> bool {
1941        self.service.is_enabled(flag_key, self.actor_id.as_deref())
1942    }
1943
1944    /// Return the underlying service for direct mutation from handlers.
1945    #[must_use]
1946    pub const fn service(&self) -> &FeatureFlagService {
1947        &self.service
1948    }
1949}
1950
1951impl axum::extract::FromRequestParts<crate::AppState> for Flags {
1952    type Rejection = crate::AutumnError;
1953
1954    async fn from_request_parts(
1955        parts: &mut axum::http::request::Parts,
1956        state: &crate::AppState,
1957    ) -> Result<Self, Self::Rejection> {
1958        let service = state
1959            .extension::<FeatureFlagService>()
1960            .map(|arc| (*arc).clone())
1961            .ok_or_else(|| {
1962                crate::AutumnError::internal_server_error_msg(
1963                    "feature flag service not registered; \
1964                     install a FlagStore via AppBuilder::with_flag_store()",
1965                )
1966            })?;
1967
1968        // Resolve actor_id from session if available (best-effort, non-blocking).
1969        let actor_id = if let Some(session) = parts.extensions.get::<crate::session::Session>() {
1970            session.get(state.auth_session_key()).await
1971        } else {
1972            None
1973        };
1974
1975        Ok(Self { service, actor_id })
1976    }
1977}
1978
1979// ── Tests ────────────────────────────────────────────────────────────────────
1980
1981#[cfg(test)]
1982mod tests {
1983    use super::*;
1984
1985    // ─────────────── RED PHASE: tests written before full implementation ──────
1986
1987    fn make_svc() -> FeatureFlagService {
1988        FeatureFlagService::new(Arc::new(InMemoryFlagStore::new()))
1989    }
1990
1991    // AC-1: service resolves flag to bool ─────────────────────────────────────
1992
1993    #[test]
1994    fn unknown_flag_returns_false() {
1995        let svc = make_svc();
1996        assert!(!svc.is_enabled("nonexistent", Some("user:1")));
1997    }
1998
1999    #[test]
2000    fn globally_enabled_flag_returns_true_for_any_actor() {
2001        let svc = make_svc();
2002        svc.enable("my_flag", None).unwrap();
2003        assert!(svc.is_enabled("my_flag", Some("user:1")));
2004        assert!(svc.is_enabled("my_flag", Some("user:99")));
2005        assert!(svc.is_enabled("my_flag", None));
2006    }
2007
2008    #[test]
2009    fn globally_disabled_flag_returns_false_for_any_actor() {
2010        let svc = make_svc();
2011        svc.enable("my_flag", None).unwrap();
2012        svc.disable("my_flag", None).unwrap();
2013        assert!(!svc.is_enabled("my_flag", Some("user:1")));
2014        assert!(!svc.is_enabled("my_flag", None));
2015    }
2016
2017    // AC-2: evaluation modes ──────────────────────────────────────────────────
2018
2019    #[test]
2020    fn actor_allowlist_enables_specific_actor() {
2021        let svc = make_svc();
2022        svc.allow_actor("beta_feature", "user:42", None).unwrap();
2023        assert!(svc.is_enabled("beta_feature", Some("user:42")));
2024        assert!(!svc.is_enabled("beta_feature", Some("user:1")));
2025    }
2026
2027    #[test]
2028    fn group_resolver_enables_group_members() {
2029        let svc = FeatureFlagService::new(Arc::new(InMemoryFlagStore::new())).with_group_resolver(
2030            Arc::new(|actor_id: &str, group: &str| {
2031                // "staff" group contains actor IDs starting with "staff:"
2032                group == "staff" && actor_id.starts_with("staff:")
2033            }),
2034        );
2035        svc.add_group("internal_feature", "staff", None).unwrap();
2036        assert!(svc.is_enabled("internal_feature", Some("staff:alice")));
2037        assert!(!svc.is_enabled("internal_feature", Some("user:bob")));
2038    }
2039
2040    #[test]
2041    fn percent_rollout_at_0_disables_for_all_actors() {
2042        let svc = make_svc();
2043        svc.set_rollout("gradual", 0, None).unwrap();
2044        // With 0% rollout and no other gates, every actor should be disabled.
2045        for i in 0..50_u32 {
2046            let actor = format!("user:{i}");
2047            assert!(
2048                !svc.is_enabled("gradual", Some(&actor)),
2049                "expected disabled for {actor} at 0% rollout"
2050            );
2051        }
2052    }
2053
2054    #[test]
2055    fn percent_rollout_at_100_enables_for_all_actors() {
2056        let svc = make_svc();
2057        svc.set_rollout("gradual", 100, None).unwrap();
2058        for i in 0..50_u32 {
2059            let actor = format!("user:{i}");
2060            assert!(
2061                svc.is_enabled("gradual", Some(&actor)),
2062                "expected enabled for {actor} at 100% rollout"
2063            );
2064        }
2065    }
2066
2067    #[test]
2068    fn percent_rollout_at_50_enables_roughly_half() {
2069        let svc = make_svc();
2070        svc.set_rollout("rollout_flag", 50, None).unwrap();
2071        let enabled_count = (0..200_u32)
2072            .filter(|i| svc.is_enabled("rollout_flag", Some(&format!("user:{i}"))))
2073            .count();
2074        // With 200 actors and 50% rollout, expect 80–120 enabled (±20%).
2075        assert!(
2076            (80..=120).contains(&enabled_count),
2077            "expected ~100 enabled actors, got {enabled_count}"
2078        );
2079    }
2080
2081    // AC-3: determinism ───────────────────────────────────────────────────────
2082
2083    #[test]
2084    fn rollout_bucket_is_stable_across_calls() {
2085        let b1 = rollout_bucket("my_flag", "user:1");
2086        let b2 = rollout_bucket("my_flag", "user:1");
2087        assert_eq!(b1, b2, "bucket must be deterministic");
2088    }
2089
2090    #[test]
2091    fn rollout_bucket_differs_for_different_actors() {
2092        // Ensure we don't always get the same bucket (birthday collision at
2093        // 100 buckets is essentially impossible with our FNV-1a implementation).
2094        let buckets: std::collections::HashSet<u8> = (0..50_u32)
2095            .map(|i| rollout_bucket("flag", &format!("user:{i}")))
2096            .collect();
2097        assert!(
2098            buckets.len() > 10,
2099            "expected diverse buckets, got {}: {buckets:?}",
2100            buckets.len()
2101        );
2102    }
2103
2104    #[test]
2105    fn rollout_bucket_in_range_0_to_99() {
2106        for i in 0..1000_u32 {
2107            let b = rollout_bucket("flag", &format!("actor:{i}"));
2108            assert!(b < 100, "bucket out of range: {b}");
2109        }
2110    }
2111
2112    /// `fnv1a_64` and `rollout_bucket` as they were before the hash took
2113    /// parts, verbatim: `format!` joins the key, then one slice is hashed.
2114    fn joined_fnv1a_64(data: &[u8]) -> u64 {
2115        const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
2116        const FNV_PRIME: u64 = 1_099_511_628_211;
2117        let mut hash = FNV_OFFSET;
2118        for &byte in data {
2119            hash ^= u64::from(byte);
2120            hash = hash.wrapping_mul(FNV_PRIME);
2121        }
2122        hash
2123    }
2124
2125    fn joined_rollout_bucket(flag_key: &str, actor_id: &str) -> u8 {
2126        let key = format!("{flag_key}:{actor_id}");
2127        let hash = joined_fnv1a_64(key.as_bytes());
2128        u8::try_from(hash % 100).unwrap_or(0)
2129    }
2130
2131    #[test]
2132    fn rollout_bucket_is_fnv1a_of_the_joined_key() {
2133        // Hashing the parts must give the joined key's bucket bit for bit,
2134        // or every partial rollout reshuffles its cohort.
2135        let edge_cases = [
2136            ("my_flag", "user:1"),
2137            ("", ""),
2138            ("f", ""),
2139            ("", "a"),
2140            (":", ":"),
2141            ("a:b", "c:d"),
2142            ("ab", "c:d"),
2143            ("a:b:", ":c"),
2144            ("ünï", "çødé"),
2145            ("flag🚩", "用户:42"),
2146            ("tab\tflag", "line\nactor"),
2147        ];
2148        for (flag, actor) in edge_cases {
2149            assert_eq!(
2150                rollout_bucket(flag, actor),
2151                joined_rollout_bucket(flag, actor),
2152                "{flag:?}/{actor:?}"
2153            );
2154        }
2155        for flag in ["flag", "beta", "dark_mode", "checkout:v2"] {
2156            for i in 0..1_000_u32 {
2157                let actor = format!("actor:{i}");
2158                assert_eq!(
2159                    rollout_bucket(flag, &actor),
2160                    joined_rollout_bucket(flag, &actor),
2161                    "{flag:?}/{actor:?}"
2162                );
2163            }
2164        }
2165    }
2166
2167    #[test]
2168    fn fnv1a_64_of_parts_equals_fnv1a_64_of_their_concatenation() {
2169        // Every way of cutting a key into three (possibly empty) parts hashes
2170        // like the uncut key.
2171        for key in ["", ":", "my_flag:user:1", "ünï:çødé", "a::b"] {
2172            let whole = joined_fnv1a_64(key.as_bytes());
2173            let bytes = key.as_bytes();
2174            for i in 0..=bytes.len() {
2175                for j in i..=bytes.len() {
2176                    let (head, rest) = bytes.split_at(i);
2177                    let (middle, tail) = rest.split_at(j - i);
2178                    assert_eq!(fnv1a_64(&[head, middle, tail]), whole, "{key:?} {i} {j}");
2179                }
2180            }
2181            assert_eq!(fnv1a_64(&[bytes]), whole, "{key:?} in one part");
2182        }
2183        assert_eq!(fnv1a_64(&[]), joined_fnv1a_64(&[]), "no parts at all");
2184    }
2185
2186    #[test]
2187    fn rollout_bucket_known_fixtures() {
2188        // FNV-1a 64-bit of "<flag>:<actor>" mod 100. A permanent contract:
2189        // a change moves actors in and out of every partial rollout.
2190        assert_eq!(rollout_bucket("my_flag", "user:1"), 62);
2191        assert_eq!(rollout_bucket("beta", "user:42"), 28);
2192        assert_eq!(rollout_bucket("dark_mode", "user:7"), 77);
2193        assert_eq!(rollout_bucket("", ""), 89);
2194        assert_eq!(rollout_bucket("a:b", "c:d"), 53);
2195        assert_eq!(rollout_bucket("ünï", "çødé"), 63);
2196    }
2197
2198    #[test]
2199    fn percent_rollout_same_actor_same_flag_always_same_result() {
2200        let svc = make_svc();
2201        svc.set_rollout("stable_flag", 42, None).unwrap();
2202        let first = svc.is_enabled("stable_flag", Some("user:123"));
2203        for _ in 0..10 {
2204            assert_eq!(
2205                svc.is_enabled("stable_flag", Some("user:123")),
2206                first,
2207                "rollout result must not flip between calls"
2208            );
2209        }
2210    }
2211
2212    // AC-7: FlagStore trait + InMemoryFlagStore ────────────────────────────────
2213
2214    #[test]
2215    fn in_memory_store_returns_none_for_unknown_flag() {
2216        let store = InMemoryFlagStore::new();
2217        assert!(store.get("unknown").unwrap().is_none());
2218    }
2219
2220    #[test]
2221    fn in_memory_store_list_is_sorted() {
2222        let store = InMemoryFlagStore::new();
2223        store.enable("zebra", None).unwrap();
2224        store.enable("alpha", None).unwrap();
2225        store.enable("mango", None).unwrap();
2226        let keys: Vec<String> = store.list().unwrap().into_iter().map(|f| f.key).collect();
2227        assert_eq!(keys, vec!["alpha", "mango", "zebra"]);
2228    }
2229
2230    #[test]
2231    fn in_memory_store_enable_creates_flag_if_absent() {
2232        let store = InMemoryFlagStore::new();
2233        store.enable("new_flag", None).unwrap();
2234        let flag = store.get("new_flag").unwrap().unwrap();
2235        assert!(flag.enabled);
2236    }
2237
2238    #[test]
2239    fn in_memory_store_disable_sets_enabled_false() {
2240        let store = InMemoryFlagStore::new();
2241        store.enable("f", None).unwrap();
2242        store.disable("f", None).unwrap();
2243        assert!(!store.get("f").unwrap().unwrap().enabled);
2244    }
2245
2246    #[test]
2247    fn in_memory_store_allow_actor_does_not_duplicate() {
2248        let store = InMemoryFlagStore::new();
2249        store.allow_actor("f", "user:1", None).unwrap();
2250        store.allow_actor("f", "user:1", None).unwrap();
2251        let flag = store.get("f").unwrap().unwrap();
2252        assert_eq!(flag.actor_allowlist.len(), 1);
2253    }
2254
2255    #[test]
2256    fn in_memory_store_add_group_does_not_duplicate() {
2257        let store = InMemoryFlagStore::new();
2258        store.add_group("f", "staff", None).unwrap();
2259        store.add_group("f", "staff", None).unwrap();
2260        let flag = store.get("f").unwrap().unwrap();
2261        assert_eq!(flag.group_allowlist.len(), 1);
2262    }
2263
2264    // AC-10: audit trail ──────────────────────────────────────────────────────
2265
2266    #[test]
2267    fn mutations_are_recorded_in_history() {
2268        let svc = make_svc();
2269        svc.enable("tracked_flag", Some("ops@example.com")).unwrap();
2270        svc.disable("tracked_flag", Some("ops@example.com"))
2271            .unwrap();
2272        let history = svc.history("tracked_flag", 10).unwrap();
2273        assert_eq!(history.len(), 2, "two mutations should be recorded");
2274        assert_eq!(history[0].mutation, "disabled");
2275        assert_eq!(history[0].actor.as_deref(), Some("ops@example.com"));
2276        assert_eq!(history[1].mutation, "enabled");
2277    }
2278
2279    #[test]
2280    fn history_respects_limit() {
2281        let svc = make_svc();
2282        for _ in 0..5 {
2283            svc.enable("limited_flag", None).unwrap();
2284        }
2285        let history = svc.history("limited_flag", 3).unwrap();
2286        assert_eq!(history.len(), 3);
2287    }
2288
2289    #[test]
2290    fn history_empty_for_unknown_flag() {
2291        let svc = make_svc();
2292        let history = svc.history("ghost_flag", 10).unwrap();
2293        assert!(history.is_empty());
2294    }
2295
2296    #[test]
2297    fn rollout_mutation_recorded_with_pct() {
2298        let svc = make_svc();
2299        svc.set_rollout("roll", 25, Some("cli")).unwrap();
2300        let history = svc.history("roll", 1).unwrap();
2301        assert_eq!(history[0].mutation, "rollout=25");
2302        assert_eq!(history[0].actor.as_deref(), Some("cli"));
2303    }
2304
2305    #[test]
2306    fn allow_actor_mutation_recorded() {
2307        let svc = make_svc();
2308        svc.allow_actor("f", "user:7", Some("cli")).unwrap();
2309        let h = svc.history("f", 1).unwrap();
2310        assert_eq!(h[0].mutation, "allowed_actor=user:7");
2311    }
2312
2313    // ── FlagConfig defaults ───────────────────────────────────────────────────
2314
2315    #[test]
2316    fn flag_config_new_defaults_to_disabled() {
2317        let f = FlagConfig::new("my_flag");
2318        assert_eq!(f.key, "my_flag");
2319        assert!(!f.enabled);
2320        assert_eq!(f.rollout_pct, 0);
2321        assert!(f.actor_allowlist.is_empty());
2322        assert!(f.group_allowlist.is_empty());
2323    }
2324
2325    // ── Rollout clamping ──────────────────────────────────────────────────────
2326
2327    #[test]
2328    fn set_rollout_clamps_to_100() {
2329        let store = InMemoryFlagStore::new();
2330        store.set_rollout("f", 200, None).unwrap();
2331        assert_eq!(store.get("f").unwrap().unwrap().rollout_pct, 100);
2332    }
2333
2334    // AC-1 kill-switch: disable() must override rollout and allowlists ─────────
2335
2336    #[test]
2337    fn disable_kills_flag_even_when_rollout_is_100_percent() {
2338        let svc = make_svc();
2339        svc.set_rollout("roll_flag", 100, None).unwrap();
2340        svc.disable("roll_flag", None).unwrap();
2341        for i in 0..20_u32 {
2342            assert!(
2343                !svc.is_enabled("roll_flag", Some(&format!("user:{i}"))),
2344                "disable() must override rollout for actor user:{i}"
2345            );
2346        }
2347        assert!(!svc.is_enabled("roll_flag", None));
2348    }
2349
2350    #[test]
2351    fn disable_kills_flag_even_when_actor_is_in_allowlist() {
2352        let svc = make_svc();
2353        svc.allow_actor("guarded", "user:42", None).unwrap();
2354        svc.disable("guarded", None).unwrap();
2355        assert!(
2356            !svc.is_enabled("guarded", Some("user:42")),
2357            "disable() must override actor allowlist"
2358        );
2359    }
2360
2361    #[test]
2362    fn enable_after_disable_restores_rollout_config() {
2363        let svc = make_svc();
2364        svc.set_rollout("roll_flag", 50, None).unwrap();
2365        svc.disable("roll_flag", None).unwrap();
2366        // Re-enable globally — disable() preserves rollout_pct=50 in the store,
2367        // but enable() resets it to 100 (globally on).
2368        svc.enable("roll_flag", None).unwrap();
2369        assert!(svc.is_enabled("roll_flag", None));
2370        assert!(svc.is_enabled("roll_flag", Some("user:1")));
2371    }
2372
2373    // AC-1 allow_actor after kill-switch must not restore global rollout ────────
2374
2375    #[test]
2376    fn allow_actor_after_kill_switch_does_not_restore_global_rollout() {
2377        // Scenario: enable globally → disable (kill-switch) → allow_actor for
2378        // one tester. The flag must be visible only to the allowlisted actor,
2379        // NOT to everyone (which would happen if rollout_pct=100 were preserved).
2380        let svc = make_svc();
2381        svc.enable("targeted", None).unwrap(); // rollout_pct = 100
2382        svc.disable("targeted", None).unwrap(); // kill-switch, rollout_pct still 100
2383        svc.allow_actor("targeted", "user:42", None).unwrap(); // re-enable allowlist-only
2384
2385        assert!(
2386            svc.is_enabled("targeted", Some("user:42")),
2387            "allowlisted actor must see the flag"
2388        );
2389        // All non-allowlisted actors should NOT see it (rollout was reset to 0).
2390        for i in [1_u32, 5, 10, 99] {
2391            let actor = format!("user:{i}");
2392            assert!(
2393                !svc.is_enabled("targeted", Some(&actor)),
2394                "non-allowlisted actor {actor} must NOT see the flag after allowlist-only re-enable"
2395            );
2396        }
2397    }
2398
2399    #[test]
2400    fn allow_actor_on_active_rollout_preserves_rollout_pct() {
2401        // When the flag is already enabled (no kill-switch), adding an actor to the
2402        // allowlist must NOT reset the existing rollout percentage.
2403        let svc = make_svc();
2404        svc.set_rollout("staged", 50, None).unwrap(); // enabled=true, rollout=50%
2405        svc.allow_actor("staged", "user:42", None).unwrap();
2406
2407        // rollout_pct should still be 50, not reset to 0.
2408        let store = InMemoryFlagStore::new();
2409        store.set_rollout("staged", 50, None).unwrap();
2410        store.allow_actor("staged", "user:42", None).unwrap();
2411        let flag = store.get("staged").unwrap().unwrap();
2412        assert_eq!(
2413            flag.rollout_pct, 50,
2414            "rollout_pct must be preserved when flag was already enabled"
2415        );
2416        assert!(flag.actor_allowlist.contains(&"user:42".to_owned()));
2417    }
2418
2419    // ── Arc<T: FlagStore> delegation ──────────────────────────────────────────
2420
2421    #[test]
2422    fn arc_flag_store_delegates_get() {
2423        let store = Arc::new(InMemoryFlagStore::new());
2424        store.enable("arc_flag", None).unwrap();
2425        let arc_store: Arc<dyn FlagStore> = store;
2426        let flag = arc_store.get("arc_flag").unwrap().unwrap();
2427        assert!(flag.enabled);
2428    }
2429
2430    #[test]
2431    fn arc_flag_store_delegates_list() {
2432        let store = Arc::new(InMemoryFlagStore::new());
2433        store.enable("f1", None).unwrap();
2434        store.enable("f2", None).unwrap();
2435        let arc_store: Arc<dyn FlagStore> = store;
2436        let flags = arc_store.list().unwrap();
2437        assert_eq!(flags.len(), 2);
2438    }
2439
2440    #[test]
2441    fn arc_flag_store_delegates_enable_and_disable() {
2442        let store = Arc::new(InMemoryFlagStore::new());
2443        let arc_store: Arc<dyn FlagStore> = store;
2444        arc_store.enable("f", None).unwrap();
2445        assert!(arc_store.get("f").unwrap().unwrap().enabled);
2446        arc_store.disable("f", None).unwrap();
2447        assert!(!arc_store.get("f").unwrap().unwrap().enabled);
2448    }
2449
2450    #[test]
2451    fn arc_flag_store_delegates_set_rollout() {
2452        let store = Arc::new(InMemoryFlagStore::new());
2453        let arc_store: Arc<dyn FlagStore> = store;
2454        arc_store.set_rollout("f", 42, None).unwrap();
2455        let flag = arc_store.get("f").unwrap().unwrap();
2456        assert_eq!(flag.rollout_pct, 42);
2457    }
2458
2459    #[test]
2460    fn arc_flag_store_delegates_allow_actor() {
2461        let store = Arc::new(InMemoryFlagStore::new());
2462        let arc_store: Arc<dyn FlagStore> = store;
2463        arc_store.allow_actor("f", "user:1", None).unwrap();
2464        let flag = arc_store.get("f").unwrap().unwrap();
2465        assert!(flag.actor_allowlist.contains(&"user:1".to_owned()));
2466    }
2467
2468    #[test]
2469    fn arc_flag_store_delegates_add_group() {
2470        let store = Arc::new(InMemoryFlagStore::new());
2471        let arc_store: Arc<dyn FlagStore> = store;
2472        arc_store.add_group("f", "beta_testers", None).unwrap();
2473        let flag = arc_store.get("f").unwrap().unwrap();
2474        assert!(flag.group_allowlist.contains(&"beta_testers".to_owned()));
2475    }
2476
2477    #[test]
2478    fn arc_flag_store_delegates_history() {
2479        let store = Arc::new(InMemoryFlagStore::new());
2480        let arc_store: Arc<dyn FlagStore> = store;
2481        arc_store.enable("f", Some("cli")).unwrap();
2482        let history = arc_store.history("f", 10).unwrap();
2483        assert_eq!(history.len(), 1);
2484        assert_eq!(history[0].mutation, "enabled");
2485    }
2486
2487    // ── Box<dyn FlagStore> delegation ─────────────────────────────────────────
2488
2489    #[test]
2490    fn box_flag_store_delegates_all_operations() {
2491        let store = InMemoryFlagStore::new();
2492        let boxed: Box<dyn FlagStore> = Box::new(store);
2493        boxed.enable("f", None).unwrap();
2494        assert!(boxed.get("f").unwrap().unwrap().enabled);
2495        boxed.set_rollout("g", 25, Some("cli")).unwrap();
2496        assert_eq!(boxed.get("g").unwrap().unwrap().rollout_pct, 25);
2497        boxed.allow_actor("h", "user:1", None).unwrap();
2498        boxed.add_group("h", "staff", None).unwrap();
2499        let flags = boxed.list().unwrap();
2500        // f, g, h are present
2501        assert_eq!(flags.len(), 3);
2502        let hist = boxed.history("f", 5).unwrap();
2503        assert_eq!(hist[0].mutation, "enabled");
2504        boxed.disable("f", None).unwrap();
2505        assert!(!boxed.get("f").unwrap().unwrap().enabled);
2506    }
2507
2508    // ── FlagStoreError display ────────────────────────────────────────────────
2509
2510    #[test]
2511    fn flag_store_error_displays_message() {
2512        let err = FlagStoreError::Backend("connection refused".to_owned());
2513        assert_eq!(
2514            err.to_string(),
2515            "flag store backend error: connection refused"
2516        );
2517    }
2518
2519    // ── FlagConfig clone and equality ─────────────────────────────────────────
2520
2521    #[test]
2522    fn flag_config_clone_is_equal_to_original() {
2523        let mut f = FlagConfig::new("cloned");
2524        f.enabled = true;
2525        f.rollout_pct = 50;
2526        f.actor_allowlist = vec!["user:1".to_owned()];
2527        let g = f.clone();
2528        assert_eq!(f, g);
2529    }
2530
2531    // ── evaluate() edge cases ─────────────────────────────────────────────────
2532
2533    #[test]
2534    fn rollout_with_no_actor_returns_false() {
2535        // When actor_id is None and there are no allowlists, a percent rollout
2536        // must not enable the flag (there's no actor to compute a bucket for).
2537        let svc = make_svc();
2538        svc.set_rollout("gradual", 99, None).unwrap();
2539        assert!(
2540            !svc.is_enabled("gradual", None),
2541            "percent rollout must not fire for anonymous (None) actor"
2542        );
2543    }
2544
2545    #[test]
2546    fn group_resolver_with_no_actor_does_not_panic() {
2547        let svc = FeatureFlagService::new(Arc::new(InMemoryFlagStore::new()))
2548            .with_group_resolver(Arc::new(|_: &str, _: &str| true));
2549        svc.add_group("f", "everyone", None).unwrap();
2550        // No actor — group check must be skipped, not panic.
2551        assert!(!svc.is_enabled("f", None));
2552    }
2553
2554    #[test]
2555    fn add_group_mutation_format() {
2556        let store = InMemoryFlagStore::new();
2557        store.add_group("f", "beta_testers", Some("cli")).unwrap();
2558        let hist = store.history("f", 1).unwrap();
2559        assert_eq!(hist[0].mutation, "added_group=beta_testers");
2560        assert_eq!(hist[0].actor.as_deref(), Some("cli"));
2561    }
2562
2563    #[test]
2564    fn service_list_returns_all_flags() {
2565        let svc = make_svc();
2566        svc.enable("a", None).unwrap();
2567        svc.disable("b", None).unwrap();
2568        svc.set_rollout("c", 10, None).unwrap();
2569        let flags = svc.list().unwrap();
2570        assert_eq!(flags.len(), 3);
2571        assert_eq!(flags[0].key, "a");
2572        assert_eq!(flags[1].key, "b");
2573        assert_eq!(flags[2].key, "c");
2574    }
2575
2576    #[test]
2577    fn service_debug_does_not_panic() {
2578        let svc = make_svc();
2579        let _ = format!("{svc:?}");
2580    }
2581
2582    #[test]
2583    fn flags_enabled_delegates_to_service() {
2584        // Test the Flags::enabled() method via FeatureFlagService directly.
2585        let svc = make_svc();
2586        svc.enable("active", None).unwrap();
2587        assert!(svc.is_enabled("active", Some("any_user")));
2588        assert!(!svc.is_enabled("missing", Some("any_user")));
2589    }
2590
2591    #[tokio::test]
2592    async fn from_request_parts_respects_custom_auth_session_key() {
2593        use axum::extract::FromRequestParts;
2594        use std::collections::HashMap;
2595
2596        let svc = make_svc();
2597        let state = crate::AppState::for_test().with_auth_session_key("custom_user_id");
2598        state.insert_extension(svc);
2599
2600        let mut data = HashMap::new();
2601        data.insert("custom_user_id".to_owned(), "user:123".to_owned());
2602        data.insert("user_id".to_owned(), "user:999".to_owned()); // distracter
2603        let session = crate::session::Session::new_for_test("session_id".to_owned(), data);
2604
2605        let mut req = axum::http::Request::builder().body(()).unwrap();
2606        req.extensions_mut().insert(session);
2607        let mut parts = req.into_parts().0;
2608
2609        let flags = Flags::from_request_parts(&mut parts, &state).await.unwrap();
2610        assert_eq!(flags.actor_id.as_deref(), Some("user:123"));
2611    }
2612
2613    // ── Store failure: last-known value and declared default (#3063) ──────
2614
2615    /// Wraps an in-memory store. The test can make `get` fail or hide a flag.
2616    #[derive(Default)]
2617    struct ScriptedStore {
2618        inner: InMemoryFlagStore,
2619        failing: std::sync::atomic::AtomicBool,
2620        hidden: std::sync::atomic::AtomicBool,
2621        preloads: std::sync::atomic::AtomicUsize,
2622        /// `preload` sleeps this long (ms).
2623        preload_delay_ms: std::sync::atomic::AtomicU64,
2624    }
2625
2626    impl ScriptedStore {
2627        fn fail(&self, on: bool) {
2628            self.failing.store(on, std::sync::atomic::Ordering::SeqCst);
2629        }
2630
2631        fn hide(&self, on: bool) {
2632            self.hidden.store(on, std::sync::atomic::Ordering::SeqCst);
2633        }
2634    }
2635
2636    impl FlagStore for ScriptedStore {
2637        fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
2638            if self.failing.load(std::sync::atomic::Ordering::SeqCst) {
2639                return Err(FlagStoreError::Backend("brownout".to_owned()));
2640            }
2641            if self.hidden.load(std::sync::atomic::Ordering::SeqCst) {
2642                return Ok(None);
2643            }
2644            self.inner.get(key)
2645        }
2646        fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
2647            self.inner.list()
2648        }
2649        fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
2650            self.inner.enable(key, actor)
2651        }
2652        fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
2653            self.inner.disable(key, actor)
2654        }
2655        fn set_rollout(
2656            &self,
2657            key: &str,
2658            pct: u8,
2659            actor: Option<&str>,
2660        ) -> Result<(), FlagStoreError> {
2661            self.inner.set_rollout(key, pct, actor)
2662        }
2663        fn allow_actor(
2664            &self,
2665            key: &str,
2666            actor_id: &str,
2667            actor: Option<&str>,
2668        ) -> Result<(), FlagStoreError> {
2669            self.inner.allow_actor(key, actor_id, actor)
2670        }
2671        fn add_group(
2672            &self,
2673            key: &str,
2674            group: &str,
2675            actor: Option<&str>,
2676        ) -> Result<(), FlagStoreError> {
2677            self.inner.add_group(key, group, actor)
2678        }
2679        fn history(
2680            &self,
2681            key: &str,
2682            limit: usize,
2683        ) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
2684            self.inner.history(key, limit)
2685        }
2686        fn preload(&self) -> Result<(), FlagStoreError> {
2687            let delay = self
2688                .preload_delay_ms
2689                .load(std::sync::atomic::Ordering::SeqCst);
2690            std::thread::sleep(std::time::Duration::from_millis(delay));
2691            self.preloads
2692                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2693            Ok(())
2694        }
2695    }
2696
2697    #[test]
2698    fn failing_store_serves_last_known_value() {
2699        let store = Arc::new(ScriptedStore::default());
2700        let svc = FeatureFlagService::new(store.clone());
2701        store.enable("beta", None).unwrap();
2702        assert!(svc.is_enabled("beta", Some("user:1")));
2703
2704        store.fail(true);
2705        assert!(
2706            svc.is_enabled("beta", Some("user:1")),
2707            "a store error must not turn a known flag off"
2708        );
2709        assert_eq!(svc.store_errors(), 1);
2710    }
2711
2712    #[test]
2713    fn disable_through_the_service_replaces_the_last_known_value() {
2714        let store = Arc::new(ScriptedStore::default());
2715        let svc = FeatureFlagService::new(store.clone()).with_default("kill", true);
2716        svc.enable("kill", None).unwrap();
2717        assert!(svc.is_enabled("kill", None));
2718
2719        // The read after the write fails too.
2720        store.fail(true);
2721        svc.disable("kill", None).unwrap();
2722        assert!(
2723            !svc.is_enabled("kill", None),
2724            "a successful disable must win over the old last-known value"
2725        );
2726
2727        // The read after the write succeeds.
2728        store.fail(false);
2729        svc.enable("kill", None).unwrap();
2730        svc.disable("kill", None).unwrap();
2731        store.fail(true);
2732        assert!(!svc.is_enabled("kill", None));
2733    }
2734
2735    #[test]
2736    fn read_started_before_a_write_does_not_replace_its_value() {
2737        let store = Arc::new(ScriptedStore::default());
2738        let svc = FeatureFlagService::new(store.clone());
2739        svc.enable("kill", None).unwrap();
2740
2741        // A read starts and sees the flag on. Then a disable succeeds.
2742        let started = svc.health.read_started();
2743        let stale = store.get("kill").unwrap();
2744        svc.disable("kill", None).unwrap();
2745        // The old read ends last.
2746        svc.health.remember("kill", stale.as_ref(), started);
2747
2748        store.fail(true);
2749        assert!(
2750            !svc.is_enabled("kill", None),
2751            "a read that started before a write must not replace its value"
2752        );
2753    }
2754
2755    #[test]
2756    fn a_write_to_one_flag_keeps_reads_of_other_flags() {
2757        let store = Arc::new(ScriptedStore::default());
2758        let svc = FeatureFlagService::new(store.clone());
2759        store.enable("b", None).unwrap();
2760
2761        let started = svc.health.read_started();
2762        let read = store.get("b").unwrap();
2763        svc.enable("a", None).unwrap(); // A write to another flag.
2764        svc.health.remember("b", read.as_ref(), started);
2765
2766        store.fail(true);
2767        assert!(svc.is_enabled("b", None), "the read of `b` is still good");
2768    }
2769
2770    #[test]
2771    fn an_older_failed_read_back_does_not_undo_a_newer_write() {
2772        let svc = make_svc();
2773        let mut on = FlagConfig::new("k");
2774        on.enabled = true;
2775        on.rollout_pct = 100;
2776
2777        let disable = svc.health.begin_write("k");
2778        let enable = svc.health.begin_write("k");
2779        svc.health.remember("k", Some(&on), enable);
2780        // The older disable's read-back failed; it ends last.
2781        svc.health.forget("k", true, disable);
2782
2783        let error = FlagStoreError::Backend("down".to_owned());
2784        assert_eq!(svc.health.recall("k", &error), Some(on));
2785    }
2786
2787    /// `enable` pauses after its write until the test lets it go on.
2788    struct PausingStore {
2789        inner: ScriptedStore,
2790        entered: std::sync::Mutex<std::sync::mpsc::Sender<()>>,
2791        release: std::sync::Mutex<std::sync::mpsc::Receiver<()>>,
2792    }
2793
2794    impl FlagStore for PausingStore {
2795        fn get(&self, key: &str) -> Result<Option<FlagConfig>, FlagStoreError> {
2796            self.inner.get(key)
2797        }
2798        fn list(&self) -> Result<Vec<FlagConfig>, FlagStoreError> {
2799            self.inner.list()
2800        }
2801        fn enable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
2802            self.inner.enable(key, actor)?;
2803            self.entered.lock().unwrap().send(()).unwrap();
2804            self.release.lock().unwrap().recv().unwrap();
2805            Ok(())
2806        }
2807        fn disable(&self, key: &str, actor: Option<&str>) -> Result<(), FlagStoreError> {
2808            self.inner.disable(key, actor)
2809        }
2810        fn set_rollout(
2811            &self,
2812            key: &str,
2813            pct: u8,
2814            actor: Option<&str>,
2815        ) -> Result<(), FlagStoreError> {
2816            self.inner.set_rollout(key, pct, actor)
2817        }
2818        fn allow_actor(
2819            &self,
2820            key: &str,
2821            actor_id: &str,
2822            actor: Option<&str>,
2823        ) -> Result<(), FlagStoreError> {
2824            self.inner.allow_actor(key, actor_id, actor)
2825        }
2826        fn add_group(
2827            &self,
2828            key: &str,
2829            group: &str,
2830            actor: Option<&str>,
2831        ) -> Result<(), FlagStoreError> {
2832            self.inner.add_group(key, group, actor)
2833        }
2834        fn history(
2835            &self,
2836            key: &str,
2837            limit: usize,
2838        ) -> Result<Vec<FlagChangeRecord>, FlagStoreError> {
2839            self.inner.history(key, limit)
2840        }
2841    }
2842
2843    #[test]
2844    fn an_older_write_cannot_undo_a_later_disable() {
2845        let (entered_tx, entered) = std::sync::mpsc::channel();
2846        let (release, release_rx) = std::sync::mpsc::channel();
2847        let store = Arc::new(PausingStore {
2848            inner: ScriptedStore::default(),
2849            entered: std::sync::Mutex::new(entered_tx),
2850            release: std::sync::Mutex::new(release_rx),
2851        });
2852        let svc = FeatureFlagService::new(store.clone()).with_default("kill", true);
2853
2854        // An enable writes, then pauses before its read-back.
2855        let older = {
2856            let svc = svc.clone();
2857            std::thread::spawn(move || svc.enable("kill", None).unwrap())
2858        };
2859        entered.recv().unwrap();
2860
2861        // A later disable starts. Give it time to finish if nothing orders it.
2862        let later = {
2863            let svc = svc.clone();
2864            std::thread::spawn(move || svc.disable("kill", None).unwrap())
2865        };
2866        std::thread::sleep(std::time::Duration::from_millis(200));
2867
2868        // Reads fail from now on. The older enable ends; then the disable.
2869        store.inner.fail(true);
2870        release.send(()).unwrap();
2871        older.join().unwrap();
2872        later.join().unwrap();
2873
2874        assert!(
2875            !svc.is_enabled("kill", None),
2876            "the later disable must hold during the outage"
2877        );
2878    }
2879
2880    #[test]
2881    fn failing_store_keeps_a_kill_switch_off() {
2882        let store = Arc::new(ScriptedStore::default());
2883        let svc = FeatureFlagService::new(store.clone()).with_default("payments", true);
2884        store.enable("payments", None).unwrap();
2885        store.disable("payments", None).unwrap();
2886        assert!(!svc.is_enabled("payments", None));
2887
2888        store.fail(true);
2889        assert!(
2890            !svc.is_enabled("payments", None),
2891            "the last-known value wins over the declared default"
2892        );
2893    }
2894
2895    #[test]
2896    fn failing_store_without_last_known_value_serves_declared_default() {
2897        let store = Arc::new(ScriptedStore::default());
2898        let svc = FeatureFlagService::new(store.clone()).with_default("fail_open", true);
2899        store.fail(true);
2900        assert!(svc.is_enabled("fail_open", None));
2901        assert!(!svc.is_enabled("no_default", None));
2902        assert_eq!(svc.store_errors(), 2);
2903    }
2904
2905    #[test]
2906    fn unknown_flag_serves_declared_default() {
2907        let svc = make_svc().with_default("absent", true);
2908        assert!(svc.is_enabled("absent", None));
2909        assert!(!svc.is_enabled("other", None));
2910        assert_eq!(svc.store_errors(), 0);
2911    }
2912
2913    #[test]
2914    fn flag_removed_from_store_forgets_last_known_value() {
2915        let store = Arc::new(ScriptedStore::default());
2916        let svc = FeatureFlagService::new(store.clone());
2917        store.enable("gone", None).unwrap();
2918        assert!(svc.is_enabled("gone", None));
2919
2920        store.hide(true);
2921        assert!(!svc.is_enabled("gone", None));
2922        store.hide(false);
2923        store.fail(true);
2924        assert!(
2925            !svc.is_enabled("gone", None),
2926            "a flag the store reported absent has no last-known value"
2927        );
2928    }
2929
2930    #[test]
2931    fn service_clones_share_last_known_values() {
2932        // The `Flags` extractor clones the service for each request.
2933        let store = Arc::new(ScriptedStore::default());
2934        let svc = FeatureFlagService::new(store.clone());
2935        store.enable("shared", None).unwrap();
2936        assert!(svc.is_enabled("shared", None));
2937
2938        let per_request = svc.clone();
2939        store.fail(true);
2940        assert!(per_request.is_enabled("shared", None));
2941        assert_eq!(svc.store_errors(), 1, "clones share the error count");
2942    }
2943
2944    #[test]
2945    fn service_preload_delegates_to_the_store() {
2946        let store = Arc::new(ScriptedStore::default());
2947        let svc = FeatureFlagService::new(store.clone());
2948        svc.preload().unwrap();
2949        assert_eq!(store.preloads.load(std::sync::atomic::Ordering::SeqCst), 1);
2950        // The default `preload` does nothing and succeeds.
2951        make_svc().preload().unwrap();
2952    }
2953
2954    #[tokio::test]
2955    async fn test_app_preloads_the_flag_store_at_build() {
2956        let store = Arc::new(ScriptedStore::default());
2957        let _client = crate::test::TestApp::new()
2958            .with_flag_store(Arc::clone(&store))
2959            .build();
2960        assert_eq!(store.preloads.load(std::sync::atomic::Ordering::SeqCst), 1);
2961    }
2962
2963    #[test]
2964    fn preload_blocking_waits_for_the_load_up_to_the_limit() {
2965        let store = Arc::new(ScriptedStore::default());
2966        let svc = FeatureFlagService::new(store.clone());
2967        preload_blocking(&svc, std::time::Duration::from_secs(10));
2968        assert_eq!(store.preloads.load(std::sync::atomic::Ordering::SeqCst), 1);
2969
2970        // A slow load: the wait ends first, and the load goes on.
2971        store
2972            .preload_delay_ms
2973            .store(300, std::sync::atomic::Ordering::SeqCst);
2974        preload_blocking(&svc, std::time::Duration::from_millis(10));
2975        assert_eq!(
2976            store.preloads.load(std::sync::atomic::Ordering::SeqCst),
2977            1,
2978            "the wait ended before the slow load"
2979        );
2980    }
2981
2982    #[test]
2983    fn feature_flag_guide_describes_polling_not_listen() {
2984        let guide = include_str!("../../docs/guide/feature-flags.md");
2985        assert!(
2986            !guide.contains("All replicas listening on that channel"),
2987            "Autumn does not LISTEN; replicas poll"
2988        );
2989        assert!(guide.contains("spawn_poll_listener"));
2990        assert!(guide.contains("last-known"));
2991    }
2992
2993    // ── Backend screening on the Postgres-only store ──────────────────────
2994
2995    // `PgFlagStore` opens a `diesel::PgConnection` and writes through
2996    // `pg_notify` — it cannot serve any other backend. Building one from a
2997    // SQLite target used to succeed and fail on first use with a driver-level
2998    // connection error naming Postgres, which is not a diagnosis an operator
2999    // who configured `sqlite://` can act on.
3000    #[cfg(feature = "db")]
3001    #[test]
3002    fn pg_flag_store_refuses_a_non_postgres_target() {
3003        use crate::config::DatabaseConfig;
3004
3005        let sqlite = DatabaseConfig {
3006            primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
3007            ..Default::default()
3008        };
3009        assert!(
3010            pg::PgFlagStore::from_database_config(&sqlite).is_none(),
3011            "a SQLite target has no Postgres flag store"
3012        );
3013
3014        // Fails closed: a target no backend claims is refused too.
3015        let unclassifiable = DatabaseConfig {
3016            primary_url: Some("/var/lib/app.db".to_owned()),
3017            ..Default::default()
3018        };
3019        assert!(
3020            pg::PgFlagStore::from_database_config(&unclassifiable).is_none(),
3021            "an unclassifiable target has no Postgres flag store"
3022        );
3023
3024        // Both Postgres spellings still build.
3025        for url in [
3026            "postgres://localhost/app",
3027            "host=db user=app dbname=app sslmode=require",
3028        ] {
3029            let pg_config = DatabaseConfig {
3030                primary_url: Some(url.to_owned()),
3031                ..Default::default()
3032            };
3033            assert!(
3034                pg::PgFlagStore::from_database_config(&pg_config).is_some(),
3035                "{url} is a Postgres target"
3036            );
3037        }
3038    }
3039
3040    // `new(url)` does not screen the target. The first connect refuses it with
3041    // a message that names the cause, and no credential goes out (#2539 §5).
3042    #[cfg(feature = "db")]
3043    #[test]
3044    fn pg_flag_store_new_refuses_a_non_postgres_target_at_first_use() {
3045        let store = pg::PgFlagStore::new("sqlite://app:hunter2@host/app.db");
3046        let err = store
3047            .refresh()
3048            .expect_err("a SQLite target is refused")
3049            .to_string();
3050        assert!(
3051            err.contains("Postgres"),
3052            "the refusal names the cause: {err}"
3053        );
3054        assert!(!err.contains("hunter2"), "the refusal leaks: {err}");
3055
3056        // libpq quotes a malformed target in its error. That must not leak.
3057        let store = pg::PgFlagStore::new("postgres://app:hunter2@[::1/db");
3058        let err = store
3059            .refresh()
3060            .expect_err("a malformed target fails")
3061            .to_string();
3062        assert!(!err.contains("hunter2"), "the driver error leaks: {err}");
3063
3064        // libpq can quote only the decoded password.
3065        let store = pg::PgFlagStore::new("postgres://app:p%ss@localhost:1/db");
3066        let err = store.refresh().expect_err("a bad escape fails").to_string();
3067        assert!(!err.contains("p%ss"), "the driver error leaks: {err}");
3068    }
3069}