krabka_bench_driver/soak/
schedule.rs1use {
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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct ScheduledFault {
27 pub id: String,
29 pub plan: String,
30 pub kind: FaultKind,
31 pub target: Target,
33 #[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 #[must_use]
44 pub fn end(&self) -> Time { self.start + self.duration }
45
46 #[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
68fn 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
77fn 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
85fn draw(seed: u64, plan: &str, n: u64) -> u64 { splitmix64(seed ^ fnv1a(plan) ^ splitmix64(n)) }
87
88fn unit_fraction(draw: u64) -> f64 {
90 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
96pub 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 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 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 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 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 check!(schedule.len() == 10);
319 let last = schedule.last().unwrap();
320 check!(last.end() + last.recovery_budget(&contract.deadlines) <= quiet_from);
321 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}