Skip to main content

feature_flag/
evaluator.rs

1//! The hot path. `FlagEvaluator` wraps a `FlagSet` (behind an `ArcSwap`-style
2//! lock) and resolves `(flag_id, subject)` to a variant.
3//!
4//! Bucketing is deterministic: `SHA-256(flag_id ‖ "/" ‖ subject_id) mod 100`,
5//! so the same subject always lands in the same slot — assuming weights don't
6//! shift. That gives us **sticky percentage rollouts** without an RNG.
7
8use std::sync::Arc;
9
10use parking_lot::RwLock;
11use sha2::{Digest, Sha256};
12
13use crate::error::FeatureFlagError;
14use crate::model::{FlagSet, Outcome, Rule, Variant};
15use crate::subject::Subject;
16
17/// What an evaluation returns.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct Evaluation {
20    /// The flag that was evaluated.
21    pub flag_id: String,
22    /// Resolved variant.
23    pub variant: Variant,
24    /// `Some(rule_id)` if a rule fired; `None` if the default variant was used.
25    pub matched_rule_id: Option<String>,
26    /// One of `"rule_match"`, `"default"`, `"disabled"`.
27    pub reason: &'static str,
28}
29
30/// Cheap to clone; the inner `FlagSet` lives behind an `Arc<RwLock<_>>`.
31#[derive(Clone, Debug)]
32pub struct FlagEvaluator {
33    inner: Arc<RwLock<FlagSet>>,
34}
35
36impl FlagEvaluator {
37    /// Build an evaluator from an already-validated flagset.
38    pub fn new(flagset: FlagSet) -> Self {
39        Self {
40            inner: Arc::new(RwLock::new(flagset)),
41        }
42    }
43
44    /// Atomically replace the loaded flagset. Used by [`crate::HotReloader`].
45    pub fn swap(&self, flagset: FlagSet) {
46        *self.inner.write() = flagset;
47    }
48
49    /// Snapshot the current flagset. Mostly useful in tests.
50    pub fn snapshot(&self) -> FlagSet {
51        self.inner.read().clone()
52    }
53
54    /// Evaluate a flag against a subject.
55    pub fn evaluate(
56        &self,
57        flag_id: &str,
58        subject: &Subject,
59    ) -> Result<Evaluation, FeatureFlagError> {
60        let guard = self.inner.read();
61        let flag = guard
62            .flags
63            .iter()
64            .find(|f| f.id == flag_id)
65            .ok_or_else(|| FeatureFlagError::UnknownFlag(flag_id.to_string()))?;
66
67        if !flag.enabled {
68            return Ok(Evaluation {
69                flag_id: flag.id.clone(),
70                variant: flag.default_variant.clone(),
71                matched_rule_id: None,
72                reason: "disabled",
73            });
74        }
75
76        for rule in &flag.rules {
77            if rule.when.matches(subject) {
78                let variant = resolve_outcome(flag_id, subject, rule);
79                return Ok(Evaluation {
80                    flag_id: flag.id.clone(),
81                    variant,
82                    matched_rule_id: Some(rule.id.clone()),
83                    reason: "rule_match",
84                });
85            }
86        }
87
88        Ok(Evaluation {
89            flag_id: flag.id.clone(),
90            variant: flag.default_variant.clone(),
91            matched_rule_id: None,
92            reason: "default",
93        })
94    }
95
96    /// Convenience: return just the variant string, falling back to `default`
97    /// on any evaluation error (including `UnknownFlag`).
98    pub fn variant_or(&self, flag_id: &str, subject: &Subject, default: &str) -> String {
99        match self.evaluate(flag_id, subject) {
100            Ok(e) => e.variant,
101            Err(_) => default.to_string(),
102        }
103    }
104}
105
106fn resolve_outcome(flag_id: &str, subject: &Subject, rule: &Rule) -> Variant {
107    match &rule.outcome {
108        Outcome::Variant { variant } => variant.clone(),
109        Outcome::Rollout { variants } => {
110            let bucket = bucket_of(flag_id, &subject.id);
111            // Bucket is in 0..100. Walk the variants in declared order and
112            // accumulate weights; the first whose cumulative weight passes
113            // the bucket wins.
114            let mut cumulative: u32 = 0;
115            for entry in variants {
116                cumulative = cumulative.saturating_add(entry.weight);
117                if u32::from(bucket) < cumulative {
118                    return entry.variant.clone();
119                }
120            }
121            // validate() guarantees the weights total 100, but be defensive.
122            variants
123                .last()
124                .map_or_else(String::new, |e| e.variant.clone())
125        }
126    }
127}
128
129/// SHA-256-based sticky bucket in `0..100`. Deterministic — given the same
130/// `(flag_id, subject_id)` you always get the same bucket.
131fn bucket_of(flag_id: &str, subject_id: &str) -> u8 {
132    let mut hasher = Sha256::new();
133    hasher.update(flag_id.as_bytes());
134    hasher.update(b"/");
135    hasher.update(subject_id.as_bytes());
136    let digest = hasher.finalize();
137    // Take the first 4 bytes as a big-endian u32, then mod 100.
138    let n = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
139    (n % 100) as u8
140}
141
142#[cfg(test)]
143mod bucket_tests {
144    use super::bucket_of;
145
146    #[test]
147    fn bucket_is_deterministic() {
148        let a = bucket_of("flag-a", "user-1");
149        let b = bucket_of("flag-a", "user-1");
150        assert_eq!(a, b);
151    }
152
153    #[test]
154    fn buckets_distribute_reasonably() {
155        // 10k subjects, 100 buckets — no bucket should be wildly overfull.
156        let mut counts = [0u32; 100];
157        for i in 0..10_000 {
158            let b = bucket_of("flag-x", &format!("user-{i}"));
159            counts[b as usize] += 1;
160        }
161        let max = counts.iter().copied().max().unwrap();
162        // Uniform expectation is 100; allow up to 200 (2x) to keep flake risk low.
163        assert!(max < 200, "bucket max = {max} — distribution too lumpy");
164    }
165}