/build/source/nativelink-sim/src/scenario.rs
Line | Count | Source |
1 | | // Copyright 2026 Trace Machina, Inc. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | //! Scenarios as Rust builders, and the runner. Each backend runs on a |
16 | | //! thread of its own with a fresh current-thread runtime in paused time, so |
17 | | //! a run's clock and tasks end with it. |
18 | | |
19 | | use core::fmt::Write as _; |
20 | | use core::time::Duration; |
21 | | use std::collections::{BTreeMap, BTreeSet}; |
22 | | use std::sync::Arc; |
23 | | |
24 | | use nativelink_config::schedulers::SimpleSpec; |
25 | | use nativelink_error::{Code, Error, make_err}; |
26 | | use nativelink_redis_tester::Fault; |
27 | | |
28 | | use crate::actions::Action; |
29 | | use crate::checker::{Facts, Invariant, Violation, check, verdict}; |
30 | | use crate::clock::SimClock; |
31 | | use crate::net::Link; |
32 | | use crate::trace::{Event, Outcome, Record, Trace, render}; |
33 | | use crate::world::{Backend, WorkerSpec, World}; |
34 | | |
35 | | /// One thing that happens at a point of the timeline. |
36 | | #[derive(Clone, Debug)] |
37 | | pub enum Step { |
38 | | /// Client `op` calls `Execute` for `action`. |
39 | | Execute { |
40 | | op: String, |
41 | | action: Action, |
42 | | }, |
43 | | /// Client `op` calls `Execute` for the action client `of` asked for. |
44 | | ExecuteSame { |
45 | | op: String, |
46 | | of: String, |
47 | | }, |
48 | | /// Client `op` drops its stream and does not come back. |
49 | | ClientGone { |
50 | | op: String, |
51 | | }, |
52 | | AddWorkers { |
53 | | count: usize, |
54 | | spec: WorkerSpec, |
55 | | }, |
56 | | /// The worker process dies. |
57 | | KillWorker { |
58 | | worker: usize, |
59 | | }, |
60 | | /// The worker's connection closes; the worker reconnects. |
61 | | CutWorker { |
62 | | worker: usize, |
63 | | }, |
64 | | /// The worker's link carries nothing (`Silent`) or works again (`Up`). |
65 | | SetLink { |
66 | | worker: usize, |
67 | | link: Link, |
68 | | }, |
69 | | /// The scheduler process dies and a new one starts at once. |
70 | | SchedulerRestart, |
71 | | SchedulerDown, |
72 | | SchedulerUp, |
73 | | /// Every Redis connection closes, then writes answer `READONLY`. |
74 | | RedisFailover { |
75 | | readonly_writes: u64, |
76 | | }, |
77 | | /// Redis connections close and are refused (`true`) or accepted again. |
78 | | RedisPartition { |
79 | | partitioned: bool, |
80 | | }, |
81 | | /// `command` (`*` for any) answers `fault` `times` times. |
82 | | RedisFail { |
83 | | command: String, |
84 | | fault: Fault, |
85 | | times: u64, |
86 | | }, |
87 | | /// Every Redis key starting with `prefix` is deleted. |
88 | | RedisEvict { |
89 | | prefix: String, |
90 | | }, |
91 | | /// Every Redis reply waits this long (`None` clears it). |
92 | | RedisLatency { |
93 | | ms: Option<u64>, |
94 | | }, |
95 | | /// What the clock reads jumps forward; timers do not move. |
96 | | ClockJump { |
97 | | ms: u64, |
98 | | }, |
99 | | } |
100 | | |
101 | | impl Step { |
102 | 11.3k | pub fn execute(op: &str, action: Action) -> Self { |
103 | 11.3k | Self::Execute { |
104 | 11.3k | op: op.to_string(), |
105 | 11.3k | action, |
106 | 11.3k | } |
107 | 11.3k | } |
108 | | |
109 | 1 | pub fn execute_same(op: &str, of: &str) -> Self { |
110 | 1 | Self::ExecuteSame { |
111 | 1 | op: op.to_string(), |
112 | 1 | of: of.to_string(), |
113 | 1 | } |
114 | 1 | } |
115 | | |
116 | 2.05k | pub fn client_gone(op: &str) -> Self { |
117 | 2.05k | Self::ClientGone { op: op.to_string() } |
118 | 2.05k | } |
119 | | |
120 | 174 | pub const fn add_workers(count: usize, spec: WorkerSpec) -> Self { |
121 | 174 | Self::AddWorkers { count, spec } |
122 | 174 | } |
123 | | |
124 | 3 | pub fn redis_fail(command: &str, fault: Fault, times: u64) -> Self { |
125 | 3 | Self::RedisFail { |
126 | 3 | command: command.to_string(), |
127 | 3 | fault, |
128 | 3 | times, |
129 | 3 | } |
130 | 3 | } |
131 | | |
132 | 1 | pub fn redis_evict(prefix: &str) -> Self { |
133 | 1 | Self::RedisEvict { |
134 | 1 | prefix: prefix.to_string(), |
135 | 1 | } |
136 | 1 | } |
137 | | |
138 | 29.8k | const fn needs_redis(&self) -> bool { |
139 | 29.8k | matches!( |
140 | 29.8k | self, |
141 | | Self::RedisFailover { .. } |
142 | | | Self::RedisPartition { .. } |
143 | | | Self::RedisFail { .. } |
144 | | | Self::RedisEvict { .. } |
145 | | | Self::RedisLatency { .. } |
146 | | ) |
147 | 29.8k | } |
148 | | |
149 | | /// The step as the builder call that makes it, for printing a shrunk |
150 | | /// property case. |
151 | 0 | pub fn to_code(&self) -> String { |
152 | 0 | match self { |
153 | 0 | Self::Execute { op, action } => { |
154 | 0 | let mut code = format!("Step::execute({op:?}, Action::new({})", action.seed); |
155 | 0 | if action.priority != 0 { |
156 | 0 | write!(code, ".priority({})", action.priority).unwrap(); |
157 | 0 | } |
158 | 0 | if !action.cacheable { |
159 | 0 | code.push_str(".uncacheable()"); |
160 | 0 | } |
161 | 0 | write!(code, ".run_ms({})", action.run.as_millis()).unwrap(); |
162 | 0 | if action.attempts.len() > 1 || action.attempts[0] != crate::actions::Run::Succeed { |
163 | 0 | write!(code, ".attempts(&{:?})", action.attempts).unwrap(); |
164 | 0 | } |
165 | 0 | code.push(')'); |
166 | 0 | code |
167 | | } |
168 | 0 | Self::ExecuteSame { op, of } => format!("Step::execute_same({op:?}, {of:?})"), |
169 | 0 | Self::ClientGone { op } => format!("Step::client_gone({op:?})"), |
170 | 0 | Self::AddWorkers { count, spec } => format!( |
171 | | "Step::add_workers({count}, WorkerSpec::default().max_inflight({}))", |
172 | | spec.max_inflight |
173 | | ), |
174 | 0 | Self::KillWorker { worker } => format!("Step::KillWorker {{ worker: {worker} }}"), |
175 | 0 | Self::CutWorker { worker } => format!("Step::CutWorker {{ worker: {worker} }}"), |
176 | 0 | Self::SetLink { worker, link } => { |
177 | 0 | format!("Step::SetLink {{ worker: {worker}, link: Link::{link:?} }}") |
178 | | } |
179 | 0 | Self::SchedulerRestart => "Step::SchedulerRestart".to_string(), |
180 | 0 | Self::SchedulerDown => "Step::SchedulerDown".to_string(), |
181 | 0 | Self::SchedulerUp => "Step::SchedulerUp".to_string(), |
182 | 0 | Self::RedisFailover { readonly_writes } => { |
183 | 0 | format!("Step::RedisFailover {{ readonly_writes: {readonly_writes} }}") |
184 | | } |
185 | 0 | Self::RedisPartition { partitioned } => { |
186 | 0 | format!("Step::RedisPartition {{ partitioned: {partitioned} }}") |
187 | | } |
188 | | Self::RedisFail { |
189 | 0 | command, |
190 | 0 | fault, |
191 | 0 | times, |
192 | 0 | } => format!("Step::redis_fail({command:?}, Fault::{fault:?}, {times})"), |
193 | 0 | Self::RedisEvict { prefix } => format!("Step::redis_evict({prefix:?})"), |
194 | 0 | Self::RedisLatency { ms } => format!("Step::RedisLatency {{ ms: {ms:?} }}"), |
195 | 0 | Self::ClockJump { ms } => format!("Step::ClockJump {{ ms: {ms} }}"), |
196 | | } |
197 | 0 | } |
198 | | } |
199 | | |
200 | | /// What a scenario should do on one backend. |
201 | | #[derive(Clone, Debug, PartialEq, Eq)] |
202 | | pub enum Expect { |
203 | | Pass, |
204 | | /// Fails exactly these invariants until `closed_by` lands. |
205 | | KnownFailure { |
206 | | invariants: BTreeSet<Invariant>, |
207 | | closed_by: &'static str, |
208 | | }, |
209 | | /// Not run on this backend. |
210 | | NotApplicable, |
211 | | } |
212 | | |
213 | | impl Expect { |
214 | 8 | pub fn known_failure(invariants: &[Invariant], closed_by: &'static str) -> Self { |
215 | 8 | Self::KnownFailure { |
216 | 8 | invariants: invariants.iter().copied().collect(), |
217 | 8 | closed_by, |
218 | 8 | } |
219 | 8 | } |
220 | | |
221 | 80 | fn invariants(&self) -> BTreeSet<Invariant> { |
222 | 80 | match self { |
223 | 8 | Self::KnownFailure { invariants, .. } => invariants.clone(), |
224 | 72 | Self::Pass | Self::NotApplicable => BTreeSet::new(), |
225 | | } |
226 | 80 | } |
227 | | } |
228 | | |
229 | | type SpecEdit = Arc<dyn Fn(&mut SimpleSpec) + Send + Sync>; |
230 | | |
231 | | #[derive(Clone)] |
232 | | pub struct Scenario { |
233 | | name: String, |
234 | | seed: u64, |
235 | | backends: Vec<Backend>, |
236 | | /// Applied in order to the default `SimpleSpec`; the spec itself is not |
237 | | /// `Clone`. |
238 | | spec_edits: Vec<SpecEdit>, |
239 | | steps: Vec<(u64, Step)>, |
240 | | settle: Duration, |
241 | | expect: BTreeMap<Backend, Expect>, |
242 | | ordered: bool, |
243 | | command_bound: Option<u64>, |
244 | | /// The backends are known to end operations differently until this |
245 | | /// item closes. |
246 | | disagree: Option<&'static str>, |
247 | | } |
248 | | |
249 | | impl core::fmt::Debug for Scenario { |
250 | 0 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
251 | 0 | f.debug_struct("Scenario") |
252 | 0 | .field("name", &self.name) |
253 | 0 | .field("seed", &self.seed) |
254 | 0 | .field("steps", &self.steps) |
255 | 0 | .finish_non_exhaustive() |
256 | 0 | } |
257 | | } |
258 | | |
259 | | impl Scenario { |
260 | 130 | pub fn new(name: &str, seed: u64) -> Self { |
261 | 130 | Self { |
262 | 130 | name: name.to_string(), |
263 | 130 | seed, |
264 | 130 | backends: vec![Backend::Memory, Backend::FakeRedis], |
265 | 130 | spec_edits: Vec::new(), |
266 | 130 | steps: Vec::new(), |
267 | 130 | settle: Duration::from_mins(1), |
268 | 130 | expect: BTreeMap::new(), |
269 | 130 | ordered: false, |
270 | 130 | command_bound: None, |
271 | 130 | disagree: None, |
272 | 130 | } |
273 | 130 | } |
274 | | |
275 | | /// The backends are known to end operations differently until |
276 | | /// `closed_by` lands; the run fails if they agree. |
277 | | #[must_use] |
278 | 0 | pub const fn backends_disagree(mut self, closed_by: &'static str) -> Self { |
279 | 0 | self.disagree = Some(closed_by); |
280 | 0 | self |
281 | 0 | } |
282 | | |
283 | 0 | pub fn name(&self) -> &str { |
284 | 0 | &self.name |
285 | 0 | } |
286 | | |
287 | | #[must_use] |
288 | 85 | pub fn backends(mut self, backends: &[Backend]) -> Self { |
289 | 85 | self.backends = backends.to_vec(); |
290 | 85 | self |
291 | 85 | } |
292 | | |
293 | | /// Changes the scheduler's config. |
294 | | #[must_use] |
295 | 3 | pub fn spec(mut self, edit: impl Fn(&mut SimpleSpec) + Send + Sync + 'static) -> Self { |
296 | 3 | self.spec_edits.push(Arc::new(edit)); |
297 | 3 | self |
298 | 3 | } |
299 | | |
300 | 182 | fn make_spec(&self) -> SimpleSpec { |
301 | 182 | let mut spec = SimpleSpec::default(); |
302 | 182 | for edit6 in &self.spec_edits { |
303 | 6 | edit(&mut spec); |
304 | 6 | } |
305 | 182 | spec |
306 | 182 | } |
307 | | |
308 | | /// `count` workers at time zero. |
309 | | #[must_use] |
310 | 127 | pub fn workers(self, count: usize, spec: WorkerSpec) -> Self { |
311 | 127 | self.at(0, Step::add_workers(count, spec)) |
312 | 127 | } |
313 | | |
314 | | #[must_use] |
315 | 13.7k | pub fn at(mut self, ms: u64, step: Step) -> Self { |
316 | 13.7k | self.steps.push((ms, step)); |
317 | 13.7k | self |
318 | 13.7k | } |
319 | | |
320 | | /// The worker's link is silent from `ms` for `for_ms`. |
321 | | #[must_use] |
322 | 44 | pub fn silence(self, worker: usize, ms: u64, for_ms: u64) -> Self { |
323 | 44 | self.at( |
324 | 44 | ms, |
325 | 44 | Step::SetLink { |
326 | 44 | worker, |
327 | 44 | link: Link::Silent, |
328 | 44 | }, |
329 | | ) |
330 | 44 | .at( |
331 | 44 | ms + for_ms, |
332 | 44 | Step::SetLink { |
333 | 44 | worker, |
334 | 44 | link: Link::Up, |
335 | 44 | }, |
336 | | ) |
337 | 44 | } |
338 | | |
339 | | /// Redis is unreachable from `ms` for `for_ms`. |
340 | | #[must_use] |
341 | 0 | pub fn redis_partition(self, ms: u64, for_ms: u64) -> Self { |
342 | 0 | self.at(ms, Step::RedisPartition { partitioned: true }) |
343 | 0 | .at(ms + for_ms, Step::RedisPartition { partitioned: false }) |
344 | 0 | } |
345 | | |
346 | | /// How long the final settle may take. |
347 | | #[must_use] |
348 | 119 | pub const fn settle(mut self, budget: Duration) -> Self { |
349 | 119 | self.settle = budget; |
350 | 119 | self |
351 | 119 | } |
352 | | |
353 | | /// Check dispatch order (I5). |
354 | | #[must_use] |
355 | 0 | pub const fn ordered(mut self) -> Self { |
356 | 0 | self.ordered = true; |
357 | 0 | self |
358 | 0 | } |
359 | | |
360 | | /// The I11 bound on store commands between two matching passes, |
361 | | /// measured from one second after the last `Execute` step, so it reads |
362 | | /// the steady cost rather than the submissions. |
363 | | #[must_use] |
364 | 2 | pub const fn command_bound(mut self, bound: u64) -> Self { |
365 | 2 | self.command_bound = Some(bound); |
366 | 2 | self |
367 | 2 | } |
368 | | |
369 | | #[must_use] |
370 | 9 | pub fn expect(mut self, backend: Backend, expect: Expect) -> Self { |
371 | 9 | self.expect.insert(backend, expect); |
372 | 9 | self |
373 | 9 | } |
374 | | |
375 | 0 | pub fn steps(&self) -> &[(u64, Step)] { |
376 | 0 | &self.steps |
377 | 0 | } |
378 | | |
379 | | #[must_use] |
380 | 0 | pub fn with_steps(mut self, steps: Vec<(u64, Step)>) -> Self { |
381 | 0 | self.steps = steps; |
382 | 0 | self |
383 | 0 | } |
384 | | |
385 | 235 | fn expectation(&self, backend: Backend) -> Expect { |
386 | 29.8k | if backend == Backend::Memory235 && self.steps.iter()117 .any117 (|(_, s)| s.needs_redis()) { |
387 | 6 | return Expect::NotApplicable; |
388 | 229 | } |
389 | 229 | self.expect.get(&backend).cloned().unwrap_or(Expect::Pass) |
390 | 235 | } |
391 | | |
392 | | /// Runs on every backend and checks each verdict against its |
393 | | /// expectation, and the backends against each other. |
394 | 46 | pub fn run(&self) -> Result<Vec<Report>, Error> { |
395 | 46 | let mut reports = Vec::new(); |
396 | 87 | for &backend in &self.backends46 { |
397 | 87 | if self.expectation(backend) == Expect::NotApplicable { |
398 | 7 | continue; |
399 | 80 | } |
400 | 80 | let report = self.run_on(backend)?0 ; |
401 | | // Full traces for a reader, when asked for. |
402 | 80 | if let Some(dir0 ) = std::env::var_os("SIM_DUMP_DIR") { |
403 | 0 | let path = |
404 | 0 | std::path::Path::new(&dir).join(format!("{}-{backend:?}.trace", self.name)); |
405 | 0 | drop(std::fs::write(path, report.trace())); |
406 | 80 | } |
407 | 80 | reports.push(report); |
408 | | } |
409 | 46 | let mut problems = String::new(); |
410 | 80 | for report in &reports46 { |
411 | 80 | let expected = self.expectation(report.backend).invariants(); |
412 | 80 | let actual = verdict(&report.violations); |
413 | 80 | if expected != actual { |
414 | 1 | writeln!( |
415 | 1 | problems, |
416 | | "{} on {:?}: expected {} but got {}", |
417 | | self.name, |
418 | | report.backend, |
419 | 1 | names(&expected), |
420 | 1 | names(&actual) |
421 | | ) |
422 | 1 | .unwrap(); |
423 | 1 | for violation0 in &report.violations { |
424 | 0 | writeln!(problems, " {violation}").unwrap(); |
425 | 0 | } |
426 | 1 | writeln!(problems, " trace:\n{}", report.tail(40)).unwrap(); |
427 | 79 | } |
428 | | } |
429 | 46 | if problems.is_empty() { |
430 | 45 | self.check_agreement(&reports, &mut problems); |
431 | 45 | }1 |
432 | 46 | if problems.is_empty() { |
433 | 45 | self.record_known_failures(&reports); |
434 | 45 | Ok(reports) |
435 | | } else { |
436 | 1 | Err(make_err!(Code::Internal, "{problems}")) |
437 | | } |
438 | 46 | } |
439 | | |
440 | | /// Appends each known failure this run confirmed, with the item that |
441 | | /// closes it, to the file `SIM_KNOWN_FAILURES` names. The nightly prints |
442 | | /// that file as the SIM column of STATUS.md. |
443 | 45 | fn record_known_failures(&self, reports: &[Report]) { |
444 | 45 | let Some(path0 ) = std::env::var_os("SIM_KNOWN_FAILURES") else { |
445 | 45 | return; |
446 | | }; |
447 | 0 | let mut lines = String::new(); |
448 | 0 | for report in reports { |
449 | | if let Expect::KnownFailure { |
450 | 0 | invariants, |
451 | 0 | closed_by, |
452 | 0 | } = self.expectation(report.backend) |
453 | 0 | { |
454 | 0 | writeln!( |
455 | 0 | lines, |
456 | 0 | "{}\t{:?}\t{}\t{closed_by}", |
457 | 0 | self.name, |
458 | 0 | report.backend, |
459 | 0 | names(&invariants) |
460 | 0 | ) |
461 | 0 | .unwrap(); |
462 | 0 | } |
463 | | } |
464 | 0 | if let Some(closed_by) = self.disagree { |
465 | 0 | writeln!(lines, "{}\tbackends disagree\t\t{closed_by}", self.name).unwrap(); |
466 | 0 | } |
467 | 0 | if lines.is_empty() { |
468 | 0 | return; |
469 | 0 | } |
470 | 0 | let file = std::fs::OpenOptions::new() |
471 | 0 | .create(true) |
472 | 0 | .append(true) |
473 | 0 | .open(path); |
474 | 0 | if let Ok(mut file) = file { |
475 | 0 | drop(std::io::Write::write_all(&mut file, lines.as_bytes())); |
476 | 0 | } |
477 | 45 | } |
478 | | |
479 | | /// Where the backends must agree: when their expectations are the same, |
480 | | /// every operation ends the same way, and an ordered scenario dispatches |
481 | | /// in the same order. |
482 | 45 | fn check_agreement(&self, reports: &[Report], problems: &mut String) { |
483 | 45 | let Some((first, rest)) = reports.split_first() else { |
484 | 0 | return; |
485 | | }; |
486 | 45 | for other34 in rest { |
487 | 34 | if self.expectation(first.backend) != self.expectation(other.backend) { |
488 | 4 | continue; |
489 | 30 | } |
490 | 30 | if let Some(closed_by1 ) = self.disagree { |
491 | 1 | if first.outcomes() == other.outcomes() { |
492 | 0 | writeln!( |
493 | 0 | problems, |
494 | 0 | "{}: {:?} and {:?} were expected to disagree until {closed_by}, but agree", |
495 | 0 | self.name, first.backend, other.backend |
496 | 0 | ) |
497 | 0 | .unwrap(); |
498 | 1 | } |
499 | 29 | } else if first.outcomes() != other.outcomes() { |
500 | 0 | writeln!( |
501 | 0 | problems, |
502 | 0 | "{}: {:?} and {:?} disagree on outcomes:\n {:?}\n {:?}", |
503 | 0 | self.name, |
504 | 0 | first.backend, |
505 | 0 | other.backend, |
506 | 0 | first.outcomes(), |
507 | 0 | other.outcomes() |
508 | 0 | ) |
509 | 0 | .unwrap(); |
510 | 29 | } |
511 | 30 | if self.ordered && first.dispatch_order()1 != other.dispatch_order() { |
512 | 0 | writeln!( |
513 | 0 | problems, |
514 | 0 | "{}: {:?} and {:?} dispatch in different orders:\n {:?}\n {:?}", |
515 | 0 | self.name, |
516 | 0 | first.backend, |
517 | 0 | other.backend, |
518 | 0 | first.dispatch_order(), |
519 | 0 | other.dispatch_order() |
520 | 0 | ) |
521 | 0 | .unwrap(); |
522 | 30 | } |
523 | | } |
524 | 45 | } |
525 | | |
526 | | /// Runs on one backend without checking expectations. |
527 | 182 | pub fn run_on(&self, backend: Backend) -> Result<Report, Error> { |
528 | 182 | let scenario = self.clone(); |
529 | | #[expect( |
530 | | clippy::disallowed_methods, |
531 | | reason = "each run needs its own thread for its thread-local clock and its own runtime" |
532 | | )] |
533 | 182 | let handle = std::thread::Builder::new() |
534 | 182 | .name(format!("sim-{}-{backend:?}", self.name)) |
535 | 182 | .stack_size(16 * 1024 * 1024) |
536 | 182 | .spawn(move || { |
537 | | #[expect( |
538 | | clippy::disallowed_methods, |
539 | | reason = "a paused current-thread runtime per run" |
540 | | )] |
541 | 182 | let runtime = tokio::runtime::Builder::new_current_thread() |
542 | 182 | .enable_all() |
543 | 182 | .start_paused(true) |
544 | 182 | // Reads the fake Redis's socket after every task, so its |
545 | 182 | // replies land in the tick they were sent in. |
546 | 182 | .event_interval(1) |
547 | 182 | .build() |
548 | 182 | .map_err(|e| make_err!0 (Code::Internal0 , "Could not build a runtime: {e}"))?0 ; |
549 | | #[expect( |
550 | | clippy::disallowed_methods, |
551 | | reason = "the run owns this runtime and nothing else blocks on it" |
552 | | )] |
553 | 182 | runtime.block_on(scenario.run_async(backend)) |
554 | 182 | }) |
555 | 182 | .map_err(|e| make_err!0 (Code::Internal0 , "Could not start the run's thread: {e}"))?0 ; |
556 | 182 | handle |
557 | 182 | .join() |
558 | 182 | .map_err(|_| make_err!0 (Code::Internal0 , "{} on {backend:?} panicked", self.name))?0 |
559 | 182 | } |
560 | | |
561 | 182 | async fn run_async(self, backend: Backend) -> Result<Report, Error> { |
562 | 182 | let clock = SimClock::start(); |
563 | | // See `clock`: on the fake Redis the runtime must never idle, and |
564 | | // time steps by hand. |
565 | 182 | let _awake = (backend == Backend::FakeRedis).then(|| {68 |
566 | 68 | nativelink_util::spawn!("sim_keep_awake", async { |
567 | | loop { |
568 | 9.67M | tokio::task::yield_now().await; |
569 | | } |
570 | | }) |
571 | 68 | }); |
572 | 182 | let trace = Trace::default(); |
573 | 182 | let mut world = World::new(backend, self.make_spec(), self.seed, trace.clone()).await?0 ; |
574 | 182 | let clock = match world.redis() { |
575 | 68 | Some(redis) => { |
576 | 68 | let redis = redis.clone(); |
577 | 2.00M | clock68 .stepped68 (Arc::new68 (move || redis.commands_total())) |
578 | | } |
579 | 114 | None => clock, |
580 | | }; |
581 | 182 | let mut steps = self.steps.clone(); |
582 | 182 | steps.sort_by_key(|(at, _)| *at); |
583 | 182 | if let Some(last_submit180 ) = steps |
584 | 182 | .iter() |
585 | 20.5k | .filter182 (|(_, s)| matches!(s, Step::Execute { .. } | Step::ExecuteSame { .. })) |
586 | 182 | .map(|(at, _)| *at) |
587 | 182 | .max() |
588 | 180 | { |
589 | 180 | world.measure_from(last_submit + 1_000); |
590 | 180 | }2 |
591 | 182 | let mut silences: Vec<(String, u64, u64)> = Vec::new(); |
592 | 20.5k | for (at, step) in steps182 { |
593 | 20.5k | let now = SimClock::now_ms(); |
594 | 20.5k | if at > now { |
595 | 13.2k | clock.advance(Duration::from_millis(at - now)).await; |
596 | 7.26k | } |
597 | | // Silences are the periods a link is silent: a second silence |
598 | | // while silent changes nothing, and any other setting ends it. |
599 | 20.5k | if let Step::SetLink { worker112 , link112 } = &step { |
600 | 112 | let name = crate::world::worker_name(*worker); |
601 | 112 | let open = silences |
602 | 112 | .iter_mut() |
603 | 112 | .find(|(w, _, end)| *w == name67 && *end == u64::MAX59 ); |
604 | 112 | match (open, *link == Link::Silent) { |
605 | 51 | (None, true) => silences.push((name, at, u64::MAX)), |
606 | 51 | (Some(open), false) => open.2 = at, |
607 | 10 | _ => {} |
608 | | } |
609 | 20.4k | } |
610 | 20.5k | apply(&mut world, &step).await?0 ; |
611 | | // What the step woke runs at the step's time, not the next tick's. |
612 | 20.5k | SimClock::yield_all().await; |
613 | | } |
614 | 182 | let quiet = world.settle(&clock, self.settle).await; |
615 | 182 | trace.record(Event::Settled { quiet }); |
616 | 182 | world.record_ledger().await?0 ; |
617 | | |
618 | 182 | let spec = world.spec(); |
619 | 182 | let facts = Facts { |
620 | 182 | max_inflight: world |
621 | 182 | .worker_specs() |
622 | 182 | .into_iter() |
623 | 1.19k | .map182 (|(name, spec)| (name, spec.max_inflight)) |
624 | 182 | .collect(), |
625 | 182 | cacheable: trace |
626 | 182 | .records() |
627 | 182 | .iter() |
628 | 134k | .filter_map182 (|r| match &r.event { |
629 | 15.9k | Event::Submit { op, action, .. } => { |
630 | 15.9k | world.action_of(op).map(|a| (action.clone(), a.cacheable)) |
631 | | } |
632 | 118k | _ => None, |
633 | 134k | }) |
634 | 182 | .collect(), |
635 | 182 | dispatch_budget: dispatch_budget(spec), |
636 | 182 | abandon_bound_ms: abandon_bound_ms(spec), |
637 | 182 | evict_bound_ms: evict_bound_ms(spec), |
638 | 182 | silences, |
639 | 182 | ordered: self.ordered, |
640 | 182 | command_bound: self.command_bound, |
641 | 182 | max_commands_per_pass: world.max_commands_per_pass(), |
642 | | }; |
643 | 182 | let passes = world.passes(); |
644 | 182 | world.shutdown(); |
645 | 182 | let records = trace.records(); |
646 | 182 | let violations = check(&records, &facts); |
647 | 182 | Ok(Report { |
648 | 182 | backend, |
649 | 182 | records, |
650 | 182 | facts, |
651 | 182 | violations, |
652 | 182 | passes, |
653 | 182 | }) |
654 | 182 | } |
655 | | } |
656 | | |
657 | 2 | fn names(invariants: &BTreeSet<Invariant>) -> String { |
658 | 2 | if invariants.is_empty() { |
659 | 1 | return "pass".to_string(); |
660 | 1 | } |
661 | 1 | invariants |
662 | 1 | .iter() |
663 | 1 | .map(ToString::to_string) |
664 | 1 | .collect::<Vec<_>>() |
665 | 1 | .join(", ") |
666 | 2 | } |
667 | | |
668 | | /// Executions an action may get: the first, the job retries and the |
669 | | /// worker-loss retries, at the scheduler's defaults when unset. |
670 | 182 | const fn dispatch_budget(spec: &SimpleSpec) -> u64 { |
671 | 182 | let job = if spec.max_job_retries == 0 { |
672 | 182 | 3 |
673 | | } else { |
674 | 0 | spec.max_job_retries |
675 | | }; |
676 | 182 | let loss = if spec.max_worker_loss_retries == 0 { |
677 | 182 | nativelink_scheduler::simple_scheduler_state_manager::RetryLimits::DEFAULT_WORKER_LOSS_RETRIES |
678 | | } else { |
679 | 0 | spec.max_worker_loss_retries |
680 | | }; |
681 | 182 | 1 + job as u64 + loss as u64 |
682 | 182 | } |
683 | | |
684 | | /// The client keepalive (10 s) after the client action timeout, plus the |
685 | | /// 5 s retirement pass. |
686 | 182 | const fn abandon_bound_ms(spec: &SimpleSpec) -> u64 { |
687 | 182 | let timeout_s = if spec.client_action_timeout_s == 0 { |
688 | 182 | 60 |
689 | | } else { |
690 | 0 | spec.client_action_timeout_s |
691 | | }; |
692 | 182 | (timeout_s + 10 + 5) * 1000 |
693 | 182 | } |
694 | | |
695 | | /// The worker timeout, plus the 1 s eviction tick and one keepalive. |
696 | 182 | const fn evict_bound_ms(spec: &SimpleSpec) -> u64 { |
697 | 182 | let timeout_s = if spec.worker_timeout_s == 0 { |
698 | 182 | 10 |
699 | | } else { |
700 | 0 | spec.worker_timeout_s |
701 | | }; |
702 | 182 | (timeout_s + 2) * 1000 |
703 | 182 | } |
704 | | |
705 | 20.5k | async fn apply(world: &mut World, step: &Step) -> Result<(), Error> { |
706 | 20.5k | let fault = |what: String| Event::Fault { what187 }187 ; |
707 | 20.5k | match step { |
708 | 15.9k | Step::Execute { op, action } => world.execute(op, action).await?0 , |
709 | 2 | Step::ExecuteSame { op, of } => { |
710 | 2 | let action = world |
711 | 2 | .action_of(of) |
712 | 2 | .ok_or_else(|| make_err!0 (Code::InvalidArgument0 , "No client named {of}"))?0 ; |
713 | 2 | world.execute(op, &action).await?0 ; |
714 | | } |
715 | 4.05k | Step::ClientGone { op } => world.client_gone(op), |
716 | 226 | Step::AddWorkers { count, spec } => world.add_workers(*count, spec), |
717 | 71 | Step::KillWorker { worker } => world.kill_worker(*worker), |
718 | 69 | Step::CutWorker { worker } => { |
719 | 69 | world.trace().record(fault(format!("cut w{worker}"))); |
720 | 69 | world.cut_worker(*worker); |
721 | 69 | } |
722 | 112 | Step::SetLink { worker, link } => { |
723 | 112 | world |
724 | 112 | .trace() |
725 | 112 | .record(fault(format!("link w{worker} {link:?}"))); |
726 | 112 | world.set_link(*worker, *link); |
727 | 112 | } |
728 | | Step::SchedulerRestart => { |
729 | 4 | world.scheduler_down(); |
730 | 4 | world.scheduler_up().await?0 ; |
731 | | } |
732 | 1 | Step::SchedulerDown => world.scheduler_down(), |
733 | 1 | Step::SchedulerUp => world.scheduler_up().await?0 , |
734 | 2 | Step::RedisFailover { readonly_writes } => { |
735 | 2 | world |
736 | 2 | .trace() |
737 | 2 | .record(fault(format!("redis failover readonly={readonly_writes}"))); |
738 | 2 | redis(world)?0 .failover(*readonly_writes); |
739 | | } |
740 | 0 | Step::RedisPartition { partitioned } => { |
741 | 0 | world |
742 | 0 | .trace() |
743 | 0 | .record(fault(format!("redis partition={partitioned}"))); |
744 | 0 | redis(world)?.partition(*partitioned); |
745 | | } |
746 | | Step::RedisFail { |
747 | 3 | command, |
748 | 3 | fault: kind, |
749 | 3 | times, |
750 | | } => { |
751 | 3 | world |
752 | 3 | .trace() |
753 | 3 | .record(fault(format!("redis {command} {kind:?} x{times}"))); |
754 | 3 | redis(world)?0 .fail_commands(command, None, kind.clone(), Some(*times)); |
755 | | } |
756 | 1 | Step::RedisEvict { prefix } => { |
757 | 1 | let evicted = redis(world)?0 .evict_prefix(prefix); |
758 | 1 | world |
759 | 1 | .trace() |
760 | 1 | .record(fault(format!("redis evict {prefix:?} ({evicted} keys)"))); |
761 | | } |
762 | 0 | Step::RedisLatency { ms } => { |
763 | 0 | world |
764 | 0 | .trace() |
765 | 0 | .record(fault(format!("redis latency {ms:?} ms"))); |
766 | 0 | redis(world)?.set_latency(ms.map(Duration::from_millis)); |
767 | | } |
768 | 3 | Step::ClockJump { ms } => { |
769 | 3 | SimClock::jump(Duration::from_millis(*ms)); |
770 | 3 | world.trace().record(Event::ClockJump { ms: *ms }); |
771 | 3 | } |
772 | | } |
773 | 20.5k | Ok(()) |
774 | 20.5k | } |
775 | | |
776 | 6 | fn redis( |
777 | 6 | world: &World, |
778 | 6 | ) -> Result< |
779 | 6 | &nativelink_redis_tester::FakeRedisBackend< |
780 | 6 | nativelink_store::redis_store::RedisSubscriptionManager, |
781 | 6 | >, |
782 | 6 | Error, |
783 | 6 | > { |
784 | 6 | world.redis().ok_or_else(|| {0 |
785 | 0 | make_err!( |
786 | 0 | Code::InvalidArgument, |
787 | | "A Redis step on {:?}", |
788 | 0 | world.backend() |
789 | | ) |
790 | 0 | }) |
791 | 6 | } |
792 | | |
793 | | /// The result of one scenario on one backend. |
794 | | #[derive(Clone, Debug)] |
795 | | pub struct Report { |
796 | | pub backend: Backend, |
797 | | pub records: Vec<Record>, |
798 | | pub facts: Facts, |
799 | | pub violations: Vec<Violation>, |
800 | | /// Matching passes the scheduler ran. |
801 | | pub passes: u64, |
802 | | } |
803 | | |
804 | | impl Report { |
805 | | /// How each client operation ended, by op name; `None` for one that |
806 | | /// left or never ended. |
807 | 86 | pub fn outcomes(&self) -> BTreeMap<String, Option<Outcome>> { |
808 | 86 | let mut out = BTreeMap::new(); |
809 | 10.4k | for r in &self.records86 { |
810 | 10.4k | match &r.event { |
811 | 816 | Event::Submit { op, .. } => { |
812 | 816 | out.entry(op.clone()).or_insert(None); |
813 | 816 | } |
814 | 808 | Event::ClientDone { op, outcome } => { |
815 | 808 | out.insert(op.clone(), Some(*outcome)); |
816 | 808 | } |
817 | 8.85k | _ => {} |
818 | | } |
819 | | } |
820 | 86 | out |
821 | 86 | } |
822 | | |
823 | | /// Actions in the order of their first dispatch. |
824 | 4 | pub fn dispatch_order(&self) -> Vec<String> { |
825 | 4 | let mut seen = BTreeSet::new(); |
826 | 4 | self.records |
827 | 4 | .iter() |
828 | 28 | .filter_map4 (|r| match &r.event { |
829 | 28 | Event::Dispatch { action, .. } if seen.insert(action.clone()) => { |
830 | 28 | Some(action.clone()) |
831 | | } |
832 | 302 | _ => None, |
833 | 330 | }) |
834 | 4 | .collect() |
835 | 4 | } |
836 | | |
837 | 0 | pub fn trace(&self) -> String { |
838 | 0 | render(&self.records) |
839 | 0 | } |
840 | | |
841 | | /// The trace with the records of each millisecond sorted, so two runs |
842 | | /// that interleave tasks differently inside one tick compare equal. |
843 | 10 | pub fn canonical_trace(&self) -> String { |
844 | 10 | let mut records = self.records.clone(); |
845 | 1.17k | records10 .sort_by_cached_key10 (|r| (r.at_ms, r.event.to_string())); |
846 | 10 | render(&records) |
847 | 10 | } |
848 | | |
849 | | /// The last `lines` records as text. |
850 | 1 | pub fn tail(&self, lines: usize) -> String { |
851 | 1 | let start = self.records.len().saturating_sub(lines); |
852 | 1 | render(&self.records[start..]) |
853 | 1 | } |
854 | | |
855 | | /// Records whose event matches. |
856 | 13 | pub fn count(&self, matches: impl Fn(&Event) -> bool) -> usize { |
857 | 338 | self.records.iter()13 .filter13 (|r| matches(&r.event)).count13 () |
858 | 13 | } |
859 | | } |