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}