Skip to main content

aion/durability/
error.rs

1//! `NonDeterminismError` and `DurabilityError` taxonomy.
2
3use aion_core::{SearchAttributeError, WorkflowId};
4use aion_store::StoreError;
5
6/// A deterministic replay mismatch between the workflow command stream and recorded history.
7#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
8#[error(
9    "non-determinism in workflow {workflow_id} at sequence {seq}: expected {expected}, found {found}"
10)]
11pub struct NonDeterminismError {
12    /// Workflow whose recorded history diverged from the replayed command stream.
13    pub workflow_id: WorkflowId,
14    /// Sequence position of the recorded event at the cursor mismatch.
15    pub seq: u64,
16    /// Shape of the command the workflow issued, including family and correlation key.
17    pub expected: String,
18    /// Shape of the recorded event found at the cursor position, including family and key.
19    pub found: String,
20}
21
22/// Errors returned by durability recording, replay, and recovery operations.
23#[derive(thiserror::Error, Debug)]
24pub enum DurabilityError {
25    /// The backing event store rejected or failed a durability operation.
26    #[error("store error: {0}")]
27    Store(#[from] StoreError),
28
29    /// Replay detected that workflow code no longer matches recorded history.
30    #[error("non-determinism violation: {0}")]
31    NonDeterminism(#[from] NonDeterminismError),
32
33    /// Recorded history is malformed or internally inconsistent.
34    #[error("history shape error: {reason}")]
35    HistoryShape {
36        /// Human-readable description of the malformed recorded history.
37        reason: String,
38    },
39
40    /// A search attribute update did not satisfy the registered schema.
41    #[error("search attribute validation error: {0}")]
42    SearchAttribute(#[from] SearchAttributeError),
43
44    /// A durable write was refused because this engine's task epoch has closed.
45    ///
46    /// 🔴 DELIBERATELY NOT [`crate::EngineError::EngineTaskEpochClosed`]. That
47    /// variant carries a documented single-construction-site invariant —
48    /// `aion-client`'s `map_engine_error` gives it no arm on the stated ground
49    /// that it is unreachable through any transport-exposed operation — and a
50    /// second construction site would silently take that transport's catch-all.
51    /// This is a different layer with a different audience: it surfaces as
52    /// `{error, _}` inside running workflow code, never over the wire.
53    #[error("engine task epoch closed: {reason}")]
54    EngineTaskEpochClosed {
55        /// What was refused, and what it means for the run.
56        reason: String,
57    },
58
59    /// A durable write was refused because it was offered on behalf of a run
60    /// whose generation the history has already moved past (aion#213).
61    ///
62    /// Continue-as-new does not stop the predecessor's process at the instant
63    /// its terminal lands — the API path never touches the pid, and the NIF
64    /// path's `cancel_pid` follows the append — so a run that is durably over
65    /// can still reach its recorder with work it had already begun. Sequencing
66    /// that append would put a `TimerStarted` (or an activity record, or a
67    /// signal) for a dead run inside the SUCCESSOR's segment, where replay
68    /// reads it as the successor's own. Nothing is appended, and the run this
69    /// names is over: the refusal is the correct outcome, not a fault to
70    /// retry.
71    #[error(
72        "run `{run_id}` of workflow `{workflow_id}` cannot append: its generation has been \
73         superseded, so the append belongs to no live run"
74    )]
75    RunSuperseded {
76        /// Workflow whose history the append was offered into.
77        workflow_id: aion_core::WorkflowId,
78        /// The superseded run the append was offered on behalf of.
79        run_id: aion_core::RunId,
80    },
81}
82
83#[cfg(test)]
84mod tests {
85    use super::{DurabilityError, NonDeterminismError};
86    use aion_core::WorkflowId;
87    use aion_store::StoreError;
88
89    fn non_determinism_error() -> NonDeterminismError {
90        NonDeterminismError {
91            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
92            seq: 42,
93            expected: "activity schedule ordinal 7".to_owned(),
94            found: "timer fired timer:named:deadline".to_owned(),
95        }
96    }
97
98    #[test]
99    fn non_determinism_display_includes_context() {
100        let error = non_determinism_error();
101
102        let message = error.to_string();
103
104        assert!(message.contains("00000000-0000-0000-0000-000000000000"));
105        assert!(message.contains("42"));
106        assert!(message.contains("activity schedule ordinal 7"));
107        assert!(message.contains("timer fired timer:named:deadline"));
108    }
109
110    /// The operator-facing `RunSuperseded` sentence: it names both identities,
111    /// and it is ONE sentence rather than two halves with a hole between them.
112    ///
113    /// # 🔴 A WRAPPED LITERAL THAT LOSES ITS `\` LEAVES A HOLE NO TOOL CATCHES
114    ///
115    /// This message shipped once as `"…has been          superseded, …"` — the
116    /// line-continuation backslash was dropped when the literal was re-wrapped.
117    /// `cargo fmt` does not touch the inside of string literals and clippy has
118    /// nothing to say about them, so nothing failed. The string is not internal:
119    /// `aion-server`'s wire mapping sends `source.to_string()` to API callers
120    /// verbatim, so an operator reads it in an incident. The run of spaces is
121    /// what the assertion below looks for, because that is the shape the defect
122    /// takes every time.
123    #[test]
124    fn run_superseded_reads_as_one_sentence_naming_both_identities() {
125        // Two DISTINCT identifiers: with identical fixtures the two
126        // `contains` assertions below collapse into one assertion twice, and
127        // dropping either `{workflow_id}` or `{run_id}` from the message
128        // would go unnoticed.
129        let workflow_id = WorkflowId::new(uuid::Uuid::from_u128(0xA1));
130        let run_id = aion_core::RunId::new(uuid::Uuid::from_u128(0xB2));
131        let error = DurabilityError::RunSuperseded {
132            workflow_id: workflow_id.clone(),
133            run_id: run_id.clone(),
134        };
135
136        let message = error.to_string();
137
138        assert!(message.contains(&workflow_id.to_string()));
139        assert!(message.contains(&run_id.to_string()));
140        assert!(
141            message.contains("its generation has been superseded"),
142            "the wrapped literal must read as one sentence: {message}"
143        );
144        assert!(
145            !message.contains("  "),
146            "a run of spaces means a dropped `\\` line continuation: {message}"
147        );
148    }
149
150    #[test]
151    fn durability_error_display_mentions_underlying_cause() {
152        let store = DurabilityError::Store(StoreError::SequenceConflict {
153            expected: 10,
154            found: 11,
155        });
156        let non_determinism = DurabilityError::NonDeterminism(non_determinism_error());
157        let history_shape = DurabilityError::HistoryShape {
158            reason: "activity result without preceding schedule".to_owned(),
159        };
160
161        let store_message = store.to_string();
162        let non_determinism_message = non_determinism.to_string();
163        let history_shape_message = history_shape.to_string();
164
165        assert!(!store_message.is_empty());
166        assert!(store_message.contains("sequence conflict"));
167        assert!(!non_determinism_message.is_empty());
168        assert!(non_determinism_message.contains("activity schedule ordinal 7"));
169        assert!(non_determinism_message.contains("timer fired timer:named:deadline"));
170        assert!(!history_shape_message.is_empty());
171        assert!(history_shape_message.contains("activity result without preceding schedule"));
172    }
173}