Skip to main content

sofie_flags/
rule.rs

1use serde_json::{Value, json};
2use sofie_core::{Error, Fnv1a64, Result, Uuid};
3
4/// A request without a principal, and a job, are in only at 100 percent.
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub struct Rule {
7    pub on: bool,
8    pub environments: Vec<String>,
9    pub areas: Vec<String>,
10    pub principals: Vec<Uuid>,
11    pub percentage: u8,
12    pub variants: Vec<(String, u32)>,
13}
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub struct Subject {
17    pub area: Option<&'static str>,
18    pub principal: Option<Uuid>,
19}
20
21impl Rule {
22    #[must_use]
23    pub fn off() -> Self {
24        Self {
25            on: false,
26            environments: Vec::new(),
27            areas: Vec::new(),
28            principals: Vec::new(),
29            percentage: 100,
30            variants: Vec::new(),
31        }
32    }
33
34    #[must_use]
35    pub fn everyone() -> Self {
36        Self {
37            on: true,
38            ..Self::off()
39        }
40    }
41
42    #[must_use]
43    pub fn is_settled(&self) -> bool {
44        let one_variant = self
45            .variants
46            .iter()
47            .filter(|(_, weight)| *weight > 0)
48            .count()
49            <= 1;
50        !self.on
51            || (self.environments.is_empty()
52                && self.areas.is_empty()
53                && self.percentage == 100
54                && one_variant)
55    }
56
57    pub(crate) fn is_on(&self, name: &str, environment: &str, subject: Subject) -> bool {
58        let listed =
59            |list: &[String], value: &str| list.is_empty() || list.iter().any(|item| item == value);
60        if !self.on || !listed(&self.environments, environment) {
61            return false;
62        }
63        if !self.areas.is_empty() && !subject.area.is_some_and(|area| listed(&self.areas, area)) {
64            return false;
65        }
66        if self.percentage >= 100 {
67            return true;
68        }
69        subject.principal.is_some_and(|principal| {
70            self.principals.contains(&principal)
71                || bucket(&[name], principal, 100) < u64::from(self.percentage)
72        })
73    }
74
75    pub(crate) fn variant(&self, name: &str, subject: Subject) -> Option<&str> {
76        let weighted = self.variants.iter().filter(|(_, weight)| *weight > 0);
77        let total: u64 = weighted.clone().map(|(_, weight)| u64::from(*weight)).sum();
78        let Some(principal) = subject.principal else {
79            return weighted.map(|(variant, _)| variant.as_str()).next();
80        };
81        let mut point = bucket(&[name, "/variant"], principal, total.max(1));
82        for (variant, weight) in weighted {
83            match point.checked_sub(u64::from(*weight)) {
84                Some(rest) => point = rest,
85                None => return Some(variant),
86            }
87        }
88        None
89    }
90
91    pub(crate) fn to_json(&self) -> String {
92        let principals: Vec<String> = self.principals.iter().map(ToString::to_string).collect();
93        json!({
94            "on": self.on,
95            "environments": self.environments,
96            "areas": self.areas,
97            "principals": principals,
98            "percentage": self.percentage,
99            "variants": self.variants,
100        })
101        .to_string()
102    }
103
104    pub(crate) fn from_json(name: &str, text: &str) -> Result<Self> {
105        let malformed = |what: &str| {
106            Error::internal_message(format!("the stored rule of flag `{name}` has {what}"))
107        };
108        let value: Value = serde_json::from_str(text).map_err(|error| {
109            Error::internal(format!("reading the stored rule of flag `{name}`"), error)
110        })?;
111        let texts = |key: &str| -> Result<Vec<String>> {
112            value[key]
113                .as_array()
114                .ok_or_else(|| malformed(&format!("no list `{key}`")))?
115                .iter()
116                .map(|item| {
117                    item.as_str()
118                        .map(str::to_owned)
119                        .ok_or_else(|| malformed(&format!("a non-text entry in `{key}`")))
120                })
121                .collect()
122        };
123        let principals = texts("principals")?
124            .iter()
125            .map(|text| {
126                text.parse()
127                    .map_err(|_| malformed("a principal that is not an id"))
128            })
129            .collect::<Result<_>>()?;
130        let rule = Self {
131            on: value["on"]
132                .as_bool()
133                .ok_or_else(|| malformed("no switch `on`"))?,
134            environments: texts("environments")?,
135            areas: texts("areas")?,
136            principals,
137            percentage: value["percentage"]
138                .as_u64()
139                .and_then(|percentage| u8::try_from(percentage).ok())
140                .ok_or_else(|| malformed("no `percentage`"))?,
141            variants: variants_of(&value["variants"])
142                .ok_or_else(|| malformed("malformed `variants`"))?,
143        };
144        rule.validate(name)?;
145        Ok(rule)
146    }
147
148    pub fn validate(&self, name: &str) -> Result<()> {
149        if self.percentage > 100 {
150            return Err(Error::invalid(
151                "percentage",
152                format!("of flag `{name}` must be between 0 and 100"),
153            ));
154        }
155        refuse_malformed_entries("environments", &self.environments)?;
156        refuse_malformed_entries("areas", &self.areas)?;
157        let variants: Vec<String> = self
158            .variants
159            .iter()
160            .map(|(variant, _)| variant.clone())
161            .collect();
162        refuse_malformed_entries("variants", &variants)
163    }
164}
165
166/// An entry is matched against a name the code declares, so one that could never match is refused.
167fn refuse_malformed_entries(field: &str, entries: &[String]) -> Result<()> {
168    let named = |entry: &String| {
169        !entry.is_empty()
170            && entry
171                .chars()
172                .all(|character| character.is_ascii_alphanumeric() || "_-".contains(character))
173    };
174    if let Some(malformed) = entries.iter().find(|entry| !named(entry)) {
175        return Err(Error::invalid(
176            field,
177            format!("`{malformed}` is not a name of letters, digits, `_` and `-`"),
178        ));
179    }
180    let repeated = entries
181        .iter()
182        .enumerate()
183        .find(|(index, entry)| entries[..*index].contains(entry));
184    if let Some((_, entry)) = repeated {
185        return Err(Error::invalid(field, format!("lists `{entry}` twice")));
186    }
187    Ok(())
188}
189
190fn variants_of(value: &Value) -> Option<Vec<(String, u32)>> {
191    value
192        .as_array()?
193        .iter()
194        .map(|pair| {
195            let name = pair.get(0)?.as_str()?.to_owned();
196            let weight = u32::try_from(pair.get(1)?.as_u64()?).ok()?;
197            Some((name, weight))
198        })
199        .collect()
200}
201
202/// The parts hash as their concatenation, so splitting a name never moves anyone's bucket.
203fn bucket(name: &[&str], principal: Uuid, range: u64) -> u64 {
204    let hash = name
205        .iter()
206        .fold(Fnv1a64::new(), |hash, part| hash.write(part.as_bytes()))
207        .write(&[0])
208        .write(principal.as_bytes())
209        .finish();
210    mixed(hash) % range
211}
212
213/// FNV-1a leaves its low bits a function of the input bytes' parity, so two flags' buckets
214/// would decide each other; splitmix64's finalizer spreads every input bit over the output.
215fn mixed(mut hash: u64) -> u64 {
216    hash ^= hash >> 30;
217    hash = hash.wrapping_mul(0xbf58_476d_1ce4_e5b9);
218    hash ^= hash >> 27;
219    hash = hash.wrapping_mul(0x94d0_49bb_1331_11eb);
220    hash ^ (hash >> 31)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn principal(n: u128) -> Uuid {
228        Uuid::from_u128(n)
229    }
230
231    fn someone(n: u128) -> Subject {
232        Subject {
233            area: Some("member"),
234            principal: Some(principal(n)),
235        }
236    }
237
238    #[test]
239    fn off_is_off_and_everyone_is_on_for_anyone() {
240        assert!(!Rule::off().is_on("a.b", "production", someone(1)));
241        assert!(Rule::everyone().is_on("a.b", "production", Subject::default()));
242    }
243
244    #[test]
245    fn environments_and_areas_narrow_it() {
246        let rule = Rule {
247            environments: vec!["staging".to_owned()],
248            areas: vec!["console".to_owned()],
249            ..Rule::everyone()
250        };
251        let console = Subject {
252            area: Some("console"),
253            principal: None,
254        };
255        assert!(rule.is_on("a.b", "staging", console));
256        assert!(!rule.is_on("a.b", "production", console));
257        assert!(!rule.is_on("a.b", "staging", someone(1)));
258        assert!(!rule.is_on("a.b", "staging", Subject::default()));
259    }
260
261    fn split() -> Rule {
262        Rule {
263            variants: vec![("a".to_owned(), 1), ("b".to_owned(), 1)],
264            ..Rule::everyone()
265        }
266    }
267
268    #[test]
269    fn two_flags_split_principals_independently() {
270        let (first, second) = (split(), split());
271        let principals =
272            (1..=4_000_u128).map(|n| n.wrapping_mul(0x9e37_79b9_7f4a_7c15_f39c_c060_5ced_c835));
273        let same = principals
274            .filter(|n| {
275                let subject = someone(*n);
276                first.variant("a.first", subject) == second.variant("a.second", subject)
277            })
278            .count();
279        assert!((1_700..2_300).contains(&same), "{same}");
280    }
281
282    #[test]
283    fn a_rollout_does_not_choose_its_principals_variant() {
284        let rule = Rule {
285            percentage: 10,
286            ..split()
287        };
288        let rolled_in: Vec<Subject> = (1..=20_000_u128)
289            .map(someone)
290            .filter(|subject| rule.is_on("a.b", "x", *subject))
291            .collect();
292        let first_arm = rolled_in
293            .iter()
294            .filter(|subject| rule.variant("a.b", **subject) == Some("a"))
295            .count();
296        let share = first_arm * 100 / rolled_in.len();
297        assert!(
298            (40..=60).contains(&share),
299            "{share}% of {}",
300            rolled_in.len()
301        );
302    }
303
304    #[test]
305    fn a_percentage_is_stable_per_principal_and_near_its_share() {
306        let rule = Rule {
307            percentage: 30,
308            ..Rule::everyone()
309        };
310        let on = (0..10_000)
311            .filter(|n| rule.is_on("a.b", "x", someone(*n)))
312            .count();
313        assert!((2_700..3_300).contains(&on), "{on}");
314        let first: Vec<bool> = (0..50)
315            .map(|n| rule.is_on("a.b", "x", someone(n)))
316            .collect();
317        let again: Vec<bool> = (0..50)
318            .map(|n| rule.is_on("a.b", "x", someone(n)))
319            .collect();
320        assert_eq!(first, again);
321        assert!(!rule.is_on("a.b", "x", Subject::default()));
322    }
323
324    #[test]
325    fn listed_principals_are_in_whatever_the_percentage() {
326        let rule = Rule {
327            percentage: 0,
328            principals: vec![principal(7)],
329            ..Rule::everyone()
330        };
331        assert!(rule.is_on("a.b", "x", someone(7)));
332        assert!(!rule.is_on("a.b", "x", someone(8)));
333    }
334
335    #[test]
336    fn variants_split_by_weight_and_default_to_the_first() {
337        let rule = Rule {
338            variants: vec![("classic".to_owned(), 1), ("compact".to_owned(), 3)],
339            ..Rule::everyone()
340        };
341        let compact = (0..4_000)
342            .filter(|n| rule.variant("a.b", someone(*n)) == Some("compact"))
343            .count();
344        assert!((2_800..3_200).contains(&compact), "{compact}");
345        assert_eq!(rule.variant("a.b", Subject::default()), Some("classic"));
346    }
347
348    #[test]
349    fn a_rule_survives_its_stored_form() {
350        let rule = Rule {
351            environments: vec!["production".to_owned()],
352            areas: vec!["member".to_owned()],
353            principals: vec![principal(3)],
354            percentage: 25,
355            variants: vec![("a".to_owned(), 1)],
356            ..Rule::everyone()
357        };
358        assert_eq!(Rule::from_json("a.b", &rule.to_json()).unwrap(), rule);
359        assert!(Rule::from_json("a.b", "{\"on\":true}").is_err());
360    }
361
362    #[test]
363    fn a_target_that_could_never_match_is_refused() {
364        for refused in [
365            Rule {
366                areas: vec![String::new()],
367                ..Rule::everyone()
368            },
369            Rule {
370                environments: vec!["pro duction".to_owned()],
371                ..Rule::everyone()
372            },
373            Rule {
374                variants: vec![("a".to_owned(), 1), ("a".to_owned(), 2)],
375                ..Rule::everyone()
376            },
377        ] {
378            assert!(refused.validate("a.b").is_err(), "{refused:?}");
379        }
380    }
381
382    #[test]
383    fn settled_means_the_same_for_everyone() {
384        assert!(Rule::off().is_settled());
385        assert!(Rule::everyone().is_settled());
386        let partial = Rule {
387            percentage: 50,
388            ..Rule::everyone()
389        };
390        assert!(!partial.is_settled());
391    }
392}