aion/error.rs
1//! Engine error taxonomy.
2
3use crate::schedule::{ScheduleError, ScheduleEvaluatorError};
4use aion_core::{RunId, ScheduleId, WorkflowId};
5use aion_package::{ContentHash, PackageError};
6use aion_store::StoreError;
7
8use crate::durability::DurabilityError;
9
10/// Errors returned by the embedded workflow engine.
11#[derive(thiserror::Error, Debug)]
12pub enum EngineError {
13 /// The builder was asked to construct an engine without an event store.
14 #[error("engine store is required")]
15 MissingStore,
16
17 /// The builder was asked to construct an engine without a visibility store.
18 #[error(
19 "engine visibility store is required; call EngineBuilder::visibility_store() or EngineBuilder::in_memory_visibility()"
20 )]
21 MissingVisibilityStore,
22
23 /// A workflow package failed to load or validate for engine registration.
24 #[error("workflow package load failed: {reason}")]
25 Load {
26 /// Human-readable load failure reason.
27 reason: String,
28 },
29
30 /// A route or unload targeted a `(workflow type, version)` that is not loaded.
31 #[error(
32 "workflow `{workflow_type}` version `{version}` is not loaded (loaded versions: {loaded})"
33 )]
34 UnknownVersion {
35 /// Logical workflow type requested by the caller.
36 workflow_type: String,
37 /// Content-hash version requested by the caller.
38 version: ContentHash,
39 /// Comma-separated loaded versions of the type, or `none`.
40 loaded: String,
41 },
42
43 /// An unload was refused because something still pins the version.
44 #[error("cannot unload workflow `{workflow_type}` version `{version}`: {pinned_by}")]
45 VersionPinned {
46 /// Logical workflow type targeted by the unload.
47 workflow_type: String,
48 /// Content-hash version targeted by the unload.
49 version: ContentHash,
50 /// What pins the version, naming the concrete holder.
51 pinned_by: PinHolder,
52 },
53
54 /// An unload was refused because the version is route-active for its type.
55 #[error(
56 "cannot unload workflow `{workflow_type}` version `{version}`: it is the route-active version; route another version first"
57 )]
58 RouteActive {
59 /// Logical workflow type targeted by the unload.
60 workflow_type: String,
61 /// Content-hash version targeted by the unload.
62 version: ContentHash,
63 },
64
65 /// An idempotent re-load presented the resident content hash with a
66 /// different manifest. The content hash covers the canonical beam set
67 /// only, so this is the wrong-deploy tripwire: the resident version is
68 /// retained untouched and the incoming archive is refused.
69 #[error(
70 "workflow `{workflow_type}` version `{version}` is already loaded with a different manifest (resident digest {resident_digest}, incoming digest {incoming_digest}); the content hash covers beams only — rebuild the archive so its manifest matches the resident version, or change the beam set"
71 )]
72 ManifestMismatch {
73 /// Logical workflow type targeted by the load.
74 workflow_type: String,
75 /// Content-hash version shared by both archives.
76 version: ContentHash,
77 /// Canonical digest of the resident manifest.
78 resident_digest: String,
79 /// Canonical digest of the incoming manifest.
80 incoming_digest: String,
81 },
82
83 /// The builder was given both `event_streaming` and an explicit event-publisher seam.
84 #[error(
85 "conflicting event publisher configuration: EngineBuilder::event_streaming installs the broadcast publisher and cannot be combined with EngineBuilder::event_publisher"
86 )]
87 ConflictingEventPublisher,
88
89 /// Live event streaming setup failed.
90 #[error("event streaming setup failed: {0}")]
91 EventStreaming(#[from] crate::publish::PublishError),
92
93 /// The configured event store returned an error.
94 #[error("store error: {0}")]
95 Store(#[from] StoreError),
96
97 /// The durability recorder or replay path returned an error.
98 #[error("durability error: {0}")]
99 Durability(#[from] DurabilityError),
100
101 /// A `.aion` package operation returned an error.
102 #[error("package error: {0}")]
103 Package(#[from] PackageError),
104
105 /// The embedded runtime returned an error.
106 #[error("runtime error: {reason}")]
107 Runtime {
108 /// Human-readable runtime failure reason.
109 reason: String,
110 },
111
112 /// A Gate-3 BIF required for tracked local fun spawns was not registered.
113 #[error("required Gate-3 BIF `{module}:{function}/{arity}` was missing during runtime startup")]
114 Gate3BifReplacementMissing {
115 /// Native module containing the required function.
116 module: String,
117 /// Required native function.
118 function: String,
119 /// Required native function arity.
120 arity: u8,
121 },
122
123 /// The runtime-owned cleanup executor's ownership state was poisoned.
124 #[error("process cleanup executor state was poisoned")]
125 CleanupExecutorPoisoned,
126
127 /// The runtime cleanup worker did not stop within the configured bound.
128 #[error("process cleanup executor did not stop within {timeout_millis}ms")]
129 CleanupExecutorShutdownTimedOut {
130 /// Configured shutdown observation bound in milliseconds.
131 timeout_millis: u128,
132 },
133
134 /// The process-exit registry lifecycle lock was poisoned.
135 #[error("process exit registry lifecycle state was poisoned")]
136 ProcessExitRegistryPoisoned,
137
138 /// A process exit record's installation/abort ownership gate was poisoned.
139 #[error("process exit ownership gate for process {process_id} was poisoned")]
140 ProcessExitOwnershipPoisoned {
141 /// Process whose monitor/abort ownership could not be serialized.
142 process_id: u64,
143 },
144
145 /// A process exit record's fan-out state was poisoned.
146 #[error("process exit outcome state for process {process_id} was poisoned")]
147 ProcessExitStatePoisoned {
148 /// Process whose cached exit state could not be accessed.
149 process_id: u64,
150 },
151
152 /// The scheduler's one exit-event subscription was already claimed.
153 #[error("beamr process exit-event subscription is already owned")]
154 ProcessExitSubscriptionUnavailable,
155
156 /// The singleton process-exit drainer could not be spawned.
157 #[error("process exit drainer could not start: {reason}")]
158 ProcessExitDrainerSpawn {
159 /// Operating-system thread creation failure.
160 reason: String,
161 },
162
163 /// The singleton process-exit drainer's ownership lock was poisoned.
164 #[error("process exit drainer state was poisoned")]
165 ProcessExitDrainerPoisoned,
166
167 /// beamr published an exit event without the promised durable outcome.
168 #[error("process {process_id} exit event had no takeable outcome")]
169 ProcessExitOutcomeMissingAfterEvent {
170 /// Process named by the contract-breaking event.
171 process_id: u64,
172 },
173
174 /// beamr disconnected its event publisher while the runtime still owned it.
175 #[error("beamr process exit-event publisher disconnected")]
176 ProcessExitEventStreamDisconnected,
177
178 /// The process-exit drainer did not stop within the configured bound.
179 #[error("process exit drainer did not stop within {timeout_millis}ms")]
180 ProcessExitDrainerShutdownTimedOut {
181 /// Configured shutdown observation bound in milliseconds.
182 timeout_millis: u128,
183 },
184
185 /// The process-exit drainer thread panicked.
186 #[error("process exit drainer terminated unexpectedly")]
187 ProcessExitDrainerPanicked,
188
189 /// The process-exit callback dispatcher's ownership state was poisoned.
190 #[error("process exit callback dispatcher state was poisoned")]
191 ProcessExitCallbackDispatcherPoisoned,
192
193 /// The process-exit callback dispatcher had already stopped.
194 #[error("process exit callback dispatcher is unavailable")]
195 ProcessExitCallbackDispatcherUnavailable,
196
197 /// The process-exit callback dispatcher did not stop within its configured bound.
198 #[error("process exit callback dispatcher did not stop within {timeout_millis}ms")]
199 ProcessExitCallbackDispatcherShutdownTimedOut {
200 /// Configured shutdown observation bound in milliseconds.
201 timeout_millis: u128,
202 },
203
204 /// A retired process generation cannot accept another outcome consumer.
205 #[error("process {process_id} already reached its terminal runtime outcome")]
206 ProcessExitAlreadyTerminal {
207 /// Process generation whose heavyweight exit record was retired.
208 process_id: u64,
209 },
210
211 /// A workflow's activity-delivery synchronization lock was poisoned.
212 #[error("activity delivery lock for process {process_id} was poisoned")]
213 ActivityDeliveryPoisoned {
214 /// Workflow process whose scoped delivery lock was poisoned.
215 process_id: u64,
216 },
217
218 /// The active workflow registry lock was poisoned.
219 #[error("active workflow registry lock was poisoned")]
220 RegistryPoisoned,
221
222 /// The workflow catalog lock was poisoned.
223 #[error("workflow catalog lock was poisoned")]
224 CatalogPoisoned,
225
226 /// A precondition on the target workflow's current state was not met.
227 ///
228 /// Raised by the reopen operation when the target run is not in a reopenable
229 /// state: not terminal, terminal for a non-reopenable reason
230 /// (Completed/`TimedOut`), or already Running. The `reason` names the actual
231 /// status so callers and operators can see why the reopen was rejected. Maps
232 /// to the `INVALID_STATE` wire code (gRPC `FailedPrecondition` / HTTP 409).
233 #[error("invalid workflow state: {reason}")]
234 InvalidState {
235 /// Human-readable precondition-failure reason naming the actual status.
236 reason: String,
237 },
238
239 /// The engine is already shutting down and no new workflow starts are accepted.
240 #[error("engine is shutting down")]
241 ShuttingDown,
242
243 /// No live, durable, or loaded workflow was found for the request.
244 #[error("workflow `{workflow_type}` was not found")]
245 WorkflowNotFound {
246 /// Logical workflow type requested by the caller.
247 workflow_type: String,
248 },
249
250 /// No durable schedule was found for the request.
251 #[error("schedule `{schedule_id}` was not found")]
252 ScheduleNotFound {
253 /// Schedule identifier requested by the caller.
254 schedule_id: ScheduleId,
255 },
256
257 /// Schedule trigger, projection, or evaluator side effect failed.
258 #[error("schedule error: {reason}")]
259 Schedule {
260 /// Human-readable schedule failure reason.
261 reason: String,
262 },
263
264 /// Native implemented function registration failed.
265 #[error("NIF registration failed: {reason}")]
266 NifRegistration {
267 /// Human-readable native implemented function registration failure reason.
268 reason: String,
269 },
270
271 /// Signal routing failed after the target was resolved.
272 #[error("signal router error: {0}")]
273 SignalRouter(#[from] SignalRouterError),
274
275 /// Live workflow query dispatch failed after the target was resolved.
276 #[error("query error: {0}")]
277 Query(#[from] crate::query::QueryError),
278}
279
280/// What pins a workflow version against unload, naming the concrete holder.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub enum PinHolder {
283 /// A start resolved this version but has not yet registered a handle.
284 InFlightStart,
285 /// A live, non-terminal run executes on this version.
286 LiveRun {
287 /// Pinning workflow id.
288 workflow_id: WorkflowId,
289 /// Pinning run id.
290 run_id: RunId,
291 },
292 /// A recoverable instance in the store is pinned to this version.
293 RecoverableRun {
294 /// Pinning workflow id.
295 workflow_id: WorkflowId,
296 },
297 /// A recorded-but-never-started child is pinned to this version.
298 RecordedChild {
299 /// Child workflow id pinned to the version.
300 child_workflow_id: WorkflowId,
301 /// Parent workflow whose history records the child.
302 recorded_by: WorkflowId,
303 },
304}
305
306impl std::fmt::Display for PinHolder {
307 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308 match self {
309 Self::InFlightStart => formatter.write_str("an in-flight start is pinned to it"),
310 Self::LiveRun {
311 workflow_id,
312 run_id,
313 } => write!(
314 formatter,
315 "live run `{workflow_id}/{run_id}` is pinned to it"
316 ),
317 Self::RecoverableRun { workflow_id } => {
318 write!(formatter, "recoverable run `{workflow_id}` is pinned to it")
319 }
320 Self::RecordedChild {
321 child_workflow_id,
322 recorded_by,
323 } => write!(
324 formatter,
325 "child `{child_workflow_id}` recorded by `{recorded_by}` is pinned to it and has not started"
326 ),
327 }
328 }
329}
330
331/// Errors surfaced by the signal routing boundary.
332#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
333pub enum SignalRouterError {
334 /// The target workflow is terminal and cannot receive new signals.
335 #[error("workflow {workflow_id}/{run_id} is terminal")]
336 Terminal {
337 /// Target workflow id.
338 workflow_id: WorkflowId,
339 /// Target run id.
340 run_id: RunId,
341 },
342
343 /// The router could not defer a recorded non-resident signal.
344 #[error("signal resume handoff failed: {reason}")]
345 Handoff {
346 /// Human-readable handoff failure reason.
347 reason: String,
348 },
349
350 /// The signal was durably recorded but could not be delivered to the live mailbox.
351 #[error(
352 "signal `{signal_name}` for workflow {workflow_id}/{run_id} could not be delivered to process {process_id}: {reason}"
353 )]
354 DeliveryFailed {
355 /// Target workflow id.
356 workflow_id: WorkflowId,
357 /// Target run id.
358 run_id: RunId,
359 /// Embedded runtime process identifier selected for delivery.
360 process_id: u64,
361 /// Signal name that was recorded and attempted.
362 signal_name: String,
363 /// Human-readable delivery failure reason.
364 reason: String,
365 },
366}
367
368impl From<ScheduleError> for EngineError {
369 fn from(error: ScheduleError) -> Self {
370 Self::Schedule {
371 reason: error.to_string(),
372 }
373 }
374}
375
376impl From<ScheduleEvaluatorError> for EngineError {
377 fn from(error: ScheduleEvaluatorError) -> Self {
378 match error {
379 ScheduleEvaluatorError::ScheduleNotFound { schedule_id } => {
380 Self::ScheduleNotFound { schedule_id }
381 }
382 other => Self::Schedule {
383 reason: other.to_string(),
384 },
385 }
386 }
387}