Skip to main content

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}