fusee_sim/scenario.rs
1//! Your workflows under the simulator.
2//!
3//! A [`Scenario`] registers your workflow functions, the starts and signals
4//! a client sends, and checks on the results. Each seed runs it on a
5//! simulated machine: the real shard runtime and Rust SDK, a seeded
6//! scheduler, a disk that tears unflushed writes and fails flushes, process
7//! crashes at random moments, delayed messages between shards, and a clock
8//! that skips ahead whenever nothing is runnable, so a workflow that sleeps
9//! for a month finishes in milliseconds. The seed also picks the shard
10//! count, park and checkpoint timing, segment size, log format and payload
11//! encryption, so a range of seeds covers configurations as well as
12//! interleavings.
13//!
14//! After every recovery and at the end the simulator checks what the engine
15//! promises: an acknowledged start or signal is never lost, a reported
16//! result never changes, no run stops on a determinism error when its
17//! journal is replayed (nondeterministic workflow code shows up here), a
18//! stream reader ([`Setup::read_stream`]) never sees a chunk change or go
19//! away, and once faults stop every workflow finishes. Your checks then see the final
20//! results and whatever state your step bodies kept.
21//!
22//! ```no_run
23//! use fusee::{Ctx, Error};
24//! use fusee_sim::scenario::Scenario;
25//! use std::sync::{Arc, Mutex};
26//! use std::time::Duration;
27//!
28//! let scenario = Scenario::new(|sim| {
29//! // State that outlives crashes, like an external system: the ledger.
30//! let ledger = Arc::new(Mutex::new(Vec::<(String, u64)>::new()));
31//! let l = ledger.clone();
32//! sim.register("pay", move |ctx: Ctx, amount: u64| {
33//! let l = l.clone();
34//! async move {
35//! ctx.sleep("a-day", Duration::from_secs(86_400)).await?;
36//! ctx.step("charge", || {
37//! let l = l.clone();
38//! let key = ctx.key();
39//! async move {
40//! fusee_sim::sleep(Duration::from_millis(3)).await;
41//! let mut l = l.lock().unwrap();
42//! if !l.iter().any(|(k, _)| *k == key) {
43//! l.push((key, amount));
44//! }
45//! Ok::<_, Error>(amount)
46//! }
47//! })
48//! .await
49//! }
50//! });
51//! for i in 0..20 {
52//! let at = Duration::from_millis(sim.random(500));
53//! sim.start(at, &format!("pay-{i}"), "pay", &(i * 10));
54//! }
55//! sim.check(move |out| {
56//! for i in 0..20u64 {
57//! assert_eq!(out.result::<u64>(&format!("pay-{i}")), Some(Ok(i * 10)));
58//! }
59//! assert_eq!(ledger.lock().unwrap().len(), 20);
60//! Ok(())
61//! });
62//! });
63//! scenario.run_seeds(0..200).unwrap();
64//! ```
65//!
66//! Step bodies run on the simulator's single thread: wait with
67//! [`crate::sleep`], not `tokio::time::sleep`, and stand in for networks
68//! and databases with in-memory state like the ledger above. Workflow code
69//! itself needs no change; the same `async fn` can be registered with
70//! [`fusee::Engine::workflow`] and here.
71
72use crate::rng::Rng;
73use crate::world::{Failure, Report, SimConfig, World, erase};
74use fusee::ctx::Ctx;
75use fusee::error::Error;
76use fusee::runtime::SharedRegistry;
77use fusee_core::record::Status;
78use serde::Serialize;
79use serde::de::DeserializeOwned;
80use std::collections::BTreeMap;
81use std::future::Future;
82use std::sync::Arc;
83use std::time::Duration;
84
85/// A check on the outcome of one seed. An `Err` (or a panic) fails the seed.
86pub type Check = Box<dyn FnOnce(&Outcome) -> Result<(), String>>;
87
88pub(crate) enum UserAction {
89 Start {
90 id: String,
91 wtype: String,
92 input: Vec<u8>,
93 },
94 Signal {
95 id: String,
96 name: String,
97 payload: Vec<u8>,
98 },
99 Cancel {
100 id: String,
101 },
102 Terminate {
103 id: String,
104 },
105 /// A client reads a stream to its end (see [`fusee::stream`]).
106 ReadStream {
107 id: String,
108 name: String,
109 },
110 /// A call to a key of an object type (see [`fusee::object`]).
111 Call {
112 wtype: String,
113 key: String,
114 handler: String,
115 call_id: String,
116 arg: Vec<u8>,
117 },
118 /// A debounced start or a batched event (see [`fusee::queue`]).
119 Enqueue {
120 workflow: String,
121 key: String,
122 event_id: String,
123 input: serde_json::Value,
124 rule: fusee::queue::Rule,
125 },
126}
127
128/// What a scenario's setup function builds for one seed: the workflows,
129/// the client's requests and the checks.
130pub struct Setup {
131 seed: u64,
132 rng: Rng,
133 registry: SharedRegistry,
134 pub(crate) actions: Vec<(u64, UserAction)>,
135 pub(crate) checks: Vec<Check>,
136}
137
138impl Setup {
139 pub(crate) fn new(seed: u64, registry: SharedRegistry) -> Self {
140 Self {
141 seed,
142 rng: Rng::new(seed ^ 0x5ce7_a210_0f1e_5eed),
143 registry,
144 actions: Vec::new(),
145 checks: Vec::new(),
146 }
147 }
148
149 /// The seed being run.
150 pub fn seed(&self) -> u64 {
151 self.seed
152 }
153
154 /// A number below `n` from the seed, to vary the scenario per seed.
155 pub fn random(&mut self, n: u64) -> u64 {
156 if n == 0 { 0 } else { self.rng.below(n) }
157 }
158
159 /// Registers a workflow type's code (at the reference's version, else
160 /// version 1). Inputs and results are JSON, as with
161 /// [`fusee::Engine::register`].
162 pub fn register<I, O, F, Fut>(
163 &mut self,
164 workflow: impl fusee::AsWorkflow<I, O>,
165 f: F,
166 ) -> &mut Self
167 where
168 I: Serialize + DeserializeOwned + 'static,
169 O: Serialize + DeserializeOwned + 'static,
170 F: Fn(Ctx, I) -> Fut + Send + Sync + 'static,
171 Fut: Future<Output = Result<O, Error>> + 'static,
172 {
173 let mut r = self.registry.write().unwrap_or_else(|e| e.into_inner());
174 r.register(workflow.name(), workflow.version().unwrap_or(1), erase(f));
175 drop(r);
176 self
177 }
178
179 /// Registers an object type with its handlers (see [`fusee::object`]).
180 /// Its keys are called by the client ([`call`](Self::call)) and by
181 /// workflow code (`ctx.object(type, key)`).
182 pub fn object(&mut self, name: &str, handlers: fusee::object::ObjectHandlers) -> &mut Self {
183 let (f, def) = handlers.into_code();
184 let mut r = self.registry.write().unwrap_or_else(|e| e.into_inner());
185 r.register(name, 1, f);
186 r.register_object(name, def);
187 drop(r);
188 self
189 }
190
191 /// The client calls `handler` of key `key` of object type `wtype` at
192 /// `at` ([`fusee::ObjectRef::call_with_id`]). Until it is answered the
193 /// client sends it again, across crashes, with the same `call_id`; the
194 /// simulator fails the seed if two answers to one call ID differ (the
195 /// call ran twice). [`Outcome::call`] has the answer.
196 pub fn call(
197 &mut self,
198 at: Duration,
199 wtype: &str,
200 key: &str,
201 handler: &str,
202 call_id: &str,
203 arg: &impl Serialize,
204 ) -> &mut Self {
205 assert!(!call_id.is_empty(), "a simulated call needs an ID");
206 self.actions.push((
207 at.as_millis() as u64,
208 UserAction::Call {
209 wtype: wtype.into(),
210 key: key.into(),
211 handler: handler.into(),
212 call_id: call_id.into(),
213 arg: serde_json::to_vec(arg).expect("the argument encodes as JSON"),
214 },
215 ));
216 self
217 }
218
219 /// The client starts `id` as a `workflow` at `at` after the
220 /// simulation begins. Until it is acknowledged the client retries,
221 /// across crashes, attaching to the existing run.
222 pub fn start(
223 &mut self,
224 at: Duration,
225 id: &str,
226 workflow: &str,
227 input: &impl Serialize,
228 ) -> &mut Self {
229 self.actions.push((
230 at.as_millis() as u64,
231 UserAction::Start {
232 id: id.into(),
233 wtype: workflow.into(),
234 input: serde_json::to_vec(input).expect("the input encodes as JSON"),
235 },
236 ));
237 self
238 }
239
240 /// The client signals `id` once its start was acknowledged; retries
241 /// carry one deduplication key, so the run receives it once.
242 pub fn signal(
243 &mut self,
244 at: Duration,
245 id: &str,
246 name: &str,
247 payload: &impl Serialize,
248 ) -> &mut Self {
249 self.actions.push((
250 at.as_millis() as u64,
251 UserAction::Signal {
252 id: id.into(),
253 name: name.into(),
254 payload: serde_json::to_vec(payload).expect("the payload encodes as JSON"),
255 },
256 ));
257 self
258 }
259
260 /// A client reads stream `name` of `id` from `at` on, to its end,
261 /// connecting again at its offset after every crash. The simulator
262 /// checks that every reader of a stream sees the same chunk at each
263 /// offset (none changes or goes away after a crash), with no offset
264 /// skipped; [`Outcome::stream`] has what they saw.
265 pub fn read_stream(&mut self, at: Duration, id: &str, name: &str) -> &mut Self {
266 self.actions.push((
267 at.as_millis() as u64,
268 UserAction::ReadStream {
269 id: id.into(),
270 name: name.into(),
271 },
272 ));
273 self
274 }
275
276 /// The client cancels `id`.
277 pub fn cancel(&mut self, at: Duration, id: &str) -> &mut Self {
278 self.actions
279 .push((at.as_millis() as u64, UserAction::Cancel { id: id.into() }));
280 self
281 }
282
283 /// The client terminates `id`.
284 pub fn terminate(&mut self, at: Duration, id: &str) -> &mut Self {
285 self.actions.push((
286 at.as_millis() as u64,
287 UserAction::Terminate { id: id.into() },
288 ));
289 self
290 }
291
292 /// The client debounces a start of `workflow` under `key` at `at`
293 /// ([`fusee::Workflow::debounce_with_id`]). Until it is acknowledged the
294 /// client sends it again, across crashes, with the same `event_id`; the
295 /// run it joined is in [`Outcome::receipt`]. The simulator checks that
296 /// every acknowledged event's run starts and finishes.
297 pub fn debounce(
298 &mut self,
299 at: Duration,
300 workflow: &str,
301 key: &str,
302 event_id: &str,
303 input: &impl Serialize,
304 rule: &fusee::Debounce,
305 ) -> &mut Self {
306 self.enqueue(
307 at,
308 workflow,
309 key,
310 event_id,
311 input,
312 fusee::queue::Rule::Debounce(rule.clone()),
313 )
314 }
315
316 /// The client adds an event to `workflow`'s batch under `key` at `at`
317 /// ([`fusee::Workflow::batch_with_id`]), retrying as for
318 /// [`debounce`](Self::debounce).
319 pub fn batch(
320 &mut self,
321 at: Duration,
322 workflow: &str,
323 key: &str,
324 event_id: &str,
325 event: &impl Serialize,
326 rule: &fusee::Batch,
327 ) -> &mut Self {
328 self.enqueue(
329 at,
330 workflow,
331 key,
332 event_id,
333 event,
334 fusee::queue::Rule::Batch(rule.clone()),
335 )
336 }
337
338 fn enqueue(
339 &mut self,
340 at: Duration,
341 workflow: &str,
342 key: &str,
343 event_id: &str,
344 input: &impl Serialize,
345 rule: fusee::queue::Rule,
346 ) -> &mut Self {
347 assert!(!event_id.is_empty(), "a simulated event needs an ID");
348 self.actions.push((
349 at.as_millis() as u64,
350 UserAction::Enqueue {
351 workflow: workflow.into(),
352 key: key.into(),
353 event_id: event_id.into(),
354 input: serde_json::to_value(input).expect("the input encodes as JSON"),
355 rule,
356 },
357 ));
358 self
359 }
360
361 /// Adds a check on the final results.
362 pub fn check(&mut self, f: impl FnOnce(&Outcome) -> Result<(), String> + 'static) -> &mut Self {
363 self.checks.push(Box::new(f));
364 self
365 }
366}
367
368/// The final results of one seed.
369#[derive(Debug)]
370pub struct Outcome {
371 pub seed: u64,
372 /// Process crashes during the run.
373 pub crashes: u32,
374 pub(crate) results: BTreeMap<String, (Status, Vec<u8>)>,
375 /// Event ID -> the workflow ID of the run it joined.
376 pub(crate) receipts: BTreeMap<String, String>,
377 /// `(workflow ID, stream)` -> the chunks its readers saw (of the ID's
378 /// newest run they read), and how it ended.
379 pub(crate) streams: BTreeMap<(String, String), (Vec<serde_json::Value>, Option<String>)>,
380 /// Object call ID -> its answer.
381 pub(crate) calls: BTreeMap<String, CallReply>,
382}
383
384/// The answer to one object call ([`Setup::call`]).
385#[derive(Clone, Debug, PartialEq, Eq)]
386pub struct CallReply {
387 /// `ok`, `failed`, ...
388 pub status: String,
389 pub payload: Vec<u8>,
390 /// Simulated time the client first sent the call, and got its answer.
391 pub sent_ms: u64,
392 pub answered_ms: u64,
393}
394
395impl Outcome {
396 /// The answer to object call `call_id`: the decoded value when the
397 /// handler succeeded, the error message otherwise.
398 pub fn call<T: DeserializeOwned>(&self, call_id: &str) -> Option<Result<T, String>> {
399 let c = self.calls.get(call_id)?;
400 Some(if c.status == "ok" {
401 serde_json::from_slice(&c.payload).map_err(|e| e.to_string())
402 } else {
403 Err(String::from_utf8_lossy(&c.payload).into_owned())
404 })
405 }
406
407 /// Every answered object call, by call ID.
408 pub fn calls(&self) -> impl Iterator<Item = (&str, &CallReply)> {
409 self.calls.iter().map(|(k, v)| (k.as_str(), v))
410 }
411
412 /// The workflow ID of the run a debounced or batched event joined.
413 pub fn receipt(&self, event_id: &str) -> Option<&str> {
414 self.receipts.get(event_id).map(String::as_str)
415 }
416
417 /// Every acknowledged event and the run it joined, by event ID.
418 pub fn receipts(&self) -> impl Iterator<Item = (&str, &str)> {
419 self.receipts.iter().map(|(e, r)| (e.as_str(), r.as_str()))
420 }
421
422 /// The chunks readers of stream `name` of `id` saw, in order (bytes as
423 /// a JSON array of numbers), if a [`Setup::read_stream`] read it.
424 pub fn stream(&self, id: &str, name: &str) -> Option<&[serde_json::Value]> {
425 self.streams
426 .get(&(id.to_owned(), name.to_owned()))
427 .map(|s| s.0.as_slice())
428 }
429
430 /// How stream `name` of `id` ended for its readers (`ok`, `failed`, the
431 /// run's status).
432 pub fn stream_end(&self, id: &str, name: &str) -> Option<&str> {
433 self.streams
434 .get(&(id.to_owned(), name.to_owned()))
435 .and_then(|s| s.1.as_deref())
436 }
437
438 /// IDs with a result.
439 pub fn ids(&self) -> impl Iterator<Item = &str> {
440 self.results.keys().map(String::as_str)
441 }
442
443 /// `ok`, `failed`, `cancelled`, `timed_out` or `terminated`; `None` for
444 /// an ID the client never started.
445 pub fn status(&self, id: &str) -> Option<&'static str> {
446 self.results.get(id).map(|r| r.0.as_str())
447 }
448
449 /// The result of `id`: the decoded value when it succeeded, the error
450 /// message otherwise.
451 pub fn result<T: DeserializeOwned>(&self, id: &str) -> Option<Result<T, String>> {
452 let (status, payload) = self.results.get(id)?;
453 Some(match status {
454 Status::Ok => serde_json::from_slice(payload).map_err(|e| e.to_string()),
455 _ => Err(String::from_utf8_lossy(payload).into_owned()),
456 })
457 }
458}
459
460type SetupFn = dyn Fn(&mut Setup) + Send + Sync;
461
462/// A scenario's setup function, carried by [`SimConfig::user`].
463#[derive(Clone)]
464pub struct UserSetup(pub(crate) Arc<SetupFn>);
465
466impl std::fmt::Debug for UserSetup {
467 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468 f.write_str("UserSetup")
469 }
470}
471
472/// Your workflows and requests, run under seeds. See the module docs.
473#[derive(Clone)]
474pub struct Scenario {
475 setup: UserSetup,
476 shards: Option<u32>,
477 fault_window_ms: u64,
478 crash_every_ms: Option<u64>,
479 flush_fail_per_mille: Option<u32>,
480 tear: bool,
481 settle_ms: u64,
482}
483
484/// Totals over the seeds [`Scenario::run_seeds`] ran.
485#[derive(Clone, Debug, Default)]
486pub struct Summary {
487 pub seeds: u64,
488 pub crashes: u64,
489 pub workflows: u64,
490 pub records: u64,
491 pub flush_failures: u64,
492 pub simulated_ms: u64,
493}
494
495impl Scenario {
496 /// `setup` runs once per seed, on a fresh machine, and builds that
497 /// seed's workflows, requests and checks.
498 pub fn new(setup: impl Fn(&mut Setup) + Send + Sync + 'static) -> Self {
499 Self {
500 setup: UserSetup(Arc::new(setup)),
501 shards: None,
502 fault_window_ms: 2_000,
503 crash_every_ms: None,
504 flush_fail_per_mille: None,
505 tear: true,
506 settle_ms: 3_600_000,
507 }
508 }
509
510 /// A fixed shard count (by default the seed picks 1 to 3).
511 pub fn shards(mut self, n: u32) -> Self {
512 self.shards = Some(n.max(1));
513 self
514 }
515
516 /// How long faults are injected, from the start of the simulation
517 /// (default 2 s of simulated time). Requests may come later.
518 pub fn fault_window(mut self, d: Duration) -> Self {
519 self.fault_window_ms = d.as_millis() as u64;
520 self
521 }
522
523 /// Mean time between process crashes in the fault window; zero for
524 /// none. By default the seed picks none, 150, 400 or 900 ms.
525 pub fn crash_every(mut self, d: Duration) -> Self {
526 self.crash_every_ms = Some(d.as_millis() as u64);
527 self
528 }
529
530 /// Flushes that fail, per thousand, in the fault window. By default
531 /// the seed picks 0, 2 or 10.
532 pub fn flush_failures_per_mille(mut self, n: u32) -> Self {
533 self.flush_fail_per_mille = Some(n);
534 self
535 }
536
537 /// Whether a crash tears unflushed writes (default true) or only loses
538 /// them.
539 pub fn torn_writes(mut self, on: bool) -> Self {
540 self.tear = on;
541 self
542 }
543
544 /// No crashes and no failed flushes: the scheduler and the clock only.
545 pub fn no_faults(self) -> Self {
546 self.crash_every(Duration::ZERO).flush_failures_per_mille(0)
547 }
548
549 /// Simulated time every workflow gets to finish once faults stop
550 /// (default one hour). Workflows that sleep longer need more.
551 pub fn settle_within(mut self, d: Duration) -> Self {
552 self.settle_ms = d.as_millis() as u64;
553 self
554 }
555
556 /// The configuration seed `seed` runs with.
557 pub fn config(&self, seed: u64) -> SimConfig {
558 let mut cfg = SimConfig::for_seed(seed);
559 if let Some(n) = self.shards {
560 cfg.shards = n;
561 }
562 cfg.duration_ms = self.fault_window_ms;
563 if let Some(c) = self.crash_every_ms {
564 cfg.crash_every_ms = c;
565 }
566 if let Some(f) = self.flush_fail_per_mille {
567 cfg.flush_fail_per_mille = f;
568 }
569 cfg.tear = self.tear;
570 cfg.settle_ms = self.settle_ms;
571 cfg.user = Some(self.setup.clone());
572 cfg
573 }
574
575 /// Runs one seed. The same seed reproduces the same run, so a failing
576 /// seed is a test case.
577 pub fn run(&self, seed: u64) -> Result<Report, Failure> {
578 World::new(self.config(seed)).run()
579 }
580
581 /// Runs every seed, stopping at the first failure.
582 pub fn run_seeds(&self, seeds: impl IntoIterator<Item = u64>) -> Result<Summary, Failure> {
583 let mut s = Summary::default();
584 for seed in seeds {
585 let r = self.run(seed)?;
586 s.seeds += 1;
587 s.crashes += u64::from(r.crashes);
588 s.workflows += u64::from(r.workflows);
589 s.records += r.records;
590 s.flush_failures += u64::from(r.flush_failures);
591 s.simulated_ms += r.sim_ms;
592 }
593 Ok(s)
594 }
595}