Skip to main content

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}