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}