dataflow_rs/def/rollout.rs
1//! Percentage-based traffic splitting: the [`Rollout`] range, and the
2//! invariants that make a *set* of them safe. v3's, unchanged but for the
3//! constructor `#[non_exhaustive]` asks for.
4//!
5//! A rollout gives one workflow a slice of the traffic on its channel. The
6//! engine matches a single range against the message's routing bucket; what
7//! makes a deployment correct is a property of the whole set — the versions of
8//! one logical workflow must partition `0..100` with no overlap and no gap.
9//! [`Rollout::partition`] builds such a set from percentages, and
10//! [`Rollout::validate_set`] checks one.
11//!
12//! Bucket *derivation* — how a caller maps a request to `0..=99`, whether by
13//! sticky hash, per-message draw or round-robin — is deliberately the caller's
14//! policy and stays outside this crate.
15
16use std::fmt;
17
18/// The bucket space every rollout range divides up: `0..100`.
19const BUCKETS: u16 = 100;
20
21/// Half-open bucket range `[bucket_start, bucket_end)` over `0..100`, giving this
22/// workflow a slice of the traffic on its channel.
23///
24/// Compared against the message's routing bucket. The engine does **not**
25/// derive the bucket: how a caller maps to one — a sticky hash of some request
26/// identity, a per-message random draw, round-robin — is entirely the caller's
27/// policy and deliberately stays outside this crate.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct Rollout {
31 /// Inclusive lower bound.
32 pub bucket_start: u8,
33 /// Exclusive upper bound. `100` means "up to and including bucket 99".
34 pub bucket_end: u8,
35}
36
37impl Rollout {
38 /// The highest routing bucket. A run given a larger one is served as
39 /// this one, so `[x, 100)` serves it, as in 3.x.
40 pub const MAX_BUCKET: u8 = 99;
41
42 /// The range `[bucket_start, bucket_end)`.
43 pub fn new(bucket_start: u8, bucket_end: u8) -> Self {
44 Self {
45 bucket_start,
46 bucket_end,
47 }
48 }
49
50 /// Whether this range serves `bucket` (`0..=99`).
51 ///
52 /// `[0, 100)` accepts everything. An empty or inverted range
53 /// (`bucket_end <= bucket_start`) accepts nothing.
54 #[inline]
55 pub fn accepts(&self, bucket: u8) -> bool {
56 bucket >= self.bucket_start && bucket < self.bucket_end
57 }
58
59 /// Turn an ordered percentage split into contiguous half-open ranges
60 /// covering exactly `0..100`.
61 ///
62 /// Entry `i` gets the range starting where entry `i-1` ended, so the input
63 /// order is the traffic order. The percentages must sum to exactly 100:
64 /// less leaves buckets that match nothing, more pushes later entries past
65 /// the end of the bucket space where they can never match. The error names
66 /// which.
67 ///
68 /// A `0` entry is allowed and yields an empty range, which accepts nothing
69 /// — the natural way to express a version that is staged but takes no
70 /// traffic.
71 ///
72 /// ```
73 /// use dataflow_rs::def::{Rollout, RolloutError};
74 ///
75 /// let split = Rollout::partition(&[90, 10]).unwrap();
76 /// assert_eq!(split[0], Rollout::new(0, 90));
77 /// assert_eq!(split[1], Rollout::new(90, 100));
78 ///
79 /// // Anything the engine can route lands in exactly one range.
80 /// for bucket in 0u8..=99 {
81 /// assert_eq!(split.iter().filter(|r| r.accepts(bucket)).count(), 1);
82 /// }
83 ///
84 /// assert_eq!(Rollout::partition(&[90, 9]), Err(RolloutError::Under { total: 99 }));
85 /// assert_eq!(Rollout::partition(&[90, 11]), Err(RolloutError::Over { total: 101 }));
86 /// ```
87 pub fn partition(percentages: &[u8]) -> Result<Vec<Self>, RolloutError> {
88 // Accumulate wider than the input. Percentages are `u8`, so a `u8`
89 // total wraps: [128, 128] and [200, 56] both wrap to exactly 0 and
90 // would pass a naive `== 100` check while describing nonsense.
91 let total: u32 = percentages.iter().map(|p| u32::from(*p)).sum();
92 match total.cmp(&u32::from(BUCKETS)) {
93 std::cmp::Ordering::Less => return Err(RolloutError::Under { total }),
94 std::cmp::Ordering::Greater => return Err(RolloutError::Over { total }),
95 std::cmp::Ordering::Equal => {}
96 }
97
98 // The sum is exactly 100, so every bound fits a u8.
99 let mut offset = 0u8;
100 let mut out = Vec::with_capacity(percentages.len());
101 for pct in percentages {
102 let end = offset + pct;
103 out.push(Self {
104 bucket_start: offset,
105 bucket_end: end,
106 });
107 offset = end;
108 }
109 Ok(out)
110 }
111
112 /// Check this range alone: an inverted range, or one reaching past bucket
113 /// 100, serves no traffic, so a workflow carrying it silently never runs.
114 /// [`Rollout::partition`] never builds one; a hand-written definition can.
115 ///
116 /// # Errors
117 ///
118 /// [`RolloutError::InvalidRange`] naming this range.
119 pub fn check(&self) -> Result<(), RolloutError> {
120 if self.bucket_end < self.bucket_start || u16::from(self.bucket_end) > BUCKETS {
121 return Err(RolloutError::InvalidRange { rollout: *self });
122 }
123 Ok(())
124 }
125
126 /// Check that a set of ranges — the versions of one logical workflow —
127 /// partitions `0..100`: every bucket served, none served twice.
128 ///
129 /// Both failures are silent in production otherwise. A gap blackholes a
130 /// slice of traffic; an overlap makes which version answers depend on
131 /// workflow ordering rather than on the rollout.
132 ///
133 /// Ranges are checked individually first, so an inverted range or one
134 /// reaching past bucket 100 is reported as itself rather than as whatever
135 /// downstream gap it happens to produce. Coverage is then reported at the
136 /// **lowest** affected bucket, so the diagnosis is deterministic.
137 ///
138 /// ```
139 /// use dataflow_rs::def::{Rollout, RolloutError};
140 ///
141 /// let good = Rollout::partition(&[50, 50]).unwrap();
142 /// assert!(Rollout::validate_set(&good).is_ok());
143 ///
144 /// // Order does not matter — this is a property of the set.
145 /// let reversed: Vec<_> = good.iter().rev().copied().collect();
146 /// assert!(Rollout::validate_set(&reversed).is_ok());
147 ///
148 /// let gapped = [Rollout::new(0, 40), Rollout::new(41, 100)];
149 /// assert_eq!(
150 /// Rollout::validate_set(&gapped),
151 /// Err(RolloutError::Gap { bucket: 40 }),
152 /// );
153 /// ```
154 pub fn validate_set<'a>(
155 rollouts: impl IntoIterator<Item = &'a Self>,
156 ) -> Result<(), RolloutError> {
157 let ranges: Vec<&Self> = rollouts.into_iter().collect();
158
159 // Diagnose a broken range by its cause, before it shows up as a
160 // confusing symptom elsewhere in the space.
161 for r in &ranges {
162 r.check()?;
163 }
164
165 // 100 buckets against a handful of ranges: counting directly is both
166 // trivially fast and obviously correct against the definition, which a
167 // sort-and-sweep would not be.
168 for bucket in 0u8..(BUCKETS as u8) {
169 match ranges.iter().filter(|r| r.accepts(bucket)).count() {
170 1 => {}
171 0 => return Err(RolloutError::Gap { bucket }),
172 _ => return Err(RolloutError::Overlap { bucket }),
173 }
174 }
175 Ok(())
176 }
177}
178
179/// Why a rollout set is not a valid traffic split.
180///
181/// Its own type rather than an [`Error`](crate::Error): these are pure
182/// arithmetic checks with no engine involvement, and routing them through the
183/// engine error would attach retryability classification that means nothing
184/// here.
185#[derive(Clone, Copy, Debug, PartialEq, Eq)]
186#[non_exhaustive]
187pub enum RolloutError {
188 /// Percentages sum to less than 100. The shortfall matches nothing, so
189 /// that slice of traffic is silently dropped.
190 Under {
191 /// What the percentages actually summed to.
192 total: u32,
193 },
194 /// Percentages sum to more than 100. The excess pushes later entries past
195 /// the end of the bucket space, where they can never match.
196 Over {
197 /// What the percentages actually summed to.
198 total: u32,
199 },
200 /// No range in the set serves this bucket — traffic mapping to it matches
201 /// nothing. Reported at the lowest such bucket.
202 Gap {
203 /// The unserved bucket.
204 bucket: u8,
205 },
206 /// More than one range serves this bucket, so which workflow answers
207 /// depends on ordering rather than on the rollout. Reported at the lowest
208 /// such bucket.
209 Overlap {
210 /// The doubly-served bucket.
211 bucket: u8,
212 },
213 /// A range is inverted (`bucket_end < bucket_start`) or reaches past bucket
214 /// 100. An empty range (`bucket_end == bucket_start`) is *not* this — that
215 /// is a legitimate 0% entry.
216 InvalidRange {
217 /// The offending range.
218 rollout: Rollout,
219 },
220}
221
222impl fmt::Display for RolloutError {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 match self {
225 Self::Under { total } => write!(
226 f,
227 "rollout percentages sum to {total}, not 100 — \
228 the remaining {} buckets match nothing",
229 u32::from(BUCKETS) - total
230 ),
231 Self::Over { total } => write!(
232 f,
233 "rollout percentages sum to {total}, not 100 — \
234 the excess {} pushes later entries past bucket 100, where they never match",
235 total - u32::from(BUCKETS)
236 ),
237 Self::Gap { bucket } => write!(
238 f,
239 "bucket {bucket} is served by no rollout range — traffic mapping to it matches nothing"
240 ),
241 Self::Overlap { bucket } => write!(
242 f,
243 "bucket {bucket} is served by more than one rollout range — \
244 which workflow answers depends on ordering, not on the rollout"
245 ),
246 Self::InvalidRange { rollout } => write!(
247 f,
248 "rollout range [{}, {}) is not usable: {}",
249 rollout.bucket_start,
250 rollout.bucket_end,
251 if rollout.bucket_end < rollout.bucket_start {
252 "the bounds are inverted"
253 } else {
254 "bucket_end reaches past 100"
255 }
256 ),
257 }
258 }
259}
260
261impl std::error::Error for RolloutError {}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn accepts_is_a_half_open_range() {
269 let all = Rollout {
270 bucket_start: 0,
271 bucket_end: 100,
272 };
273 assert!(all.accepts(0));
274 assert!(all.accepts(99));
275
276 let lower = Rollout {
277 bucket_start: 0,
278 bucket_end: 50,
279 };
280 assert!(lower.accepts(0));
281 assert!(lower.accepts(49));
282 assert!(!lower.accepts(50), "bucket_end is exclusive");
283 assert!(!lower.accepts(99));
284
285 // `start` inclusive, `end` exclusive — boundary exactness.
286 let upper = Rollout {
287 bucket_start: 50,
288 bucket_end: 100,
289 };
290 assert!(upper.accepts(50), "bucket_start is inclusive");
291 assert!(upper.accepts(99));
292 assert!(!upper.accepts(49));
293
294 // The two halves partition 0..=99 exactly.
295 for b in 0u8..=99 {
296 assert_ne!(
297 lower.accepts(b),
298 upper.accepts(b),
299 "bucket {b} must be served by exactly one half"
300 );
301 }
302 }
303
304 #[test]
305 fn empty_and_inverted_ranges_accept_nothing() {
306 let empty = Rollout {
307 bucket_start: 50,
308 bucket_end: 50,
309 };
310 let inverted = Rollout {
311 bucket_start: 60,
312 bucket_end: 20,
313 };
314 for b in 0u8..=99 {
315 assert!(!empty.accepts(b), "empty range accepted {b}");
316 assert!(!inverted.accepts(b), "inverted range accepted {b}");
317 }
318 }
319
320 #[test]
321 fn end_of_100_is_representable_without_overflow() {
322 // `bucket_end = 100` fits a u8 and `accepts` does no arithmetic on it.
323 let r = Rollout {
324 bucket_start: 99,
325 bucket_end: 100,
326 };
327 assert!(r.accepts(99));
328 assert!(!r.accepts(98));
329 }
330
331 // -----------------------------------------------------------------
332 // partition
333 // -----------------------------------------------------------------
334
335 fn bounds(rollouts: &[Rollout]) -> Vec<(u8, u8)> {
336 rollouts
337 .iter()
338 .map(|r| (r.bucket_start, r.bucket_end))
339 .collect()
340 }
341
342 #[test]
343 fn partition_splits_the_bucket_space_contiguously() {
344 assert_eq!(bounds(&Rollout::partition(&[100]).unwrap()), [(0, 100)]);
345 assert_eq!(
346 bounds(&Rollout::partition(&[90, 10]).unwrap()),
347 [(0, 90), (90, 100)]
348 );
349 assert_eq!(
350 bounds(&Rollout::partition(&[34, 33, 33]).unwrap()),
351 [(0, 34), (34, 67), (67, 100)],
352 "input order is traffic order"
353 );
354 }
355
356 #[test]
357 fn partition_rejects_a_shortfall_naming_the_direction() {
358 let err = Rollout::partition(&[90, 9]).unwrap_err();
359 assert_eq!(err, RolloutError::Under { total: 99 });
360 let msg = err.to_string();
361 assert!(msg.contains("match nothing"), "got: {msg}");
362 }
363
364 #[test]
365 fn partition_rejects_an_excess_naming_the_direction() {
366 let err = Rollout::partition(&[90, 11]).unwrap_err();
367 assert_eq!(err, RolloutError::Over { total: 101 });
368 let msg = err.to_string();
369 assert!(msg.contains("never match"), "got: {msg}");
370 }
371
372 #[test]
373 fn partition_does_not_wrap_on_a_large_sum() {
374 // Both of these wrap a u8 accumulator to exactly 0, and a naive
375 // `sum == 100` check would reject them for the wrong reason — or a
376 // `sum as u8 == 100` check would accept [128, 228].
377 for input in [vec![128u8, 128], vec![200, 56], vec![255, 255, 255]] {
378 let total: u32 = input.iter().map(|p| u32::from(*p)).sum();
379 assert_eq!(
380 Rollout::partition(&input),
381 Err(RolloutError::Over { total }),
382 "{input:?} sums to {total} and must be rejected as an excess"
383 );
384 }
385 }
386
387 #[test]
388 fn an_empty_input_is_a_shortfall_not_an_empty_partition() {
389 assert_eq!(
390 Rollout::partition(&[]),
391 Err(RolloutError::Under { total: 0 })
392 );
393 }
394
395 #[test]
396 fn a_zero_percent_entry_is_an_empty_range_that_accepts_nothing() {
397 let split = Rollout::partition(&[100, 0]).unwrap();
398 assert_eq!(bounds(&split), [(0, 100), (100, 100)]);
399 for b in 0u8..=99 {
400 assert!(!split[1].accepts(b), "a 0% entry serves no traffic");
401 }
402 // The two functions agree: a 0% entry is neither gap nor overlap.
403 assert!(Rollout::validate_set(&split).is_ok());
404 }
405
406 #[test]
407 fn partition_output_always_validates() {
408 let splits: &[&[u8]] = &[
409 &[100],
410 &[50, 50],
411 &[90, 10],
412 &[34, 33, 33],
413 &[1, 99],
414 &[100, 0],
415 &[0, 100],
416 &[25, 25, 25, 25],
417 &[1, 1, 98],
418 ];
419 for pcts in splits {
420 let split = Rollout::partition(pcts).expect("sums to 100");
421 assert!(
422 Rollout::validate_set(&split).is_ok(),
423 "partition({pcts:?}) produced a set that does not validate"
424 );
425 }
426 }
427
428 // -----------------------------------------------------------------
429 // validate_set
430 // -----------------------------------------------------------------
431
432 #[test]
433 fn validate_set_accepts_an_exact_partition_in_any_order() {
434 let split = Rollout::partition(&[20, 30, 50]).unwrap();
435 assert!(Rollout::validate_set(&split).is_ok());
436
437 let reversed: Vec<Rollout> = split.iter().rev().copied().collect();
438 assert!(
439 Rollout::validate_set(&reversed).is_ok(),
440 "partitioning is a property of the set, not of its order"
441 );
442 }
443
444 #[test]
445 fn validate_set_reports_the_first_gap() {
446 let gapped = [
447 Rollout {
448 bucket_start: 0,
449 bucket_end: 40,
450 },
451 Rollout {
452 bucket_start: 41,
453 bucket_end: 100,
454 },
455 ];
456 assert_eq!(
457 Rollout::validate_set(&gapped),
458 Err(RolloutError::Gap { bucket: 40 })
459 );
460 }
461
462 #[test]
463 fn validate_set_reports_the_first_overlap() {
464 let overlapping = [
465 Rollout {
466 bucket_start: 0,
467 bucket_end: 60,
468 },
469 Rollout {
470 bucket_start: 40,
471 bucket_end: 100,
472 },
473 ];
474 assert_eq!(
475 Rollout::validate_set(&overlapping),
476 Err(RolloutError::Overlap { bucket: 40 }),
477 "the lowest affected bucket, so the diagnosis is deterministic"
478 );
479 }
480
481 #[test]
482 fn validate_set_rejects_an_inverted_range_by_its_cause() {
483 // Without the per-range check this surfaces as a gap somewhere else,
484 // pointing at the wrong thing.
485 let inverted = Rollout {
486 bucket_start: 60,
487 bucket_end: 20,
488 };
489 let set = [
490 Rollout {
491 bucket_start: 0,
492 bucket_end: 60,
493 },
494 inverted,
495 ];
496 assert_eq!(
497 Rollout::validate_set(&set),
498 Err(RolloutError::InvalidRange { rollout: inverted })
499 );
500 assert!(
501 Rollout::validate_set(&set)
502 .unwrap_err()
503 .to_string()
504 .contains("inverted")
505 );
506 }
507
508 #[test]
509 fn validate_set_rejects_a_range_past_the_bucket_space() {
510 // Covers 0..=99 exactly once, so it produces neither gap nor overlap —
511 // it would pass a coverage-only check while being nonsense.
512 let over = Rollout {
513 bucket_start: 0,
514 bucket_end: 200,
515 };
516 assert_eq!(
517 Rollout::validate_set(&[over]),
518 Err(RolloutError::InvalidRange { rollout: over })
519 );
520 assert!(
521 Rollout::validate_set(&[over])
522 .unwrap_err()
523 .to_string()
524 .contains("past 100")
525 );
526 }
527
528 #[test]
529 fn validate_set_rejects_an_empty_set() {
530 let none: [Rollout; 0] = [];
531 assert_eq!(
532 Rollout::validate_set(&none),
533 Err(RolloutError::Gap { bucket: 0 }),
534 "no ranges means every bucket is unserved"
535 );
536 }
537
538 #[test]
539 fn a_zero_percent_range_does_not_count_as_covering_its_bucket() {
540 // [40,40) is legal but serves nothing, so it cannot fill a gap.
541 let set = [
542 Rollout {
543 bucket_start: 0,
544 bucket_end: 40,
545 },
546 Rollout {
547 bucket_start: 40,
548 bucket_end: 40,
549 },
550 Rollout {
551 bucket_start: 41,
552 bucket_end: 100,
553 },
554 ];
555 assert_eq!(
556 Rollout::validate_set(&set),
557 Err(RolloutError::Gap { bucket: 40 })
558 );
559 }
560}