Skip to main content

autumn_web/
experiments.rs

1//! A/B experiments with deterministic bucketing and exposure telemetry.
2//!
3//! Provides multi-variant experiment assignment with stable per-actor bucketing,
4//! sticky assignments, structured exposure events, override support for QA/staff,
5//! mutual exclusion groups, and experiment lifecycle management.
6//!
7//! # Quick start
8//!
9//! ```rust
10//! use autumn_web::experiments::{
11//!     ExperimentConfig, ExperimentService, InMemoryExperimentStore, VariantConfig,
12//! };
13//! use std::sync::Arc;
14//!
15//! // 1. Create a store and service.
16//! let store = Arc::new(InMemoryExperimentStore::new());
17//! let svc = ExperimentService::new(store);
18//!
19//! // 2. Declare an experiment with two 50/50 variants.
20//! svc.create(ExperimentConfig::new("checkout_v2", vec![
21//!     VariantConfig::new("control", 50),
22//!     VariantConfig::new("treatment", 50),
23//! ])).unwrap();
24//!
25//! // 3. Start it.
26//! svc.start("checkout_v2").unwrap();
27//!
28//! // 4. Assign an actor — stable and deterministic.
29//! let variant = svc.assign("checkout_v2", "user:1").unwrap();
30//! assert!(variant == "control" || variant == "treatment");
31//!
32//! // 5. Re-assignment returns the same sticky variant.
33//! let again = svc.assign("checkout_v2", "user:1").unwrap();
34//! assert_eq!(variant, again);
35//! ```
36//!
37//! # Assignment semantics
38//!
39//! Assignment is deterministic per `(experiment_name, actor_id)` using a
40//! FNV-1a 64-bit hash of the UTF-8 string `"<experiment>:<actor>"`, bucketed
41//! into `[0, 10 000)`. This hash function is **stable**: the same inputs always
42//! produce the same output across restarts, replicas, and library versions.
43//! Changing the hash function requires a documented migration path (see
44//! [`experiment_bucket`]).
45//!
46//! Variant selection maps the bucket to a variant proportionally by weight:
47//! given variants `[("control", 30), ("treatment", 70)]`, actors with
48//! buckets 0–2 999 are assigned `"control"` and the rest `"treatment"`.
49//!
50//! # Lifecycle
51//!
52//! Experiments move through states in order:
53//!
54//! ```text
55//! draft ─────── running ──── concluded
56//!        │              │
57//!        └── archived ──┘   (archived from any state)
58//! ```
59//!
60//! - **Draft**: declared but not yet accepting assignments.
61//! - **Running**: assignments + exposures active.
62//! - **Concluded**: winner pinned; all actors see the winner; no new exposures emitted.
63//! - **Archived**: `assign()` returns `Err(ExperimentError::Archived)`.
64//!
65//! # Exposure events
66//!
67//! Every successful `assign()` call on a `Running` experiment emits one
68//! [`ExposureRecord`] to the configured [`ExposureSink`]. The default sink
69//! logs at `INFO` via `tracing`. Supply a custom sink to forward events to
70//! your analytics pipeline.
71//!
72//! # Mutual exclusion groups
73//!
74//! Experiments can share a named group. An actor who has already been assigned
75//! to any experiment in the group will be excluded from all sibling experiments
76//! (`Err(ExperimentError::ExcludedByGroup)`). This prevents interaction effects
77//! between experiments targeting the same funnel.
78
79// autumn-determinism-gate: production code in this module must read time and
80// mint identifiers through the framework's injected seams (ClockSource /
81// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
82// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
83// (issue #1797). Justify exceptions with
84// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
85#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
86
87use std::collections::HashMap;
88use std::sync::{Arc, Mutex, RwLock};
89
90use serde::{Deserialize, Serialize};
91
92// ── ExperimentState ──────────────────────────────────────────────────────────
93
94/// Lifecycle state of an experiment.
95///
96/// Experiments move through `draft → running → concluded`. They may be
97/// archived from any state. See module-level documentation for semantics.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum ExperimentState {
101    /// Declared but not yet accepting assignments.
102    Draft,
103    /// Accepting assignments and emitting exposures.
104    Running,
105    /// Winner pinned; all actors see the winner variant; no new exposures.
106    Concluded,
107    /// Archived; `assign()` returns [`ExperimentError::Archived`].
108    Archived,
109}
110
111impl std::fmt::Display for ExperimentState {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Self::Draft => write!(f, "draft"),
115            Self::Running => write!(f, "running"),
116            Self::Concluded => write!(f, "concluded"),
117            Self::Archived => write!(f, "archived"),
118        }
119    }
120}
121
122// ── VariantConfig ────────────────────────────────────────────────────────────
123
124/// A single variant in an experiment with its assignment weight.
125///
126/// Weights are relative — they do not need to sum to 100. For a 30/70 split
127/// use `[VariantConfig::new("control", 30), VariantConfig::new("treatment", 70)]`.
128/// Equal weights produce equal splits. Use weight `0` to disable a variant.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct VariantConfig {
131    /// Unique variant name within the experiment (e.g. `"control"`, `"treatment_a"`).
132    pub name: String,
133    /// Relative assignment weight. Use `0` to disable a variant without removing it.
134    pub weight: u32,
135}
136
137impl VariantConfig {
138    /// Create a new variant with the given name and weight.
139    #[must_use]
140    pub fn new(name: impl Into<String>, weight: u32) -> Self {
141        Self {
142            name: name.into(),
143            weight,
144        }
145    }
146}
147
148// ── ExperimentConfig ─────────────────────────────────────────────────────────
149
150/// Full configuration of a single experiment.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct ExperimentConfig {
153    /// Unique experiment name (e.g. `"checkout_v2"`).
154    pub name: String,
155    /// Optional human-readable description.
156    pub description: Option<String>,
157    /// Current lifecycle state.
158    pub state: ExperimentState,
159    /// Ordered list of variants and their relative weights.
160    pub variants: Vec<VariantConfig>,
161    /// Set when the experiment is concluded: name of the winning variant.
162    pub winner: Option<String>,
163    /// Named mutual-exclusion group. Actors assigned to any experiment in
164    /// this group are excluded from all sibling experiments in the group.
165    pub exclusion_group: Option<String>,
166    /// Wall-clock time the experiment was last modified (seconds since UNIX epoch).
167    pub updated_at_secs: u64,
168}
169
170impl ExperimentConfig {
171    /// Create a new experiment in `Draft` state with the given variants.
172    #[must_use]
173    pub fn new(name: impl Into<String>, variants: Vec<VariantConfig>) -> Self {
174        Self {
175            name: name.into(),
176            description: None,
177            state: ExperimentState::Draft,
178            variants,
179            winner: None,
180            exclusion_group: None,
181            updated_at_secs: now_secs(),
182        }
183    }
184
185    /// Set a human-readable description.
186    #[must_use]
187    pub fn description(mut self, desc: impl Into<String>) -> Self {
188        self.description = Some(desc.into());
189        self
190    }
191
192    /// Set the mutual exclusion group name.
193    #[must_use]
194    pub fn exclusion_group(mut self, group: impl Into<String>) -> Self {
195        self.exclusion_group = Some(group.into());
196        self
197    }
198}
199
200// ── Assignment ───────────────────────────────────────────────────────────────
201
202/// A recorded sticky assignment for an actor in an experiment.
203///
204/// Once an actor is assigned, subsequent `assign()` calls return the same
205/// variant without re-computing the bucket (sticky semantics).
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct Assignment {
208    /// Experiment name.
209    pub experiment: String,
210    /// Actor identifier.
211    pub actor: String,
212    /// Assigned variant name.
213    pub variant: String,
214    /// `true` when this assignment was produced by a staff/QA override rather
215    /// than weight-based bucketing.
216    pub is_override: bool,
217    /// Wall-clock time of first assignment (seconds since UNIX epoch).
218    pub assigned_at_secs: u64,
219}
220
221impl Assignment {
222    fn new(experiment: &str, actor: &str, variant: &str, is_override: bool) -> Self {
223        Self {
224            experiment: experiment.to_owned(),
225            actor: actor.to_owned(),
226            variant: variant.to_owned(),
227            is_override,
228            assigned_at_secs: now_secs(),
229        }
230    }
231}
232
233// ── ChangeRecord ─────────────────────────────────────────────────────────────
234
235/// A single mutation recorded in the experiment change log.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ChangeRecord {
238    /// Experiment name.
239    pub experiment: String,
240    /// Human-readable description of the mutation.
241    pub mutation: String,
242    /// Actor who performed the mutation (username, `"cli"`, etc.).
243    pub actor: Option<String>,
244    /// Wall-clock time (seconds since UNIX epoch).
245    pub timestamp_secs: u64,
246}
247
248impl ChangeRecord {
249    fn now(experiment: &str, mutation: impl Into<String>, actor: Option<&str>) -> Self {
250        Self {
251            experiment: experiment.to_owned(),
252            mutation: mutation.into(),
253            actor: actor.map(str::to_owned),
254            timestamp_secs: now_secs(),
255        }
256    }
257}
258
259// ── ExposureRecord ────────────────────────────────────────────────────────────
260
261/// Structured exposure event emitted by each successful `assign()` call.
262///
263/// Consumers pipe these into their analytics pipeline to join exposures with
264/// outcome events (conversions, revenue, etc.).
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ExposureRecord {
267    /// Experiment name.
268    pub experiment: String,
269    /// Assigned variant name.
270    pub variant: String,
271    /// Actor identifier.
272    pub actor: String,
273    /// Optional request ID for correlation (propagated from the HTTP request).
274    pub request_id: Option<String>,
275    /// `true` when this assignment was produced by a staff/QA override.
276    pub is_override: bool,
277    /// Wall-clock time of exposure (seconds since UNIX epoch).
278    pub timestamp_secs: u64,
279}
280
281// ── ExposureSink ─────────────────────────────────────────────────────────────
282
283/// Pluggable sink for exposure events.
284///
285/// Every successful `assign()` call on a `Running` experiment emits one
286/// [`ExposureRecord`] to the configured sink. The default is
287/// [`TracingExposureSink`] which logs the event at `INFO` level via the
288/// `tracing` crate.
289///
290/// # Custom sink
291///
292/// ```rust,ignore
293/// use autumn_web::experiments::{ExposureSink, ExposureRecord, ExperimentService,
294///     InMemoryExperimentStore};
295/// use std::sync::Arc;
296///
297/// struct MyAnalyticsSink;
298///
299/// impl ExposureSink for MyAnalyticsSink {
300///     fn record(&self, exposure: ExposureRecord) {
301///         // Forward to your analytics pipeline.
302///         send_to_segment(&exposure);
303///     }
304/// }
305///
306/// let svc = ExperimentService::new(Arc::new(InMemoryExperimentStore::new()))
307///     .with_exposure_sink(Arc::new(MyAnalyticsSink));
308/// ```
309pub trait ExposureSink: Send + Sync + 'static {
310    /// Record a single exposure event.
311    fn record(&self, exposure: ExposureRecord);
312}
313
314/// Default [`ExposureSink`]: emits a structured `tracing::info!` event.
315///
316/// The event carries `experiment`, `variant`, `actor`, `request_id`, and
317/// `is_override` fields so structured logging pipelines (JSON, OTLP) can
318/// aggregate exposures without parsing message text.
319pub struct TracingExposureSink;
320
321impl ExposureSink for TracingExposureSink {
322    fn record(&self, exposure: ExposureRecord) {
323        tracing::info!(
324            experiment = %exposure.experiment,
325            variant    = %exposure.variant,
326            actor      = %exposure.actor,
327            request_id = exposure.request_id.as_deref().unwrap_or(""),
328            is_override = %exposure.is_override,
329            "experiment_exposure"
330        );
331    }
332}
333
334/// A no-op [`ExposureSink`] that discards all exposure events.
335///
336/// Useful in benchmarks or contexts where exposure recording is handled
337/// out-of-band.
338pub struct NoOpExposureSink;
339
340impl ExposureSink for NoOpExposureSink {
341    fn record(&self, _: ExposureRecord) {}
342}
343
344/// A recording [`ExposureSink`] that collects all exposure events in memory.
345///
346/// Primarily useful for integration tests.
347///
348/// ```rust
349/// use autumn_web::experiments::{
350///     RecordingExposureSink, ExperimentService, ExperimentConfig,
351///     VariantConfig, InMemoryExperimentStore,
352/// };
353/// use std::sync::Arc;
354///
355/// let (sink, records) = RecordingExposureSink::new();
356/// let store = Arc::new(InMemoryExperimentStore::new());
357/// let svc = ExperimentService::new(store)
358///     .with_exposure_sink(Arc::new(sink));
359/// svc.create(ExperimentConfig::new("exp", vec![
360///     VariantConfig::new("control", 1),
361///     VariantConfig::new("treatment", 1),
362/// ])).unwrap();
363/// svc.start("exp").unwrap();
364/// svc.assign("exp", "user:1").unwrap();
365/// assert_eq!(records.lock().unwrap().len(), 1);
366/// ```
367pub struct RecordingExposureSink {
368    records: Arc<Mutex<Vec<ExposureRecord>>>,
369}
370
371impl Default for RecordingExposureSink {
372    fn default() -> Self {
373        Self::new().0
374    }
375}
376
377impl RecordingExposureSink {
378    /// Create a new recording sink, returning it alongside a shared handle to
379    /// the collected records.
380    #[must_use]
381    pub fn new() -> (Self, Arc<Mutex<Vec<ExposureRecord>>>) {
382        let records = Arc::new(Mutex::new(Vec::new()));
383        let sink = Self {
384            records: Arc::clone(&records),
385        };
386        (sink, records)
387    }
388}
389
390impl ExposureSink for RecordingExposureSink {
391    fn record(&self, exposure: ExposureRecord) {
392        self.records.lock().unwrap().push(exposure);
393    }
394}
395
396// ── ExperimentStoreError ──────────────────────────────────────────────────────
397
398/// Error from an [`ExperimentStore`] backend.
399#[derive(Debug, thiserror::Error)]
400pub enum ExperimentStoreError {
401    /// The backend reported an I/O or connection failure.
402    #[error("experiment store backend error: {0}")]
403    Backend(String),
404}
405
406// ── ExperimentStore trait ─────────────────────────────────────────────────────
407
408/// Pluggable storage backend for experiments.
409///
410/// All mutation methods should record an audit trail in the change log
411/// (accessible via [`history`](ExperimentStore::history)).
412pub trait ExperimentStore: Send + Sync + 'static {
413    /// Return the current configuration for `name`, or `None` if unknown.
414    ///
415    /// # Errors
416    ///
417    /// Returns [`ExperimentStoreError`] on backend failure.
418    fn get(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError>;
419
420    /// Return all known experiments, sorted by name.
421    ///
422    /// # Errors
423    ///
424    /// Returns [`ExperimentStoreError`] on backend failure.
425    fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError>;
426
427    /// Insert or update an experiment configuration.
428    ///
429    /// # Errors
430    ///
431    /// Returns [`ExperimentStoreError`] on backend failure.
432    fn upsert(&self, config: ExperimentConfig) -> Result<(), ExperimentStoreError>;
433
434    /// Update the lifecycle state of an experiment.
435    ///
436    /// Set `winner` when concluding; ignored otherwise.
437    ///
438    /// # Errors
439    ///
440    /// Returns [`ExperimentStoreError`] on backend failure.
441    fn set_state(
442        &self,
443        name: &str,
444        state: ExperimentState,
445        winner: Option<&str>,
446    ) -> Result<(), ExperimentStoreError>;
447
448    /// Update the variants and weights for an experiment.
449    ///
450    /// Existing sticky assignments are NOT re-bucketed.
451    ///
452    /// # Errors
453    ///
454    /// Returns [`ExperimentStoreError`] on backend failure.
455    fn set_variants(
456        &self,
457        name: &str,
458        variants: Vec<VariantConfig>,
459        actor: Option<&str>,
460    ) -> Result<(), ExperimentStoreError>;
461
462    /// Return the sticky assignment for `(experiment, actor)`, or `None`.
463    ///
464    /// # Errors
465    ///
466    /// Returns [`ExperimentStoreError`] on backend failure.
467    fn get_assignment(
468        &self,
469        experiment: &str,
470        actor: &str,
471    ) -> Result<Option<Assignment>, ExperimentStoreError>;
472
473    /// Record a sticky assignment.
474    ///
475    /// # Errors
476    ///
477    /// Returns the variant that was persisted (the existing row's variant on
478    /// conflict, or the newly-inserted variant on first write). Callers must
479    /// use this return value so that concurrent first-writers agree on the
480    /// same sticky bucket.
481    ///
482    /// Returns [`ExperimentStoreError`] on backend failure.
483    fn record_assignment(&self, assignment: Assignment) -> Result<String, ExperimentStoreError>;
484
485    /// Return the override variant name for `(experiment, actor)`, or `None`.
486    ///
487    /// # Errors
488    ///
489    /// Returns [`ExperimentStoreError`] on backend failure.
490    fn get_override(
491        &self,
492        experiment: &str,
493        actor: &str,
494    ) -> Result<Option<String>, ExperimentStoreError>;
495
496    /// Pin `actor` to `variant` in `experiment`, bypassing weight-based bucketing.
497    ///
498    /// # Errors
499    ///
500    /// Returns [`ExperimentStoreError`] on backend failure.
501    fn set_override(
502        &self,
503        experiment: &str,
504        actor: &str,
505        variant: &str,
506    ) -> Result<(), ExperimentStoreError>;
507
508    /// Return `true` if `actor` has an existing assignment in any experiment
509    /// belonging to `group`, excluding `exclude_experiment` itself.
510    ///
511    /// Used to enforce mutual exclusion: once an actor is in one experiment
512    /// of a group, they are excluded from all siblings.
513    ///
514    /// # Errors
515    ///
516    /// Returns [`ExperimentStoreError`] on backend failure.
517    fn has_assignment_in_group(
518        &self,
519        actor: &str,
520        group: &str,
521        exclude_experiment: &str,
522    ) -> Result<bool, ExperimentStoreError>;
523
524    /// Return the change log for `experiment` (most-recent first), capped at
525    /// `limit` entries. Returns an empty `Vec` for unknown experiments.
526    ///
527    /// # Errors
528    ///
529    /// Returns [`ExperimentStoreError`] on backend failure.
530    fn history(
531        &self,
532        experiment: &str,
533        limit: usize,
534    ) -> Result<Vec<ChangeRecord>, ExperimentStoreError>;
535
536    /// Load the store before the first read. This can block.
537    ///
538    /// `AppBuilder::with_experiment_store` calls it once, on its own thread,
539    /// when the app installs the store. The default does nothing.
540    ///
541    /// # Errors
542    ///
543    /// Returns [`ExperimentStoreError`] on backend failure.
544    fn preload(&self) -> Result<(), ExperimentStoreError> {
545        Ok(())
546    }
547}
548
549impl<T: ExperimentStore> ExperimentStore for Arc<T> {
550    fn get(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError> {
551        (**self).get(name)
552    }
553    fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
554        (**self).list()
555    }
556    fn upsert(&self, config: ExperimentConfig) -> Result<(), ExperimentStoreError> {
557        (**self).upsert(config)
558    }
559    fn set_state(
560        &self,
561        name: &str,
562        state: ExperimentState,
563        winner: Option<&str>,
564    ) -> Result<(), ExperimentStoreError> {
565        (**self).set_state(name, state, winner)
566    }
567    fn set_variants(
568        &self,
569        name: &str,
570        variants: Vec<VariantConfig>,
571        actor: Option<&str>,
572    ) -> Result<(), ExperimentStoreError> {
573        (**self).set_variants(name, variants, actor)
574    }
575    fn get_assignment(
576        &self,
577        experiment: &str,
578        actor: &str,
579    ) -> Result<Option<Assignment>, ExperimentStoreError> {
580        (**self).get_assignment(experiment, actor)
581    }
582    fn record_assignment(&self, assignment: Assignment) -> Result<String, ExperimentStoreError> {
583        (**self).record_assignment(assignment)
584    }
585    fn get_override(
586        &self,
587        experiment: &str,
588        actor: &str,
589    ) -> Result<Option<String>, ExperimentStoreError> {
590        (**self).get_override(experiment, actor)
591    }
592    fn set_override(
593        &self,
594        experiment: &str,
595        actor: &str,
596        variant: &str,
597    ) -> Result<(), ExperimentStoreError> {
598        (**self).set_override(experiment, actor, variant)
599    }
600    fn has_assignment_in_group(
601        &self,
602        actor: &str,
603        group: &str,
604        exclude_experiment: &str,
605    ) -> Result<bool, ExperimentStoreError> {
606        (**self).has_assignment_in_group(actor, group, exclude_experiment)
607    }
608    fn history(
609        &self,
610        experiment: &str,
611        limit: usize,
612    ) -> Result<Vec<ChangeRecord>, ExperimentStoreError> {
613        (**self).history(experiment, limit)
614    }
615    fn preload(&self) -> Result<(), ExperimentStoreError> {
616        (**self).preload()
617    }
618}
619
620// ── InMemoryExperimentStore ───────────────────────────────────────────────────
621
622#[derive(Default)]
623struct StoreInner {
624    experiments: HashMap<String, ExperimentConfig>,
625    /// Keyed `(experiment, actor)`. Probed through `PairKey`, so a lookup
626    /// builds no owned key.
627    assignments: HashMap<PairKeyOwned, Assignment>,
628    /// Keyed `(experiment, actor)`, probed like `assignments`.
629    overrides: HashMap<PairKeyOwned, String>,
630    changes: HashMap<String, Vec<ChangeRecord>>,
631}
632
633/// A borrowed view of an `(experiment, actor)` key, so the in-memory maps can
634/// be probed with two `&str`s instead of a fresh `PairKeyOwned`.
635trait PairKey {
636    fn pair(&self) -> (&str, &str);
637}
638
639/// The owned `(experiment, actor)` key of the in-memory maps.
640///
641/// A private newtype rather than a bare `(String, String)`, so the
642/// `Borrow<dyn PairKey>` impl below stays out of downstream crates: a second
643/// `Borrow` impl on `(String, String)` would break inference in their
644/// unannotated `map.get(k.borrow())` calls.
645#[derive(Debug, PartialEq, Eq)]
646struct PairKeyOwned(String, String);
647
648impl PairKey for PairKeyOwned {
649    fn pair(&self) -> (&str, &str) {
650        (self.0.as_str(), self.1.as_str())
651    }
652}
653
654// Hashes exactly what `dyn PairKey` hashes, so the owned key and its borrowed
655// form agree by construction (the `Borrow` contract).
656impl std::hash::Hash for PairKeyOwned {
657    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
658        std::hash::Hash::hash(&self.pair(), state);
659    }
660}
661
662impl PairKey for (&str, &str) {
663    fn pair(&self) -> (&str, &str) {
664        (self.0, self.1)
665    }
666}
667
668impl<'a> std::borrow::Borrow<dyn PairKey + 'a> for PairKeyOwned {
669    fn borrow(&self) -> &(dyn PairKey + 'a) {
670        self
671    }
672}
673
674// `Borrow` requires the borrowed form to hash and compare like the owned key:
675// a `PairKeyOwned` hashes as its two `str`s in order, and so does this.
676impl std::hash::Hash for dyn PairKey + '_ {
677    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
678        std::hash::Hash::hash(&self.pair(), state);
679    }
680}
681
682impl PartialEq for dyn PairKey + '_ {
683    fn eq(&self, other: &Self) -> bool {
684        self.pair() == other.pair()
685    }
686}
687
688impl Eq for dyn PairKey + '_ {}
689
690/// In-memory [`ExperimentStore`] implementation.
691///
692/// All data is lost when the process exits. Best suited for tests and single-
693/// replica development setups where cross-restart persistence is not required.
694///
695/// For production, use `pg::PgExperimentStore`. It persists assignments across
696/// restarts.
697#[derive(Default)]
698pub struct InMemoryExperimentStore {
699    inner: RwLock<StoreInner>,
700}
701
702impl InMemoryExperimentStore {
703    /// Create an empty in-memory store.
704    #[must_use]
705    pub fn new() -> Self {
706        Self::default()
707    }
708}
709
710impl ExperimentStore for InMemoryExperimentStore {
711    fn get(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError> {
712        Ok(self.inner.read().unwrap().experiments.get(name).cloned())
713    }
714
715    fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
716        let mut exps: Vec<ExperimentConfig> = {
717            let inner = self.inner.read().unwrap();
718            inner.experiments.values().cloned().collect()
719        };
720        exps.sort_by(|a, b| a.name.cmp(&b.name));
721        Ok(exps)
722    }
723
724    fn upsert(&self, config: ExperimentConfig) -> Result<(), ExperimentStoreError> {
725        let name = config.name.clone();
726        {
727            let mut inner = self.inner.write().unwrap();
728            let active_variants: std::collections::HashSet<String> = inner
729                .assignments
730                .values()
731                .filter(|a| a.experiment == name)
732                .map(|a| a.variant.clone())
733                .collect();
734
735            let new_variants: std::collections::HashSet<&str> =
736                config.variants.iter().map(|v| v.name.as_str()).collect();
737
738            for variant in active_variants {
739                if !new_variants.contains(variant.as_str()) {
740                    return Err(ExperimentStoreError::Backend(format!(
741                        "cannot delete variant '{variant}' because it has active assignments"
742                    )));
743                }
744            }
745
746            let exists = inner.experiments.contains_key(&name);
747            inner.experiments.insert(name.clone(), config);
748            let mutation = if exists { "updated" } else { "created" };
749            inner
750                .changes
751                .entry(name.clone())
752                .or_default()
753                .push(ChangeRecord::now(&name, mutation, None));
754        }
755        Ok(())
756    }
757
758    fn set_state(
759        &self,
760        name: &str,
761        state: ExperimentState,
762        winner: Option<&str>,
763    ) -> Result<(), ExperimentStoreError> {
764        {
765            let mut inner = self.inner.write().unwrap();
766            if let Some(exp) = inner.experiments.get_mut(name) {
767                exp.state = state;
768                if let Some(w) = winner {
769                    exp.winner = Some(w.to_owned());
770                }
771                exp.updated_at_secs = now_secs();
772            }
773            inner
774                .changes
775                .entry(name.to_owned())
776                .or_default()
777                .push(ChangeRecord::now(
778                    name,
779                    winner.map_or_else(|| format!("state={state}"), |w| format!("concluded={w}")),
780                    None,
781                ));
782        }
783        Ok(())
784    }
785
786    fn set_variants(
787        &self,
788        name: &str,
789        variants: Vec<VariantConfig>,
790        actor: Option<&str>,
791    ) -> Result<(), ExperimentStoreError> {
792        {
793            let mut inner = self.inner.write().unwrap();
794            let active_variants: std::collections::HashSet<String> = inner
795                .assignments
796                .values()
797                .filter(|a| a.experiment == name)
798                .map(|a| a.variant.clone())
799                .collect();
800
801            let new_variants: std::collections::HashSet<&str> =
802                variants.iter().map(|v| v.name.as_str()).collect();
803
804            for variant in active_variants {
805                if !new_variants.contains(variant.as_str()) {
806                    return Err(ExperimentStoreError::Backend(format!(
807                        "cannot delete variant '{variant}' because it has active assignments"
808                    )));
809                }
810            }
811
812            if let Some(exp) = inner.experiments.get_mut(name) {
813                exp.variants = variants;
814                exp.updated_at_secs = now_secs();
815            }
816            inner
817                .changes
818                .entry(name.to_owned())
819                .or_default()
820                .push(ChangeRecord::now(name, "set_weights", actor));
821        }
822        Ok(())
823    }
824
825    fn get_assignment(
826        &self,
827        experiment: &str,
828        actor: &str,
829    ) -> Result<Option<Assignment>, ExperimentStoreError> {
830        let inner = self.inner.read().unwrap();
831        // Probe by borrow (see `PairKey`): a lookup copies neither string.
832        Ok(inner
833            .assignments
834            .get(&(experiment, actor) as &dyn PairKey)
835            .cloned())
836    }
837
838    fn record_assignment(&self, assignment: Assignment) -> Result<String, ExperimentStoreError> {
839        let mut inner = self.inner.write().unwrap();
840
841        if !assignment.is_override {
842            // Find the exclusion group for this experiment.
843            if let Some(group) = inner
844                .experiments
845                .get(&assignment.experiment)
846                .and_then(|c| c.exclusion_group.as_ref())
847            {
848                // Check if there is a sibling assignment in the same group.
849                for (exp_name, exp_config) in &inner.experiments {
850                    if exp_name == &assignment.experiment {
851                        continue;
852                    }
853                    if exp_config.exclusion_group.as_ref() == Some(group)
854                        && inner
855                            .assignments
856                            .contains_key(
857                                &(exp_name.as_str(), assignment.actor.as_str()) as &dyn PairKey
858                            )
859                    {
860                        return Err(ExperimentStoreError::Backend(format!(
861                            "ExcludedByGroup:{group}"
862                        )));
863                    }
864                }
865            }
866        }
867
868        let variant = assignment.variant.clone();
869        let key = PairKeyOwned(assignment.experiment.clone(), assignment.actor.clone());
870        inner.assignments.insert(key, assignment);
871        drop(inner);
872        Ok(variant)
873    }
874
875    fn get_override(
876        &self,
877        experiment: &str,
878        actor: &str,
879    ) -> Result<Option<String>, ExperimentStoreError> {
880        let inner = self.inner.read().unwrap();
881        Ok(inner
882            .overrides
883            .get(&(experiment, actor) as &dyn PairKey)
884            .cloned())
885    }
886
887    fn set_override(
888        &self,
889        experiment: &str,
890        actor: &str,
891        variant: &str,
892    ) -> Result<(), ExperimentStoreError> {
893        let key = PairKeyOwned(experiment.to_owned(), actor.to_owned());
894        self.inner
895            .write()
896            .unwrap()
897            .overrides
898            .insert(key, variant.to_owned());
899        Ok(())
900    }
901
902    fn has_assignment_in_group(
903        &self,
904        actor: &str,
905        group: &str,
906        exclude_experiment: &str,
907    ) -> Result<bool, ExperimentStoreError> {
908        let inner = self.inner.read().unwrap();
909        for (exp_name, config) in &inner.experiments {
910            if exp_name == exclude_experiment {
911                continue;
912            }
913            if config.exclusion_group.as_deref() != Some(group) {
914                continue;
915            }
916            if inner
917                .assignments
918                .contains_key(&(exp_name.as_str(), actor) as &dyn PairKey)
919            {
920                return Ok(true);
921            }
922        }
923        Ok(false)
924    }
925
926    fn history(
927        &self,
928        experiment: &str,
929        limit: usize,
930    ) -> Result<Vec<ChangeRecord>, ExperimentStoreError> {
931        let records = {
932            let inner = self.inner.read().unwrap();
933            inner
934                .changes
935                .get(experiment)
936                .map(|v| {
937                    if limit == 0 {
938                        v.clone()
939                    } else {
940                        v.iter().rev().take(limit).cloned().collect()
941                    }
942                })
943                .unwrap_or_default()
944        };
945        Ok(records)
946    }
947}
948
949// ── Hash and bucketing helpers ────────────────────────────────────────────────
950
951fn now_secs() -> u64 {
952    crate::time::clock_unix_secs(&crate::time::AmbientClock)
953}
954
955/// FNV-1a 64-bit hash of `parts` laid end to end: exactly the hash of their
956/// concatenation, since FNV-1a folds one byte at a time.
957///
958/// Stable, dependency-free, and specified by the FNV standard.
959fn fnv1a_64(parts: &[&[u8]]) -> u64 {
960    const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
961    const FNV_PRIME: u64 = 1_099_511_628_211;
962    let mut hash = FNV_OFFSET;
963    for &byte in parts.iter().copied().flatten() {
964        #[allow(clippy::cast_lossless)]
965        {
966            hash ^= byte as u64;
967        }
968        hash = hash.wrapping_mul(FNV_PRIME);
969    }
970    hash
971}
972
973/// Compute the assignment bucket for `(experiment_name, actor_id)`.
974///
975/// Returns a value in `[0, 10 000)`. The same inputs always produce the same
976/// output across restarts, replicas, and library versions.
977///
978/// ## Algorithm
979///
980/// FNV-1a 64-bit hash of the UTF-8 encoding of
981/// `"<experiment_name>:<actor_id>"`, reduced modulo 10 000.
982///
983/// **This function MUST NOT change** between releases without a documented
984/// migration path: changing it silently re-buckets every actor in every running
985/// experiment, corrupting all in-flight A/B tests. If the algorithm must change,
986/// bump the experiment schema version, migrate existing assignments, and update
987/// the regression test below.
988#[must_use]
989pub fn experiment_bucket(experiment: &str, actor_id: &str) -> u64 {
990    // `"<experiment>:<actor_id>"`, hashed part by part so no joined key is
991    // built. Pinned by `stable_hash_regression_known_fixtures`.
992    fnv1a_64(&[experiment.as_bytes(), b":".as_slice(), actor_id.as_bytes()]) % 10_000
993}
994
995/// Select a variant from `variants` given a `bucket` in `[0, 10 000)`.
996///
997/// Returns `None` if all variant weights are zero.
998fn select_variant(variants: &[VariantConfig], bucket: u64) -> Option<&str> {
999    let total_weight: u64 = variants.iter().map(|v| u64::from(v.weight)).sum();
1000    if total_weight == 0 {
1001        return None;
1002    }
1003    let threshold = bucket * total_weight / 10_000;
1004    let mut cumulative: u64 = 0;
1005    for v in variants {
1006        cumulative += u64::from(v.weight);
1007        if threshold < cumulative {
1008            return Some(&v.name);
1009        }
1010    }
1011    variants.last().map(|v| v.name.as_str())
1012}
1013
1014// ── ExperimentError ───────────────────────────────────────────────────────────
1015
1016/// Error from [`ExperimentService::assign`] or other service methods.
1017#[derive(Debug, thiserror::Error)]
1018pub enum ExperimentError {
1019    /// No experiment with this name was found.
1020    #[error("experiment '{0}' not found")]
1021    NotFound(String),
1022
1023    /// The experiment exists but is not in `Running` state.
1024    #[error("experiment '{0}' is not running (state: {1})")]
1025    NotRunning(String, ExperimentState),
1026
1027    /// The experiment is archived and rejects all assignments.
1028    #[error("experiment '{0}' is archived")]
1029    Archived(String),
1030
1031    /// The actor is excluded from this experiment by a mutual exclusion group.
1032    #[error("actor excluded from experiment '{0}' by mutual exclusion group '{1}'")]
1033    ExcludedByGroup(String, String),
1034
1035    /// No variant could be selected (all variant weights are zero).
1036    #[error("experiment '{0}' has no assignable variant (all weights are zero)")]
1037    NoVariant(String),
1038
1039    /// Store backend failure.
1040    #[error(transparent)]
1041    Store(#[from] ExperimentStoreError),
1042}
1043
1044// ── Validation helpers ────────────────────────────────────────────────────────
1045
1046fn validate_variants(variants: &[VariantConfig]) -> Result<(), ExperimentError> {
1047    let mut seen = std::collections::HashSet::new();
1048    for v in variants {
1049        if v.name.trim().is_empty() {
1050            return Err(ExperimentError::NoVariant(
1051                "variant name must not be empty".into(),
1052            ));
1053        }
1054        if !seen.insert(v.name.as_str()) {
1055            return Err(ExperimentError::NoVariant(format!(
1056                "duplicate variant name: '{}'",
1057                v.name
1058            )));
1059        }
1060    }
1061    Ok(())
1062}
1063
1064// ── ExperimentService ─────────────────────────────────────────────────────────
1065
1066/// The main experiment service.
1067///
1068/// Wrap an [`ExperimentStore`] (for persistence) and an optional
1069/// [`ExposureSink`] (default: [`TracingExposureSink`]). The service is cheaply
1070/// clone-able and intended to be stored as an `AppState` extension.
1071///
1072/// # Example
1073///
1074/// ```rust
1075/// use autumn_web::experiments::{
1076///     ExperimentConfig, ExperimentService, InMemoryExperimentStore, VariantConfig,
1077/// };
1078/// use std::sync::Arc;
1079///
1080/// let store = Arc::new(InMemoryExperimentStore::new());
1081/// let svc = ExperimentService::new(store);
1082///
1083/// svc.create(ExperimentConfig::new("onboarding_v3", vec![
1084///     VariantConfig::new("control", 50),
1085///     VariantConfig::new("wizard", 50),
1086/// ])).unwrap();
1087/// svc.start("onboarding_v3").unwrap();
1088///
1089/// let variant = svc.assign("onboarding_v3", "user:42").unwrap();
1090/// assert!(matches!(variant.as_str(), "control" | "wizard"));
1091/// ```
1092#[derive(Clone)]
1093pub struct ExperimentService {
1094    store: Arc<dyn ExperimentStore>,
1095    exposure_sink: Arc<dyn ExposureSink>,
1096}
1097
1098impl std::fmt::Debug for ExperimentService {
1099    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1100        f.debug_struct("ExperimentService").finish_non_exhaustive()
1101    }
1102}
1103
1104impl ExperimentService {
1105    /// Create a new service backed by `store`, with the default
1106    /// [`TracingExposureSink`].
1107    #[must_use]
1108    pub fn new(store: Arc<dyn ExperimentStore>) -> Self {
1109        Self {
1110            store,
1111            exposure_sink: Arc::new(TracingExposureSink),
1112        }
1113    }
1114
1115    /// Override the default [`ExposureSink`].
1116    #[must_use]
1117    pub fn with_exposure_sink(mut self, sink: Arc<dyn ExposureSink>) -> Self {
1118        self.exposure_sink = sink;
1119        self
1120    }
1121
1122    /// Assign a variant to `actor` in `experiment`.
1123    ///
1124    /// Assignment rules (evaluated in order):
1125    /// 1. `Archived` → `Err(Archived)`.
1126    /// 2. `Concluded` → `Ok(winner)` without emitting an exposure.
1127    /// 3. `Draft` → `Err(NotRunning)`.
1128    /// 4. Staff/QA override present → return override variant (exposure emitted,
1129    ///    tagged `is_override = true`).
1130    /// 5. Sticky assignment already recorded → return cached variant (exposure
1131    ///    emitted each call).
1132    /// 6. Mutual exclusion check → if `actor` has any assignment in a sibling
1133    ///    experiment in the same group, return `Err(ExcludedByGroup)`.
1134    /// 7. Compute bucket → select variant by weight → store sticky → emit exposure.
1135    ///
1136    /// # Errors
1137    ///
1138    /// - [`ExperimentError::NotFound`] — no such experiment.
1139    /// - [`ExperimentError::Archived`] — experiment is archived.
1140    /// - [`ExperimentError::NotRunning`] — experiment is in `Draft` state.
1141    /// - [`ExperimentError::ExcludedByGroup`] — actor excluded by mutual exclusion.
1142    /// - [`ExperimentError::NoVariant`] — all variant weights are zero.
1143    /// - [`ExperimentError::Store`] — backend failure.
1144    pub fn assign(&self, experiment: &str, actor: &str) -> Result<String, ExperimentError> {
1145        self.assign_with_request_id(experiment, actor, None)
1146    }
1147
1148    /// Like [`assign`](Self::assign), but run on the blocking pool.
1149    ///
1150    /// Use it in async handlers. The store calls can connect to a database,
1151    /// and `assign` would block the Tokio worker for that time.
1152    ///
1153    /// # Errors
1154    ///
1155    /// See [`assign`](Self::assign). A panic in the store call gives
1156    /// [`ExperimentError::Store`].
1157    pub async fn assign_async(
1158        &self,
1159        experiment: &str,
1160        actor: &str,
1161        request_id: Option<&str>,
1162    ) -> Result<String, ExperimentError> {
1163        let service = self.clone();
1164        let (experiment, actor) = (experiment.to_owned(), actor.to_owned());
1165        let request_id = request_id.map(str::to_owned);
1166        crate::time::spawn_blocking(move || {
1167            service.assign_with_request_id(&experiment, &actor, request_id.as_deref())
1168        })
1169        .await
1170        .unwrap_or_else(|error| {
1171            Err(ExperimentError::Store(ExperimentStoreError::Backend(
1172                format!("assignment task failed: {error}"),
1173            )))
1174        })
1175    }
1176
1177    /// Load the store before the first read. See [`ExperimentStore::preload`].
1178    ///
1179    /// # Errors
1180    ///
1181    /// Returns [`ExperimentStoreError`] on backend failure.
1182    pub fn preload(&self) -> Result<(), ExperimentStoreError> {
1183        self.store.preload()
1184    }
1185
1186    /// Like [`assign`](Self::assign) but propagates a `request_id` into the
1187    /// exposure record.
1188    ///
1189    /// Call this from HTTP handlers where a request trace ID is available.
1190    ///
1191    /// # Errors
1192    ///
1193    /// See [`assign`](Self::assign).
1194    pub fn assign_with_request_id(
1195        &self,
1196        experiment: &str,
1197        actor: &str,
1198        request_id: Option<&str>,
1199    ) -> Result<String, ExperimentError> {
1200        let config = self
1201            .store
1202            .get(experiment)?
1203            .ok_or_else(|| ExperimentError::NotFound(experiment.to_owned()))?;
1204
1205        match config.state {
1206            ExperimentState::Archived => {
1207                return Err(ExperimentError::Archived(experiment.to_owned()));
1208            }
1209            ExperimentState::Concluded => {
1210                // Return winner for all actors; no exposure emitted (experiment is done).
1211                let winner = config
1212                    .winner
1213                    .ok_or_else(|| ExperimentError::NoVariant(experiment.to_owned()))?;
1214                return Ok(winner);
1215            }
1216            ExperimentState::Draft => {
1217                return Err(ExperimentError::NotRunning(
1218                    experiment.to_owned(),
1219                    ExperimentState::Draft,
1220                ));
1221            }
1222            ExperimentState::Running => {}
1223        }
1224
1225        // Check for staff/QA override (takes precedence over sticky).
1226        // Skip stale overrides whose variant was removed from the experiment config.
1227        if let Some(override_variant) = self.store.get_override(experiment, actor)?
1228            && config.variants.iter().any(|v| v.name == override_variant)
1229        {
1230            let sticky = Assignment::new(experiment, actor, &override_variant, true);
1231            if let Err(e) = self.store.record_assignment(sticky) {
1232                match e {
1233                    ExperimentStoreError::Backend(msg) if msg.starts_with("ExcludedByGroup:") => {
1234                        let group = msg
1235                            .strip_prefix("ExcludedByGroup:")
1236                            .unwrap_or("")
1237                            .trim()
1238                            .to_owned();
1239                        return Err(ExperimentError::ExcludedByGroup(
1240                            experiment.to_owned(),
1241                            group,
1242                        ));
1243                    }
1244                    other @ ExperimentStoreError::Backend(_) => {
1245                        return Err(ExperimentError::Store(other));
1246                    }
1247                }
1248            }
1249            self.emit_exposure(experiment, &override_variant, actor, request_id, true);
1250            return Ok(override_variant);
1251        }
1252
1253        // Return existing sticky assignment (emit exposure each call).
1254        if let Some(existing) = self.store.get_assignment(experiment, actor)? {
1255            self.emit_exposure(
1256                experiment,
1257                &existing.variant,
1258                actor,
1259                request_id,
1260                existing.is_override,
1261            );
1262            return Ok(existing.variant);
1263        }
1264
1265        // Mutual exclusion group check.
1266        if let Some(group) = &config.exclusion_group
1267            && self
1268                .store
1269                .has_assignment_in_group(actor, group, experiment)?
1270        {
1271            return Err(ExperimentError::ExcludedByGroup(
1272                experiment.to_owned(),
1273                group.clone(),
1274            ));
1275        }
1276
1277        // Bucket the actor and pick a variant.
1278        let bucket = experiment_bucket(experiment, actor);
1279        let variant_name = select_variant(&config.variants, bucket)
1280            .ok_or_else(|| ExperimentError::NoVariant(experiment.to_owned()))?
1281            .to_owned();
1282
1283        // Store sticky assignment. Use the persisted variant (the winner of any
1284        // concurrent first-write race), not the locally-computed one.
1285        let assignment = Assignment::new(experiment, actor, &variant_name, false);
1286        let persisted_variant = match self.store.record_assignment(assignment) {
1287            Ok(v) => v,
1288            Err(ExperimentStoreError::Backend(msg)) if msg.starts_with("ExcludedByGroup:") => {
1289                let group = msg
1290                    .strip_prefix("ExcludedByGroup:")
1291                    .unwrap_or("")
1292                    .trim()
1293                    .to_owned();
1294                return Err(ExperimentError::ExcludedByGroup(
1295                    experiment.to_owned(),
1296                    group,
1297                ));
1298            }
1299            Err(other @ ExperimentStoreError::Backend(_)) => {
1300                return Err(ExperimentError::Store(other));
1301            }
1302        };
1303        self.emit_exposure(experiment, &persisted_variant, actor, request_id, false);
1304
1305        Ok(persisted_variant)
1306    }
1307
1308    fn emit_exposure(
1309        &self,
1310        experiment: &str,
1311        variant: &str,
1312        actor: &str,
1313        request_id: Option<&str>,
1314        is_override: bool,
1315    ) {
1316        self.exposure_sink.record(ExposureRecord {
1317            experiment: experiment.to_owned(),
1318            variant: variant.to_owned(),
1319            actor: actor.to_owned(),
1320            request_id: request_id.map(str::to_owned),
1321            is_override,
1322            timestamp_secs: now_secs(),
1323        });
1324    }
1325
1326    /// Declare a new experiment (starts in `Draft` state).
1327    ///
1328    /// # Errors
1329    ///
1330    /// Returns [`ExperimentError::Store`] on backend failure.
1331    pub fn create(&self, config: ExperimentConfig) -> Result<(), ExperimentError> {
1332        validate_variants(&config.variants)?;
1333        if config.state == ExperimentState::Concluded {
1334            let winner = config
1335                .winner
1336                .as_deref()
1337                .filter(|w| !w.trim().is_empty())
1338                .ok_or_else(|| {
1339                    ExperimentError::NoVariant(
1340                        "concluded experiment requires a non-empty winner".into(),
1341                    )
1342                })?;
1343            if !config.variants.iter().any(|v| v.name == winner) {
1344                return Err(ExperimentError::NoVariant(format!(
1345                    "'{winner}' is not a configured variant"
1346                )));
1347            }
1348        }
1349        self.store.upsert(config)?;
1350        Ok(())
1351    }
1352
1353    /// Transition a `Draft` or `Concluded` experiment to `Running`.
1354    ///
1355    /// `Archived` experiments are terminal and cannot be restarted.
1356    ///
1357    /// # Errors
1358    ///
1359    /// Returns [`ExperimentError::NotFound`] if the experiment is unknown.
1360    /// Returns [`ExperimentError::Archived`] if the experiment is archived.
1361    pub fn start(&self, name: &str) -> Result<(), ExperimentError> {
1362        let config = self
1363            .store
1364            .get(name)?
1365            .ok_or_else(|| ExperimentError::NotFound(name.to_owned()))?;
1366        if config.state == ExperimentState::Archived {
1367            return Err(ExperimentError::Archived(name.to_owned()));
1368        }
1369        self.store.set_state(name, ExperimentState::Running, None)?;
1370        Ok(())
1371    }
1372
1373    /// Conclude a running experiment, pinning `winner` as the result.
1374    ///
1375    /// After concluding, `assign()` returns `winner` for all actors without
1376    /// emitting new exposure events.
1377    ///
1378    /// # Errors
1379    ///
1380    /// Returns [`ExperimentError::NotFound`] if the experiment is unknown.
1381    pub fn conclude(&self, name: &str, winner: &str) -> Result<(), ExperimentError> {
1382        let config = self
1383            .store
1384            .get(name)?
1385            .ok_or_else(|| ExperimentError::NotFound(name.to_owned()))?;
1386        if config.state == ExperimentState::Archived {
1387            return Err(ExperimentError::Archived(name.to_owned()));
1388        }
1389        if !config.variants.iter().any(|v| v.name == winner) {
1390            return Err(ExperimentError::NoVariant(format!(
1391                "'{winner}' is not a configured variant of experiment '{name}'"
1392            )));
1393        }
1394        self.store
1395            .set_state(name, ExperimentState::Concluded, Some(winner))?;
1396        Ok(())
1397    }
1398
1399    /// Archive an experiment.
1400    ///
1401    /// Archived experiments reject all new assignments with
1402    /// [`ExperimentError::Archived`].
1403    ///
1404    /// # Errors
1405    ///
1406    /// Returns [`ExperimentError::NotFound`] if the experiment is unknown.
1407    pub fn archive(&self, name: &str) -> Result<(), ExperimentError> {
1408        self.store
1409            .get(name)?
1410            .ok_or_else(|| ExperimentError::NotFound(name.to_owned()))?;
1411        self.store
1412            .set_state(name, ExperimentState::Archived, None)?;
1413        Ok(())
1414    }
1415
1416    /// Update the variant weights for `name`.
1417    ///
1418    /// Existing sticky assignments are **not** re-bucketed: already-assigned
1419    /// actors keep their variant. New actors are bucketed against the updated
1420    /// weights.
1421    ///
1422    /// # Errors
1423    ///
1424    /// Returns [`ExperimentError::NotFound`] if the experiment is unknown.
1425    pub fn set_weights(
1426        &self,
1427        name: &str,
1428        variants: Vec<VariantConfig>,
1429        actor: Option<&str>,
1430    ) -> Result<(), ExperimentError> {
1431        validate_variants(&variants)?;
1432        let config = self
1433            .store
1434            .get(name)?
1435            .ok_or_else(|| ExperimentError::NotFound(name.to_owned()))?;
1436        match config.state {
1437            ExperimentState::Concluded => {
1438                return Err(ExperimentError::NotRunning(
1439                    name.to_owned(),
1440                    ExperimentState::Concluded,
1441                ));
1442            }
1443            ExperimentState::Archived => {
1444                return Err(ExperimentError::Archived(name.to_owned()));
1445            }
1446            _ => {}
1447        }
1448        self.store.set_variants(name, variants, actor)?;
1449        Ok(())
1450    }
1451
1452    /// Pin `actor` to `variant` in `experiment`, bypassing weight-based bucketing.
1453    ///
1454    /// Overrides are used by staff/QA to force a specific variant during manual
1455    /// testing. Exposure events include `is_override: true`.
1456    ///
1457    /// # Errors
1458    ///
1459    /// Returns [`ExperimentError::NotFound`] if the experiment is unknown.
1460    pub fn set_override(
1461        &self,
1462        experiment: &str,
1463        actor: &str,
1464        variant: &str,
1465    ) -> Result<(), ExperimentError> {
1466        let config = self
1467            .store
1468            .get(experiment)?
1469            .ok_or_else(|| ExperimentError::NotFound(experiment.to_owned()))?;
1470        if !config.variants.iter().any(|v| v.name == variant) {
1471            return Err(ExperimentError::NoVariant(format!(
1472                "'{variant}' is not a configured variant of experiment '{experiment}'"
1473            )));
1474        }
1475        self.store.set_override(experiment, actor, variant)?;
1476        Ok(())
1477    }
1478
1479    /// List all declared experiments.
1480    ///
1481    /// # Errors
1482    ///
1483    /// Returns [`ExperimentError::Store`] on backend failure.
1484    pub fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentError> {
1485        Ok(self.store.list()?)
1486    }
1487
1488    /// Return the current configuration for `name`.
1489    ///
1490    /// # Errors
1491    ///
1492    /// Returns [`ExperimentError::NotFound`] if the experiment is unknown.
1493    pub fn status(&self, name: &str) -> Result<ExperimentConfig, ExperimentError> {
1494        self.store
1495            .get(name)?
1496            .ok_or_else(|| ExperimentError::NotFound(name.to_owned()))
1497    }
1498
1499    /// Return the change log for `experiment` (most-recent first), capped at `limit`.
1500    ///
1501    /// # Errors
1502    ///
1503    /// Returns [`ExperimentError::Store`] on backend failure.
1504    pub fn history(
1505        &self,
1506        experiment: &str,
1507        limit: usize,
1508    ) -> Result<Vec<ChangeRecord>, ExperimentError> {
1509        Ok(self.store.history(experiment, limit)?)
1510    }
1511}
1512
1513/// Preload the store of `service` on its own thread, and wait up to `wait`.
1514///
1515/// The app calls it when it installs the service. A failure logs a warning;
1516/// it does not stop the app.
1517pub(crate) fn preload_blocking(service: &ExperimentService, wait: std::time::Duration) {
1518    let service = service.clone();
1519    crate::feature_flags::preload_with(
1520        "experiment",
1521        "assignments fail until the first refresh",
1522        wait,
1523        move || service.preload(),
1524    );
1525}
1526
1527// ── Experiments extractor ─────────────────────────────────────────────────────
1528
1529/// Request extractor that resolves the current user's experiment service handle.
1530///
1531/// Extracts [`ExperimentService`] from the `AppState` extension slot. Fails
1532/// with `500 Internal Server Error` if no service has been registered.
1533///
1534/// Also resolves:
1535/// - **`actor_id`** from the session `user_id` key (Autumn's default).
1536/// - **`request_id`** from the `x-request-id` HTTP header (if present).
1537///
1538/// # Example
1539///
1540/// ```rust,ignore
1541/// use autumn_web::prelude::*;
1542/// use autumn_web::experiments::Experiments;
1543///
1544/// #[get("/checkout")]
1545/// async fn checkout(exps: Experiments) -> AutumnResult<Markup> {
1546///     let variant = exps.assign_async("checkout_v2").await?;
1547///     Ok(html! {
1548///         @match variant.as_str() {
1549///             "treatment" => (render_new_checkout()),
1550///             _           => (render_classic_checkout()),
1551///         }
1552///     })
1553/// }
1554/// ```
1555pub struct Experiments {
1556    service: ExperimentService,
1557    actor_id: Option<String>,
1558    request_id: Option<String>,
1559}
1560
1561impl Experiments {
1562    /// Assign a variant for the current session actor.
1563    ///
1564    /// Propagates the session actor ID and `x-request-id` header automatically.
1565    /// For logged-out sessions, falls back to the session ID so each visitor
1566    /// gets a stable, per-session bucket rather than collapsing all anonymous
1567    /// traffic into a single `"anonymous"` actor.
1568    ///
1569    /// # Errors
1570    ///
1571    /// See [`ExperimentService::assign_with_request_id`].
1572    ///
1573    /// With a Postgres store, this can block on a database connect. In an
1574    /// async handler, use [`assign_async`](Self::assign_async).
1575    pub fn assign(&self, experiment: &str) -> Result<String, ExperimentError> {
1576        let actor = self.actor_id.as_deref().unwrap_or("anonymous");
1577        self.service
1578            .assign_with_request_id(experiment, actor, self.request_id.as_deref())
1579    }
1580
1581    /// Like [`assign`](Self::assign), but run on the blocking pool.
1582    ///
1583    /// Use it in async handlers: `assign` can block the Tokio worker on a
1584    /// database connect.
1585    ///
1586    /// # Errors
1587    ///
1588    /// See [`ExperimentService::assign_async`].
1589    pub async fn assign_async(&self, experiment: &str) -> Result<String, ExperimentError> {
1590        let actor = self.actor_id.as_deref().unwrap_or("anonymous");
1591        self.service
1592            .assign_async(experiment, actor, self.request_id.as_deref())
1593            .await
1594    }
1595
1596    /// Return the underlying service for direct access to admin operations.
1597    #[must_use]
1598    pub const fn service(&self) -> &ExperimentService {
1599        &self.service
1600    }
1601}
1602
1603impl axum::extract::FromRequestParts<crate::AppState> for Experiments {
1604    type Rejection = crate::AutumnError;
1605
1606    async fn from_request_parts(
1607        parts: &mut axum::http::request::Parts,
1608        state: &crate::AppState,
1609    ) -> Result<Self, Self::Rejection> {
1610        let service = state
1611            .extension::<ExperimentService>()
1612            .map(|arc| (*arc).clone())
1613            .ok_or_else(|| {
1614                crate::AutumnError::internal_server_error_msg(
1615                    "experiment service not registered; \
1616                     install an ExperimentStore via AppBuilder::with_experiment_store()",
1617                )
1618            })?;
1619
1620        let actor_id = if let Some(session) = parts.extensions.get::<crate::session::Session>() {
1621            // Use the configured auth session key (e.g. "user_id"); fall back to
1622            // the session ID so each anonymous visitor gets a stable, per-session
1623            // bucket rather than all collapsing into a single "anonymous" actor.
1624            let session_key = state.auth_session_key();
1625            if let Some(uid) = session.get(session_key).await {
1626                Some(uid)
1627            } else {
1628                // Use or create a stable per-session anonymous actor. We must
1629                // insert into the session (marking it dirty) so the framework
1630                // sets a cookie and the ID persists across requests; a bare
1631                // session.id() call does not mark the session dirty.
1632                const ANON_KEY: &str = "_autumn_anon_actor";
1633                if let Some(existing) = session.get(ANON_KEY).await {
1634                    Some(existing)
1635                } else {
1636                    let id = session.id().await;
1637                    session.insert(ANON_KEY, &id).await;
1638                    Some(id)
1639                }
1640            }
1641        } else {
1642            None
1643        };
1644
1645        let request_id = parts
1646            .headers
1647            .get("x-request-id")
1648            .and_then(|v| v.to_str().ok())
1649            .map(str::to_owned);
1650
1651        Ok(Self {
1652            service,
1653            actor_id,
1654            request_id,
1655        })
1656    }
1657}
1658
1659// ── pg module ─────────────────────────────────────────────────────────────────
1660
1661#[cfg(feature = "postgres")]
1662pub mod pg {
1663    use super::{
1664        Assignment, ChangeRecord, ExperimentConfig, ExperimentState, ExperimentStore,
1665        ExperimentStoreError, VariantConfig,
1666    };
1667    use crate::feature_flags::pg::{STATEMENT_TIMEOUT_MS, retry_delay, with_connect_timeout};
1668    use diesel::connection::SimpleConnection;
1669    use diesel::prelude::*;
1670    use std::collections::HashMap;
1671    use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
1672    use std::sync::{Arc, Mutex, PoisonError, RwLock};
1673    use std::time::{Duration, Instant};
1674
1675    // ── Row structs ───────────────────────────────────────────────────────────
1676
1677    #[derive(diesel::QueryableByName)]
1678    struct ExperimentRow {
1679        #[diesel(sql_type = diesel::sql_types::Text)]
1680        name: String,
1681        #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1682        description: Option<String>,
1683        #[diesel(sql_type = diesel::sql_types::Text)]
1684        state: String,
1685        #[diesel(sql_type = diesel::sql_types::Text)]
1686        variants: String,
1687        #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1688        winner: Option<String>,
1689        #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1690        exclusion_group: Option<String>,
1691        #[diesel(sql_type = diesel::sql_types::BigInt)]
1692        updated_at_secs: i64,
1693    }
1694
1695    impl ExperimentRow {
1696        fn into_config(self) -> ExperimentConfig {
1697            let variants: Vec<VariantConfig> =
1698                serde_json::from_str(&self.variants).unwrap_or_default();
1699            let state = match self.state.as_str() {
1700                "running" => ExperimentState::Running,
1701                "concluded" => ExperimentState::Concluded,
1702                "archived" => ExperimentState::Archived,
1703                _ => ExperimentState::Draft,
1704            };
1705            ExperimentConfig {
1706                name: self.name,
1707                description: self.description,
1708                state,
1709                variants,
1710                winner: self.winner,
1711                exclusion_group: self.exclusion_group,
1712                updated_at_secs: u64::try_from(self.updated_at_secs).unwrap_or(0),
1713            }
1714        }
1715    }
1716
1717    #[derive(diesel::QueryableByName)]
1718    struct AssignmentRow {
1719        #[diesel(sql_type = diesel::sql_types::Text)]
1720        experiment: String,
1721        #[diesel(sql_type = diesel::sql_types::Text)]
1722        actor: String,
1723        #[diesel(sql_type = diesel::sql_types::Text)]
1724        variant: String,
1725        #[diesel(sql_type = diesel::sql_types::Bool)]
1726        is_override: bool,
1727        #[diesel(sql_type = diesel::sql_types::BigInt)]
1728        assigned_at_secs: i64,
1729    }
1730
1731    #[derive(diesel::QueryableByName)]
1732    struct BoolRow {
1733        #[diesel(sql_type = diesel::sql_types::Bool)]
1734        result: bool,
1735    }
1736
1737    #[derive(diesel::QueryableByName)]
1738    struct VariantNameRow {
1739        #[diesel(sql_type = diesel::sql_types::Text)]
1740        variant: String,
1741    }
1742
1743    #[derive(diesel::QueryableByName)]
1744    struct ExclusionGroupRow {
1745        #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1746        exclusion_group: Option<String>,
1747    }
1748
1749    #[derive(diesel::QueryableByName)]
1750    struct ChangeRow {
1751        #[diesel(sql_type = diesel::sql_types::Text)]
1752        experiment: String,
1753        #[diesel(sql_type = diesel::sql_types::Text)]
1754        mutation: String,
1755        #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
1756        actor: Option<String>,
1757        #[diesel(sql_type = diesel::sql_types::BigInt)]
1758        timestamp_secs: i64,
1759    }
1760
1761    // ── Snapshot ──────────────────────────────────────────────────────────────
1762
1763    /// All experiments, as the last refresh left them.
1764    #[derive(Debug, Default)]
1765    struct Snapshot {
1766        experiments: HashMap<String, ExperimentConfig>,
1767        /// `true` after the first full load.
1768        loaded: bool,
1769        /// Start of the last refresh attempt, good or bad.
1770        checked_at: Option<Instant>,
1771    }
1772
1773    /// State that a background refresh shares with the store.
1774    struct Shared {
1775        database_url: String,
1776        /// `database_url` with a connect timeout.
1777        connect_url: String,
1778        cache_ttl: Duration,
1779        snapshot: RwLock<Snapshot>,
1780        /// `true` while a background refresh runs. Only one runs at a time.
1781        refreshing: AtomicBool,
1782        /// The refresh connection, kept between refreshes. Loads hold this
1783        /// lock, so they run and install in order.
1784        refresh_conn: Mutex<Option<diesel::PgConnection>>,
1785        refresh_errors: AtomicU64,
1786        /// Failed refreshes in a row. Zero after a good refresh.
1787        failures: AtomicU32,
1788        #[cfg(test)]
1789        connect_threads: Mutex<Vec<std::thread::ThreadId>>,
1790    }
1791
1792    impl std::fmt::Debug for Shared {
1793        /// Leaves out the URL: it can hold a password.
1794        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1795            f.debug_struct("PgExperimentStore")
1796                .field("cache_ttl", &self.cache_ttl)
1797                .field("refresh_errors", &self.refresh_errors)
1798                .finish_non_exhaustive()
1799        }
1800    }
1801
1802    /// Holds the single refresh slot. Drop releases it, also when a spawned
1803    /// refresh never runs.
1804    struct RefreshSlot(Arc<Shared>);
1805
1806    impl Drop for RefreshSlot {
1807        fn drop(&mut self) {
1808            self.0.refreshing.store(false, Ordering::Release);
1809        }
1810    }
1811
1812    impl Shared {
1813        fn connect(&self) -> Result<diesel::PgConnection, ExperimentStoreError> {
1814            #[cfg(test)]
1815            self.connect_threads
1816                .lock()
1817                .unwrap_or_else(PoisonError::into_inner)
1818                .push(std::thread::current().id());
1819            crate::db_url::require_postgres_target(&self.database_url, "PgExperimentStore")
1820                .map_err(ExperimentStoreError::Backend)?;
1821            // libpq quotes the target in its error, so redact it.
1822            let mut conn = diesel::PgConnection::establish(&self.connect_url).map_err(|e| {
1823                ExperimentStoreError::Backend(crate::db_url::redact_driver_error(
1824                    &e.to_string(),
1825                    &self.database_url,
1826                ))
1827            })?;
1828            conn.batch_execute(&format!("SET statement_timeout = {STATEMENT_TIMEOUT_MS}"))
1829                .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
1830            Ok(conn)
1831        }
1832
1833        /// `true` when the last refresh attempt is older than the TTL, or
1834        /// older than the retry delay while refreshes fail.
1835        fn is_stale(&self) -> bool {
1836            let wait = retry_delay(self.cache_ttl, self.failures.load(Ordering::Relaxed));
1837            let snapshot = self.snapshot.read().unwrap_or_else(PoisonError::into_inner);
1838            snapshot.checked_at.is_none_or(|checked| {
1839                checked
1840                    .checked_add(wait)
1841                    .is_some_and(|due| crate::time::system_instant() >= due)
1842            })
1843        }
1844
1845        fn is_loaded(&self) -> bool {
1846            self.snapshot
1847                .read()
1848                .unwrap_or_else(PoisonError::into_inner)
1849                .loaded
1850        }
1851
1852        fn mark_stale(&self) {
1853            self.snapshot
1854                .write()
1855                .unwrap_or_else(PoisonError::into_inner)
1856                .checked_at = None;
1857        }
1858
1859        fn read(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError> {
1860            let snapshot = self.snapshot.read().unwrap_or_else(PoisonError::into_inner);
1861            match snapshot.experiments.get(name) {
1862                Some(config) => Ok(Some(config.clone())),
1863                None if snapshot.loaded => Ok(None),
1864                None => Err(ExperimentStoreError::Backend(
1865                    "experiment snapshot not loaded yet; call PgExperimentStore::refresh at startup"
1866                        .to_owned(),
1867                )),
1868            }
1869        }
1870
1871        /// Take the refresh slot, or `None` when a refresh already runs.
1872        fn begin_refresh(self: &Arc<Self>) -> Option<RefreshSlot> {
1873            self.refreshing
1874                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1875                .ok()
1876                .map(|_| RefreshSlot(Arc::clone(self)))
1877        }
1878
1879        /// Load all experiments on this thread and install them. This blocks.
1880        fn reload(&self) -> Result<(), ExperimentStoreError> {
1881            self.load().map(drop)
1882        }
1883
1884        /// Load all experiments on this thread, install them, and return
1885        /// them. This blocks.
1886        fn load(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
1887            let mut conn = self
1888                .refresh_conn
1889                .lock()
1890                .unwrap_or_else(PoisonError::into_inner);
1891            self.snapshot
1892                .write()
1893                .unwrap_or_else(PoisonError::into_inner)
1894                .checked_at = Some(crate::time::system_instant());
1895            let fresh = || {
1896                let mut open = self.connect()?;
1897                let all = load_all(&mut open)?;
1898                Ok((open, all))
1899            };
1900            // A kept connection can be dead. Then retry once on a new one.
1901            let loaded = conn
1902                .take()
1903                .map_or_else(fresh, |mut open| {
1904                    load_all(&mut open)
1905                        .map(|all| (open, all))
1906                        .or_else(|_| fresh())
1907                })
1908                .map(|(open, all)| {
1909                    // Keep the connection only after a good load.
1910                    *conn = Some(open);
1911                    all
1912                });
1913            if let Ok(all) = &loaded {
1914                // Install before the lock drops, so loads install in order.
1915                let mut snapshot = self
1916                    .snapshot
1917                    .write()
1918                    .unwrap_or_else(PoisonError::into_inner);
1919                snapshot.experiments = all.iter().map(|e| (e.name.clone(), e.clone())).collect();
1920                snapshot.loaded = true;
1921            }
1922            drop(conn);
1923            match loaded {
1924                Ok(all) => {
1925                    if self.failures.swap(0, Ordering::AcqRel) > 0 {
1926                        tracing::info!("experiment refresh recovered");
1927                    }
1928                    Ok(all)
1929                }
1930                Err(error) => {
1931                    self.refresh_errors.fetch_add(1, Ordering::Relaxed);
1932                    if self.failures.fetch_add(1, Ordering::AcqRel) == 0 {
1933                        tracing::warn!(
1934                            %error,
1935                            "experiment refresh failed; serving the last-known snapshot"
1936                        );
1937                    } else {
1938                        tracing::debug!(%error, "experiment refresh failed again");
1939                    }
1940                    Err(error)
1941                }
1942            }
1943        }
1944    }
1945
1946    fn load_all(
1947        conn: &mut diesel::PgConnection,
1948    ) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
1949        diesel::sql_query(
1950            "SELECT name, description, state::text, variants::text, winner, \
1951                    exclusion_group, \
1952                    EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_secs \
1953             FROM autumn_experiments ORDER BY name",
1954        )
1955        .load::<ExperimentRow>(conn)
1956        .map(|rows| rows.into_iter().map(ExperimentRow::into_config).collect())
1957        .map_err(|e| ExperimentStoreError::Backend(e.to_string()))
1958    }
1959
1960    // ── PgExperimentStore ─────────────────────────────────────────────────────
1961
1962    /// Postgres-backed [`ExperimentStore`] that serves config reads from memory.
1963    ///
1964    /// `get` reads an in-memory snapshot of all experiments. It does not
1965    /// connect on the calling thread when a Tokio runtime is present. When the
1966    /// snapshot is older than the cache TTL, `get` starts one refresh on the
1967    /// blocking pool and returns the last-known value without waiting. With no runtime,
1968    /// `get` refreshes on the calling thread.
1969    ///
1970    /// When a refresh fails, the store keeps the last-known snapshot, logs a
1971    /// warning and counts the error ([`refresh_errors`](Self::refresh_errors)).
1972    /// Before the first load, `get` returns an error.
1973    ///
1974    /// Per-actor calls (`get_assignment`, `record_assignment`, `get_override`,
1975    /// `has_assignment_in_group`) still connect on the calling thread. In an
1976    /// async handler, use
1977    /// [`Experiments::assign_async`](super::Experiments::assign_async).
1978    ///
1979    /// Load the snapshot at startup: `AppBuilder::with_experiment_store` calls
1980    /// [`ExperimentStore::preload`], and
1981    /// [`spawn_poll_listener`](Self::spawn_poll_listener) refreshes on each
1982    /// interval. Replicas see a remote write on their next refresh.
1983    ///
1984    /// Clone class: shares its snapshot-and-poll shape with `PgFlagStore`
1985    /// (`autumn/src/feature_flags.rs` — see its doc for the full
1986    /// cross-reference and the #3262/#3273 port history) and, less closely,
1987    /// with `PgConfigStore` (`autumn/src/runtime_config.rs`). `retry_delay`,
1988    /// `with_connect_timeout` and `STATEMENT_TIMEOUT_MS` are imported from
1989    /// `feature_flags::pg` rather than redefined here. Unlike `PgFlagStore`,
1990    /// this store has no local-write fast path: every write ends in
1991    /// `refresh_after_write`, a full reload serialized through the same
1992    /// `refresh_conn` lock the background poll uses, so it needs no
1993    /// `generation`/`write_lock` pair to stay race-free.
1994    #[derive(Debug)]
1995    pub struct PgExperimentStore {
1996        shared: Arc<Shared>,
1997    }
1998
1999    impl Clone for PgExperimentStore {
2000        /// Make a new store with the same URL and TTL and an empty snapshot.
2001        fn clone(&self) -> Self {
2002            Self::with_cache_ttl(self.shared.database_url.clone(), self.shared.cache_ttl)
2003        }
2004    }
2005
2006    impl PgExperimentStore {
2007        /// Default snapshot lifetime before `get` starts a refresh.
2008        pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(1);
2009
2010        /// Create a store with the default 1 s snapshot TTL.
2011        #[must_use]
2012        pub fn new(database_url: impl Into<String>) -> Self {
2013            Self::with_cache_ttl(database_url, Self::DEFAULT_CACHE_TTL)
2014        }
2015
2016        /// Create a store with an explicit snapshot TTL. With `Duration::ZERO`,
2017        /// each `get` starts a refresh.
2018        #[must_use]
2019        pub fn with_cache_ttl(database_url: impl Into<String>, cache_ttl: Duration) -> Self {
2020            let database_url = database_url.into();
2021            Self {
2022                shared: Arc::new(Shared {
2023                    connect_url: with_connect_timeout(&database_url),
2024                    database_url,
2025                    cache_ttl,
2026                    snapshot: RwLock::new(Snapshot::default()),
2027                    refreshing: AtomicBool::new(false),
2028                    refresh_conn: Mutex::new(None),
2029                    refresh_errors: AtomicU64::new(0),
2030                    failures: AtomicU32::new(0),
2031                    #[cfg(test)]
2032                    connect_threads: Mutex::new(Vec::new()),
2033                }),
2034            }
2035        }
2036
2037        /// Create a store from Autumn's primary database configuration.
2038        ///
2039        /// Returns `None` when no primary URL is configured, and — since it
2040        /// opens a `diesel::PgConnection` and issues `jsonb` /
2041        /// `pg_advisory_xact_lock` SQL — when the configured target does not
2042        /// name Postgres.
2043        #[must_use]
2044        pub fn from_database_config(config: &crate::config::DatabaseConfig) -> Option<Self> {
2045            config.effective_primary_postgres_url().map(Self::new)
2046        }
2047
2048        /// Load all experiments into the snapshot now. This blocks: call it
2049        /// at startup or from `spawn_blocking`.
2050        ///
2051        /// # Errors
2052        ///
2053        /// Returns [`ExperimentStoreError::Backend`] when the load fails. The
2054        /// last-known snapshot stays in use.
2055        pub fn refresh(&self) -> Result<(), ExperimentStoreError> {
2056            self.shared.reload()
2057        }
2058
2059        /// Number of failed refreshes since the store was made.
2060        #[must_use]
2061        pub fn refresh_errors(&self) -> u64 {
2062            self.shared.refresh_errors.load(Ordering::Relaxed)
2063        }
2064
2065        fn connect(&self) -> Result<diesel::PgConnection, ExperimentStoreError> {
2066            self.shared.connect()
2067        }
2068
2069        /// Make a local write visible to the next `get`. The writer already
2070        /// blocks on its own connect, so it can reload here. When the reload
2071        /// fails, the next `get` starts a refresh.
2072        fn refresh_after_write(&self) {
2073            if self.shared.reload().is_err() {
2074                self.shared.mark_stale();
2075            }
2076        }
2077
2078        /// Spawn a thread that refreshes the snapshot at once and then on each
2079        /// `poll_interval`.
2080        ///
2081        /// Replicas see remote writes on the next refresh. Call this once at
2082        /// startup and share the `Arc` with the app. The thread stops after
2083        /// all clones of the `Arc` drop.
2084        #[allow(
2085            clippy::must_use_candidate,
2086            reason = "the thread runs detached; callers drop the handle"
2087        )]
2088        pub fn spawn_poll_listener(
2089            store: Arc<Self>,
2090            poll_interval: Duration,
2091        ) -> std::thread::JoinHandle<()> {
2092            let shared = Arc::downgrade(&store.shared);
2093            drop(store);
2094            std::thread::spawn(move || {
2095                while let Some(shared) = shared.upgrade() {
2096                    if let Some(slot) = shared.begin_refresh() {
2097                        // `reload` logs and counts its errors. A panic must
2098                        // not stop the polls.
2099                        let reload = std::panic::AssertUnwindSafe(|| slot.0.reload());
2100                        if std::panic::catch_unwind(reload).is_err() {
2101                            tracing::warn!("experiment refresh panicked");
2102                        }
2103                    }
2104                    let wait = retry_delay(poll_interval, shared.failures.load(Ordering::Relaxed));
2105                    drop(shared);
2106                    std::thread::sleep(wait);
2107                }
2108            })
2109        }
2110
2111        #[cfg(test)]
2112        fn install_for_test(&self, experiments: Vec<ExperimentConfig>) {
2113            let mut snapshot = self
2114                .shared
2115                .snapshot
2116                .write()
2117                .unwrap_or_else(PoisonError::into_inner);
2118            snapshot.experiments = experiments
2119                .into_iter()
2120                .map(|e| (e.name.clone(), e))
2121                .collect();
2122            snapshot.loaded = true;
2123            snapshot.checked_at = Some(crate::time::system_instant());
2124        }
2125
2126        #[cfg(test)]
2127        fn connect_threads_for_test(&self) -> Vec<std::thread::ThreadId> {
2128            self.shared
2129                .connect_threads
2130                .lock()
2131                .unwrap_or_else(PoisonError::into_inner)
2132                .clone()
2133        }
2134
2135        #[cfg(test)]
2136        fn refreshing_for_test(&self) -> bool {
2137            self.shared.refreshing.load(Ordering::Acquire)
2138        }
2139
2140        #[cfg(test)]
2141        fn begin_refresh_for_test(&self) -> Option<RefreshSlot> {
2142            self.shared.begin_refresh()
2143        }
2144    }
2145
2146    // ── ExperimentStore impl ──────────────────────────────────────────────────
2147
2148    impl ExperimentStore for PgExperimentStore {
2149        fn get(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError> {
2150            if self.shared.is_stale()
2151                && let Some(slot) = self.shared.begin_refresh()
2152            {
2153                match tokio::runtime::Handle::try_current() {
2154                    // On a runtime: refresh on the blocking pool, never here.
2155                    Ok(_) => {
2156                        drop(crate::time::spawn_blocking(move || {
2157                            // `reload` logs and counts its errors.
2158                            let _ = slot.0.reload();
2159                        }));
2160                    }
2161                    // No runtime: this thread can block. Before the first
2162                    // load, report why it failed.
2163                    Err(_) => {
2164                        if let Err(error) = slot.0.reload()
2165                            && !self.shared.is_loaded()
2166                        {
2167                            return Err(error);
2168                        }
2169                    }
2170                }
2171            }
2172            self.shared.read(name)
2173        }
2174
2175        fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
2176            self.shared.load()
2177        }
2178
2179        fn preload(&self) -> Result<(), ExperimentStoreError> {
2180            self.refresh()
2181        }
2182
2183        fn upsert(&self, config: ExperimentConfig) -> Result<(), ExperimentStoreError> {
2184            let mut conn = self.connect()?;
2185
2186            let active_variants = diesel::sql_query(
2187                "SELECT DISTINCT variant FROM autumn_experiment_assignments WHERE experiment = $1",
2188            )
2189            .bind::<diesel::sql_types::Text, _>(&config.name)
2190            .load::<VariantNameRow>(&mut conn)
2191            .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2192
2193            let new_variants: std::collections::HashSet<&str> =
2194                config.variants.iter().map(|v| v.name.as_str()).collect();
2195
2196            for row in active_variants {
2197                if !new_variants.contains(row.variant.as_str()) {
2198                    return Err(ExperimentStoreError::Backend(format!(
2199                        "cannot delete variant '{}' because it has active assignments",
2200                        row.variant
2201                    )));
2202                }
2203            }
2204
2205            let variants_json =
2206                serde_json::to_string(&config.variants).unwrap_or_else(|_| "[]".to_owned());
2207            let state_str = config.state.to_string();
2208            let rows_affected = diesel::sql_query(
2209                "WITH upserted AS ( \
2210                     INSERT INTO autumn_experiments \
2211                         (name, description, state, variants, winner, exclusion_group) \
2212                     VALUES ($1, $2, $3::autumn_experiment_state, $4::jsonb, $5, $6) \
2213                     ON CONFLICT (name) DO UPDATE SET \
2214                         description = EXCLUDED.description, \
2215                         state = EXCLUDED.state, \
2216                         variants = EXCLUDED.variants, \
2217                         winner = EXCLUDED.winner, \
2218                         exclusion_group = EXCLUDED.exclusion_group, \
2219                         updated_at = NOW() \
2220                     WHERE NOT EXISTS ( \
2221                         SELECT 1 FROM autumn_experiment_assignments a \
2222                         WHERE a.experiment = EXCLUDED.name \
2223                           AND a.variant NOT IN ( \
2224                               SELECT x.name FROM jsonb_to_recordset(EXCLUDED.variants) AS x(name text) \
2225                           ) \
2226                     ) \
2227                     RETURNING name, (xmax = 0) AS is_insert \
2228                 ) \
2229                 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
2230                 SELECT name, CASE WHEN is_insert THEN 'created' ELSE 'updated' END, NULL FROM upserted",
2231            )
2232            .bind::<diesel::sql_types::Text, _>(&config.name)
2233            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(config.description)
2234            .bind::<diesel::sql_types::Text, _>(&state_str)
2235            .bind::<diesel::sql_types::Text, _>(&variants_json)
2236            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(config.winner)
2237            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(config.exclusion_group)
2238            .execute(&mut conn)
2239            .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2240
2241            if rows_affected == 0 {
2242                return Err(ExperimentStoreError::Backend(
2243                    "cannot delete variant because it has active assignments".to_owned(),
2244                ));
2245            }
2246
2247            self.refresh_after_write();
2248            Ok(())
2249        }
2250
2251        fn set_state(
2252            &self,
2253            name: &str,
2254            state: ExperimentState,
2255            winner: Option<&str>,
2256        ) -> Result<(), ExperimentStoreError> {
2257            let state_str = state.to_string();
2258            let mutation =
2259                winner.map_or_else(|| format!("state={state}"), |w| format!("concluded={w}"));
2260            let mut conn = self.connect()?;
2261            diesel::sql_query(
2262                "WITH updated AS ( \
2263                     UPDATE autumn_experiments \
2264                     SET state = $2::autumn_experiment_state, \
2265                         winner = COALESCE($3, winner), \
2266                         updated_at = NOW() \
2267                     WHERE name = $1 \
2268                     RETURNING name \
2269                 ) \
2270                 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
2271                 SELECT name, $4, NULL FROM updated",
2272            )
2273            .bind::<diesel::sql_types::Text, _>(name)
2274            .bind::<diesel::sql_types::Text, _>(&state_str)
2275            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
2276                winner.map(str::to_owned),
2277            )
2278            .bind::<diesel::sql_types::Text, _>(&mutation)
2279            .execute(&mut conn)
2280            .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2281            self.refresh_after_write();
2282            Ok(())
2283        }
2284
2285        fn set_variants(
2286            &self,
2287            name: &str,
2288            variants: Vec<VariantConfig>,
2289            actor: Option<&str>,
2290        ) -> Result<(), ExperimentStoreError> {
2291            let mut conn = self.connect()?;
2292
2293            let active_variants = diesel::sql_query(
2294                "SELECT DISTINCT variant FROM autumn_experiment_assignments WHERE experiment = $1",
2295            )
2296            .bind::<diesel::sql_types::Text, _>(name)
2297            .load::<VariantNameRow>(&mut conn)
2298            .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2299
2300            let new_variants: std::collections::HashSet<&str> =
2301                variants.iter().map(|v| v.name.as_str()).collect();
2302
2303            for row in active_variants {
2304                if !new_variants.contains(row.variant.as_str()) {
2305                    return Err(ExperimentStoreError::Backend(format!(
2306                        "cannot delete variant '{}' because it has active assignments",
2307                        row.variant
2308                    )));
2309                }
2310            }
2311
2312            let variants_json =
2313                serde_json::to_string(&variants).unwrap_or_else(|_| "[]".to_owned());
2314            let rows_affected = diesel::sql_query(
2315                "WITH updated AS ( \
2316                     UPDATE autumn_experiments \
2317                     SET variants = $2::jsonb, updated_at = NOW() \
2318                     WHERE name = $1 \
2319                       AND NOT EXISTS ( \
2320                           SELECT 1 FROM autumn_experiment_assignments a \
2321                           WHERE a.experiment = name \
2322                             AND a.variant NOT IN ( \
2323                                 SELECT x.name FROM jsonb_to_recordset($2::jsonb) AS x(name text) \
2324                             ) \
2325                       ) \
2326                     RETURNING name \
2327                 ) \
2328                 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
2329                 SELECT name, 'set_weights', $3 FROM updated",
2330            )
2331            .bind::<diesel::sql_types::Text, _>(name)
2332            .bind::<diesel::sql_types::Text, _>(&variants_json)
2333            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
2334                actor.map(str::to_owned),
2335            )
2336            .execute(&mut conn)
2337            .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2338
2339            if rows_affected == 0 {
2340                let exists_row = diesel::sql_query(
2341                    "SELECT EXISTS(SELECT 1 FROM autumn_experiments WHERE name = $1) AS result",
2342                )
2343                .bind::<diesel::sql_types::Text, _>(name)
2344                .get_result::<BoolRow>(&mut conn)
2345                .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2346
2347                if exists_row.result {
2348                    return Err(ExperimentStoreError::Backend(
2349                        "cannot delete variant because it has active assignments".to_owned(),
2350                    ));
2351                }
2352            }
2353
2354            self.refresh_after_write();
2355            Ok(())
2356        }
2357
2358        fn get_assignment(
2359            &self,
2360            experiment: &str,
2361            actor: &str,
2362        ) -> Result<Option<Assignment>, ExperimentStoreError> {
2363            let mut conn = self.connect()?;
2364            diesel::sql_query(
2365                "SELECT experiment, actor, variant, is_override, \
2366                        EXTRACT(EPOCH FROM assigned_at)::bigint AS assigned_at_secs \
2367                 FROM autumn_experiment_assignments \
2368                 WHERE experiment = $1 AND actor = $2",
2369            )
2370            .bind::<diesel::sql_types::Text, _>(experiment)
2371            .bind::<diesel::sql_types::Text, _>(actor)
2372            .get_result::<AssignmentRow>(&mut conn)
2373            .optional()
2374            .map(|r| {
2375                r.map(|row| Assignment {
2376                    experiment: row.experiment,
2377                    actor: row.actor,
2378                    variant: row.variant,
2379                    is_override: row.is_override,
2380                    assigned_at_secs: u64::try_from(row.assigned_at_secs).unwrap_or(0),
2381                })
2382            })
2383            .map_err(|e| ExperimentStoreError::Backend(e.to_string()))
2384        }
2385
2386        fn record_assignment(
2387            &self,
2388            assignment: Assignment,
2389        ) -> Result<String, ExperimentStoreError> {
2390            use diesel::connection::Connection as _;
2391
2392            #[derive(Debug)]
2393            enum TxError {
2394                Database(diesel::result::Error),
2395                Excluded(String),
2396            }
2397            impl From<diesel::result::Error> for TxError {
2398                fn from(e: diesel::result::Error) -> Self {
2399                    Self::Database(e)
2400                }
2401            }
2402
2403            let mut conn = self.connect()?;
2404            let result = conn.transaction::<String, TxError, _>(|conn| {
2405                // 1. Acquire advisory lock on actor to serialize concurrent assignments for this actor.
2406                diesel::sql_query("SELECT pg_advisory_xact_lock(hashtext($1))")
2407                    .bind::<diesel::sql_types::Text, _>(&assignment.actor)
2408                    .execute(conn)?;
2409
2410                // 2. Check exclusion group if this is NOT an override.
2411                if !assignment.is_override {
2412                    // Fetch the exclusion group for this experiment.
2413                    let group_row = diesel::sql_query(
2414                        "SELECT exclusion_group FROM autumn_experiments WHERE name = $1"
2415                    )
2416                    .bind::<diesel::sql_types::Text, _>(&assignment.experiment)
2417                    .get_result::<ExclusionGroupRow>(conn)
2418                    .optional()?;
2419
2420                    if let Some(ExclusionGroupRow { exclusion_group: Some(group) }) = group_row {
2421                        // Check if there are other assignments in the same group.
2422                        let exists = diesel::sql_query(
2423                            "SELECT EXISTS ( \
2424                                 SELECT 1 \
2425                                 FROM autumn_experiment_assignments a \
2426                                 JOIN autumn_experiments e ON e.name = a.experiment \
2427                                 WHERE a.actor = $1 \
2428                                   AND e.exclusion_group = $2 \
2429                                   AND a.experiment <> $3 \
2430                             ) AS result",
2431                        )
2432                        .bind::<diesel::sql_types::Text, _>(&assignment.actor)
2433                        .bind::<diesel::sql_types::Text, _>(&group)
2434                        .bind::<diesel::sql_types::Text, _>(&assignment.experiment)
2435                        .get_result::<BoolRow>(conn)
2436                        .map(|r| r.result)?;
2437
2438                        if exists {
2439                            return Err(TxError::Excluded(group));
2440                        }
2441                    }
2442                }
2443
2444                // 3. Insert or update on conflict.
2445                let variant_name = diesel::sql_query(
2446                    "INSERT INTO autumn_experiment_assignments \
2447                         (experiment, actor, variant, is_override) \
2448                     VALUES ($1, $2, $3, $4) \
2449                     ON CONFLICT (experiment, actor) DO UPDATE \
2450                         SET variant = CASE WHEN EXCLUDED.is_override THEN EXCLUDED.variant ELSE autumn_experiment_assignments.variant END, \
2451                             is_override = CASE WHEN EXCLUDED.is_override THEN EXCLUDED.is_override ELSE autumn_experiment_assignments.is_override END \
2452                     RETURNING variant",
2453                )
2454                .bind::<diesel::sql_types::Text, _>(&assignment.experiment)
2455                .bind::<diesel::sql_types::Text, _>(&assignment.actor)
2456                .bind::<diesel::sql_types::Text, _>(&assignment.variant)
2457                .bind::<diesel::sql_types::Bool, _>(assignment.is_override)
2458                .get_result::<VariantNameRow>(conn)
2459                .map(|r| r.variant)?;
2460
2461                Ok(variant_name)
2462            });
2463
2464            match result {
2465                Ok(v) => Ok(v),
2466                Err(TxError::Database(e)) => Err(ExperimentStoreError::Backend(e.to_string())),
2467                Err(TxError::Excluded(group)) => Err(ExperimentStoreError::Backend(format!(
2468                    "ExcludedByGroup:{group}"
2469                ))),
2470            }
2471        }
2472
2473        fn get_override(
2474            &self,
2475            experiment: &str,
2476            actor: &str,
2477        ) -> Result<Option<String>, ExperimentStoreError> {
2478            let mut conn = self.connect()?;
2479            diesel::sql_query(
2480                "SELECT variant FROM autumn_experiment_overrides \
2481                 WHERE experiment = $1 AND actor = $2",
2482            )
2483            .bind::<diesel::sql_types::Text, _>(experiment)
2484            .bind::<diesel::sql_types::Text, _>(actor)
2485            .get_result::<VariantNameRow>(&mut conn)
2486            .optional()
2487            .map(|r| r.map(|row| row.variant))
2488            .map_err(|e| ExperimentStoreError::Backend(e.to_string()))
2489        }
2490
2491        fn set_override(
2492            &self,
2493            experiment: &str,
2494            actor: &str,
2495            variant: &str,
2496        ) -> Result<(), ExperimentStoreError> {
2497            let mut conn = self.connect()?;
2498            diesel::sql_query(
2499                "WITH upserted AS ( \
2500                     INSERT INTO autumn_experiment_overrides (experiment, actor, variant) \
2501                     VALUES ($1, $2, $3) \
2502                     ON CONFLICT (experiment, actor) DO UPDATE SET variant = EXCLUDED.variant \
2503                     RETURNING experiment, actor, variant \
2504                 ) \
2505                 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
2506                 SELECT experiment, 'override=' || actor || ':' || variant, NULL FROM upserted",
2507            )
2508            .bind::<diesel::sql_types::Text, _>(experiment)
2509            .bind::<diesel::sql_types::Text, _>(actor)
2510            .bind::<diesel::sql_types::Text, _>(variant)
2511            .execute(&mut conn)
2512            .map_err(|e| ExperimentStoreError::Backend(e.to_string()))?;
2513            Ok(())
2514        }
2515
2516        fn has_assignment_in_group(
2517            &self,
2518            actor: &str,
2519            group: &str,
2520            exclude_experiment: &str,
2521        ) -> Result<bool, ExperimentStoreError> {
2522            let mut conn = self.connect()?;
2523            diesel::sql_query(
2524                "SELECT EXISTS ( \
2525                     SELECT 1 \
2526                     FROM autumn_experiment_assignments a \
2527                     JOIN autumn_experiments e ON e.name = a.experiment \
2528                     WHERE a.actor = $1 \
2529                       AND e.exclusion_group = $2 \
2530                       AND a.experiment <> $3 \
2531                 ) AS result",
2532            )
2533            .bind::<diesel::sql_types::Text, _>(actor)
2534            .bind::<diesel::sql_types::Text, _>(group)
2535            .bind::<diesel::sql_types::Text, _>(exclude_experiment)
2536            .get_result::<BoolRow>(&mut conn)
2537            .map(|r| r.result)
2538            .map_err(|e| ExperimentStoreError::Backend(e.to_string()))
2539        }
2540
2541        fn history(
2542            &self,
2543            experiment: &str,
2544            limit: usize,
2545        ) -> Result<Vec<ChangeRecord>, ExperimentStoreError> {
2546            let limit = i64::try_from(limit).unwrap_or(i64::MAX);
2547            let mut conn = self.connect()?;
2548            diesel::sql_query(
2549                "SELECT experiment, mutation, actor, \
2550                        EXTRACT(EPOCH FROM changed_at)::bigint AS timestamp_secs \
2551                 FROM autumn_experiment_changes \
2552                 WHERE experiment = $1 \
2553                 ORDER BY changed_at DESC \
2554                 LIMIT NULLIF($2::bigint, 0)",
2555            )
2556            .bind::<diesel::sql_types::Text, _>(experiment)
2557            .bind::<diesel::sql_types::BigInt, _>(limit)
2558            .load::<ChangeRow>(&mut conn)
2559            .map(|rows| {
2560                rows.into_iter()
2561                    .map(|r| ChangeRecord {
2562                        experiment: r.experiment,
2563                        mutation: r.mutation,
2564                        actor: r.actor,
2565                        timestamp_secs: u64::try_from(r.timestamp_secs).unwrap_or(0),
2566                    })
2567                    .collect()
2568            })
2569            .map_err(|e| ExperimentStoreError::Backend(e.to_string()))
2570        }
2571    }
2572
2573    #[cfg(test)]
2574    mod pg_tests {
2575        use super::*;
2576
2577        /// Nothing listens on port 1, so a connect fails at once.
2578        const DEAD_URL: &str = "postgres://autumn@127.0.0.1:1/autumn?connect_timeout=2";
2579
2580        fn running(name: &str) -> ExperimentConfig {
2581            let mut config = ExperimentConfig::new(name, vec![VariantConfig::new("control", 100)]);
2582            config.state = ExperimentState::Running;
2583            // `new` stamps the wall clock. Pin it so equal configs compare equal.
2584            config.updated_at_secs = 0;
2585            config
2586        }
2587
2588        fn current_thread_runtime() -> tokio::runtime::Runtime {
2589            tokio::runtime::Builder::new_current_thread()
2590                .enable_all()
2591                .build()
2592                .unwrap()
2593        }
2594
2595        /// Wait (on the runtime) until no background refresh runs.
2596        async fn settle(store: &PgExperimentStore) {
2597            for _ in 0..1000 {
2598                if !store.refreshing_for_test() {
2599                    return;
2600                }
2601                tokio::time::sleep(Duration::from_millis(10)).await;
2602            }
2603            panic!("background refresh did not finish");
2604        }
2605
2606        #[test]
2607        fn get_serves_last_known_value_when_refresh_fails() {
2608            let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
2609            store.install_for_test(vec![running("exp")]);
2610
2611            // No runtime here, so `get` refreshes on this thread. It fails.
2612            assert_eq!(store.get("exp").unwrap(), Some(running("exp")));
2613            assert_eq!(store.get("absent").unwrap(), None);
2614            assert_eq!(store.refresh_errors(), 2);
2615        }
2616
2617        #[test]
2618        fn get_within_ttl_does_not_connect() {
2619            let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
2620            store.install_for_test(vec![running("exp")]);
2621            assert_eq!(store.get("exp").unwrap(), Some(running("exp")));
2622            assert!(store.connect_threads_for_test().is_empty());
2623        }
2624
2625        #[test]
2626        fn get_on_runtime_never_connects_on_the_worker() {
2627            let store =
2628                std::sync::Arc::new(PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::ZERO));
2629            store.install_for_test(vec![running("exp")]);
2630
2631            current_thread_runtime().block_on(async {
2632                let worker = std::thread::current().id();
2633                assert_eq!(store.get("exp").unwrap(), Some(running("exp")));
2634                settle(&store).await;
2635
2636                let threads = store.connect_threads_for_test();
2637                assert!(!threads.is_empty(), "the stale snapshot is refreshed");
2638                assert!(!threads.contains(&worker), "no connect on the Tokio worker");
2639            });
2640            assert!(store.refresh_errors() >= 1);
2641        }
2642
2643        #[test]
2644        fn cold_get_on_runtime_errors_and_loads_in_background() {
2645            let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
2646            current_thread_runtime().block_on(async {
2647                let worker = std::thread::current().id();
2648                assert!(store.get("exp").is_err(), "no snapshot yet");
2649                settle(&store).await;
2650                let threads = store.connect_threads_for_test();
2651                assert!(!threads.is_empty());
2652                assert!(!threads.contains(&worker));
2653            });
2654        }
2655
2656        #[test]
2657        fn only_one_refresh_runs_at_a_time() {
2658            let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
2659            let slot = store.begin_refresh_for_test();
2660            assert!(slot.is_some());
2661            assert!(store.begin_refresh_for_test().is_none());
2662            drop(slot);
2663            assert!(store.begin_refresh_for_test().is_some());
2664        }
2665
2666        #[test]
2667        fn failed_refresh_after_write_marks_the_snapshot_stale() {
2668            let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
2669            store.install_for_test(vec![running("exp")]);
2670            assert!(!store.shared.is_stale());
2671            store.refresh_after_write();
2672            assert!(store.shared.is_stale());
2673        }
2674
2675        #[test]
2676        fn cold_get_without_runtime_reports_the_refresh_error() {
2677            let store = PgExperimentStore::new(
2678                "postgres://autumn:hunter2@127.0.0.1:1/autumn?connect_timeout=2",
2679            );
2680            let error = store.get("exp").unwrap_err().to_string();
2681            assert!(!error.contains("not loaded"), "{error}");
2682            assert!(!error.contains("hunter2"), "{error}");
2683        }
2684
2685        #[test]
2686        fn cold_get_while_a_refresh_runs_does_not_connect() {
2687            let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::ZERO);
2688            let _slot = store.begin_refresh_for_test();
2689            let error = store.get("exp").unwrap_err().to_string();
2690            assert!(error.contains("not loaded"), "{error}");
2691            assert!(store.connect_threads_for_test().is_empty());
2692        }
2693
2694        #[test]
2695        fn refresh_reports_backend_error() {
2696            let store = PgExperimentStore::new(DEAD_URL);
2697            assert!(store.refresh().is_err());
2698            assert_eq!(store.refresh_errors(), 1);
2699            assert!(store.preload().is_err());
2700        }
2701
2702        #[test]
2703        fn refresh_error_hides_the_password() {
2704            let store = PgExperimentStore::new(
2705                "postgres://autumn:hunter2@127.0.0.1:1/autumn?connect_timeout=2",
2706            );
2707            let error = store.refresh().unwrap_err().to_string();
2708            assert!(!error.contains("hunter2"), "{error}");
2709        }
2710
2711        #[test]
2712        fn debug_output_hides_the_password() {
2713            let store = PgExperimentStore::new(
2714                "postgres://autumn:hunter2@127.0.0.1:1/autumn?connect_timeout=2",
2715            );
2716            let shown = format!("{store:?}");
2717            assert!(shown.contains("PgExperimentStore"), "{shown}");
2718            assert!(!shown.contains("hunter2"), "{shown}");
2719        }
2720
2721        #[test]
2722        fn clone_keeps_the_ttl_and_starts_with_an_empty_snapshot() {
2723            let store = PgExperimentStore::with_cache_ttl(DEAD_URL, Duration::from_secs(60));
2724            store.install_for_test(vec![running("exp")]);
2725            let copy = store.clone();
2726            assert_eq!(copy.shared.cache_ttl, Duration::from_secs(60));
2727            assert!(!copy.shared.is_loaded());
2728            assert!(store.shared.is_loaded(), "the original keeps its snapshot");
2729        }
2730
2731        #[test]
2732        fn poll_listener_refreshes_until_the_store_drops() {
2733            let store = std::sync::Arc::new(PgExperimentStore::new(DEAD_URL));
2734            let handle = PgExperimentStore::spawn_poll_listener(
2735                std::sync::Arc::clone(&store),
2736                Duration::from_millis(10),
2737            );
2738            for _ in 0..1000 {
2739                if store.refresh_errors() >= 1 {
2740                    break;
2741                }
2742                std::thread::sleep(Duration::from_millis(10));
2743            }
2744            assert!(store.refresh_errors() >= 1, "the listener refreshed");
2745            drop(store);
2746            for _ in 0..3000 {
2747                if handle.is_finished() {
2748                    return;
2749                }
2750                std::thread::sleep(Duration::from_millis(10));
2751            }
2752            panic!("poll listener outlived its store");
2753        }
2754
2755        #[test]
2756        fn poll_listener_stops_when_the_store_drops() {
2757            let store = std::sync::Arc::new(PgExperimentStore::new(DEAD_URL));
2758            let handle = PgExperimentStore::spawn_poll_listener(
2759                std::sync::Arc::clone(&store),
2760                Duration::from_millis(10),
2761            );
2762            drop(store);
2763            for _ in 0..1000 {
2764                if handle.is_finished() {
2765                    return;
2766                }
2767                std::thread::sleep(Duration::from_millis(10));
2768            }
2769            panic!("poll listener outlived its store");
2770        }
2771    }
2772}
2773
2774// ── Test probe ────────────────────────────────────────────────────────────────
2775
2776/// In-memory store that counts `preload` calls and records the thread of each
2777/// per-actor call.
2778#[cfg(test)]
2779#[derive(Default)]
2780pub(crate) struct ProbeStore {
2781    inner: InMemoryExperimentStore,
2782    pub(crate) preloads: std::sync::atomic::AtomicUsize,
2783    pub(crate) threads: Mutex<Vec<std::thread::ThreadId>>,
2784    /// Make `get_assignment` panic.
2785    panic_on_assignment: std::sync::atomic::AtomicBool,
2786}
2787
2788#[cfg(test)]
2789impl ProbeStore {
2790    fn seen(&self) {
2791        self.threads
2792            .lock()
2793            .unwrap()
2794            .push(std::thread::current().id());
2795    }
2796}
2797
2798#[cfg(test)]
2799impl ExperimentStore for ProbeStore {
2800    fn get(&self, name: &str) -> Result<Option<ExperimentConfig>, ExperimentStoreError> {
2801        self.inner.get(name)
2802    }
2803    fn list(&self) -> Result<Vec<ExperimentConfig>, ExperimentStoreError> {
2804        self.inner.list()
2805    }
2806    fn upsert(&self, config: ExperimentConfig) -> Result<(), ExperimentStoreError> {
2807        self.inner.upsert(config)
2808    }
2809    fn set_state(
2810        &self,
2811        name: &str,
2812        state: ExperimentState,
2813        winner: Option<&str>,
2814    ) -> Result<(), ExperimentStoreError> {
2815        self.inner.set_state(name, state, winner)
2816    }
2817    fn set_variants(
2818        &self,
2819        name: &str,
2820        variants: Vec<VariantConfig>,
2821        actor: Option<&str>,
2822    ) -> Result<(), ExperimentStoreError> {
2823        self.inner.set_variants(name, variants, actor)
2824    }
2825    fn get_assignment(
2826        &self,
2827        experiment: &str,
2828        actor: &str,
2829    ) -> Result<Option<Assignment>, ExperimentStoreError> {
2830        self.seen();
2831        assert!(
2832            !self
2833                .panic_on_assignment
2834                .load(std::sync::atomic::Ordering::SeqCst),
2835            "store call panicked"
2836        );
2837        self.inner.get_assignment(experiment, actor)
2838    }
2839    fn record_assignment(&self, assignment: Assignment) -> Result<String, ExperimentStoreError> {
2840        self.seen();
2841        self.inner.record_assignment(assignment)
2842    }
2843    fn get_override(
2844        &self,
2845        experiment: &str,
2846        actor: &str,
2847    ) -> Result<Option<String>, ExperimentStoreError> {
2848        self.seen();
2849        self.inner.get_override(experiment, actor)
2850    }
2851    fn set_override(
2852        &self,
2853        experiment: &str,
2854        actor: &str,
2855        variant: &str,
2856    ) -> Result<(), ExperimentStoreError> {
2857        self.inner.set_override(experiment, actor, variant)
2858    }
2859    fn has_assignment_in_group(
2860        &self,
2861        actor: &str,
2862        group: &str,
2863        exclude_experiment: &str,
2864    ) -> Result<bool, ExperimentStoreError> {
2865        self.seen();
2866        self.inner
2867            .has_assignment_in_group(actor, group, exclude_experiment)
2868    }
2869    fn history(
2870        &self,
2871        experiment: &str,
2872        limit: usize,
2873    ) -> Result<Vec<ChangeRecord>, ExperimentStoreError> {
2874        self.inner.history(experiment, limit)
2875    }
2876    fn preload(&self) -> Result<(), ExperimentStoreError> {
2877        self.preloads
2878            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2879        Ok(())
2880    }
2881}
2882
2883// ── Tests ─────────────────────────────────────────────────────────────────────
2884
2885#[cfg(test)]
2886mod tests {
2887    use super::*;
2888
2889    // ── Helpers ───────────────────────────────────────────────────────────────
2890
2891    fn make_svc() -> ExperimentService {
2892        ExperimentService::new(Arc::new(InMemoryExperimentStore::new()))
2893    }
2894
2895    fn running_config(name: &str) -> ExperimentConfig {
2896        let mut config = ExperimentConfig::new(
2897            name,
2898            vec![
2899                VariantConfig::new("control", 50),
2900                VariantConfig::new("b", 50),
2901            ],
2902        );
2903        config.state = ExperimentState::Running;
2904        config
2905    }
2906
2907    #[tokio::test(flavor = "current_thread")]
2908    async fn assign_async_runs_store_calls_off_the_worker() {
2909        let store = Arc::new(ProbeStore::default());
2910        store.upsert(running_config("exp")).unwrap();
2911        let svc = ExperimentService::new(store.clone());
2912        let worker = std::thread::current().id();
2913
2914        let first = svc.assign_async("exp", "user:1", None).await.unwrap();
2915        let again = svc.assign_async("exp", "user:1", None).await.unwrap();
2916
2917        assert_eq!(first, again, "assignment stays sticky");
2918        let threads = store.threads.lock().unwrap().clone();
2919        assert!(!threads.is_empty(), "the store was called");
2920        assert!(!threads.contains(&worker), "no store call on the worker");
2921    }
2922
2923    #[tokio::test(flavor = "current_thread")]
2924    async fn experiments_assign_async_uses_actor_and_runs_off_the_worker() {
2925        let store = Arc::new(ProbeStore::default());
2926        store.upsert(running_config("exp")).unwrap();
2927        let exps = Experiments {
2928            service: ExperimentService::new(store.clone()),
2929            actor_id: Some("user:7".to_owned()),
2930            request_id: None,
2931        };
2932        let worker = std::thread::current().id();
2933
2934        let variant = exps.assign_async("exp").await.unwrap();
2935
2936        let threads = store.threads.lock().unwrap().clone();
2937        assert!(!threads.contains(&worker));
2938        let sticky = store.get_assignment("exp", "user:7").unwrap().unwrap();
2939        assert_eq!(sticky.variant, variant);
2940    }
2941
2942    #[tokio::test]
2943    async fn assign_async_maps_a_store_panic_to_a_store_error() {
2944        let store = Arc::new(ProbeStore::default());
2945        store.upsert(running_config("exp")).unwrap();
2946        store
2947            .panic_on_assignment
2948            .store(true, std::sync::atomic::Ordering::SeqCst);
2949        let svc = ExperimentService::new(store);
2950        let result = svc.assign_async("exp", "user:1", None).await;
2951        assert!(
2952            matches!(
2953                &result,
2954                Err(ExperimentError::Store(ExperimentStoreError::Backend(m)))
2955                    if m.contains("assignment task failed")
2956            ),
2957            "{result:?}"
2958        );
2959    }
2960
2961    #[test]
2962    fn preload_with_returns_after_the_wait_when_the_load_is_slow() {
2963        let started = std::time::Instant::now();
2964        crate::feature_flags::preload_with(
2965            "test",
2966            "none",
2967            std::time::Duration::from_millis(10),
2968            || {
2969                std::thread::sleep(std::time::Duration::from_millis(300));
2970                Ok::<(), String>(())
2971            },
2972        );
2973        assert!(started.elapsed() < std::time::Duration::from_millis(250));
2974    }
2975
2976    #[test]
2977    fn preload_with_returns_when_the_load_panics_or_fails() {
2978        let wait = std::time::Duration::from_secs(5);
2979        crate::feature_flags::preload_with("test", "none", wait, || -> Result<(), String> {
2980            panic!("load panicked")
2981        });
2982        crate::feature_flags::preload_with("test", "none", wait, || Err("failed".to_owned()));
2983    }
2984
2985    #[test]
2986    fn probe_store_delegates_to_the_in_memory_store() {
2987        let store = ProbeStore::default();
2988        store.upsert(running_config("exp")).unwrap();
2989        store
2990            .set_state("exp", ExperimentState::Concluded, Some("control"))
2991            .unwrap();
2992        store
2993            .set_variants("exp", vec![VariantConfig::new("control", 100)], None)
2994            .unwrap();
2995        store.set_override("exp", "user:1", "control").unwrap();
2996        assert_eq!(store.list().unwrap().len(), 1);
2997        assert_eq!(
2998            store.get("exp").unwrap().unwrap().winner.as_deref(),
2999            Some("control")
3000        );
3001        assert!(!store.history("exp", 10).unwrap().is_empty());
3002    }
3003
3004    #[test]
3005    fn store_preload_defaults_to_ok() {
3006        assert!(InMemoryExperimentStore::new().preload().is_ok());
3007        let probe = Arc::new(ProbeStore::default());
3008        probe.preload().unwrap();
3009        assert_eq!(probe.preloads.load(std::sync::atomic::Ordering::SeqCst), 1);
3010    }
3011
3012    fn make_svc_with_sink() -> (ExperimentService, Arc<Mutex<Vec<ExposureRecord>>>) {
3013        let (sink, records) = RecordingExposureSink::new();
3014        let svc = ExperimentService::new(Arc::new(InMemoryExperimentStore::new()))
3015            .with_exposure_sink(Arc::new(sink));
3016        (svc, records)
3017    }
3018
3019    fn fifty_fifty(name: &str) -> ExperimentConfig {
3020        ExperimentConfig::new(
3021            name,
3022            vec![
3023                VariantConfig::new("control", 50),
3024                VariantConfig::new("treatment", 50),
3025            ],
3026        )
3027    }
3028
3029    fn running(svc: &ExperimentService, name: &str) {
3030        svc.create(fifty_fifty(name)).unwrap();
3031        svc.start(name).unwrap();
3032    }
3033
3034    // ═══════════════════════════════════ RED PHASE ═══════════════════════════
3035    // These tests were written before the full implementation existed and drove
3036    // the design of ExperimentService, ExperimentStore, and ExposureSink.
3037    // ═════════════════════════════════════════════════════════════════════════
3038
3039    // ── AC: assign() returns unknown-experiment error ─────────────────────────
3040
3041    #[test]
3042    fn assign_unknown_experiment_returns_not_found() {
3043        let svc = make_svc();
3044        let err = svc.assign("ghost", "user:1").unwrap_err();
3045        assert!(
3046            matches!(err, ExperimentError::NotFound(_)),
3047            "expected NotFound, got {err}"
3048        );
3049    }
3050
3051    // ── AC: lifecycle — draft rejects assignments ─────────────────────────────
3052
3053    #[test]
3054    fn assign_draft_experiment_returns_not_running() {
3055        let svc = make_svc();
3056        svc.create(fifty_fifty("exp")).unwrap();
3057        let err = svc.assign("exp", "user:1").unwrap_err();
3058        assert!(
3059            matches!(err, ExperimentError::NotRunning(_, ExperimentState::Draft)),
3060            "expected NotRunning(Draft), got {err}"
3061        );
3062    }
3063
3064    // ── AC: lifecycle — archived rejects assignments ──────────────────────────
3065
3066    #[test]
3067    fn assign_archived_experiment_returns_archived() {
3068        let svc = make_svc();
3069        running(&svc, "exp");
3070        svc.archive("exp").unwrap();
3071        let err = svc.assign("exp", "user:1").unwrap_err();
3072        assert!(
3073            matches!(err, ExperimentError::Archived(_)),
3074            "expected Archived, got {err}"
3075        );
3076    }
3077
3078    // ── AC: lifecycle — concluded returns winner for all actors ───────────────
3079
3080    #[test]
3081    fn concluded_experiment_returns_winner_for_all_actors() {
3082        let svc = make_svc();
3083        running(&svc, "exp");
3084        svc.conclude("exp", "treatment").unwrap();
3085        // Every actor sees the winner — regardless of their bucket.
3086        for i in 0..100_u32 {
3087            let actor = format!("user:{i}");
3088            let v = svc.assign("exp", &actor).unwrap();
3089            assert_eq!(
3090                v, "treatment",
3091                "concluded experiment must return winner for {actor}"
3092            );
3093        }
3094    }
3095
3096    // ── AC: concluded experiment emits no new exposures ───────────────────────
3097
3098    #[test]
3099    fn concluded_experiment_emits_no_exposures() {
3100        let (svc, records) = make_svc_with_sink();
3101        let store = Arc::new(InMemoryExperimentStore::new());
3102        let (sink2, records2) = RecordingExposureSink::new();
3103        let svc2 = ExperimentService::new(store as Arc<dyn ExperimentStore>)
3104            .with_exposure_sink(Arc::new(sink2));
3105        svc2.create(fifty_fifty("exp")).unwrap();
3106        svc2.start("exp").unwrap();
3107        svc2.conclude("exp", "treatment").unwrap();
3108        svc2.assign("exp", "user:1").unwrap();
3109        assert_eq!(
3110            records2.lock().unwrap().len(),
3111            0,
3112            "concluded experiment must not emit exposure events"
3113        );
3114        let _ = records; // silence unused-variable warning for the other sink
3115        let _ = svc;
3116    }
3117
3118    // ── AC: assign() returns deterministic variant for running experiment ─────
3119
3120    #[test]
3121    fn assign_running_experiment_returns_valid_variant() {
3122        let svc = make_svc();
3123        running(&svc, "exp");
3124        let v = svc.assign("exp", "user:1").unwrap();
3125        assert!(
3126            v == "control" || v == "treatment",
3127            "variant must be one of the declared names, got {v:?}"
3128        );
3129    }
3130
3131    // ── AC: deterministic bucketing — same actor always gets same variant ─────
3132
3133    #[test]
3134    fn assign_is_deterministic_for_same_actor() {
3135        let svc = make_svc();
3136        running(&svc, "exp");
3137        let v1 = svc.assign("exp", "user:42").unwrap();
3138        let v2 = svc.assign("exp", "exp").unwrap(); // different key — just to exercise more
3139        // Re-create without sticky to verify pure hash determinism.
3140        let svc2 = make_svc();
3141        running(&svc2, "exp");
3142        let v3 = svc2.assign("exp", "user:42").unwrap();
3143        assert_eq!(
3144            v1, v3,
3145            "same actor must receive the same variant across different service instances"
3146        );
3147        let _ = v2;
3148    }
3149
3150    // ── AC: 10 000 stable requests — zero reassignments ───────────────────────
3151
3152    #[test]
3153    fn zero_reassignments_across_10000_requests() {
3154        let svc = make_svc();
3155        running(&svc, "exp");
3156        let first = svc.assign("exp", "stable_user").unwrap();
3157        for _ in 1..10_000 {
3158            let v = svc.assign("exp", "stable_user").unwrap();
3159            assert_eq!(v, first, "re-assignment must return the same variant");
3160        }
3161    }
3162
3163    // ── AC: stable hash regression — known fixture inputs ────────────────────
3164
3165    #[test]
3166    fn stable_hash_regression_known_fixtures() {
3167        // Pre-computed: FNV-1a 64-bit of "<experiment>:<actor>" mod 10 000.
3168        // MUST NOT change without a documented migration path for existing assignments.
3169        // To regenerate: run `experiment_bucket(name, actor)` and record the output.
3170        let b1 = experiment_bucket("checkout_v2", "user:1");
3171        let b2 = experiment_bucket("checkout_v2", "user:1");
3172        assert_eq!(
3173            b1, b2,
3174            "hash must be deterministic (same input → same output)"
3175        );
3176
3177        // Fixture values established on first run — sentinel against algorithm drift.
3178        assert_eq!(
3179            b1, 4_830,
3180            "checkout_v2:user:1 bucket changed — hash regression"
3181        );
3182        assert_eq!(
3183            experiment_bucket("checkout_v2", "user:2"),
3184            6_619,
3185            "checkout_v2:user:2 bucket changed — hash regression"
3186        );
3187        assert_eq!(
3188            experiment_bucket("onboarding_v3", "user:100"),
3189            6_602,
3190            "onboarding_v3:user:100 bucket changed — hash regression"
3191        );
3192    }
3193
3194    /// `fnv1a_64` and `experiment_bucket` as they were before the hash took
3195    /// parts, verbatim: `format!` joins the key, then one slice is hashed.
3196    fn joined_fnv1a_64(data: &[u8]) -> u64 {
3197        const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
3198        const FNV_PRIME: u64 = 1_099_511_628_211;
3199        let mut hash = FNV_OFFSET;
3200        for &byte in data {
3201            #[allow(clippy::cast_lossless)]
3202            {
3203                hash ^= byte as u64;
3204            }
3205            hash = hash.wrapping_mul(FNV_PRIME);
3206        }
3207        hash
3208    }
3209
3210    fn joined_experiment_bucket(experiment: &str, actor_id: &str) -> u64 {
3211        let key = format!("{experiment}:{actor_id}");
3212        joined_fnv1a_64(key.as_bytes()) % 10_000
3213    }
3214
3215    #[test]
3216    fn experiment_bucket_is_fnv1a_of_the_joined_key() {
3217        // Hashing the parts must give the joined key's bucket bit for bit,
3218        // or every running experiment re-buckets its actors.
3219        let edge_cases = [
3220            ("checkout_v2", "user:1"),
3221            ("", ""),
3222            ("e", ""),
3223            ("", "a"),
3224            (":", ":"),
3225            ("a:b", "c:d"),
3226            ("ab", "c:d"),
3227            ("a:b:", ":c"),
3228            ("ünï", "çødé"),
3229            ("exp🧪", "用户:42"),
3230            ("tab\texp", "line\nactor"),
3231        ];
3232        for (experiment, actor) in edge_cases {
3233            assert_eq!(
3234                experiment_bucket(experiment, actor),
3235                joined_experiment_bucket(experiment, actor),
3236                "{experiment:?}/{actor:?}"
3237            );
3238        }
3239        for experiment in ["checkout_v2", "onboarding_v3", "exp", "a:b"] {
3240            for i in 0..1_000_u32 {
3241                let actor = format!("user:{i}");
3242                assert_eq!(
3243                    experiment_bucket(experiment, &actor),
3244                    joined_experiment_bucket(experiment, &actor),
3245                    "{experiment:?}/{actor:?}"
3246                );
3247            }
3248        }
3249    }
3250
3251    #[test]
3252    fn fnv1a_64_of_parts_equals_fnv1a_64_of_their_concatenation() {
3253        // Every way of cutting a key into three (possibly empty) parts hashes
3254        // like the uncut key.
3255        for key in ["", ":", "checkout_v2:user:1", "ünï:çødé", "a::b"] {
3256            let whole = joined_fnv1a_64(key.as_bytes());
3257            let bytes = key.as_bytes();
3258            for i in 0..=bytes.len() {
3259                for j in i..=bytes.len() {
3260                    let (head, rest) = bytes.split_at(i);
3261                    let (middle, tail) = rest.split_at(j - i);
3262                    assert_eq!(fnv1a_64(&[head, middle, tail]), whole, "{key:?} {i} {j}");
3263                }
3264            }
3265            assert_eq!(fnv1a_64(&[bytes]), whole, "{key:?} in one part");
3266        }
3267        assert_eq!(fnv1a_64(&[]), joined_fnv1a_64(&[]), "no parts at all");
3268    }
3269
3270    // ── In-memory store: `(experiment, actor)` keys probed by borrow ─────────
3271
3272    #[test]
3273    fn pair_key_hashes_and_compares_like_the_owned_key() {
3274        // The `Borrow<dyn PairKey>` contract: the borrowed form must hash and
3275        // compare exactly like the `PairKeyOwned` key it stands for.
3276        use std::hash::BuildHasher as _;
3277        let state = std::collections::hash_map::RandomState::new();
3278        for (experiment, actor) in [("exp", "user:1"), ("", ""), ("ex", "puser:1"), ("ünï", "ø")]
3279        {
3280            let owned = PairKeyOwned(experiment.to_owned(), actor.to_owned());
3281            let owned_view: &dyn PairKey = &owned;
3282            let borrowed: &dyn PairKey = &(experiment, actor);
3283            assert_eq!(
3284                state.hash_one(&owned),
3285                state.hash_one(borrowed),
3286                "{owned:?}"
3287            );
3288            assert_eq!(
3289                state.hash_one(owned_view),
3290                state.hash_one(borrowed),
3291                "{owned:?}"
3292            );
3293            assert!(owned_view == borrowed, "{owned:?}");
3294        }
3295        // The same bytes, split elsewhere, are a different key.
3296        let split_elsewhere: &dyn PairKey = &("ex", "puser:1");
3297        let owned = PairKeyOwned("exp".to_owned(), "user:1".to_owned());
3298        let owned_view: &dyn PairKey = &owned;
3299        assert!(owned_view != split_elsewhere);
3300    }
3301
3302    #[test]
3303    fn in_memory_store_probes_pair_keys_by_borrow() {
3304        let store = InMemoryExperimentStore::new();
3305        store.set_override("exp", "qa:1", "treatment").unwrap();
3306        store
3307            .record_assignment(Assignment::new("exp", "user:1", "control", false))
3308            .unwrap();
3309        assert_eq!(
3310            store.get_override("exp", "qa:1").unwrap().as_deref(),
3311            Some("treatment")
3312        );
3313        assert_eq!(store.get_override("exp", "qa:2").unwrap(), None);
3314        // Moving the split point must not collide.
3315        assert_eq!(store.get_override("ex", "pqa:1").unwrap(), None);
3316        assert_eq!(store.get_override("exp:", "qa:1").unwrap(), None);
3317        assert_eq!(
3318            store
3319                .get_assignment("exp", "user:1")
3320                .unwrap()
3321                .unwrap()
3322                .variant,
3323            "control"
3324        );
3325        assert!(store.get_assignment("exp:user", "1").unwrap().is_none());
3326        assert!(store.get_assignment("exp", "user:2").unwrap().is_none());
3327    }
3328
3329    #[test]
3330    fn exclusion_group_probe_finds_a_sibling_assignment_by_borrow() {
3331        // `record_assignment` and `has_assignment_in_group` probe the sibling
3332        // experiment's `(experiment, actor)` key without building it.
3333        let store = InMemoryExperimentStore::new();
3334        for name in ["exp_a", "exp_b"] {
3335            store
3336                .upsert(
3337                    ExperimentConfig::new(name, vec![VariantConfig::new("v1", 1)])
3338                        .exclusion_group("group"),
3339                )
3340                .unwrap();
3341        }
3342        store
3343            .record_assignment(Assignment::new("exp_a", "user:1", "v1", false))
3344            .unwrap();
3345        assert!(
3346            store
3347                .has_assignment_in_group("user:1", "group", "exp_b")
3348                .unwrap()
3349        );
3350        assert!(
3351            !store
3352                .has_assignment_in_group("user:2", "group", "exp_b")
3353                .unwrap()
3354        );
3355        assert!(
3356            !store
3357                .has_assignment_in_group("user:1", "group", "exp_a")
3358                .unwrap(),
3359            "the excluded experiment itself does not count"
3360        );
3361        let err = store
3362            .record_assignment(Assignment::new("exp_b", "user:1", "v1", false))
3363            .unwrap_err();
3364        assert!(err.to_string().contains("ExcludedByGroup:group"), "{err}");
3365        store
3366            .record_assignment(Assignment::new("exp_b", "user:2", "v1", false))
3367            .unwrap();
3368    }
3369
3370    // ── AC: weights — 0% weight variant is never assigned ────────────────────
3371
3372    #[test]
3373    fn zero_weight_variant_never_assigned() {
3374        let svc = make_svc();
3375        svc.create(ExperimentConfig::new(
3376            "exp",
3377            vec![
3378                VariantConfig::new("control", 100),
3379                VariantConfig::new("dead", 0),
3380            ],
3381        ))
3382        .unwrap();
3383        svc.start("exp").unwrap();
3384        for i in 0..200_u32 {
3385            let v = svc.assign("exp", &format!("user:{i}")).unwrap();
3386            assert_eq!(v, "control", "zero-weight variant must never be assigned");
3387        }
3388    }
3389
3390    // ── AC: weights — single variant at 100% is always assigned ──────────────
3391
3392    #[test]
3393    fn single_variant_always_assigned() {
3394        let svc = make_svc();
3395        svc.create(ExperimentConfig::new(
3396            "exp",
3397            vec![VariantConfig::new("only", 100)],
3398        ))
3399        .unwrap();
3400        svc.start("exp").unwrap();
3401        for i in 0..100_u32 {
3402            let v = svc.assign("exp", &format!("user:{i}")).unwrap();
3403            assert_eq!(v, "only");
3404        }
3405    }
3406
3407    // ── AC: weights — roughly 50/50 split ────────────────────────────────────
3408
3409    #[test]
3410    fn fifty_fifty_weights_split_roughly_evenly() {
3411        let svc = make_svc();
3412        running(&svc, "exp");
3413        let mut control_count = 0_u32;
3414        for i in 0..1000_u32 {
3415            if svc.assign("exp", &format!("user:{i}")).unwrap() == "control" {
3416                control_count += 1;
3417            }
3418        }
3419        assert!(
3420            (400..=600).contains(&control_count),
3421            "expected ~500 control assignments, got {control_count}"
3422        );
3423    }
3424
3425    // ── AC: all-zero-weight returns NoVariant error ───────────────────────────
3426
3427    #[test]
3428    fn all_zero_weights_returns_no_variant_error() {
3429        let svc = make_svc();
3430        svc.create(ExperimentConfig::new(
3431            "exp",
3432            vec![VariantConfig::new("a", 0), VariantConfig::new("b", 0)],
3433        ))
3434        .unwrap();
3435        svc.start("exp").unwrap();
3436        let err = svc.assign("exp", "user:1").unwrap_err();
3437        assert!(
3438            matches!(err, ExperimentError::NoVariant(_)),
3439            "expected NoVariant, got {err}"
3440        );
3441    }
3442
3443    // ── AC: sticky assignment — subsequent calls return same variant ──────────
3444
3445    #[test]
3446    fn sticky_assignment_returned_on_subsequent_calls() {
3447        let svc = make_svc();
3448        running(&svc, "exp");
3449        let first = svc.assign("exp", "user:1").unwrap();
3450        // Subsequent calls must return the same variant even with new service.
3451        for _ in 0..10 {
3452            assert_eq!(
3453                svc.assign("exp", "user:1").unwrap(),
3454                first,
3455                "sticky assignment must be returned on all subsequent calls"
3456            );
3457        }
3458    }
3459
3460    // ── AC: sticky assignment not re-bucketed on weight change ────────────────
3461
3462    #[test]
3463    fn set_weights_does_not_rebucket_existing_assignments() {
3464        let svc = make_svc();
3465        running(&svc, "exp");
3466        let original = svc.assign("exp", "user:1").unwrap();
3467
3468        // Flip weights completely — existing assignment must remain unchanged.
3469        let (new_heavy, new_light) = if original == "control" {
3470            (0_u32, 100_u32)
3471        } else {
3472            (100_u32, 0_u32)
3473        };
3474        svc.set_weights(
3475            "exp",
3476            vec![
3477                VariantConfig::new("control", new_heavy),
3478                VariantConfig::new("treatment", new_light),
3479            ],
3480            None,
3481        )
3482        .unwrap();
3483
3484        let after = svc.assign("exp", "user:1").unwrap();
3485        assert_eq!(
3486            original, after,
3487            "existing sticky assignment must not be re-bucketed after weight change"
3488        );
3489    }
3490
3491    #[test]
3492    fn set_weights_rejects_deleting_assigned_variant() {
3493        let svc = make_svc();
3494        running(&svc, "exp");
3495        let original = svc.assign("exp", "user:1").unwrap();
3496
3497        let remaining_variant = if original == "control" {
3498            "treatment"
3499        } else {
3500            "control"
3501        };
3502        let err = svc
3503            .set_weights(
3504                "exp",
3505                vec![VariantConfig::new(remaining_variant, 100)],
3506                None,
3507            )
3508            .unwrap_err();
3509        assert!(
3510            err.to_string().contains("cannot delete variant"),
3511            "expected active assignment delete guard error, got {err}"
3512        );
3513    }
3514
3515    // ── AC: exposure emitted exactly once per assign() call ───────────────────
3516
3517    #[test]
3518    fn exposure_emitted_exactly_once_per_assign_call() {
3519        let (svc, records) = make_svc_with_sink();
3520        running(&svc, "exp");
3521        svc.assign("exp", "user:1").unwrap();
3522        assert_eq!(
3523            records.lock().unwrap().len(),
3524            1,
3525            "first assign → 1 exposure"
3526        );
3527        svc.assign("exp", "user:1").unwrap();
3528        assert_eq!(
3529            records.lock().unwrap().len(),
3530            2,
3531            "second assign → 2 total exposures"
3532        );
3533        svc.assign("exp", "user:2").unwrap();
3534        assert_eq!(
3535            records.lock().unwrap().len(),
3536            3,
3537            "different actor → 3 total exposures"
3538        );
3539    }
3540
3541    // ── AC: exposure record contains correct fields ───────────────────────────
3542
3543    #[test]
3544    fn exposure_record_contains_correct_fields() {
3545        let (svc, records) = make_svc_with_sink();
3546        running(&svc, "checkout_v2");
3547        let variant = svc
3548            .assign_with_request_id("checkout_v2", "user:42", Some("req-abc"))
3549            .unwrap();
3550        let (len, exp_name, exp_variant, exp_actor, exp_req_id, exp_is_override) = {
3551            let rec = records.lock().unwrap();
3552            let r = &rec[0];
3553            (
3554                rec.len(),
3555                r.experiment.clone(),
3556                r.variant.clone(),
3557                r.actor.clone(),
3558                r.request_id.clone(),
3559                r.is_override,
3560            )
3561        };
3562        assert_eq!(len, 1);
3563        assert_eq!(exp_name, "checkout_v2");
3564        assert_eq!(exp_variant, variant);
3565        assert_eq!(exp_actor, "user:42");
3566        assert_eq!(exp_req_id.as_deref(), Some("req-abc"));
3567        assert!(!exp_is_override);
3568    }
3569
3570    // ── AC: override bypasses weight-based bucketing ──────────────────────────
3571
3572    #[test]
3573    fn override_bypasses_weights() {
3574        let svc = make_svc();
3575        // 100% control — without override every actor gets "control".
3576        svc.create(ExperimentConfig::new(
3577            "exp",
3578            vec![
3579                VariantConfig::new("control", 100),
3580                VariantConfig::new("treatment", 0),
3581            ],
3582        ))
3583        .unwrap();
3584        svc.start("exp").unwrap();
3585        svc.set_override("exp", "qa:alice", "treatment").unwrap();
3586        let v = svc.assign("exp", "qa:alice").unwrap();
3587        assert_eq!(
3588            v, "treatment",
3589            "override must bypass weight-based bucketing"
3590        );
3591    }
3592
3593    // ── AC: override emits exposure tagged as override ────────────────────────
3594
3595    #[test]
3596    fn override_emits_exposure_tagged_as_override() {
3597        let (svc, records) = make_svc_with_sink();
3598        running(&svc, "exp");
3599        svc.set_override("exp", "qa:alice", "treatment").unwrap();
3600        svc.assign("exp", "qa:alice").unwrap();
3601        let (len, is_override, exp_variant) = {
3602            let rec = records.lock().unwrap();
3603            (rec.len(), rec[0].is_override, rec[0].variant.clone())
3604        };
3605        assert_eq!(len, 1);
3606        assert!(
3607            is_override,
3608            "exposure from override must be tagged is_override = true"
3609        );
3610        assert_eq!(exp_variant, "treatment");
3611    }
3612
3613    // ── AC: mutual exclusion — second experiment in group is excluded ─────────
3614
3615    #[test]
3616    fn mutual_exclusion_prevents_sibling_assignment() {
3617        let svc = make_svc();
3618        // Two experiments in the same group.
3619        svc.create(
3620            ExperimentConfig::new("exp_a", vec![VariantConfig::new("v1", 1)])
3621                .exclusion_group("checkout"),
3622        )
3623        .unwrap();
3624        svc.start("exp_a").unwrap();
3625        svc.create(
3626            ExperimentConfig::new("exp_b", vec![VariantConfig::new("v1", 1)])
3627                .exclusion_group("checkout"),
3628        )
3629        .unwrap();
3630        svc.start("exp_b").unwrap();
3631
3632        // Assign actor to exp_a first.
3633        svc.assign("exp_a", "user:1").unwrap();
3634
3635        // exp_b must exclude the same actor.
3636        let err = svc.assign("exp_b", "user:1").unwrap_err();
3637        assert!(
3638            matches!(err, ExperimentError::ExcludedByGroup(_, _)),
3639            "expected ExcludedByGroup, got {err}"
3640        );
3641    }
3642
3643    // ── AC: mutual exclusion — different group allows both experiments ─────────
3644
3645    #[test]
3646    fn different_groups_do_not_exclude_each_other() {
3647        let svc = make_svc();
3648        svc.create(
3649            ExperimentConfig::new("exp_a", vec![VariantConfig::new("v1", 1)])
3650                .exclusion_group("group_a"),
3651        )
3652        .unwrap();
3653        svc.start("exp_a").unwrap();
3654        svc.create(
3655            ExperimentConfig::new("exp_b", vec![VariantConfig::new("v1", 1)])
3656                .exclusion_group("group_b"),
3657        )
3658        .unwrap();
3659        svc.start("exp_b").unwrap();
3660
3661        svc.assign("exp_a", "user:1").unwrap();
3662        let result = svc.assign("exp_b", "user:1");
3663        assert!(
3664            result.is_ok(),
3665            "experiments in different groups must not exclude each other"
3666        );
3667    }
3668
3669    // ── AC: mutual exclusion — no group means no exclusion ────────────────────
3670
3671    #[test]
3672    fn no_exclusion_group_allows_both_assignments() {
3673        let svc = make_svc();
3674        running(&svc, "exp_a");
3675        running(&svc, "exp_b");
3676        svc.assign("exp_a", "user:1").unwrap();
3677        let result = svc.assign("exp_b", "user:1");
3678        assert!(
3679            result.is_ok(),
3680            "experiments without exclusion groups must not exclude each other"
3681        );
3682    }
3683
3684    // ── AC: experiment_bucket is stable and in range ──────────────────────────
3685
3686    #[test]
3687    fn experiment_bucket_is_stable_and_in_range() {
3688        for i in 0..100_u32 {
3689            let actor = format!("user:{i}");
3690            let b1 = experiment_bucket("my_exp", &actor);
3691            let b2 = experiment_bucket("my_exp", &actor);
3692            assert_eq!(b1, b2, "bucket must be deterministic for {actor}");
3693            assert!(b1 < 10_000, "bucket must be in [0, 10000) for {actor}");
3694        }
3695    }
3696
3697    // ── AC: experiment_bucket differs across actors ───────────────────────────
3698
3699    #[test]
3700    fn experiment_bucket_produces_diverse_values() {
3701        let buckets: std::collections::HashSet<u64> = (0..100_u32)
3702            .map(|i| experiment_bucket("exp", &format!("user:{i}")))
3703            .collect();
3704        assert!(
3705            buckets.len() > 50,
3706            "expected diverse buckets across 100 actors, got {}",
3707            buckets.len()
3708        );
3709    }
3710
3711    // ── AC: list returns all experiments ─────────────────────────────────────
3712
3713    #[test]
3714    fn list_returns_all_experiments() {
3715        let svc = make_svc();
3716        svc.create(fifty_fifty("alpha")).unwrap();
3717        svc.create(fifty_fifty("beta")).unwrap();
3718        svc.create(fifty_fifty("gamma")).unwrap();
3719        let experiments = svc.list().unwrap();
3720        assert_eq!(experiments.len(), 3);
3721        assert_eq!(experiments[0].name, "alpha");
3722        assert_eq!(experiments[1].name, "beta");
3723        assert_eq!(experiments[2].name, "gamma");
3724    }
3725
3726    // ── AC: status returns experiment config ─────────────────────────────────
3727
3728    #[test]
3729    fn status_returns_current_config() {
3730        let svc = make_svc();
3731        running(&svc, "exp");
3732        let cfg = svc.status("exp").unwrap();
3733        assert_eq!(cfg.state, ExperimentState::Running);
3734        assert_eq!(cfg.variants.len(), 2);
3735    }
3736
3737    // ── AC: history records mutations ─────────────────────────────────────────
3738
3739    #[test]
3740    fn history_records_create_and_start_mutations() {
3741        let svc = make_svc();
3742        svc.create(fifty_fifty("exp")).unwrap();
3743        svc.start("exp").unwrap();
3744        let hist = svc.history("exp", 10).unwrap();
3745        assert!(!hist.is_empty(), "history must record mutations");
3746    }
3747
3748    // ── AC: set_weights records mutation in history ───────────────────────────
3749
3750    #[test]
3751    fn set_weights_recorded_in_history() {
3752        let svc = make_svc();
3753        running(&svc, "exp");
3754        svc.set_weights(
3755            "exp",
3756            vec![
3757                VariantConfig::new("control", 30),
3758                VariantConfig::new("treatment", 70),
3759            ],
3760            Some("ops@example.com"),
3761        )
3762        .unwrap();
3763        let hist = svc.history("exp", 10).unwrap();
3764        let has_set_weights = hist.iter().any(|r| r.mutation == "set_weights");
3765        assert!(has_set_weights, "set_weights must be recorded in history");
3766    }
3767
3768    // ── AC: select_variant helper ─────────────────────────────────────────────
3769
3770    #[test]
3771    fn select_variant_returns_none_for_empty_variants() {
3772        assert_eq!(select_variant(&[], 0), None);
3773    }
3774
3775    #[test]
3776    fn select_variant_returns_none_for_all_zero_weights() {
3777        let vs = vec![VariantConfig::new("a", 0), VariantConfig::new("b", 0)];
3778        assert_eq!(select_variant(&vs, 5_000), None);
3779    }
3780
3781    #[test]
3782    fn select_variant_50_50_boundary() {
3783        let vs = vec![
3784            VariantConfig::new("control", 50),
3785            VariantConfig::new("treatment", 50),
3786        ];
3787        // Bucket 0 (threshold = 0 * 100 / 10000 = 0) → cumulative[0]=50 > 0 → control
3788        assert_eq!(select_variant(&vs, 0), Some("control"));
3789        // Bucket 4999 (threshold = 4999 * 100 / 10000 = 49) → control
3790        assert_eq!(select_variant(&vs, 4_999), Some("control"));
3791        // Bucket 5000 (threshold = 5000 * 100 / 10000 = 50) → cumulative[0]=50 NOT > 50
3792        // → cumulative[1]=100 > 50 → treatment
3793        assert_eq!(select_variant(&vs, 5_000), Some("treatment"));
3794        // Bucket 9999 → treatment
3795        assert_eq!(select_variant(&vs, 9_999), Some("treatment"));
3796    }
3797
3798    // ── AC: InMemoryExperimentStore — Arc delegation ──────────────────────────
3799
3800    #[test]
3801    fn arc_experiment_store_delegates_all_operations() {
3802        let store = Arc::new(InMemoryExperimentStore::new());
3803        let arc_store: Arc<dyn ExperimentStore> = Arc::clone(&store) as _;
3804
3805        let cfg = fifty_fifty("my_exp");
3806        arc_store.upsert(cfg).unwrap();
3807        assert!(arc_store.get("my_exp").unwrap().is_some());
3808
3809        arc_store
3810            .set_state("my_exp", ExperimentState::Running, None)
3811            .unwrap();
3812        assert_eq!(
3813            arc_store.get("my_exp").unwrap().unwrap().state,
3814            ExperimentState::Running
3815        );
3816
3817        arc_store
3818            .record_assignment(Assignment::new("my_exp", "user:1", "control", false))
3819            .unwrap();
3820        let asgn = arc_store.get_assignment("my_exp", "user:1").unwrap();
3821        assert_eq!(asgn.unwrap().variant, "control");
3822
3823        arc_store
3824            .set_override("my_exp", "qa:1", "treatment")
3825            .unwrap();
3826        assert_eq!(
3827            arc_store.get_override("my_exp", "qa:1").unwrap().unwrap(),
3828            "treatment"
3829        );
3830    }
3831
3832    // ── AC: ExperimentState display ───────────────────────────────────────────
3833
3834    #[test]
3835    fn experiment_state_display_matches_expected() {
3836        assert_eq!(ExperimentState::Draft.to_string(), "draft");
3837        assert_eq!(ExperimentState::Running.to_string(), "running");
3838        assert_eq!(ExperimentState::Concluded.to_string(), "concluded");
3839        assert_eq!(ExperimentState::Archived.to_string(), "archived");
3840    }
3841
3842    // ── AC: ExperimentError display ───────────────────────────────────────────
3843
3844    #[test]
3845    fn experiment_error_display() {
3846        assert!(
3847            ExperimentError::NotFound("x".to_owned())
3848                .to_string()
3849                .contains("not found")
3850        );
3851        assert!(
3852            ExperimentError::Archived("x".to_owned())
3853                .to_string()
3854                .contains("archived")
3855        );
3856        assert!(
3857            ExperimentError::ExcludedByGroup("x".to_owned(), "g".to_owned())
3858                .to_string()
3859                .contains("mutual exclusion")
3860        );
3861        assert!(
3862            ExperimentError::NoVariant("x".to_owned())
3863                .to_string()
3864                .contains("weights are zero")
3865        );
3866    }
3867
3868    // ── AC: service debug ─────────────────────────────────────────────────────
3869
3870    #[test]
3871    fn service_debug_does_not_panic() {
3872        let svc = make_svc();
3873        let _ = format!("{svc:?}");
3874    }
3875
3876    #[test]
3877    fn upsert_logs_created_or_updated() {
3878        let svc = make_svc();
3879        let exp = fifty_fifty("exp");
3880        svc.create(exp.clone()).unwrap();
3881
3882        // Upsert the same experiment again to trigger update
3883        svc.create(exp).unwrap();
3884
3885        let hist = svc.history("exp", 10).unwrap();
3886        assert_eq!(hist.len(), 2);
3887        assert_eq!(hist[1].mutation, "created");
3888        assert_eq!(hist[0].mutation, "updated");
3889    }
3890
3891    #[test]
3892    fn upsert_rejects_deleting_variant_with_active_assignments() {
3893        let store = InMemoryExperimentStore::new();
3894        let name = "test_exp";
3895
3896        let config = ExperimentConfig {
3897            name: name.to_string(),
3898            description: None,
3899            state: ExperimentState::Running,
3900            variants: vec![
3901                VariantConfig {
3902                    name: "control".to_string(),
3903                    weight: 50,
3904                },
3905                VariantConfig {
3906                    name: "treatment".to_string(),
3907                    weight: 50,
3908                },
3909            ],
3910            winner: None,
3911            exclusion_group: None,
3912            updated_at_secs: 0,
3913        };
3914        store.upsert(config.clone()).unwrap();
3915
3916        // Assign an actor to "treatment"
3917        store
3918            .record_assignment(Assignment {
3919                experiment: name.to_string(),
3920                actor: "user1".to_string(),
3921                variant: "treatment".to_string(),
3922                is_override: false,
3923                assigned_at_secs: 0,
3924            })
3925            .unwrap();
3926
3927        // Attempting to upsert config without "treatment" should fail
3928        let mut new_config = config;
3929        new_config.variants = vec![VariantConfig {
3930            name: "control".to_string(),
3931            weight: 100,
3932        }];
3933
3934        let res = store.upsert(new_config);
3935        assert!(
3936            res.is_err(),
3937            "expected upsert to fail due to deleting active variant"
3938        );
3939        if let Err(ExperimentStoreError::Backend(msg)) = res {
3940            assert!(
3941                msg.contains("treatment"),
3942                "expected error message to mention 'treatment', got: {msg}"
3943            );
3944        } else {
3945            panic!("expected Backend error");
3946        }
3947    }
3948
3949    // ── Backend screening on the Postgres-only store ──────────────────────
3950
3951    // `PgExperimentStore` opens a `diesel::PgConnection` and writes `jsonb` and
3952    // `pg_advisory_xact_lock` SQL — it cannot serve any other backend. Building one from a
3953    // SQLite target used to succeed and fail on first use with a driver-level
3954    // connection error naming Postgres, which is not a diagnosis an operator
3955    // who configured `sqlite://` can act on.
3956    #[cfg(feature = "db")]
3957    #[test]
3958    fn pg_experiment_store_refuses_a_non_postgres_target() {
3959        use crate::config::DatabaseConfig;
3960
3961        let sqlite = DatabaseConfig {
3962            primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
3963            ..Default::default()
3964        };
3965        assert!(
3966            pg::PgExperimentStore::from_database_config(&sqlite).is_none(),
3967            "a SQLite target has no Postgres experiment store"
3968        );
3969
3970        // Fails closed: a target no backend claims is refused too.
3971        let unclassifiable = DatabaseConfig {
3972            primary_url: Some("/var/lib/app.db".to_owned()),
3973            ..Default::default()
3974        };
3975        assert!(
3976            pg::PgExperimentStore::from_database_config(&unclassifiable).is_none(),
3977            "an unclassifiable target has no Postgres experiment store"
3978        );
3979
3980        // Both Postgres spellings still build.
3981        for url in [
3982            "postgres://localhost/app",
3983            "host=db user=app dbname=app sslmode=require",
3984        ] {
3985            let pg_config = DatabaseConfig {
3986                primary_url: Some(url.to_owned()),
3987                ..Default::default()
3988            };
3989            assert!(
3990                pg::PgExperimentStore::from_database_config(&pg_config).is_some(),
3991                "{url} is a Postgres target"
3992            );
3993        }
3994    }
3995
3996    // `new(url)` does not screen the target. The first connect refuses it with
3997    // a message that names the cause, and no credential goes out (#2539 §5).
3998    #[cfg(feature = "db")]
3999    #[test]
4000    fn pg_experiment_store_new_refuses_a_non_postgres_target_at_first_use() {
4001        use crate::experiments::ExperimentStore as _;
4002
4003        let store = pg::PgExperimentStore::new("sqlite://app:hunter2@host/app.db");
4004        let err = store
4005            .get("exp")
4006            .expect_err("a SQLite target is refused")
4007            .to_string();
4008        assert!(
4009            err.contains("Postgres"),
4010            "the refusal names the cause: {err}"
4011        );
4012        assert!(!err.contains("hunter2"), "the refusal leaks: {err}");
4013
4014        // libpq quotes a malformed target in its error. That must not leak.
4015        let store = pg::PgExperimentStore::new("postgres://app:hunter2@[::1/db");
4016        let err = store
4017            .get("exp")
4018            .expect_err("a malformed target fails")
4019            .to_string();
4020        assert!(!err.contains("hunter2"), "the driver error leaks: {err}");
4021
4022        // libpq can quote only the decoded password.
4023        let store = pg::PgExperimentStore::new("postgres://app:p%ss@localhost:1/db");
4024        let err = store
4025            .get("exp")
4026            .expect_err("a bad escape fails")
4027            .to_string();
4028        assert!(!err.contains("p%ss"), "the driver error leaks: {err}");
4029    }
4030}