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, ContractIdentityError, 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 stop-drain
18    /// bound. The engine invents no patience of its own (AE-017): the value
19    /// is the operator's, handed down from the server's configuration.
20    #[error("engine stop-drain bound is required: call EngineBuilder::stop_drain_timeout")]
21    MissingStopDrainTimeout,
22
23    /// The runtime was handed a zero stop-drain bound. Zero would fail every
24    /// stop the instant a callback was in flight — the opposite of a bound.
25    #[error("engine stop-drain bound must be non-zero")]
26    ZeroStopDrainTimeout,
27
28    /// The builder was asked to construct an engine without a visibility store.
29    #[error(
30        "engine visibility store is required; call EngineBuilder::visibility_store() or EngineBuilder::in_memory_visibility()"
31    )]
32    MissingVisibilityStore,
33
34    /// A workflow package failed to load or validate for engine registration.
35    #[error("workflow package load failed: {reason}")]
36    Load {
37        /// Human-readable load failure reason.
38        reason: String,
39    },
40
41    /// A package offered for deployment declares a contract the engine cannot
42    /// enforce: at least one declared schema does not compile into a validator.
43    ///
44    /// Refused at the door rather than admitted, because the alternative is
45    /// silent: every admission boundary answers an uncompilable schema by
46    /// letting the value through unchecked, so a package that reaches the
47    /// catalog with one runs with that part of its declared contract switched
48    /// off and only a log line to say so.
49    #[error(
50        "workflow package `{workflow_type}` declares {count} type(s) the engine cannot compile into a validator, so nothing it declares there could ever be enforced: {detail}"
51    )]
52    UnenforceableContract {
53        /// Logical workflow type of the refused package.
54        workflow_type: String,
55        /// How many declarations could not be compiled.
56        count: usize,
57        /// Each unenforceable declaration, named, with the compiler's reason.
58        detail: String,
59    },
60
61    /// A route or unload targeted a `(workflow type, version)` that is not loaded.
62    #[error(
63        "workflow `{workflow_type}` version `{version}` is not loaded (loaded versions: {loaded})"
64    )]
65    UnknownVersion {
66        /// Logical workflow type requested by the caller.
67        workflow_type: String,
68        /// Content-hash version requested by the caller.
69        version: ContentHash,
70        /// Comma-separated loaded versions of the type, or `none`.
71        loaded: String,
72    },
73
74    /// An unload was refused because something still pins the version.
75    #[error("cannot unload workflow `{workflow_type}` version `{version}`: {pinned_by}")]
76    VersionPinned {
77        /// Logical workflow type targeted by the unload.
78        workflow_type: String,
79        /// Content-hash version targeted by the unload.
80        version: ContentHash,
81        /// What pins the version, naming the concrete holder.
82        pinned_by: PinHolder,
83    },
84
85    /// An unload was refused because the version is route-active for its type.
86    #[error(
87        "cannot unload workflow `{workflow_type}` version `{version}`: it is the route-active version; route another version first"
88    )]
89    RouteActive {
90        /// Logical workflow type targeted by the unload.
91        workflow_type: String,
92        /// Content-hash version targeted by the unload.
93        version: ContentHash,
94    },
95
96    /// An idempotent re-load presented the resident package identity with a
97    /// different manifest. V4 binds beams and the durable execution contract,
98    /// but not every packaging/admin field, so this remains the wrong-deploy
99    /// tripwire: the resident version is retained and the archive is refused.
100    #[error(
101        "workflow `{workflow_type}` version `{version}` is already loaded with a different manifest (resident digest {resident_digest}, incoming digest {incoming_digest}); rebuild the archive so its complete manifest matches the resident version, or change its contract-bound content"
102    )]
103    ManifestMismatch {
104        /// Logical workflow type targeted by the load.
105        workflow_type: String,
106        /// Content-hash version shared by both archives.
107        version: ContentHash,
108        /// Canonical digest of the resident manifest.
109        resident_digest: String,
110        /// Canonical digest of the incoming manifest.
111        incoming_digest: String,
112    },
113
114    /// The builder was given both `event_streaming` and an explicit event-publisher seam.
115    #[error(
116        "conflicting event publisher configuration: EngineBuilder::event_streaming installs the broadcast publisher and cannot be combined with EngineBuilder::event_publisher"
117    )]
118    ConflictingEventPublisher,
119
120    /// Live event streaming setup failed.
121    #[error("event streaming setup failed: {0}")]
122    EventStreaming(#[from] crate::publish::PublishError),
123
124    /// The configured event store returned an error.
125    #[error("store error: {0}")]
126    Store(#[from] StoreError),
127
128    /// The durability recorder or replay path returned an error.
129    #[error("durability error: {0}")]
130    Durability(#[from] DurabilityError),
131
132    /// A `.aion` package operation returned an error.
133    #[error("package error: {0}")]
134    Package(#[from] PackageError),
135
136    /// The selected package identity predates the `.v4` contract commitment.
137    #[error("workflow `{workflow_type}` cannot start: {source}")]
138    ContractIdentity {
139        /// Workflow type selected for the start.
140        workflow_type: String,
141        /// Typed migration refusal from the package identity boundary.
142        #[source]
143        source: ContractIdentityError,
144    },
145
146    /// The package names activities without a durable queue-scoped contract.
147    #[error(
148        "NO_QUEUE_DECLARATION: workflow `{workflow_type}` version `{version}` has unscoped activities {activities}; re-deploy from a checked AWL contract"
149    )]
150    NoQueueDeclaration {
151        /// Workflow type selected for the start.
152        workflow_type: String,
153        /// Exact `.v4` package identity selected for the run.
154        version: ContentHash,
155        /// Stable comma-separated unscoped activity names.
156        activities: String,
157    },
158
159    /// A start's input did not satisfy the declared input schema of the exact
160    /// package identity the start resolved to.
161    ///
162    /// Returned at the start boundary BEFORE any history is appended and
163    /// before any process is spawned, so a refused start leaves no trace: the
164    /// caller sees their own mistake at the moment they made it, with nothing
165    /// to clean up.
166    #[error(
167        "start input for workflow `{workflow_type}` does not satisfy the input type declared by package version `{version}`: {reason}"
168    )]
169    StartInputRefused {
170        /// Workflow type selected for the start.
171        workflow_type: String,
172        /// Exact `.v4` package identity the start resolved to.
173        version: ContentHash,
174        /// What did not match, naming every field that failed.
175        reason: String,
176    },
177
178    /// A signal was refused at the boundary: its name is not declared by the
179    /// target run's package, or its payload did not satisfy the declared
180    /// payload type.
181    ///
182    /// Returned BEFORE anything is recorded and before the arrival can be
183    /// consumed, so the target run's history is unchanged and it stays parked
184    /// on exactly the wait it was parked on. That ordering is the whole point:
185    /// a signal decoded after being consumed destroys a durable run that a
186    /// refusal merely inconveniences.
187    #[error(
188        "signal `{signal_name}` was refused for workflow `{workflow_id}` run `{run_id}` against the contract declared by package version `{version}`: {reason}"
189    )]
190    SignalRefused {
191        /// Workflow execution the signal targeted.
192        workflow_id: WorkflowId,
193        /// Concrete run the signal targeted.
194        run_id: RunId,
195        /// Signal name the caller sent.
196        signal_name: String,
197        /// Exact `.v4` package identity the target run is pinned to.
198        version: ContentHash,
199        /// Why the signal was refused — an undeclared name, or the fields of
200        /// the payload that did not match the declared type.
201        reason: String,
202    },
203
204    /// The embedded runtime returned an error.
205    #[error("runtime error: {reason}")]
206    Runtime {
207        /// Human-readable runtime failure reason.
208        reason: String,
209    },
210
211    /// A Gate-3 BIF required for tracked local fun spawns was not registered.
212    #[error("required Gate-3 BIF `{module}:{function}/{arity}` was missing during runtime startup")]
213    Gate3BifReplacementMissing {
214        /// Native module containing the required function.
215        module: String,
216        /// Required native function.
217        function: String,
218        /// Required native function arity.
219        arity: u8,
220    },
221
222    /// [`crate::Engine::run_startup_recovery`] was called on an engine whose
223    /// build was not deferred — `build()` already ran startup recovery, and
224    /// running it twice would re-dispatch every in-flight activity.
225    #[error(
226        "startup recovery was not deferred: EngineBuilder::build() already ran it \
227         (call defer_startup_recovery() on the builder to take ownership of the steps)"
228    )]
229    StartupRecoveryNotDeferred,
230
231    /// [`crate::Engine::run_startup_recovery`] was called a second time.
232    #[error("startup recovery already ran: run_startup_recovery() is one-shot")]
233    StartupRecoveryAlreadyRan,
234
235    /// [`crate::Engine::run_startup_catchup`] was called before the
236    /// workflow-recovery leg ran — catch-up delivers owed timer fires to
237    /// resident workflows, so residency recovery must precede it.
238    #[error(
239        "startup catch-up was requested before workflow recovery: call \
240         recover_workflows_on_startup() first"
241    )]
242    StartupCatchupBeforeWorkflowRecovery,
243
244    /// The deferred-startup-recovery slot lock was poisoned.
245    #[error("deferred startup recovery slot was poisoned")]
246    StartupRecoverySlotPoisoned,
247
248    /// The runtime-owned cleanup executor's ownership state was poisoned.
249    #[error("process cleanup executor state was poisoned")]
250    CleanupExecutorPoisoned,
251
252    /// The runtime cleanup worker did not stop within the configured bound.
253    #[error(
254        "process cleanup executor did not stop: nothing completed for {since_progress_millis}ms (no-progress bound {timeout_millis}ms, {queued} queued)"
255    )]
256    CleanupExecutorShutdownTimedOut {
257        /// The no-progress bound the drain waited under, in milliseconds.
258        timeout_millis: u128,
259        /// How long the worker had completed nothing when the drain gave up.
260        since_progress_millis: u128,
261        /// Jobs still queued behind the one in flight.
262        queued: usize,
263    },
264
265    /// The process-exit registry lifecycle lock was poisoned.
266    #[error("process exit registry lifecycle state was poisoned")]
267    ProcessExitRegistryPoisoned,
268
269    /// A process exit record's installation/abort ownership gate was poisoned.
270    #[error("process exit ownership gate for process {process_id} was poisoned")]
271    ProcessExitOwnershipPoisoned {
272        /// Process whose monitor/abort ownership could not be serialized.
273        process_id: u64,
274    },
275
276    /// A process exit record's fan-out state was poisoned.
277    #[error("process exit outcome state for process {process_id} was poisoned")]
278    ProcessExitStatePoisoned {
279        /// Process whose cached exit state could not be accessed.
280        process_id: u64,
281    },
282
283    /// The scheduler's one exit-event subscription was already claimed.
284    #[error("beamr process exit-event subscription is already owned")]
285    ProcessExitSubscriptionUnavailable,
286
287    /// The singleton process-exit drainer could not be spawned.
288    #[error("process exit drainer could not start: {reason}")]
289    ProcessExitDrainerSpawn {
290        /// Operating-system thread creation failure.
291        reason: String,
292    },
293
294    /// The singleton process-exit drainer's ownership lock was poisoned.
295    #[error("process exit drainer state was poisoned")]
296    ProcessExitDrainerPoisoned,
297
298    /// beamr published an exit event without the promised durable outcome.
299    #[error("process {process_id} exit event had no takeable outcome")]
300    ProcessExitOutcomeMissingAfterEvent {
301        /// Process named by the contract-breaking event.
302        process_id: u64,
303    },
304
305    /// beamr disconnected its event publisher while the runtime still owned it.
306    #[error("beamr process exit-event publisher disconnected")]
307    ProcessExitEventStreamDisconnected,
308
309    /// The process-exit drainer did not stop within the configured bound.
310    #[error(
311        "process exit drainer did not stop: nothing completed for {since_progress_millis}ms (no-progress bound {timeout_millis}ms, {queued} queued)"
312    )]
313    ProcessExitDrainerShutdownTimedOut {
314        /// The no-progress bound the drain waited under, in milliseconds.
315        timeout_millis: u128,
316        /// How long the worker had completed nothing when the drain gave up.
317        since_progress_millis: u128,
318        /// Jobs still queued behind the one in flight.
319        queued: usize,
320    },
321
322    /// The process-exit drainer thread panicked.
323    #[error("process exit drainer terminated unexpectedly")]
324    ProcessExitDrainerPanicked,
325
326    /// The process-exit callback dispatcher's ownership state was poisoned.
327    #[error("process exit callback dispatcher state was poisoned")]
328    ProcessExitCallbackDispatcherPoisoned,
329
330    /// The process-exit callback dispatcher had already stopped.
331    #[error("process exit callback dispatcher is unavailable")]
332    ProcessExitCallbackDispatcherUnavailable,
333
334    /// The process-exit callback dispatcher did not stop within its configured bound.
335    #[error(
336        "process exit callback dispatcher did not stop: nothing completed for {since_progress_millis}ms (no-progress bound {timeout_millis}ms, {queued} queued)"
337    )]
338    ProcessExitCallbackDispatcherShutdownTimedOut {
339        /// The no-progress bound the drain waited under, in milliseconds.
340        timeout_millis: u128,
341        /// How long the worker had completed nothing when the drain gave up.
342        since_progress_millis: u128,
343        /// Jobs still queued behind the one in flight.
344        queued: usize,
345    },
346
347    /// A retired process generation cannot accept another outcome consumer.
348    #[error("process {process_id} already reached its terminal runtime outcome")]
349    ProcessExitAlreadyTerminal {
350        /// Process generation whose heavyweight exit record was retired.
351        process_id: u64,
352    },
353
354    /// A workflow's activity-delivery synchronization lock was poisoned.
355    #[error("activity delivery lock for process {process_id} was poisoned")]
356    ActivityDeliveryPoisoned {
357        /// Workflow process whose scoped delivery lock was poisoned.
358        process_id: u64,
359    },
360
361    /// The active workflow registry lock was poisoned.
362    #[error("active workflow registry lock was poisoned")]
363    RegistryPoisoned,
364
365    /// A registered run has no `WorkflowStarted` in the history it was
366    /// reconciled against — the registry and the store disagree that it exists.
367    ///
368    /// Raised by registry reconciliation rather than defaulting the projection.
369    /// `status_from_events` returns `Running` for a slice holding no lifecycle
370    /// event, so a run absent from the history it is projected against would
371    /// otherwise be silently cached as RUNNING — a terminal run reported live,
372    /// produced by the reconciliation whose whole job is to stop exactly that.
373    ///
374    /// Not reachable through a normal start: `WorkflowStarted` is recorded
375    /// before the handle is published. It means a genuine invariant breach, so
376    /// it is surfaced rather than absorbed.
377    #[error(
378        "run {run_id} of workflow {workflow_id} is absent from the history it was reconciled against"
379    )]
380    RunNotInHistory {
381        /// Workflow whose history was read.
382        workflow_id: WorkflowId,
383        /// Run that the history does not contain.
384        run_id: RunId,
385    },
386
387    /// The workflow catalog lock was poisoned.
388    #[error("workflow catalog lock was poisoned")]
389    CatalogPoisoned,
390
391    /// A precondition on the target workflow's current state was not met.
392    ///
393    /// Raised by the reopen operation when the target run is not in a reopenable
394    /// state: not terminal, terminal for a non-reopenable reason
395    /// (Completed/`TimedOut`), or already Running. The `reason` names the actual
396    /// status so callers and operators can see why the reopen was rejected. Maps
397    /// to the `INVALID_STATE` wire code (gRPC `FailedPrecondition` / HTTP 409).
398    #[error("invalid workflow state: {reason}")]
399    InvalidState {
400        /// Human-readable precondition-failure reason naming the actual status.
401        reason: String,
402    },
403
404    /// The engine is already shutting down and no new workflow starts are accepted.
405    #[error("engine is shutting down")]
406    ShuttingDown,
407
408    /// A worker's lease of an activity attempt arrived after the run's
409    /// terminal event, so there is no open attempt for it to attribute.
410    ///
411    /// Nothing is recorded: appending behind a terminal would put a fact about
412    /// an attempt into a lease segment that has already closed. The caller
413    /// (the server's handoff seam) logs and counts it; the completion that
414    /// closed the run stands.
415    #[error(
416        "activity {activity_id} attempt {attempt} of workflow `{workflow_id}` run `{run_id}` was leased after the run reached a terminal state; the lease was not recorded"
417    )]
418    ActivityLeaseAfterTerminal {
419        /// Workflow whose run had already terminated.
420        workflow_id: WorkflowId,
421        /// The terminated run.
422        run_id: RunId,
423        /// Ordinal of the activity the lease named.
424        activity_id: aion_core::ActivityId,
425        /// One-based attempt the lease named.
426        attempt: u32,
427    },
428
429    /// No live, durable, or loaded workflow was found for the request.
430    #[error("workflow `{workflow_type}` was not found")]
431    WorkflowNotFound {
432        /// Logical workflow type requested by the caller.
433        workflow_type: String,
434    },
435
436    /// A terminal-writer reservation could not be taken because the workflow
437    /// already has a writer (#117(c)).
438    ///
439    /// The extraordinary cancellation path exists only for a run that can never
440    /// obtain a handle. A workflow that has one — or that another reservation is
441    /// already writing — is not that case, and taking a second writer would
442    /// break the single-writer invariant this refusal protects.
443    #[error("workflow `{workflow_id}` run `{run_id}` cannot take the terminal writer: {holder}")]
444    TerminalWriterUnavailable {
445        /// Workflow whose writer slot is occupied.
446        workflow_id: String,
447        /// Run the refused reservation named.
448        run_id: String,
449        /// What holds the slot, in the operator's terms.
450        holder: String,
451    },
452
453    /// A handle could not be registered because a terminal-writer reservation
454    /// holds this workflow's writer slot (#117(c)).
455    ///
456    /// The mirror of [`Self::TerminalWriterUnavailable`], and transient by
457    /// construction: a reservation lives only across one terminal transition.
458    #[error(
459        "workflow `{workflow_id}` cannot register a handle: run `{run_id}` holds the terminal writer"
460    )]
461    TerminalWriterHeld {
462        /// Workflow whose writer slot is reserved.
463        workflow_id: String,
464        /// Run holding the reservation.
465        run_id: String,
466    },
467
468    /// A caller asked to register the SOLE handle for a workflow while another
469    /// run of that workflow already held one.
470    ///
471    /// Distinct from [`Self::TerminalWriterHeld`], which reports a reservation
472    /// rather than a live process, and from an ordinary `insert`, which
473    /// deliberately replaces. A `Recorder` writes the WORKFLOW's event stream,
474    /// so a handle on any run of the same workflow is a second writer
475    /// (invariant #3) — this is what a caller receives when it demanded to be
476    /// the only one and was not.
477    #[error(
478        "workflow `{workflow_id}` cannot register a sole writer: run `{holder_run_id}` \
479         (process {holder_pid}) already holds a live handle for it"
480    )]
481    WorkflowWriterHeld {
482        /// Workflow that already has a writer.
483        workflow_id: String,
484        /// Run holding the live handle.
485        holder_run_id: String,
486        /// Process backing the incumbent handle.
487        holder_pid: u64,
488    },
489
490    /// A start was asked to seed a FRESH recorder for a workflow id that
491    /// already has a live handle (aion#213).
492    ///
493    /// The start path derives a new recorder's sequence head from an UNLOCKED
494    /// history read. That read is only sound for an id nothing is writing: a
495    /// workflow that already has a registered handle has a recorder that owns
496    /// its head, and seeding a second one from a store read produces two
497    /// writers for one history — the double-writer this refusal exists to
498    /// stop. A continuation (continue-as-new, a workloop generation) must
499    /// therefore go through the incumbent recorder rather than re-seed, and a
500    /// caller that genuinely meant a fresh execution must choose an unused id.
501    #[error(
502        "workflow `{workflow_id}` cannot be started under an id that is already live: run \
503         `{holder_run_id}` (process {holder_pid}) holds its recorder, and seeding a second one \
504         from a history read would make two writers for one history"
505    )]
506    WorkflowIdAlreadyLive {
507        /// Workflow id the start requested.
508        workflow_id: String,
509        /// Run whose handle already holds the workflow's recorder.
510        holder_run_id: String,
511        /// Process backing the incumbent handle.
512        holder_pid: u64,
513    },
514
515    /// Two or more live handles were found for one workflow id (aion#213).
516    ///
517    /// A `Recorder` writes the WORKFLOW's event stream, so exactly one handle
518    /// may exist per workflow id (invariant 3). A resolver that found several
519    /// reports this instead of picking one: picking the first would route a
520    /// durable append through whichever handle a `HashMap` iteration happened
521    /// to yield, which is how a second writer stays invisible.
522    #[error(
523        "workflow `{workflow_id}` has more than one live handle ({runs}), so no single writer \
524         could be resolved for it"
525    )]
526    WorkflowWritersAmbiguous {
527        /// Workflow with more than one live handle.
528        workflow_id: String,
529        /// The runs holding the competing handles, comma separated.
530        runs: String,
531    },
532
533    /// A terminal event was about to be appended after the engine-task epoch
534    /// had already closed.
535    ///
536    /// Raised at the append boundary itself, which is the only instant at which
537    /// the hazard it guards is real. The engine that owned this run has been
538    /// shut down or released, so this process is no longer that workflow's
539    /// single writer (invariant 3). Appending here risks two writers.
540    ///
541    /// # This is not only the successor case
542    ///
543    /// The obvious reading — a successor engine is already recovering the same
544    /// history — is the *eventual* case, not the whole of it. `Engine::shutdown`
545    /// closes the epoch as its FIRST act and only stops admitting process-exit
546    /// callbacks several steps later, so this error is also raised for runs that
547    /// exit during **this** engine's own graceful teardown, while no successor
548    /// exists yet. Saying "a successor may already be recovering" would tell an
549    /// operator reading the message during a clean shutdown to go looking for a
550    /// second node that is not there.
551    ///
552    /// Deliberately **not** transient: no later attempt re-opens a closed
553    /// epoch. In both cases the run stays `Running` and a startup sweep — the
554    /// successor's, or this node's own on restart — re-installs a monitor,
555    /// which is the mechanism that actually repairs it.
556    #[error(
557        "run `{run_id}` of workflow `{workflow_id}` could not append its terminal event: the \
558         engine-task epoch closed first, so this engine is no longer the run's single writer"
559    )]
560    EngineTaskEpochClosed {
561        /// Workflow whose terminal event was refused.
562        workflow_id: String,
563        /// Run whose terminal event was refused.
564        run_id: String,
565    },
566
567    /// The extraordinary cancellation path was asked for a run whose pinned
568    /// package resolves right now, so the run is recoverable (#117(c)).
569    ///
570    /// Measured at the moment of the request, never cited from an earlier boot's
571    /// verdict: a redeploy between then and now is exactly the remedy that makes
572    /// the ordinary path work again, and the ordinary path must be used when it
573    /// does.
574    #[error(
575        "workflow `{workflow_id}` run `{run_id}` is recoverable: its pinned package version `{version}` resolves, so it must be recovered and cancelled through the ordinary path"
576    )]
577    RunIsRecoverable {
578        /// Workflow the request named.
579        workflow_id: String,
580        /// Run the request named.
581        run_id: String,
582        /// The pinned package version that resolved.
583        version: String,
584    },
585
586    /// A run holds no handle, cannot obtain one, and this engine has no recorded
587    /// reason why (#117(c)).
588    ///
589    /// Distinct from [`Self::WorkflowNotFound`] on purpose: the run EXISTS and
590    /// its history is readable. What is absent is a verdict from this process's
591    /// startup recovery, so the extraordinary cancellation path — which must
592    /// cite that verdict — has nothing to cite.
593    #[error(
594        "workflow `{workflow_id}` run `{run_id}` is not resident and this engine recorded no reason it could not be made resident; it exists but cannot be cancelled here"
595    )]
596    NoResidencyVerdict {
597        /// Workflow the request named.
598        workflow_id: String,
599        /// Run the request named.
600        run_id: String,
601    },
602
603    /// No durable schedule was found for the request.
604    #[error("schedule `{schedule_id}` was not found")]
605    ScheduleNotFound {
606        /// Schedule identifier requested by the caller.
607        schedule_id: ScheduleId,
608    },
609
610    /// Schedule trigger, projection, or evaluator side effect failed.
611    #[error("schedule error: {reason}")]
612    Schedule {
613        /// Human-readable schedule failure reason.
614        reason: String,
615    },
616
617    /// Native implemented function registration failed.
618    #[error("NIF registration failed: {reason}")]
619    NifRegistration {
620        /// Human-readable native implemented function registration failure reason.
621        reason: String,
622    },
623
624    /// Signal routing failed after the target was resolved.
625    #[error("signal router error: {0}")]
626    SignalRouter(#[from] SignalRouterError),
627
628    /// Live workflow query dispatch failed after the target was resolved.
629    #[error("query error: {0}")]
630    Query(#[from] crate::query::QueryError),
631}
632
633/// What pins a workflow version against unload, naming the concrete holder.
634#[derive(Debug, Clone, PartialEq, Eq)]
635pub enum PinHolder {
636    /// A start resolved this version but has not yet registered a handle.
637    InFlightStart,
638    /// A live, non-terminal run executes on this version.
639    LiveRun {
640        /// Pinning workflow id.
641        workflow_id: WorkflowId,
642        /// Pinning run id.
643        run_id: RunId,
644    },
645    /// A recoverable instance in the store is pinned to this version.
646    RecoverableRun {
647        /// Pinning workflow id.
648        workflow_id: WorkflowId,
649    },
650    /// A recorded-but-never-started child is pinned to this version.
651    RecordedChild {
652        /// Child workflow id pinned to the version.
653        child_workflow_id: WorkflowId,
654        /// Parent workflow whose history records the child.
655        recorded_by: WorkflowId,
656    },
657}
658
659impl std::fmt::Display for PinHolder {
660    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
661        match self {
662            Self::InFlightStart => formatter.write_str("an in-flight start is pinned to it"),
663            Self::LiveRun {
664                workflow_id,
665                run_id,
666            } => write!(
667                formatter,
668                "live run `{workflow_id}/{run_id}` is pinned to it"
669            ),
670            Self::RecoverableRun { workflow_id } => {
671                write!(formatter, "recoverable run `{workflow_id}` is pinned to it")
672            }
673            Self::RecordedChild {
674                child_workflow_id,
675                recorded_by,
676            } => write!(
677                formatter,
678                "child `{child_workflow_id}` recorded by `{recorded_by}` is pinned to it and has not started"
679            ),
680        }
681    }
682}
683
684/// Errors surfaced by the signal routing boundary.
685#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
686pub enum SignalRouterError {
687    /// The target workflow is terminal and cannot receive new signals.
688    #[error("workflow {workflow_id}/{run_id} is terminal")]
689    Terminal {
690        /// Target workflow id.
691        workflow_id: WorkflowId,
692        /// Target run id.
693        run_id: RunId,
694    },
695
696    /// The router could not defer a recorded non-resident signal.
697    #[error("signal resume handoff failed: {reason}")]
698    Handoff {
699        /// Human-readable handoff failure reason.
700        reason: String,
701    },
702
703    /// The signal was durably recorded but could not be delivered to the live mailbox.
704    #[error(
705        "signal `{signal_name}` for workflow {workflow_id}/{run_id} could not be delivered to process {process_id}: {reason}"
706    )]
707    DeliveryFailed {
708        /// Target workflow id.
709        workflow_id: WorkflowId,
710        /// Target run id.
711        run_id: RunId,
712        /// Embedded runtime process identifier selected for delivery.
713        process_id: u64,
714        /// Signal name that was recorded and attempted.
715        signal_name: String,
716        /// Human-readable delivery failure reason.
717        reason: String,
718    },
719}
720
721impl From<ScheduleError> for EngineError {
722    fn from(error: ScheduleError) -> Self {
723        Self::Schedule {
724            reason: error.to_string(),
725        }
726    }
727}
728
729impl From<ScheduleEvaluatorError> for EngineError {
730    fn from(error: ScheduleEvaluatorError) -> Self {
731        match error {
732            ScheduleEvaluatorError::ScheduleNotFound { schedule_id } => {
733                Self::ScheduleNotFound { schedule_id }
734            }
735            other => Self::Schedule {
736                reason: other.to_string(),
737            },
738        }
739    }
740}