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}