Skip to main content

krabka_bench_driver/soak/
schedule.rs

1//! The fault schedule a contract expands to.
2//!
3//! A fault plan is a template: a first offset, an optional period and a jitter.
4//! Expanding it against the contract's seed gives the concrete occurrences,
5//! each with a start, an end and the recoveries it is measured for. The same
6//! contract always expands to the same schedule, so a failing run can be
7//! repeated fault for fault.
8//!
9//! Faults are bounded in two ways here. They never overlap: an occurrence may
10//! start only once the previous one has ended and every recovery it is measured
11//! for has had its full deadline. And none runs into the settle window at the
12//! end of the load, so reconciliation always reads a cluster that has had time
13//! to recover.
14
15use {
16    super::contract::{
17        Contract, ContractError, FaultKind, RecoveryCheck, RecoveryDeadlines, Target, human,
18    },
19    krabka_units::prelude::*,
20    serde::{Deserialize, Serialize},
21    std::cmp::Ordering,
22};
23
24/// One occurrence of a fault plan.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct ScheduledFault {
27    /// `<plan>#<n>`, counting occurrences of the plan from zero.
28    pub id: String,
29    pub plan: String,
30    pub kind: FaultKind,
31    /// The plan's target, with `any` already resolved from the seed.
32    pub target: Target,
33    /// Offset from the start of the load.
34    #[serde(with = "krabka_units::serde_units::human::time")]
35    pub start: Time,
36    #[serde(with = "krabka_units::serde_units::human::time")]
37    pub duration: Time,
38    pub checks: Vec<RecoveryCheck>,
39}
40
41impl ScheduledFault {
42    /// When the fault's cleanup runs, from the start of the load.
43    #[must_use]
44    pub fn end(&self) -> Time { self.start + self.duration }
45
46    /// The longest of the contract's deadlines among the recoveries this
47    /// fault is measured for: how long after [`Self::end`] the cluster may
48    /// still be recovering from it.
49    ///
50    /// It is the contract's own deadline, never a self-check override: the
51    /// override is a judging device, and the cluster is given its real
52    /// deadline to recover.
53    #[must_use]
54    pub fn recovery_budget(&self, deadlines: &RecoveryDeadlines) -> Time {
55        self.checks
56            .iter()
57            .map(|check| check.deadline(deadlines))
58            .fold(Time::ZERO, |longest, deadline| {
59                if deadline > longest {
60                    deadline
61                } else {
62                    longest
63                }
64            })
65    }
66}
67
68/// `SplitMix64`, the generator `java.util.SplittableRandom` uses: one 64-bit
69/// state, no dependency, and the same stream on every platform.
70fn splitmix64(state: u64) -> u64 {
71    let mut z = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
72    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
73    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
74    z ^ (z >> 31)
75}
76
77/// FNV-1a over a plan's name, so each plan draws its own stream from one seed
78/// and reordering plans in the file does not move another plan's faults.
79fn fnv1a(text: &str) -> u64 {
80    text.bytes().fold(0xCBF2_9CE4_8422_2325, |hash, byte| {
81        (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01B3)
82    })
83}
84
85/// The draw for occurrence `n` of `plan` under `seed`.
86fn draw(seed: u64, plan: &str, n: u64) -> u64 { splitmix64(seed ^ fnv1a(plan) ^ splitmix64(n)) }
87
88/// A uniform fraction in `[0, 1)` from a draw: its top 53 bits over `2^53`.
89fn unit_fraction(draw: u64) -> f64 {
90    // 53 bits fit an f64 mantissa exactly, so neither conversion rounds.
91    let top = u32::try_from(draw >> 32).unwrap_or(u32::MAX);
92    let low = u32::try_from((draw >> 11) & 0x1F_FFFF).unwrap_or(0);
93    f64::mul_add(f64::from(top), f64::from(1_u32 << 21), f64::from(low)) / 9_007_199_254_740_992.0
94}
95
96/// Expand every fault plan of `contract` into its occurrences, in start order.
97///
98/// # Errors
99///
100/// Returns every pair of occurrences that overlap (counting each one's
101/// recovery deadlines), and every plan whose first occurrence would still be
102/// running or recovering inside the settle window. A later occurrence that
103/// would do so is not an error: the plan's repeats stop before it.
104pub fn expand(contract: &Contract) -> Result<Vec<ScheduledFault>, Vec<ContractError>> {
105    let quiet_from = contract.duration - contract.settle;
106    let mut schedule = Vec::new();
107    let mut errors = Vec::new();
108    for plan in &contract.faults {
109        // A period that does not advance would expand to the same slot forever.
110        if plan.every.is_some_and(|every| every <= Time::ZERO) {
111            errors.push(ContractError(format!(
112                "fault `{}` repeats every {}; a period must be positive",
113                plan.name,
114                human(plan.every.unwrap_or(Time::ZERO))
115            )));
116            continue;
117        }
118        for n in 0_u64.. {
119            let slot = match plan.every {
120                Some(every) => plan.first_at + every * u64_to_f64(n),
121                None if n == 0 => plan.first_at,
122                None => break,
123            };
124            let roll = draw(contract.seed, &plan.name, n);
125            let start = slot + plan.jitter * unit_fraction(roll);
126            let checks = plan.fault.checks(plan.target);
127            let fault = ScheduledFault {
128                id: format!("{}#{n}", plan.name),
129                plan: plan.name.clone(),
130                kind: plan.fault.clone(),
131                target: plan.target,
132                start,
133                duration: plan.duration,
134                checks,
135            };
136            // A repeat that would still be running or recovering when the
137            // settle window starts ends the plan. A first occurrence that
138            // does not fit is kept, so the validation below reports it: that
139            // plan cannot fit the run at all.
140            let recovered_at = fault.end() + fault.recovery_budget(&contract.deadlines);
141            if n > 0 && (start >= quiet_from || recovered_at > quiet_from) {
142                break;
143            }
144            let target = match plan.target {
145                Target::Any => Target::Broker(
146                    u32::try_from(splitmix64(roll) % u64::from(contract.cluster.brokers.max(1)))
147                        .unwrap_or(0),
148                ),
149                other => other,
150            };
151            schedule.push(ScheduledFault { target, ..fault });
152            if plan.every.is_none() {
153                break;
154            }
155        }
156    }
157    schedule.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(Ordering::Equal));
158
159    for fault in &schedule {
160        if fault.end() + fault.recovery_budget(&contract.deadlines) > quiet_from {
161            errors.push(ContractError(format!(
162                "fault `{}` ends at {} and may recover until {}, inside the settle window that starts at {}",
163                fault.id,
164                human(fault.end()),
165                human(fault.end() + fault.recovery_budget(&contract.deadlines)),
166                human(quiet_from)
167            )));
168        }
169    }
170    for pair in schedule.windows(2) {
171        let [earlier, later] = pair else { continue };
172        let free_at = earlier.end() + earlier.recovery_budget(&contract.deadlines);
173        if later.start < free_at {
174            errors.push(ContractError(format!(
175                "fault `{}` starts at {} and overlaps `{}`, which with its recovery deadlines runs until {}",
176                later.id,
177                human(later.start),
178                earlier.id,
179                human(free_at)
180            )));
181        }
182    }
183    if errors.is_empty() {
184        Ok(schedule)
185    } else {
186        Err(errors)
187    }
188}
189
190fn u64_to_f64(n: u64) -> f64 { f64::from(u32::try_from(n).unwrap_or(u32::MAX)) }
191
192#[cfg(test)]
193mod tests {
194    use {
195        super::{expand, unit_fraction},
196        crate::soak::contract::{Contract, FaultKind, RecoveryCheck, Target, fixture},
197        assert2::{assert, check},
198        krabka_units::prelude::*,
199        std::collections::BTreeSet,
200    };
201
202    fn periodic(seed: u64) -> Contract {
203        let mut contract = fixture::self_check();
204        contract.seed = seed;
205        contract.duration = hours(2);
206        contract.settle = minutes(10);
207        contract.faults.truncate(1);
208        contract.faults[0].target = Target::Any;
209        contract.faults[0].every = Some(minutes(10));
210        contract.faults[0].jitter = minutes(2);
211        contract.self_check = None;
212        contract
213    }
214
215    #[test]
216    fn the_fixture_expands_to_its_two_faults_in_order() {
217        let schedule = expand(&fixture::self_check()).unwrap();
218        let ids: Vec<&str> = schedule.iter().map(|fault| fault.id.as_str()).collect();
219        check!(ids == ["short-pause#0", "leader-restart#0"]);
220        check!(schedule[0].start == secs(30));
221        check!(schedule[0].checks == [RecoveryCheck::CatchUpAfterPause]);
222        check!(schedule[1].end() == secs(95));
223        check!(schedule[1].target == Target::Leader);
224    }
225
226    #[test]
227    fn one_seed_always_expands_to_one_schedule() {
228        check!(expand(&periodic(1392)).unwrap() == expand(&periodic(1392)).unwrap());
229    }
230
231    #[test]
232    fn another_seed_moves_the_faults() {
233        let starts = |seed| -> Vec<Time> {
234            expand(&periodic(seed))
235                .unwrap()
236                .iter()
237                .map(|f| f.start)
238                .collect()
239        };
240        check!(starts(1) != starts(2));
241    }
242
243    #[test]
244    fn a_periodic_plan_fills_the_run_up_to_the_settle_window() {
245        let schedule = expand(&periodic(1392)).unwrap();
246        // Slots at 30s, 10m30s, ..., 1h40m30s; the 1h50m30s slot is past the
247        // settle window at 1h50m.
248        check!(schedule.len() == 11);
249        for (n, fault) in schedule.iter().enumerate() {
250            let slot = secs(30) + minutes(10) * f64::from(u32::try_from(n).unwrap());
251            check!(fault.start >= slot);
252            check!(fault.start < slot + minutes(2));
253            check!(matches!(fault.target, Target::Broker(index) if index < 3));
254        }
255    }
256
257    #[test]
258    fn any_resolves_to_more_than_one_broker_over_a_long_run() {
259        let mut contract = periodic(1392);
260        contract.duration = hours(24);
261        let targets: BTreeSet<String> = expand(&contract)
262            .unwrap()
263            .iter()
264            .map(|fault| format!("{:?}", fault.target))
265            .collect();
266        check!(targets.len() > 1);
267    }
268
269    #[test]
270    fn a_zero_period_is_refused_rather_than_expanded_forever() {
271        let mut contract = periodic(1392);
272        contract.faults[0].every = Some(Time::ZERO);
273        contract.faults[0].jitter = Time::ZERO;
274        let errors = expand(&contract).unwrap_err();
275        check!(errors.len() == 1);
276        check!(errors[0].0.contains("a period must be positive"));
277    }
278
279    #[test]
280    fn a_period_shorter_than_the_recovery_budget_is_refused() {
281        let mut contract = periodic(1392);
282        contract.faults[0].fault = FaultKind::Restart { graceful: true };
283        contract.faults[0].every = Some(secs(40));
284        contract.faults[0].jitter = Time::ZERO;
285        let errors = expand(&contract).unwrap_err();
286        assert!(errors.iter().any(|error| error.0.contains("overlaps")));
287    }
288
289    #[yare::parameterized(
290        zero = { 0, 0.0 },
291        max = { u64::MAX, 1.0 - 1.0 / 9_007_199_254_740_992.0 },
292        half = { 1 << 63, 0.5 },
293    )]
294    fn draws_map_to_fractions_below_one(draw: u64, expected: f64) {
295        check!(unit_fraction(draw).to_bits() == expected.to_bits());
296    }
297
298    /// A plan whose slot at 1h49m40s, 20s before the settle window at 1h50m,
299    /// starts inside the window's recovery budget for every seed: the pause
300    /// lasts 1s and its catch-up deadline is 30s.
301    fn straddling(seed: u64) -> Contract {
302        let mut contract = periodic(seed);
303        contract.faults[0].first_at = minutes(9) + secs(40);
304        contract.faults[0].jitter = secs(10);
305        contract
306    }
307
308    #[yare::parameterized(
309        seed_1 = { 1 },
310        seed_7 = { 7 },
311        seed_1392 = { 1392 },
312    )]
313    fn a_repeat_that_would_recover_inside_the_settle_window_is_dropped(seed: u64) {
314        let contract = straddling(seed);
315        let quiet_from = contract.duration - contract.settle;
316        let schedule = expand(&contract).unwrap();
317        // Slots at 9m40s, 19m40s, ..., 1h39m40s; the 1h49m40s slot is dropped.
318        check!(schedule.len() == 10);
319        let last = schedule.last().unwrap();
320        check!(last.end() + last.recovery_budget(&contract.deadlines) <= quiet_from);
321        // The contract's own validation finds nothing wrong with the schedule.
322        // (The fixture without its self-check section is refused for that.)
323        let errors = contract.validate();
324        check!(
325            errors
326                .iter()
327                .all(|error| !error.0.contains("settle window")),
328            "{errors:?}"
329        );
330    }
331
332    #[yare::parameterized(
333        one_shot = { None },
334        periodic = { Some(minutes(10)) },
335    )]
336    fn a_plan_whose_first_occurrence_cannot_fit_is_refused(every: Option<Time>) {
337        let mut contract = straddling(1392);
338        contract.faults[0].first_at = hours(1) + minutes(49) + secs(40);
339        contract.faults[0].every = every;
340        let errors = expand(&contract).unwrap_err();
341        check!(errors.len() == 1);
342        check!(
343            errors[0].0.contains("inside the settle window"),
344            "{:?}",
345            errors
346        );
347    }
348}