use serde::Deserialize;
pub const BUCKETS: u32 = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RolloutKey {
#[default]
Session,
Request,
Tenant,
}
impl RolloutKey {
#[must_use]
pub const fn tag(self) -> &'static str {
match self {
Self::Session => "session",
Self::Request => "request",
Self::Tenant => "tenant",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Rollout {
pub percent: f64,
#[serde(default)]
pub key: RolloutKey,
}
impl Rollout {
pub fn validate(&self) -> Result<(), String> {
if !self.percent.is_finite() || !(0.0..=100.0).contains(&self.percent) {
return Err(format!(
"route.rollout.percent must be finite and within [0, 100], got {}",
self.percent
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Shadow {
pub sample_rate: f64,
pub max_usd_per_day: f64,
}
impl Shadow {
pub fn validate(&self) -> Result<(), String> {
if !self.sample_rate.is_finite() || !(0.0..=1.0).contains(&self.sample_rate) {
return Err(format!(
"route.shadow.sample_rate must be finite and within [0, 1], got {}",
self.sample_rate
));
}
if !self.max_usd_per_day.is_finite() || self.max_usd_per_day < 0.0 {
return Err(format!(
"route.shadow.max_usd_per_day must be finite and >= 0, got {}",
self.max_usd_per_day
));
}
Ok(())
}
#[must_use]
pub fn sampled(&self, salt: &str, key_value: &str) -> bool {
if self.sample_rate <= 0.0 {
return false;
}
if self.sample_rate >= 1.0 {
return true;
}
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(salt.as_bytes());
h.update([0u8]);
h.update(b"shadow"); h.update([0u8]);
h.update(key_value.as_bytes());
let d = h.finalize();
let bucket = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) % BUCKETS;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "sample_rate is validated finite in [0,1]"
)]
let cutoff = (self.sample_rate * f64::from(BUCKETS)) as u32;
bucket < cutoff
}
}
#[must_use]
pub fn bucket_of(salt: &str, key: RolloutKey, key_value: &str) -> u32 {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(salt.as_bytes());
h.update([0u8]); h.update(key.tag().as_bytes());
h.update([0u8]);
h.update(key_value.as_bytes());
let d = h.finalize();
u32::from_be_bytes([d[0], d[1], d[2], d[3]]) % BUCKETS
}
#[must_use]
pub fn in_rollout(bucket: u32, percent: f64) -> bool {
if percent <= 0.0 {
return false;
}
if percent >= 100.0 {
return true;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "percent is validated finite in [0,100]; the product is within u32"
)]
let cutoff = (percent / 100.0 * f64::from(BUCKETS)) as u32;
bucket < cutoff
}
#[must_use]
pub fn request_identity(body: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(body);
hex::encode(&h.finalize()[..8])
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RolloutDecision {
pub percent: f64,
pub key: RolloutKey,
pub bucket: u32,
pub enforced: bool,
}
#[must_use]
pub fn decide(salt: &str, rollout: &Rollout, key_value: &str) -> RolloutDecision {
let bucket = bucket_of(salt, rollout.key, key_value);
RolloutDecision {
percent: rollout.percent,
key: rollout.key,
bucket,
enforced: in_rollout(bucket, rollout.percent),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn assignment_is_stable_for_a_key() {
let r = Rollout {
percent: 5.0,
key: RolloutKey::Session,
};
let first = decide("salt-a", &r, "session-123");
for _ in 0..1000 {
assert_eq!(decide("salt-a", &r, "session-123"), first);
}
}
#[test]
fn salt_reshuffles_assignment() {
let differing = (0..200)
.filter(|i| {
let k = format!("session-{i}");
bucket_of("salt-a", RolloutKey::Session, &k)
!= bucket_of("salt-b", RolloutKey::Session, &k)
})
.count();
assert!(
differing > 190,
"salt barely changed assignment: {differing}/200"
);
}
#[test]
fn key_kind_is_domain_separated() {
let v = "same-identifier";
let s = bucket_of("salt", RolloutKey::Session, v);
let t = bucket_of("salt", RolloutKey::Tenant, v);
let q = bucket_of("salt", RolloutKey::Request, v);
assert!(s != t || t != q, "key kinds collapsed to one bucket");
}
#[test]
fn buckets_are_roughly_uniform() {
for pct in [1.0, 5.0, 10.0, 50.0] {
let r = Rollout {
percent: pct,
key: RolloutKey::Session,
};
let n = 20_000;
let hits = (0..n)
.filter(|i| decide("salt", &r, &format!("s{i}")).enforced)
.count();
let observed = hits as f64 / f64::from(n) * 100.0;
assert!(
(observed - pct).abs() < 1.0,
"{pct}% rollout produced {observed:.2}% of {n} keys"
);
}
}
#[test]
fn endpoints_are_exact() {
for i in 0..500 {
let k = format!("s{i}");
assert!(
!decide(
"salt",
&Rollout {
percent: 0.0,
key: RolloutKey::Session
},
&k
)
.enforced
);
assert!(
decide(
"salt",
&Rollout {
percent: 100.0,
key: RolloutKey::Session
},
&k
)
.enforced
);
}
}
#[test]
fn ramping_up_is_monotonic() {
let keys: Vec<String> = (0..2000).map(|i| format!("s{i}")).collect();
let arm = |pct: f64| -> Vec<bool> {
keys.iter()
.map(|k| {
decide(
"salt",
&Rollout {
percent: pct,
key: RolloutKey::Session,
},
k,
)
.enforced
})
.collect()
};
let (small, large) = (arm(5.0), arm(25.0));
for (i, (s, l)) in small.iter().zip(&large).enumerate() {
assert!(!*s || *l, "key {i} enforced at 5% but not at 25%");
}
}
#[test]
fn validate_rejects_impossible_percentages() {
for bad in [-0.1, 100.1, f64::NAN, f64::INFINITY] {
assert!(
Rollout {
percent: bad,
key: RolloutKey::Session
}
.validate()
.is_err()
);
}
for good in [0.0, 0.01, 50.0, 100.0] {
assert!(
Rollout {
percent: good,
key: RolloutKey::Session
}
.validate()
.is_ok()
);
}
}
#[test]
fn shadow_sampling_is_independent_of_the_rollout_arm() {
let roll = Rollout {
percent: 50.0,
key: RolloutKey::Session,
};
let shadow = Shadow {
sample_rate: 0.5,
max_usd_per_day: 5.0,
};
let (mut both, mut only_shadow, mut only_arm, mut neither) = (0, 0, 0, 0);
for i in 0..4000 {
let k = format!("s{i}");
match (
decide("salt", &roll, &k).enforced,
shadow.sampled("salt", &k),
) {
(true, true) => both += 1,
(false, true) => only_shadow += 1,
(true, false) => only_arm += 1,
(false, false) => neither += 1,
}
}
for (name, n) in [
("both", both),
("shadow-only", only_shadow),
("arm-only", only_arm),
("neither", neither),
] {
let pct = f64::from(n) / 4000.0 * 100.0;
assert!(
(pct - 25.0).abs() < 3.0,
"{name} cell held {pct:.1}% — shadow sampling correlates with the rollout arm"
);
}
}
#[test]
fn shadow_sample_rate_endpoints_are_exact() {
for i in 0..300 {
let k = format!("s{i}");
assert!(
!Shadow {
sample_rate: 0.0,
max_usd_per_day: 1.0
}
.sampled("salt", &k)
);
assert!(
Shadow {
sample_rate: 1.0,
max_usd_per_day: 1.0
}
.sampled("salt", &k)
);
}
}
#[test]
fn shadow_validate_rejects_impossible_settings() {
for bad in [-0.1, 1.1, f64::NAN] {
assert!(
Shadow {
sample_rate: bad,
max_usd_per_day: 1.0
}
.validate()
.is_err()
);
}
for bad in [-1.0, f64::INFINITY] {
assert!(
Shadow {
sample_rate: 0.1,
max_usd_per_day: bad
}
.validate()
.is_err()
);
}
assert!(
Shadow {
sample_rate: 0.1,
max_usd_per_day: 5.0
}
.validate()
.is_ok()
);
}
}