Skip to main content

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}