Skip to main content

dataflow_rs/engine/
rollout.rs

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