Skip to main content

aion_server/
error.rs

1//! `ServerError` taxonomy for server library modules.
2
3use std::borrow::Cow;
4use std::net::SocketAddr;
5use std::path::PathBuf;
6
7use aion::EngineError;
8use aion_core::{ActivityId, WorkflowId};
9use aion_proto::WireError;
10use aion_store::StoreError;
11use thiserror::Error;
12
13#[path = "error_engine.rs"]
14mod engine;
15
16use engine::{durability_trace_fields, simple_engine_fields, store_trace_fields};
17#[path = "error_process_exit.rs"]
18mod process_exit;
19
20/// Server-library error taxonomy.
21#[derive(Debug, Error)]
22pub enum ServerError {
23    /// Operator configuration could not be loaded or validated.
24    #[error("configuration error: {message}")]
25    Config {
26        /// Redacted, operator-facing failure message.
27        message: String,
28    },
29
30    /// A path-ambient store backend was configured beneath a renameable directory.
31    #[error(
32        "unsafe store.data_dir `{}`: ancestor `{}` is not owner-controlled: {reason}; \
33         leave store.data_dir unset so it defaults beneath the private Aion home \
34         (`$AION_HOME`, default `$HOME/.aion`), or set a path whose ancestor chain is \
35         owner-only (a leading `~` expands against $HOME; a relative path resolves \
36         against the server's working directory)",
37        .data_root.display(),
38        .component.display()
39    )]
40    UnsafeDataRootAncestor {
41        /// Descriptor-resolved data root that the backend would use by pathname.
42        data_root: PathBuf,
43        /// First unsafe component in the resolved root's ancestor chain.
44        component: PathBuf,
45        /// Ownership, mode, or inspection failure that made the component unsafe.
46        reason: String,
47    },
48
49    /// A transport listener could not bind or start.
50    #[error("{transport} transport failed at {address}: {message}")]
51    TransportBind {
52        /// Transport name.
53        transport: &'static str,
54        /// Configured listener address.
55        address: SocketAddr,
56        /// Redacted, operator-facing failure message.
57        message: String,
58    },
59
60    /// A running transport task aborted: it panicked or was cancelled.
61    #[error("{transport} transport task failed: {message}")]
62    Transport {
63        /// Transport name.
64        transport: &'static str,
65        /// Redacted, operator-facing failure message.
66        message: String,
67    },
68
69    /// A termination-signal listener could not be installed or failed.
70    #[error("{listener} listener failed: {message}")]
71    SignalListener {
72        /// Listener name (`SIGTERM`, `SIGINT`, or the portable fallback).
73        listener: &'static str,
74        /// Redacted, operator-facing failure message.
75        message: String,
76    },
77
78    /// The death note — the durable record of what killed the process — could
79    /// not be armed at boot.
80    #[error("death note error: {message}")]
81    DeathNote {
82        /// Redacted, operator-facing failure message.
83        message: String,
84    },
85
86    /// The pid file or a control-verb record could not be written or read.
87    #[error("pid file error: {message}")]
88    PidFile {
89        /// Redacted, operator-facing failure message.
90        message: String,
91    },
92
93    /// A LIVE server incarnation already holds this Aion home on addresses
94    /// that collide with the ones this boot intends to bind. Refusing is the
95    /// whole point: two servers on one home do not share it — the second
96    /// blocks silently inside the store's writer lock — so this is an
97    /// operator-facing refusal (exit 2), not a crash.
98    #[error(transparent)]
99    HomeAlreadyClaimed {
100        /// The refusal, carrying the live holder's whole record so a caller
101        /// can render whatever the operator needs. Boxed to keep
102        /// [`ServerError`] small: every other variant is a handful of words.
103        refusal: Box<crate::control::claim::HomeAlreadyClaimed>,
104    },
105
106    /// This process's own incarnation identity (process-table start instant,
107    /// executable path, binary hash) could not be read.
108    #[error("incarnation identity error: {message}")]
109    Incarnation {
110        /// Redacted, operator-facing failure message.
111        message: String,
112    },
113
114    /// Namespace validation or authorization failed.
115    #[error("namespace error: {message}")]
116    Namespace {
117        /// Redacted namespace failure message.
118        message: String,
119    },
120
121    /// Engine call failed.
122    #[error("engine call failed: {source}")]
123    EngineCall {
124        /// Typed engine error returned by the embedded engine.
125        #[from]
126        source: EngineError,
127    },
128
129    /// Store backend call failed before an engine handle was available.
130    #[error("store backend failed: {source}")]
131    StoreBackend {
132        /// Typed store error returned by the configured backend.
133        #[from]
134        source: StoreError,
135    },
136
137    /// Streaming failure.
138    #[error("stream failure: {failure}")]
139    Stream {
140        /// Stream failure class.
141        failure: StreamFailure,
142    },
143
144    /// A scheduled activity could not be pushed to a worker.
145    #[error(
146        "worker dispatch failed for namespace {namespace}, activity type {activity_type}: {reason}"
147    )]
148    WorkerDispatch {
149        /// Namespace scoped before dispatch.
150        namespace: String,
151        /// Activity type requested by the engine.
152        activity_type: String,
153        /// Redacted dispatch failure reason.
154        reason: String,
155    },
156
157    /// The worker connection chosen for a dispatch was lost mid-flight: the
158    /// connection was already gone at push time, or it closed before the worker
159    /// sent its correlated push reply.
160    ///
161    /// This is DISTINCT from [`Self::WorkerDispatch`]: a `WorkerDispatch` covers a
162    /// genuine reply timeout (the worker is alive but slow), a no-worker-available
163    /// selection failure, or any other dispatch fault, all of which keep the
164    /// outbox's normal exponential backoff. A `WorkerConnectionLost` instead means
165    /// the chosen worker is gone (and has already been deregistered by liminal's
166    /// `on_worker_unregistered`), so the row can be re-armed for IMMEDIATE re-claim
167    /// to fail over to a live worker without waiting out the backoff. The outbox
168    /// dispatcher keys its fast-failover decision on this variant.
169    #[error("worker connection lost during dispatch on {channel}: {detail}")]
170    WorkerConnectionLost {
171        /// Row-derived dispatch channel for operator diagnostics.
172        channel: String,
173        /// Redacted, operator-facing description of how the connection was lost.
174        detail: String,
175    },
176
177    /// The worker connection chosen for a dispatch refused admission because it
178    /// already holds its pending-push cap — the worker is ALIVE and WORKING,
179    /// its connection is simply full of held dispatches.
180    ///
181    /// DISTINCT from both [`Self::WorkerDispatch`] (which consumes a retry
182    /// attempt on backoff) and [`Self::WorkerConnectionLost`] (immediate
183    /// attempt-consuming failover): a busy worker is neither slow to reply nor
184    /// gone, so the outbox re-arms the row ATTEMPT-NEUTRALLY after a short
185    /// backoff — capacity pressure must never spend the retry budget that
186    /// prices genuine delivery failures, and must never dead-letter work.
187    #[error("worker connection busy during dispatch on {channel}: {detail}")]
188    WorkerBusy {
189        /// Row-derived dispatch channel for operator diagnostics.
190        channel: String,
191        /// Redacted, operator-facing description of the admission refusal.
192        detail: String,
193    },
194
195    /// The dispatch can never be delivered on this worker connection at this
196    /// bound, on this attempt or any later one: liminal proved the encoded
197    /// dispatch frame is larger than the connection's WHOLE outbound buffer
198    /// (`liminal_server::ServerError::PushFrameExceedsOutboundCapacity`), so no
199    /// amount of draining, waiting or failover to an identically-bounded worker
200    /// can carry it.
201    ///
202    /// DISTINCT from all three dispatch-failure classes above, and the
203    /// distinction is the fix: [`Self::WorkerConnectionLost`] fails over
204    /// immediately, [`Self::WorkerBusy`] parks attempt-neutrally, and
205    /// [`Self::WorkerDispatch`] retries on backoff. Each of those is the right
206    /// answer to a fault that can clear. This one cannot clear, and every retry
207    /// of it writes another lease and another copy of the oversize input into
208    /// durable history for a step that never ran (measured: 846 leases,
209    /// 5.93 MiB, one activity). Both dispatch paths therefore settle it
210    /// TERMINALLY on the first observation: the outbox dead-letters the row, the
211    /// engine-seam bridge reports a `terminal:` outcome. The `detail` names the
212    /// frame's size, the bound, and the operator key that sets the bound.
213    #[error("dispatch is unservable on {channel}: {detail}")]
214    WorkerDispatchUnservable {
215        /// Row-derived dispatch channel for operator diagnostics.
216        channel: String,
217        /// Operator-facing description naming the frame size, the bound and the
218        /// key that sets it.
219        detail: String,
220    },
221
222    /// A second dispatcher tried to install a responder for an execution site
223    /// whose prior attempt is still held live, and the incoming attempt does
224    /// not supersede the holder.
225    ///
226    /// A HIGHER attempt never reaches this error: it takes the site over
227    /// (aion#195) — the fence already superseded the holder's tokens when the
228    /// higher attempt was issued, so the holder is a corpse whose completion
229    /// would be refused anyway, and refusing the live retry on its account
230    /// killed healthy runs. Only a same-or-lower attempt — a genuine double
231    /// dispatch of work that may still be executing — is refused here.
232    #[error(
233        "pending activity collision for workflow {workflow_id}, activity {activity_id}: \
234         attempt {incoming_attempt} arrived while attempt {held_attempt} still holds this \
235         execution site, and does not supersede it"
236    )]
237    PendingActivityCollision {
238        /// Workflow whose responder slot is already occupied.
239        workflow_id: WorkflowId,
240        /// Activity site whose responder slot is already occupied.
241        activity_id: ActivityId,
242        /// Attempt the live holder was dispatched at.
243        held_attempt: u32,
244        /// Attempt the refused dispatch arrived at.
245        incoming_attempt: u32,
246    },
247
248    /// A worker result did not prove ownership of the current execution
249    /// generation and was refused before reaching workflow state.
250    #[error(
251        "activity completion rejected for workflow {workflow_id}, activity {activity_id}: {reason}"
252    )]
253    ActivityCompletionRejected {
254        /// Workflow named by the submitted completion.
255        workflow_id: WorkflowId,
256        /// Activity site named by the submitted completion.
257        activity_id: ActivityId,
258        /// Typed reason the completion token did not authorize this write.
259        reason: CompletionRejectionReason,
260    },
261
262    /// A second server-executed declared body was started for an execution site
263    /// whose prior attempt is still running here.
264    ///
265    /// Distinct from [`Self::PendingActivityCollision`], which is about the
266    /// responder slot a dispatch installs: this is about the PROCESS. The
267    /// registry that makes a running declared command cancellable holds one
268    /// cancellation handle per attempt, so admitting a second execution would
269    /// replace the first command's handle — and a replaced handle is a command
270    /// running on the operator's machine that nothing can stop. Refusing the
271    /// second is the only outcome that leaves both attempts accounted for.
272    #[error(
273        "declared command for workflow {workflow_id}, activity {activity_id} attempt {attempt} \
274         is already executing at this server"
275    )]
276    DeclaredAttemptCollision {
277        /// Workflow whose declared body is already executing.
278        workflow_id: WorkflowId,
279        /// Activity site whose declared body is already executing.
280        activity_id: ActivityId,
281        /// Attempt whose declared body is already executing.
282        attempt: u32,
283    },
284
285    /// A draining server refused to start a declared command.
286    ///
287    /// The drain latch is the promise `aion server stop` makes to the
288    /// operator: no new work starts after the stop is requested. A declared
289    /// body executes at the server itself — no worker gate can refuse it —
290    /// so the registration that admits it to the cancel path is also the door
291    /// the drain closes. The dispatcher maps this refusal to the park
292    /// sentinel, exactly like a worker dispatch parked mid-drain: nothing is
293    /// recorded, and the next boot re-dispatches the attempt.
294    #[error(
295        "declared command for workflow {workflow_id}, activity {activity_id} attempt {attempt} \
296         refused: this server is draining and starts no new work"
297    )]
298    DrainingRefusedDeclaredAttempt {
299        /// Workflow whose declared body was refused.
300        workflow_id: WorkflowId,
301        /// Activity site whose declared body was refused.
302        activity_id: ActivityId,
303        /// Attempt whose declared body was refused.
304        attempt: u32,
305    },
306
307    /// A lock was poisoned and the protected state cannot be trusted.
308    #[error("{resource} lock was poisoned")]
309    LockPoisoned {
310        /// Protected resource name.
311        resource: &'static str,
312    },
313
314    /// A failure already translated into the public wire taxonomy.
315    #[error("wire error: {wire}")]
316    Wire {
317        /// Stable wire error.
318        wire: WireError,
319    },
320}
321
322/// Typed completion-fence refusal classes.
323#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
324pub enum CompletionRejectionReason {
325    /// A worker from before completion-token support omitted the required token.
326    #[error("completion token is missing (worker registration era is incompatible)")]
327    MissingCompletionToken,
328    /// No generation is currently authorized for this activity execution.
329    #[error("no execution generation is currently accepting completion")]
330    NoCurrentGeneration,
331    /// A later dispatch superseded the submitted generation.
332    #[error("completion token belongs to a stale execution generation")]
333    StaleGeneration,
334}
335
336/// Bounded-stream and connection failure classes.
337#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
338pub enum StreamFailure {
339    /// Bounded per-connection buffer overflowed because the consumer lagged.
340    #[error("consumer lagged behind bounded buffer")]
341    Lagged,
342    /// Subscriber closed the connection.
343    #[error("subscriber connection closed")]
344    Closed,
345    /// Upstream engine event stream ended unexpectedly.
346    #[error("engine event stream closed")]
347    UpstreamClosed,
348}
349
350impl From<WireError> for ServerError {
351    fn from(wire: WireError) -> Self {
352        Self::Wire { wire }
353    }
354}
355
356impl ServerError {
357    /// Convert a server error that crosses a transport boundary into the stable
358    /// public wire taxonomy.
359    #[must_use]
360    pub fn to_wire_error(&self) -> WireError {
361        match self {
362            Self::Config { .. }
363            | Self::UnsafeDataRootAncestor { .. }
364            | Self::TransportBind { .. }
365            | Self::Transport { .. }
366            | Self::SignalListener { .. }
367            | Self::DeathNote { .. }
368            | Self::PidFile { .. }
369            | Self::HomeAlreadyClaimed { .. }
370            | Self::Incarnation { .. }
371            | Self::LockPoisoned { .. } => WireError::backend("server backend failure"),
372            Self::ActivityCompletionRejected { .. } => {
373                WireError::backend("stale activity completion rejected")
374            }
375            Self::PendingActivityCollision { .. } => {
376                WireError::backend("pending activity collision")
377            }
378            Self::DeclaredAttemptCollision { .. } => {
379                WireError::backend("declared command attempt collision")
380            }
381            Self::DrainingRefusedDeclaredAttempt { .. } => {
382                WireError::backend("declared command refused: server draining")
383            }
384            Self::WorkerDispatch { .. } => WireError::backend("worker dispatch failed"),
385            Self::WorkerConnectionLost { .. } => {
386                WireError::backend("worker connection lost during dispatch")
387            }
388            Self::WorkerBusy { .. } => WireError::backend("worker connection busy during dispatch"),
389            Self::WorkerDispatchUnservable { .. } => {
390                WireError::backend("dispatch frame exceeds the worker connection's outbound bound")
391            }
392            Self::Namespace { message } => WireError::namespace_denied(message.clone()),
393            Self::EngineCall { source } => wire_from_engine(source),
394            Self::StoreBackend { source } => wire_from_store(source),
395            Self::Stream { failure } => match failure {
396                StreamFailure::Lagged => WireError::lagged("subscriber lagged behind"),
397                StreamFailure::Closed | StreamFailure::UpstreamClosed => {
398                    WireError::backend("event stream closed")
399                }
400            },
401            Self::Wire { wire } => wire.clone(),
402        }
403    }
404
405    /// Return true when this is an operator configuration failure.
406    #[must_use]
407    pub const fn is_config(&self) -> bool {
408        // A home already held by a live server is an operator-facing REFUSAL,
409        // not a crash: the remedy is a command to type (`aion server status` /
410        // `stop`) or a home to move, exactly like a bad config key. It earns
411        // the same exit code so a supervisor can tell "you asked for something
412        // impossible" from "something broke".
413        matches!(
414            self,
415            Self::Config { .. }
416                | Self::UnsafeDataRootAncestor { .. }
417                | Self::HomeAlreadyClaimed { .. }
418        )
419    }
420
421    /// Construct a namespace-denied error without embedding authorization logic.
422    #[must_use]
423    pub fn namespace_denied(message: impl Into<String>) -> Self {
424        Self::Namespace {
425            message: message.into(),
426        }
427    }
428
429    /// Construct the loud, whole-registration rejection when a worker's advertised
430    /// `node` violates a `Pinned{L}` namespace's placement (Control-Plane Phase 2,
431    /// P2-I1). Names the offending namespace, the worker's advertised node (or
432    /// "none"), and the required label set, so the operator sees exactly why the
433    /// registration was refused. Carried on the namespace-denied wire code — a
434    /// registration refused on isolation grounds is a namespace-authorization
435    /// failure, not a transient dispatch error.
436    #[must_use]
437    pub fn placement_admission_denied(
438        namespace: &str,
439        worker_node: Option<&str>,
440        required: &std::collections::BTreeSet<String>,
441    ) -> Self {
442        let node = worker_node.unwrap_or("none");
443        let required = required
444            .iter()
445            .map(String::as_str)
446            .collect::<Vec<_>>()
447            .join(", ");
448        Self::namespace_denied(format!(
449            "worker registration rejected: namespace {namespace} is Pinned to node label(s) \
450             [{required}] but the worker advertises node {node}, which is not in the required set"
451        ))
452    }
453
454    /// Construct a deploy-authorization denial carried on the dedicated
455    /// `deploy_denied` wire code (deploy is not a namespace operation).
456    #[must_use]
457    pub fn deploy_denied(message: impl Into<String>) -> Self {
458        Self::Wire {
459            wire: WireError::deploy_denied(message),
460        }
461    }
462
463    /// Construct a grant-authorization denial carried on the dedicated
464    /// `grant_denied` wire code.
465    ///
466    /// Separate from [`Self::deploy_denied`] on purpose: `deploy_denied`
467    /// names the deploy surface, so spelling it for a caller refused on some
468    /// other grant word would send that caller — and the operator reading the
469    /// audit line — to a surface the request never touched. Built by
470    /// [`crate::namespace::grants::require_grant`], which fills in the word
471    /// and the knob that carries it.
472    #[must_use]
473    pub fn grant_denied(message: impl Into<String>) -> Self {
474        Self::Wire {
475            wire: WireError::grant_denied(message),
476        }
477    }
478
479    /// Construct a lagged-stream error.
480    #[must_use]
481    pub const fn lagged_stream() -> Self {
482        Self::Stream {
483            failure: StreamFailure::Lagged,
484        }
485    }
486
487    /// Construct a worker-dispatch error.
488    #[must_use]
489    pub fn worker_dispatch(
490        namespace: impl Into<String>,
491        activity_type: impl Into<String>,
492        reason: impl Into<String>,
493    ) -> Self {
494        Self::WorkerDispatch {
495            namespace: namespace.into(),
496            activity_type: activity_type.into(),
497            reason: reason.into(),
498        }
499    }
500
501    /// Construct a worker-connection-lost error for a dispatch whose chosen
502    /// worker connection was gone at push time or closed before replying.
503    #[must_use]
504    pub fn worker_connection_lost(channel: impl Into<String>, detail: impl Into<String>) -> Self {
505        Self::WorkerConnectionLost {
506            channel: channel.into(),
507            detail: detail.into(),
508        }
509    }
510
511    /// Construct an unservable-dispatch error for a frame liminal proved larger
512    /// than the worker connection's whole outbound buffer.
513    #[must_use]
514    pub fn worker_dispatch_unservable(
515        channel: impl Into<String>,
516        detail: impl Into<String>,
517    ) -> Self {
518        Self::WorkerDispatchUnservable {
519            channel: channel.into(),
520            detail: detail.into(),
521        }
522    }
523
524    /// Return true when this is a lost-worker-connection dispatch failure.
525    ///
526    /// The outbox dispatcher keys its fast cross-node failover on this: a lost
527    /// connection means the worker is gone (already deregistered), so the row is
528    /// re-armed for immediate re-claim instead of waiting out the retry backoff.
529    #[must_use]
530    pub const fn is_worker_connection_lost(&self) -> bool {
531        matches!(self, Self::WorkerConnectionLost { .. })
532    }
533
534    /// Construct a worker-busy error for a dispatch refused at push admission
535    /// because the worker's connection already holds its pending-push cap.
536    #[must_use]
537    pub fn worker_busy(channel: impl Into<String>, detail: impl Into<String>) -> Self {
538        Self::WorkerBusy {
539            channel: channel.into(),
540            detail: detail.into(),
541        }
542    }
543
544    /// Return true when this is a busy-worker admission refusal.
545    ///
546    /// The outbox dispatcher keys its ATTEMPT-NEUTRAL re-arm on this: a full
547    /// connection means the worker is alive and holding earlier dispatches, so
548    /// the row waits for capacity without spending the retry budget that prices
549    /// genuine delivery failures.
550    #[must_use]
551    pub const fn is_worker_busy(&self) -> bool {
552        matches!(self, Self::WorkerBusy { .. })
553    }
554
555    /// Return true when this dispatch can never be delivered on its connection
556    /// at the configured outbound bound, so no retry of it may ever be scheduled.
557    ///
558    /// Both dispatch paths key their terminal settlement on this: the outbox
559    /// dead-letters the row on the first observation, and the engine-seam bridge
560    /// reports a non-retryable outcome.
561    #[must_use]
562    pub const fn is_worker_dispatch_unservable(&self) -> bool {
563        matches!(self, Self::WorkerDispatchUnservable { .. })
564    }
565
566    /// Construct a lock-poison error at the lock boundary.
567    #[must_use]
568    pub const fn lock_poisoned(resource: &'static str) -> Self {
569        Self::LockPoisoned { resource }
570    }
571}
572
573/// Stable structured error metadata for tracing events.
574#[derive(Clone)]
575pub struct ErrorTraceFields<'a> {
576    /// Outer error type recorded in the `error_type` tracing field.
577    pub error_type: Cow<'a, str>,
578    /// Optional inner store error type for `StoreError` records.
579    pub store_error_type: Option<&'static str>,
580    /// Human-readable reason safe for operator logs.
581    pub reason: &'a dyn std::fmt::Display,
582}
583
584impl<'a> ErrorTraceFields<'a> {
585    /// The common arm shape: a named outer type, no inner store type.
586    fn plain(error_type: &'static str, reason: &'a dyn std::fmt::Display) -> Self {
587        Self {
588            error_type: Cow::Borrowed(error_type),
589            store_error_type: None,
590            reason,
591        }
592    }
593}
594
595impl ServerError {
596    /// Return stable typed fields for structured error logging.
597    #[must_use]
598    pub fn trace_fields(&self) -> ErrorTraceFields<'_> {
599        match self {
600            Self::Config { message } => ErrorTraceFields::plain("Config", message),
601            Self::UnsafeDataRootAncestor { reason, .. } => {
602                ErrorTraceFields::plain("UnsafeDataRootAncestor", reason)
603            }
604            Self::TransportBind { message, .. } => {
605                ErrorTraceFields::plain("TransportBind", message)
606            }
607            Self::Transport { message, .. } => ErrorTraceFields::plain("Transport", message),
608            Self::SignalListener { message, .. } => {
609                ErrorTraceFields::plain("SignalListener", message)
610            }
611            Self::PidFile { message } => ErrorTraceFields::plain("PidFile", message),
612            Self::HomeAlreadyClaimed { refusal } => {
613                ErrorTraceFields::plain("HomeAlreadyClaimed", refusal.as_ref())
614            }
615            Self::Incarnation { message } => ErrorTraceFields::plain("Incarnation", message),
616            Self::DeathNote { message } => ErrorTraceFields::plain("DeathNote", message),
617            Self::Namespace { message } => ErrorTraceFields::plain("Namespace", message),
618            Self::EngineCall { source } => engine_trace_fields(source),
619            Self::StoreBackend { source } => store_trace_fields(source),
620            Self::Stream { failure } => ErrorTraceFields::plain("Stream", failure),
621            Self::WorkerDispatch { reason, .. } => {
622                ErrorTraceFields::plain("WorkerDispatch", reason)
623            }
624            Self::WorkerConnectionLost { detail, .. } => {
625                ErrorTraceFields::plain("WorkerConnectionLost", detail)
626            }
627            Self::WorkerBusy { detail, .. } => ErrorTraceFields::plain("WorkerBusy", detail),
628            Self::WorkerDispatchUnservable { detail, .. } => {
629                ErrorTraceFields::plain("WorkerDispatchUnservable", detail)
630            }
631            Self::PendingActivityCollision { activity_id, .. } => {
632                ErrorTraceFields::plain("PendingActivityCollision", activity_id)
633            }
634            Self::DeclaredAttemptCollision { activity_id, .. } => {
635                ErrorTraceFields::plain("DeclaredAttemptCollision", activity_id)
636            }
637            Self::DrainingRefusedDeclaredAttempt { activity_id, .. } => {
638                ErrorTraceFields::plain("DrainingRefusedDeclaredAttempt", activity_id)
639            }
640            Self::ActivityCompletionRejected { reason, .. } => {
641                ErrorTraceFields::plain("ActivityCompletionRejected", reason)
642            }
643            Self::LockPoisoned { resource } => ErrorTraceFields::plain("LockPoisoned", resource),
644            Self::Wire { wire } => ErrorTraceFields {
645                error_type: wire
646                    .error_type
647                    .as_deref()
648                    .map_or_else(|| Cow::Borrowed(wire.code.as_str()), Cow::Borrowed),
649                store_error_type: None,
650                reason: wire,
651            },
652        }
653    }
654}
655
656/// The wire type name for each #117(c) never-alive cancellation refusal.
657///
658/// Each keeps its OWN name rather than collapsing into one: an operator told
659/// "not found" for a run whose history they are reading learns nothing, and
660/// telling them "this workflow already has a writer" versus "your package now
661/// loads again" is the difference between a dead end and the next action.
662///
663/// Total without a panic. A variant that should never reach here gets the
664/// family label — an admittedly vague answer, which is the right failure mode
665/// for a function whose only job is to name an error someone is already
666/// receiving.
667fn never_alive_error_type(source: &EngineError) -> &'static str {
668    match source {
669        EngineError::TerminalWriterUnavailable { .. } => "TerminalWriterUnavailable",
670        EngineError::TerminalWriterHeld { .. } => "TerminalWriterHeld",
671        EngineError::WorkflowWriterHeld { .. } => "WorkflowWriterHeld",
672        EngineError::WorkflowIdAlreadyLive { .. } => "WorkflowIdAlreadyLive",
673        EngineError::WorkflowWritersAmbiguous { .. } => "WorkflowWritersAmbiguous",
674        EngineError::RunIsRecoverable { .. } => "RunIsRecoverable",
675        EngineError::NoResidencyVerdict { .. } => "NoResidencyVerdict",
676        _ => "EngineError",
677    }
678}
679
680fn engine_trace_fields(source: &EngineError) -> ErrorTraceFields<'_> {
681    match source {
682        EngineError::WorkflowNotFound { .. } => simple_engine_fields("WorkflowNotFound", source),
683        // #117(c): the never-alive cancellation path's four refusals.
684        EngineError::TerminalWriterUnavailable { .. }
685        | EngineError::TerminalWriterHeld { .. }
686        // The sole-writer refusal joins them: it is the same family of fact —
687        // this workflow's single-writer slot is occupied by someone else. So
688        // do aion#213's two: a start refused because the id is already live,
689        // and a resolution refused because the id has more than one writer.
690        | EngineError::WorkflowWriterHeld { .. }
691        | EngineError::WorkflowIdAlreadyLive { .. }
692        | EngineError::WorkflowWritersAmbiguous { .. }
693        | EngineError::RunIsRecoverable { .. }
694        | EngineError::NoResidencyVerdict { .. } => {
695            simple_engine_fields(never_alive_error_type(source), source)
696        }
697        EngineError::InvalidState { .. } => simple_engine_fields("InvalidState", source),
698        EngineError::ScheduleNotFound { .. } => simple_engine_fields("ScheduleNotFound", source),
699        EngineError::ShuttingDown => simple_engine_fields("ShuttingDown", source),
700        EngineError::EngineTaskEpochClosed { .. } => {
701            simple_engine_fields("EngineTaskEpochClosed", source)
702        }
703        EngineError::Store(store) => store_trace_fields(store),
704        EngineError::Durability(durability) => durability_trace_fields(durability, source),
705        EngineError::MissingStore
706        | EngineError::MissingVisibilityStore
707        | EngineError::MissingStopDrainTimeout
708        | EngineError::ZeroStopDrainTimeout
709        | EngineError::ConflictingEventPublisher => builder_trace_fields(source),
710        EngineError::EventStreaming(_) => simple_engine_fields("EventStreaming", source),
711        EngineError::Load { .. } => simple_engine_fields("Load", source),
712        EngineError::UnenforceableContract { .. } => {
713            simple_engine_fields("UnenforceableContract", source)
714        }
715        EngineError::UnknownVersion { .. } => simple_engine_fields("UnknownVersion", source),
716        EngineError::VersionPinned { .. } => simple_engine_fields("VersionPinned", source),
717        EngineError::RouteActive { .. } => simple_engine_fields("RouteActive", source),
718        EngineError::ManifestMismatch { .. } => simple_engine_fields("ManifestMismatch", source),
719        EngineError::Package(_) => simple_engine_fields("Package", source),
720        EngineError::ContractIdentity { .. } => simple_engine_fields("ContractIdentity", source),
721        EngineError::NoQueueDeclaration { .. } => {
722            simple_engine_fields("NoQueueDeclaration", source)
723        }
724        EngineError::StartInputRefused { .. } => simple_engine_fields("StartInputRefused", source),
725        EngineError::SignalRefused { .. } => simple_engine_fields("SignalRefused", source),
726        EngineError::ActivityLeaseAfterTerminal { .. } => {
727            simple_engine_fields("ActivityLeaseAfterTerminal", source)
728        }
729        EngineError::Schedule { .. } => simple_engine_fields("Schedule", source),
730        EngineError::Runtime { .. } => simple_engine_fields("Runtime", source),
731        EngineError::Gate3BifReplacementMissing { .. } => {
732            simple_engine_fields("Gate3BifReplacementMissing", source)
733        }
734        EngineError::StartupRecoveryNotDeferred => {
735            simple_engine_fields("StartupRecoveryNotDeferred", source)
736        }
737        EngineError::StartupRecoveryAlreadyRan => {
738            simple_engine_fields("StartupRecoveryAlreadyRan", source)
739        }
740        EngineError::StartupCatchupBeforeWorkflowRecovery => {
741            simple_engine_fields("StartupCatchupBeforeWorkflowRecovery", source)
742        }
743        EngineError::StartupRecoverySlotPoisoned => {
744            simple_engine_fields("StartupRecoverySlotPoisoned", source)
745        }
746        EngineError::CleanupExecutorPoisoned => {
747            simple_engine_fields("CleanupExecutorPoisoned", source)
748        }
749        EngineError::CleanupExecutorShutdownTimedOut { .. } => {
750            simple_engine_fields("CleanupExecutorShutdownTimedOut", source)
751        }
752        EngineError::RunNotInHistory { .. } => simple_engine_fields("RunNotInHistory", source),
753        EngineError::ProcessExitRegistryPoisoned => {
754            simple_engine_fields("ProcessExitRegistryPoisoned", source)
755        }
756        EngineError::ProcessExitOwnershipPoisoned { .. } => {
757            simple_engine_fields("ProcessExitOwnershipPoisoned", source)
758        }
759        EngineError::ProcessExitStatePoisoned { .. }
760        | EngineError::ProcessExitSubscriptionUnavailable
761        | EngineError::ProcessExitDrainerSpawn { .. }
762        | EngineError::ProcessExitDrainerPoisoned
763        | EngineError::ProcessExitOutcomeMissingAfterEvent { .. }
764        | EngineError::ProcessExitEventStreamDisconnected
765        | EngineError::ProcessExitDrainerShutdownTimedOut { .. }
766        | EngineError::ProcessExitDrainerPanicked => process_exit::drainer_trace(source),
767        EngineError::ProcessExitCallbackDispatcherPoisoned
768        | EngineError::ProcessExitCallbackDispatcherUnavailable
769        | EngineError::ProcessExitCallbackDispatcherShutdownTimedOut { .. } => {
770            process_exit::callback_trace(source)
771        }
772        EngineError::ProcessExitAlreadyTerminal { .. } => {
773            simple_engine_fields("ProcessExitAlreadyTerminal", source)
774        }
775        EngineError::ActivityDeliveryPoisoned { .. } => {
776            simple_engine_fields("ActivityDeliveryPoisoned", source)
777        }
778        EngineError::RegistryPoisoned => simple_engine_fields("RegistryPoisoned", source),
779        EngineError::CatalogPoisoned => simple_engine_fields("CatalogPoisoned", source),
780        EngineError::NifRegistration { .. } => simple_engine_fields("NifRegistration", source),
781        EngineError::SignalRouter(_) => simple_engine_fields("SignalRouter", source),
782        EngineError::Query(query) => simple_engine_fields(engine::query_error_type(query), source),
783    }
784}
785
786/// The engine-builder assembly refusals: a store, a visibility store, or a
787/// stop-drain bound the builder was never given, or two event publishers.
788fn builder_trace_fields(source: &EngineError) -> ErrorTraceFields<'_> {
789    match source {
790        EngineError::MissingStore => simple_engine_fields("MissingStore", source),
791        EngineError::MissingVisibilityStore => {
792            simple_engine_fields("MissingVisibilityStore", source)
793        }
794        EngineError::MissingStopDrainTimeout => {
795            simple_engine_fields("MissingStopDrainTimeout", source)
796        }
797        EngineError::ZeroStopDrainTimeout => simple_engine_fields("ZeroStopDrainTimeout", source),
798        EngineError::ConflictingEventPublisher => {
799            simple_engine_fields("ConflictingEventPublisher", source)
800        }
801        _ => simple_engine_fields("EngineBuilder", source),
802    }
803}
804
805fn wire_from_engine(source: &EngineError) -> WireError {
806    use EngineError as E;
807    use engine::backend_wire as backend;
808
809    match source {
810        EngineError::WorkflowNotFound { .. } => {
811            WireError::not_found_with_type("WorkflowNotFound", source.to_string())
812        }
813        // Reopen preconditions use failed-precondition/HTTP 409, not NotFound.
814        EngineError::InvalidState { reason } => engine::invalid_state_wire(reason),
815        // #117(c). All four are PRECONDITION failures, never NotFound: in every
816        // one of them the run exists and its history is readable, and what has
817        // failed is a condition on cancelling it here and now. Mapping any of
818        // them to 404 would reproduce, one layer out, the exact lie this path
819        // exists to stop telling.
820        E::TerminalWriterUnavailable { .. }
821        | E::TerminalWriterHeld { .. }
822        // A sole-writer refusal is a precondition failure by the same
823        // reasoning: the workflow exists, its history is readable, and what
824        // failed is a condition on becoming its writer here and now. aion#213's
825        // pair are that same condition seen from the two ends — a start refused
826        // because the id is already live, and a resolution refused because the
827        // id has two writers — and a caller's remedy for both is the same:
828        // choose an unused id, or wait for the incumbent run to end.
829        | E::WorkflowWriterHeld { .. }
830        | E::WorkflowIdAlreadyLive { .. }
831        | E::WorkflowWritersAmbiguous { .. }
832        | E::RunIsRecoverable { .. }
833        | E::NoResidencyVerdict { .. } => engine::invalid_state_wire(&source.to_string())
834            .with_error_type(never_alive_error_type(source)),
835        EngineError::ScheduleNotFound { .. } => {
836            WireError::not_found_with_type("ScheduleNotFound", source.to_string())
837        }
838        EngineError::ShuttingDown => {
839            WireError::not_running_with_type("ShuttingDown", source.to_string())
840        }
841        // The engine-task epoch closed before this run's terminal could be
842        // appended, so this process is no longer the run's single writer. The
843        // work was refused because the engine is going away, not because the
844        // request was wrong. Reachable only from an internal completion path,
845        // never from a request — but a total match is what keeps a new variant
846        // a COMPILE failure here rather than a silent default.
847        //
848        // 🔴 `backend`, NOT `not_running`, and an earlier revision of this arm
849        // had it wrong. The variant's own doc (`crates/aion/src/error.rs:389`)
850        // states the opposite of what `not_running` claims: "in both cases THE
851        // RUN STAYS `Running`", and a startup sweep re-installs a monitor. A
852        // caller told `not_running` is then sent to the CLI hint for that class
853        // (`aion-cli/src/render.rs:157-160`) — "the target run is no longer
854        // running; `aion list --status running` shows runs that can still serve
855        // queries" — which is false about this run twice over, and would have
856        // the operator hunting for a terminal that never landed. `backend`
857        // makes no claim about the run's state at all and carries the variant's
858        // own `Display`, which says exactly what happened; it is also the class
859        // this table already gives every other engine-internal refusal
860        // (`MissingStore`, `ConflictingEventPublisher`, `EventStreaming`).
861        E::EngineTaskEpochClosed { .. } => backend("EngineTaskEpochClosed", source),
862        // WA-010: a lease arrived for a run that had already reached its
863        // terminal. The run exists and its history is readable; what failed is
864        // a precondition on recording a lease against it now. Reachable only
865        // from the lease-record seam (which logs and counts the loss), never
866        // from a request — kept total so a new variant is a compile failure.
867        E::ActivityLeaseAfterTerminal { .. } => engine::invalid_state_wire(&source.to_string())
868            .with_error_type("ActivityLeaseAfterTerminal"),
869        EngineError::Store(store) => wire_from_store(store),
870        EngineError::Durability(durability) => engine::durability_wire(durability, source),
871        E::MissingStore => backend("MissingStore", source),
872        E::MissingVisibilityStore => backend("MissingVisibilityStore", source),
873        E::MissingStopDrainTimeout => backend("MissingStopDrainTimeout", source),
874        E::ZeroStopDrainTimeout => backend("ZeroStopDrainTimeout", source),
875        E::ConflictingEventPublisher => backend("ConflictingEventPublisher", source),
876        E::EventStreaming(_) => backend("EventStreaming", source),
877        E::Load { .. } => backend("Load", source),
878        // The archive is well-formed and the defect is in what it DECLARES, so
879        // this is the operator's own input to correct — not a backend fault.
880        EngineError::UnenforceableContract { .. } => {
881            WireError::invalid_input(source.to_string()).with_error_type("UnenforceableContract")
882        }
883        // Deploy refusals preserve not-found, state-conflict, and input classes.
884        EngineError::UnknownVersion { .. } => {
885            WireError::not_found_with_type("UnknownVersion", source.to_string())
886        }
887        EngineError::VersionPinned { .. } => {
888            WireError::version_pinned(source.to_string()).with_error_type("VersionPinned")
889        }
890        EngineError::RouteActive { .. } => {
891            WireError::version_pinned(source.to_string()).with_error_type("RouteActive")
892        }
893        EngineError::ManifestMismatch { .. } => {
894            WireError::invalid_input(source.to_string()).with_error_type("ManifestMismatch")
895        }
896        E::Package(_) => backend("Package", source),
897        E::ContractIdentity { .. } => engine::contract_refusal_wire("ContractIdentity", source),
898        E::NoQueueDeclaration { .. } => engine::contract_refusal_wire("NoQueueDeclaration", source),
899        E::StartInputRefused { .. } => engine::declared_contract_wire("StartInputRefused", source),
900        E::SignalRefused { .. } => engine::declared_contract_wire("SignalRefused", source),
901        EngineError::Schedule { .. } => backend("Schedule", source),
902        E::Runtime { .. } => backend("Runtime", source),
903        E::Gate3BifReplacementMissing { .. } => backend("Gate3BifReplacementMissing", source),
904        // Boot-sequencing invariants (#266): misuse of the one-shot deferred
905        // recovery slot is a server construction bug, never a caller's fault.
906        E::StartupRecoveryNotDeferred => backend("StartupRecoveryNotDeferred", source),
907        E::StartupRecoveryAlreadyRan => backend("StartupRecoveryAlreadyRan", source),
908        E::StartupCatchupBeforeWorkflowRecovery => {
909            backend("StartupCatchupBeforeWorkflowRecovery", source)
910        }
911        E::StartupRecoverySlotPoisoned => backend("StartupRecoverySlotPoisoned", source),
912        E::CleanupExecutorPoisoned => backend("CleanupExecutorPoisoned", source),
913        E::CleanupExecutorShutdownTimedOut { .. } => {
914            backend("CleanupExecutorShutdownTimedOut", source)
915        }
916        // aion#94: the registry holds a run that the history it was reconciled
917        // against does not contain. Nothing about the caller's request is wrong
918        // — it is an engine-internal disagreement between two of the engine's
919        // own stores — so `backend`, which makes no claim about the run's state
920        // and carries the variant's own Display.
921        E::RunNotInHistory { .. } => backend("RunNotInHistory", source),
922        E::ProcessExitRegistryPoisoned => backend("ProcessExitRegistryPoisoned", source),
923        E::ProcessExitOwnershipPoisoned { .. } => backend("ProcessExitOwnershipPoisoned", source),
924        EngineError::ProcessExitStatePoisoned { .. }
925        | EngineError::ProcessExitSubscriptionUnavailable
926        | EngineError::ProcessExitDrainerSpawn { .. }
927        | EngineError::ProcessExitDrainerPoisoned
928        | EngineError::ProcessExitOutcomeMissingAfterEvent { .. }
929        | EngineError::ProcessExitEventStreamDisconnected
930        | EngineError::ProcessExitDrainerShutdownTimedOut { .. }
931        | EngineError::ProcessExitDrainerPanicked => process_exit::drainer_wire(source),
932        EngineError::ProcessExitCallbackDispatcherPoisoned
933        | EngineError::ProcessExitCallbackDispatcherUnavailable
934        | EngineError::ProcessExitCallbackDispatcherShutdownTimedOut { .. } => {
935            process_exit::callback_wire(source)
936        }
937        E::ProcessExitAlreadyTerminal { .. } => backend("ProcessExitAlreadyTerminal", source),
938        E::ActivityDeliveryPoisoned { .. } => backend("ActivityDeliveryPoisoned", source),
939        E::CatalogPoisoned => backend("CatalogPoisoned", source),
940        E::RegistryPoisoned => backend("RegistryPoisoned", source),
941        E::NifRegistration { .. } => backend("NifRegistration", source),
942        E::SignalRouter(_) => backend("SignalRouter", source),
943        EngineError::Query(query) => engine::query_wire(query, source),
944    }
945}
946
947fn wire_from_store(source: &StoreError) -> WireError {
948    match source {
949        StoreError::SequenceConflict { .. } => WireError::new_with_type(
950            aion_proto::WireErrorCode::SequenceConflict,
951            "SequenceConflict",
952            source.to_string(),
953        ),
954        StoreError::NotFound { .. } => {
955            WireError::not_found_with_type("NotFound", source.to_string())
956        }
957        // A session that is not there is a not-found like any other; the typed
958        // name distinguishes it so a client can tell which KIND of thing was
959        // missing without parsing the message.
960        StoreError::AssistantSessionNotFound { .. } => {
961            WireError::not_found_with_type("AssistantSessionNotFound", source.to_string())
962        }
963        StoreError::NotOwner { .. } => {
964            WireError::not_owner(source.to_string()).with_error_type("NotOwner")
965        }
966        StoreError::Backend(_) => WireError::backend_with_type("Backend", source.to_string()),
967        StoreError::Serialization(_) => {
968            WireError::backend_with_type("Serialization", source.to_string())
969        }
970        // A zero limit or a cursor replayed under another query: the caller's
971        // input, never a backend fault, so a client restarts from the first
972        // page instead of retrying.
973        StoreError::InvalidQuery(_) => {
974            WireError::invalid_input(source.to_string()).with_error_type("InvalidQuery")
975        }
976    }
977}
978
979#[cfg(test)]
980#[path = "error_tests.rs"]
981mod tests;