use serde::{Deserialize, Serialize};
use std::fmt;
const BUCKETS: u16 = 100;
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Rollout {
pub bucket_start: u8,
pub bucket_end: u8,
}
impl Rollout {
#[inline]
pub fn accepts(&self, bucket: u8) -> bool {
bucket >= self.bucket_start && bucket < self.bucket_end
}
pub fn partition(percentages: &[u8]) -> Result<Vec<Self>, RolloutError> {
let total: u32 = percentages.iter().map(|p| u32::from(*p)).sum();
match total.cmp(&u32::from(BUCKETS)) {
std::cmp::Ordering::Less => return Err(RolloutError::Under { total }),
std::cmp::Ordering::Greater => return Err(RolloutError::Over { total }),
std::cmp::Ordering::Equal => {}
}
let mut offset = 0u8;
let mut out = Vec::with_capacity(percentages.len());
for pct in percentages {
let end = offset + pct;
out.push(Self {
bucket_start: offset,
bucket_end: end,
});
offset = end;
}
Ok(out)
}
pub fn validate_set<'a>(
rollouts: impl IntoIterator<Item = &'a Self>,
) -> Result<(), RolloutError> {
let ranges: Vec<&Self> = rollouts.into_iter().collect();
for r in &ranges {
if r.bucket_end < r.bucket_start || u16::from(r.bucket_end) > BUCKETS {
return Err(RolloutError::InvalidRange { rollout: **r });
}
}
for bucket in 0u8..(BUCKETS as u8) {
match ranges.iter().filter(|r| r.accepts(bucket)).count() {
1 => {}
0 => return Err(RolloutError::Gap { bucket }),
_ => return Err(RolloutError::Overlap { bucket }),
}
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RolloutError {
Under {
total: u32,
},
Over {
total: u32,
},
Gap {
bucket: u8,
},
Overlap {
bucket: u8,
},
InvalidRange {
rollout: Rollout,
},
}
impl fmt::Display for RolloutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Under { total } => write!(
f,
"rollout percentages sum to {total}, not 100 — \
the remaining {} buckets match nothing",
u32::from(BUCKETS) - total
),
Self::Over { total } => write!(
f,
"rollout percentages sum to {total}, not 100 — \
the excess {} pushes later entries past bucket 100, where they never match",
total - u32::from(BUCKETS)
),
Self::Gap { bucket } => write!(
f,
"bucket {bucket} is served by no rollout range — traffic mapping to it matches nothing"
),
Self::Overlap { bucket } => write!(
f,
"bucket {bucket} is served by more than one rollout range — \
which workflow answers depends on ordering, not on the rollout"
),
Self::InvalidRange { rollout } => write!(
f,
"rollout range [{}, {}) is not usable: {}",
rollout.bucket_start,
rollout.bucket_end,
if rollout.bucket_end < rollout.bucket_start {
"the bounds are inverted"
} else {
"bucket_end reaches past 100"
}
),
}
}
}
impl std::error::Error for RolloutError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_is_a_half_open_range() {
let all = Rollout {
bucket_start: 0,
bucket_end: 100,
};
assert!(all.accepts(0));
assert!(all.accepts(99));
let lower = Rollout {
bucket_start: 0,
bucket_end: 50,
};
assert!(lower.accepts(0));
assert!(lower.accepts(49));
assert!(!lower.accepts(50), "bucket_end is exclusive");
assert!(!lower.accepts(99));
let upper = Rollout {
bucket_start: 50,
bucket_end: 100,
};
assert!(upper.accepts(50), "bucket_start is inclusive");
assert!(upper.accepts(99));
assert!(!upper.accepts(49));
for b in 0u8..=99 {
assert_ne!(
lower.accepts(b),
upper.accepts(b),
"bucket {b} must be served by exactly one half"
);
}
}
#[test]
fn empty_and_inverted_ranges_accept_nothing() {
let empty = Rollout {
bucket_start: 50,
bucket_end: 50,
};
let inverted = Rollout {
bucket_start: 60,
bucket_end: 20,
};
for b in 0u8..=99 {
assert!(!empty.accepts(b), "empty range accepted {b}");
assert!(!inverted.accepts(b), "inverted range accepted {b}");
}
}
#[test]
fn end_of_100_is_representable_without_overflow() {
let r = Rollout {
bucket_start: 99,
bucket_end: 100,
};
assert!(r.accepts(99));
assert!(!r.accepts(98));
}
fn bounds(rollouts: &[Rollout]) -> Vec<(u8, u8)> {
rollouts
.iter()
.map(|r| (r.bucket_start, r.bucket_end))
.collect()
}
#[test]
fn partition_splits_the_bucket_space_contiguously() {
assert_eq!(bounds(&Rollout::partition(&[100]).unwrap()), [(0, 100)]);
assert_eq!(
bounds(&Rollout::partition(&[90, 10]).unwrap()),
[(0, 90), (90, 100)]
);
assert_eq!(
bounds(&Rollout::partition(&[34, 33, 33]).unwrap()),
[(0, 34), (34, 67), (67, 100)],
"input order is traffic order"
);
}
#[test]
fn partition_rejects_a_shortfall_naming_the_direction() {
let err = Rollout::partition(&[90, 9]).unwrap_err();
assert_eq!(err, RolloutError::Under { total: 99 });
let msg = err.to_string();
assert!(msg.contains("match nothing"), "got: {msg}");
}
#[test]
fn partition_rejects_an_excess_naming_the_direction() {
let err = Rollout::partition(&[90, 11]).unwrap_err();
assert_eq!(err, RolloutError::Over { total: 101 });
let msg = err.to_string();
assert!(msg.contains("never match"), "got: {msg}");
}
#[test]
fn partition_does_not_wrap_on_a_large_sum() {
for input in [vec![128u8, 128], vec![200, 56], vec![255, 255, 255]] {
let total: u32 = input.iter().map(|p| u32::from(*p)).sum();
assert_eq!(
Rollout::partition(&input),
Err(RolloutError::Over { total }),
"{input:?} sums to {total} and must be rejected as an excess"
);
}
}
#[test]
fn an_empty_input_is_a_shortfall_not_an_empty_partition() {
assert_eq!(
Rollout::partition(&[]),
Err(RolloutError::Under { total: 0 })
);
}
#[test]
fn a_zero_percent_entry_is_an_empty_range_that_accepts_nothing() {
let split = Rollout::partition(&[100, 0]).unwrap();
assert_eq!(bounds(&split), [(0, 100), (100, 100)]);
for b in 0u8..=99 {
assert!(!split[1].accepts(b), "a 0% entry serves no traffic");
}
assert!(Rollout::validate_set(&split).is_ok());
}
#[test]
fn partition_output_always_validates() {
let splits: &[&[u8]] = &[
&[100],
&[50, 50],
&[90, 10],
&[34, 33, 33],
&[1, 99],
&[100, 0],
&[0, 100],
&[25, 25, 25, 25],
&[1, 1, 98],
];
for pcts in splits {
let split = Rollout::partition(pcts).expect("sums to 100");
assert!(
Rollout::validate_set(&split).is_ok(),
"partition({pcts:?}) produced a set that does not validate"
);
}
}
#[test]
fn validate_set_accepts_an_exact_partition_in_any_order() {
let split = Rollout::partition(&[20, 30, 50]).unwrap();
assert!(Rollout::validate_set(&split).is_ok());
let reversed: Vec<Rollout> = split.iter().rev().copied().collect();
assert!(
Rollout::validate_set(&reversed).is_ok(),
"partitioning is a property of the set, not of its order"
);
}
#[test]
fn validate_set_reports_the_first_gap() {
let gapped = [
Rollout {
bucket_start: 0,
bucket_end: 40,
},
Rollout {
bucket_start: 41,
bucket_end: 100,
},
];
assert_eq!(
Rollout::validate_set(&gapped),
Err(RolloutError::Gap { bucket: 40 })
);
}
#[test]
fn validate_set_reports_the_first_overlap() {
let overlapping = [
Rollout {
bucket_start: 0,
bucket_end: 60,
},
Rollout {
bucket_start: 40,
bucket_end: 100,
},
];
assert_eq!(
Rollout::validate_set(&overlapping),
Err(RolloutError::Overlap { bucket: 40 }),
"the lowest affected bucket, so the diagnosis is deterministic"
);
}
#[test]
fn validate_set_rejects_an_inverted_range_by_its_cause() {
let inverted = Rollout {
bucket_start: 60,
bucket_end: 20,
};
let set = [
Rollout {
bucket_start: 0,
bucket_end: 60,
},
inverted,
];
assert_eq!(
Rollout::validate_set(&set),
Err(RolloutError::InvalidRange { rollout: inverted })
);
assert!(
Rollout::validate_set(&set)
.unwrap_err()
.to_string()
.contains("inverted")
);
}
#[test]
fn validate_set_rejects_a_range_past_the_bucket_space() {
let over = Rollout {
bucket_start: 0,
bucket_end: 200,
};
assert_eq!(
Rollout::validate_set(&[over]),
Err(RolloutError::InvalidRange { rollout: over })
);
assert!(
Rollout::validate_set(&[over])
.unwrap_err()
.to_string()
.contains("past 100")
);
}
#[test]
fn validate_set_rejects_an_empty_set() {
let none: [Rollout; 0] = [];
assert_eq!(
Rollout::validate_set(&none),
Err(RolloutError::Gap { bucket: 0 }),
"no ranges means every bucket is unserved"
);
}
#[test]
fn a_zero_percent_range_does_not_count_as_covering_its_bucket() {
let set = [
Rollout {
bucket_start: 0,
bucket_end: 40,
},
Rollout {
bucket_start: 40,
bucket_end: 40,
},
Rollout {
bucket_start: 41,
bucket_end: 100,
},
];
assert_eq!(
Rollout::validate_set(&set),
Err(RolloutError::Gap { bucket: 40 })
);
}
}