Skip to main content

turnframe_test/executors/
mod.rs

1//! An executable statement of the **executor** contract (I13, I14, spec §23.1).
2//!
3//! A store proves itself against `turnframe_store::conformance` and a provider
4//! adapter against [`providers::conformance`](crate::providers::conformance).
5//! The third thing an adopter writes is the [`WorkflowExecutor`], and it is
6//! where optimistic concurrency and idempotency actually live: a card bound to
7//! revision `N` is only safe because some executor refuses a stale write, and a
8//! turn replayed after a crash is only harmless because some executor
9//! recognises the key it already committed. Those rules are prose on the trait,
10//! and prose does not fail a build.
11//!
12//! The check nobody writes for themselves is **partial-batch recovery**, which
13//! is why [`ExecutorFactory::interrupt_after`] exists: the suite cannot
14//! half-commit a batch through the trait, so it asks the implementation to.
15//! [`docs/recipes.md`](https://github.com/turnframe-rs/turnframe/blob/main/docs/recipes.md)
16//! describes the two shapes that defect usually takes.
17//!
18//! Nothing here panics: every check returns a result, [`run_all`] collects them
19//! all rather than stopping at the first, and a failure names revisions,
20//! digests, error variants and check names — never a case's state, which is the
21//! adopter's data and is compared as a digest.
22//!
23//! # Running it
24//!
25//! Implement [`ExecutorFactory`] once, then hand it to [`run_all`].
26//!
27//! ```
28//! use turnframe_test::executors;
29//! use turnframe_test::workflows::trip::conformance_case;
30//!
31//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
32//! # tokio::runtime::Runtime::new()?.block_on(async {
33//! let report = executors::run_all(&conformance_case()).await;
34//! assert!(report.passed(), "{report}");
35//! assert_eq!(report.outcomes.len(), executors::CHECK_COUNT);
36//! # });
37//! # Ok(())
38//! # }
39//! ```
40//!
41//! [`InMemoryCase`] is the reference factory: it wires the kit's own
42//! [`InMemoryExecutor`](crate::workflows::InMemoryExecutor) to any
43//! [`PureWorkflow`](crate::workflows::PureWorkflow), and it is the smallest
44//! complete example of what an adopter writes to point the suite at their own
45//! executor.
46//!
47//! While an executor is still being written, run one rule at a time: every
48//! `check_*` function is public and takes the same factory.
49//!
50//! # It never panics
51//!
52//! Every check returns [`Result<(), ConformanceFailure>`](ConformanceFailure)
53//! and [`run_all`] collects the results into a [`ConformanceReport`] without
54//! stopping at the first failure — a broken executor usually breaks several
55//! rules at once, and seeing all of them is faster to fix than seeing the first
56//! one seven times. Nothing here unwraps, asserts or panics, so the suite is
57//! usable outside a test harness: in a migration tool, or as a boot-time gate
58//! on a freshly written adapter.
59//!
60//! Failure details name revisions, digests, error variants and check names.
61//! They never render a case's state, because that state is the adopter's data:
62//! two states are compared through
63//! [`canonical_digest`](turnframe_core::hash::canonical_digest), and a
64//! mismatch is reported as two digests.
65//!
66//! # What is covered
67//!
68//! | Check | Rule |
69//! |---|---|
70//! | [`check_stale_expected_revision_is_a_conflict`] | a batch planned against a superseded revision is refused, and the case is not overwritten (I13) |
71//! | [`check_commit_reports_the_revision_it_reached`] | the revision in the commit is the one a later batch must be planned against |
72//! | [`check_repeated_key_replays_the_outcome`] | a key seen before returns the original outcome and repeats no effect (I14) |
73//! | [`check_repeated_key_with_another_command_is_refused`] | the same key carrying a different command is a mismatch, never a replay (I14) |
74//! | [`check_per_case_batch_is_all_or_nothing`] | one refused envelope discards the whole batch, and a `PerCase` batch may not span two cases |
75//! | [`check_interrupted_batch_resumes_to_the_same_state`] | a batch that half-committed resumes to exactly the state an uninterrupted one reaches |
76//! | [`check_refused_command_leaves_the_case_byte_identical`] | a refusal writes nothing at all, and stays a refusal when it is retried |
77
78mod batch;
79mod fixtures;
80mod idempotency;
81mod in_memory;
82mod revision;
83
84use std::fmt;
85
86use turnframe_core::case::CaseRef;
87use turnframe_core::command::CommandBatch;
88use turnframe_core::flow::{WorkflowDefinition, WorkflowExecutor};
89use turnframe_core::turn::ActorContext;
90
91pub use self::batch::{
92    check_interrupted_batch_resumes_to_the_same_state, check_per_case_batch_is_all_or_nothing,
93    check_refused_command_leaves_the_case_byte_identical,
94};
95pub use self::idempotency::{
96    check_repeated_key_replays_the_outcome, check_repeated_key_with_another_command_is_refused,
97};
98pub use self::in_memory::{CONFORMANCE_ACCOUNT, CONFORMANCE_USER, InMemoryCase};
99pub use self::revision::{
100    check_commit_reports_the_revision_it_reached, check_stale_expected_revision_is_a_conflict,
101};
102
103/// The workflow an [`ExecutorFactory`] builds executors for.
104pub type WorkflowOf<F> = <F as ExecutorFactory>::Workflow;
105
106/// The command type of [`WorkflowOf<F>`](WorkflowOf).
107pub type CommandOf<F> = <WorkflowOf<F> as WorkflowDefinition>::Command;
108
109/// The batch type the suite hands to the executor under test.
110pub type BatchOf<F> = CommandBatch<CommandOf<F>>;
111
112/// The [`SeededCase`] an [`ExecutorFactory`] produces.
113pub type SeedOf<F> = SeededCase<WorkflowOf<F>, <F as ExecutorFactory>::Executor>;
114
115/// One rule of the executor contract that an implementation broke.
116///
117/// `detail` names what the check expected and what it observed. It carries
118/// revisions, digests and error variants only — never a rendered case state —
119/// so it is safe to log in full.
120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121#[error("executor conformance check `{check}` failed: {detail}")]
122pub struct ConformanceFailure {
123    /// Name of the failing check, matching its function name.
124    pub check: &'static str,
125    /// What was expected and what happened.
126    pub detail: String,
127}
128
129impl ConformanceFailure {
130    /// Builds a failure.
131    #[must_use]
132    pub fn new(check: &'static str, detail: impl Into<String>) -> Self {
133        Self {
134            check,
135            detail: detail.into(),
136        }
137    }
138}
139
140/// What one check concluded.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct CheckOutcome {
143    /// Name of the check.
144    pub check: &'static str,
145    /// The failure, when it failed.
146    pub failure: Option<ConformanceFailure>,
147}
148
149impl CheckOutcome {
150    /// Returns `true` when the check passed.
151    #[must_use]
152    pub fn passed(&self) -> bool {
153        self.failure.is_none()
154    }
155}
156
157/// The result of a whole [`run_all`].
158///
159/// `Display` renders one line per check, so `assert!(report.passed(), "{report}")`
160/// prints a usable diagnosis.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct ConformanceReport {
163    /// Every check, in the order [`run_all`] ran them.
164    pub outcomes: Vec<CheckOutcome>,
165}
166
167impl ConformanceReport {
168    /// Returns `true` when every check passed.
169    #[must_use]
170    pub fn passed(&self) -> bool {
171        self.outcomes.iter().all(CheckOutcome::passed)
172    }
173
174    /// Every failure, in run order.
175    pub fn failures(&self) -> impl Iterator<Item = &ConformanceFailure> {
176        self.outcomes
177            .iter()
178            .filter_map(|outcome| outcome.failure.as_ref())
179    }
180
181    /// Names of the checks that failed, in run order.
182    ///
183    /// This is what a falsification test asserts on: break one rule in an
184    /// executor and exactly one name must appear here.
185    pub fn failed_checks(&self) -> impl Iterator<Item = &'static str> {
186        self.failures().map(|failure| failure.check)
187    }
188
189    /// Turns the report into a `Result`, keeping the first failure.
190    ///
191    /// # Errors
192    /// * The first [`ConformanceFailure`] in run order.
193    pub fn into_result(self) -> Result<(), ConformanceFailure> {
194        match self.outcomes.into_iter().find_map(|o| o.failure) {
195            Some(failure) => Err(failure),
196            None => Ok(()),
197        }
198    }
199}
200
201impl fmt::Display for ConformanceReport {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        let failed = self.failures().count();
204        writeln!(
205            f,
206            "{} of {} executor conformance checks passed",
207            self.outcomes.len() - failed,
208            self.outcomes.len()
209        )?;
210        for outcome in &self.outcomes {
211            match &outcome.failure {
212                None => writeln!(f, "  pass  {}", outcome.check)?,
213                Some(failure) => writeln!(f, "  FAIL  {}: {}", outcome.check, failure.detail)?,
214            }
215        }
216        Ok(())
217    }
218}
219
220/// A fresh executor, the case seeded in it, and the commands the suite drives
221/// it with.
222///
223/// The suite builds every envelope itself — identifiers, idempotency keys and
224/// batch scope are derived, not supplied — so an adopter only has to say *what*
225/// to execute, never *how* to address it.
226pub struct SeededCase<W: WorkflowDefinition, E: WorkflowExecutor<W>> {
227    /// The executor under test, holding exactly one case.
228    pub executor: E,
229    /// The actor every envelope runs as. Its account owns the seeded case.
230    pub actor: ActorContext,
231    /// The seeded case, at the revision the seed left it.
232    pub case_ref: CaseRef,
233    /// A command that applies from the seeded state.
234    ///
235    /// It must **not** be naturally idempotent: applying it twice has to leave
236    /// a state different from applying it once (append a line, do not assign a
237    /// field). A suite that used an assignment could not tell a replay from a
238    /// second execution, which is the whole point of half the checks here.
239    pub first: W::Command,
240    /// A command that applies after [`first`](Self::first) and serializes
241    /// differently from it.
242    pub second: W::Command,
243    /// A command the domain refuses, both from the seeded state and from the
244    /// state [`first`](Self::first) leaves behind.
245    ///
246    /// The refusal must come from the domain — [`ExecutionError::Rejected`] —
247    /// and not from a revision or an idempotency rule, because the checks that
248    /// use it are about what a *refusal* leaves behind.
249    ///
250    /// [`ExecutionError::Rejected`]: turnframe_core::error::ExecutionError::Rejected
251    pub refused: W::Command,
252}
253
254impl<W: WorkflowDefinition, E: WorkflowExecutor<W>> SeededCase<W, E> {
255    /// Assembles a seeded case.
256    pub fn new(
257        executor: E,
258        actor: ActorContext,
259        case_ref: CaseRef,
260        first: W::Command,
261        second: W::Command,
262        refused: W::Command,
263    ) -> Self {
264        Self {
265            executor,
266            actor,
267            case_ref,
268            first,
269            second,
270            refused,
271        }
272    }
273}
274
275impl<W: WorkflowDefinition, E: WorkflowExecutor<W>> fmt::Debug for SeededCase<W, E> {
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        f.debug_struct("SeededCase")
278            .field("workflow", &self.case_ref.workflow)
279            .field("case_id", &self.case_ref.case_id)
280            .field("revision", &self.case_ref.expected_revision)
281            .finish_non_exhaustive()
282    }
283}
284
285/// Builds a fresh executor with one case in it, once per check.
286///
287/// # The seed must be deterministic
288///
289/// [`seed`](Self::seed) is called more than once inside a single check —
290/// [`check_interrupted_batch_resumes_to_the_same_state`] compares an
291/// interrupted run against an uninterrupted one — and the two runs are only
292/// comparable if both start from the same state at the same revision, with the
293/// same account and case identifier. A factory that mints a random case id per
294/// call, or reuses one executor across calls, makes that check meaningless
295/// rather than failing it.
296///
297/// The seeded revision may be anything, including
298/// [`CaseRevision::ZERO`](turnframe_core::ids::CaseRevision::ZERO) for a case
299/// that does not exist yet, as long as `case_ref.expected_revision` is the
300/// revision `load` reports.
301#[async_trait::async_trait]
302pub trait ExecutorFactory: Send + Sync {
303    /// The workflow whose executor is under test.
304    type Workflow: WorkflowDefinition;
305    /// The executor implementation being proven.
306    type Executor: WorkflowExecutor<Self::Workflow> + Send + Sync;
307
308    /// Builds a fresh executor and seeds one case in it.
309    ///
310    /// # Errors
311    /// * A description of what went wrong while building or seeding. The suite
312    ///   turns it into a [`ConformanceFailure`] for the check that asked.
313    async fn seed(&self) -> Result<SeedOf<Self>, String>;
314
315    /// Commits only the first `applied` envelopes of `batch`, as a process that
316    /// died mid-batch would have left them.
317    ///
318    /// This is the one thing the suite cannot do through [`WorkflowExecutor`],
319    /// and the check that matters most needs it. Implement it against your own
320    /// tables, with the same code path `execute` uses, stopping after `applied`
321    /// envelopes have committed.
322    ///
323    /// **If a partial batch is impossible in your executor** — every envelope
324    /// commits inside one database transaction, so a crash rolls all of them
325    /// back — then implement this by executing the *whole* batch and returning
326    /// `Ok(())`. That is the honest translation: the state a crash can leave
327    /// behind is either "none of it", which needs no resume, or "all of it",
328    /// which the resume must recognise as a replay. The checks then prove that
329    /// stronger property instead of a weaker one.
330    ///
331    /// # Errors
332    /// * A description of why the prefix could not be committed. `applied` is
333    ///   always at least one and never larger than the batch, so a bounds
334    ///   complaint is a bug in the suite, not in the implementation.
335    async fn interrupt_after(
336        &self,
337        seeded: &SeedOf<Self>,
338        batch: &BatchOf<Self>,
339        applied: usize,
340    ) -> Result<(), String>;
341}
342
343/// How many checks [`run_all`] runs.
344///
345/// Exported so a caller asserting full coverage cannot drift from the suite: a
346/// check added here changes this constant, while an assertion written against a
347/// literal would keep passing while covering less.
348pub const CHECK_COUNT: usize = 7;
349
350/// Runs every check against a fresh executor each and reports.
351///
352/// Never panics and never stops early.
353pub async fn run_all<F: ExecutorFactory>(factory: &F) -> ConformanceReport {
354    let mut outcomes = Vec::with_capacity(CHECK_COUNT);
355    macro_rules! run {
356        ($($check:path),* $(,)?) => {
357            $(
358                outcomes.push(CheckOutcome {
359                    check: stringify!($check).rsplit("::").next().unwrap_or(stringify!($check)),
360                    failure: $check(factory).await.err(),
361                });
362            )*
363        };
364    }
365    run!(
366        check_stale_expected_revision_is_a_conflict,
367        check_commit_reports_the_revision_it_reached,
368        check_repeated_key_replays_the_outcome,
369        check_repeated_key_with_another_command_is_refused,
370        check_per_case_batch_is_all_or_nothing,
371        check_interrupted_batch_resumes_to_the_same_state,
372        check_refused_command_leaves_the_case_byte_identical,
373    );
374    ConformanceReport { outcomes }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn report_renders_and_keeps_the_first_failure() {
383        let report = ConformanceReport {
384            outcomes: vec![
385                CheckOutcome {
386                    check: "check_a",
387                    failure: None,
388                },
389                CheckOutcome {
390                    check: "check_b",
391                    failure: Some(ConformanceFailure::new("check_b", "expected 1, got 2")),
392                },
393            ],
394        };
395        assert!(!report.passed());
396        assert_eq!(report.failures().count(), 1);
397        assert_eq!(report.failed_checks().collect::<Vec<_>>(), vec!["check_b"]);
398        let rendered = report.to_string();
399        assert!(rendered.contains("1 of 2 executor conformance checks passed"));
400        assert!(rendered.contains("FAIL  check_b: expected 1, got 2"));
401        assert_eq!(report.into_result().unwrap_err().check, "check_b");
402    }
403
404    #[test]
405    fn an_empty_report_passes() {
406        let report = ConformanceReport {
407            outcomes: Vec::new(),
408        };
409        assert!(report.passed());
410        assert!(report.into_result().is_ok());
411    }
412}