Skip to main content

oxidelake_core/
error.rs

1//! The unified error type and result alias.
2
3use datafusion::arrow::error::ArrowError;
4use datafusion::error::DataFusionError;
5
6use crate::BackendKind;
7
8/// Result alias using [`EngineError`].
9pub type Result<T, E = EngineError> = core::result::Result<T, E>;
10
11/// Errors produced by OxideLake library crates.
12///
13/// Binaries convert this into `anyhow::Error`; DataFusion boundaries convert it
14/// into [`DataFusionError::External`] via the provided `From` impl.
15#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum EngineError {
18    /// An operating-system or object-store I/O failure.
19    #[error("I/O error: {0}")]
20    Io(#[from] std::io::Error),
21
22    /// A host or device allocation failed.
23    #[error("allocation of {bytes} bytes (align {align}) failed: {detail}")]
24    Allocation {
25        /// Requested size in bytes.
26        bytes: usize,
27        /// Requested alignment in bytes.
28        align: usize,
29        /// Backend- or allocator-specific detail.
30        detail: String,
31    },
32
33    /// A device or driver call failed.
34    #[error("{backend} device error: {detail}")]
35    Device {
36        /// The backend that reported the failure.
37        backend: BackendKind,
38        /// Driver- or runtime-specific detail.
39        detail: String,
40    },
41
42    /// Operator execution failed.
43    #[error("execution error: {0}")]
44    Execution(String),
45
46    /// Planning or plan (de)serialization failed.
47    #[error("plan error: {0}")]
48    Plan(String),
49
50    /// A file or stream is malformed (corrupt or truncated data).
51    #[error("format error: {0}")]
52    Format(String),
53
54    /// The requested capability is not implemented on this path.
55    ///
56    /// This is the only permitted "stub": callers must be able to fall back or
57    /// report the gap; panicking placeholders are forbidden.
58    #[error("unsupported: {feature} ({detail})")]
59    Unsupported {
60        /// Stable identifier of the missing capability, e.g. `"metal.hash_join"`.
61        feature: &'static str,
62        /// Human-readable detail.
63        detail: String,
64    },
65
66    /// An Arrow error.
67    #[error(transparent)]
68    Arrow(#[from] ArrowError),
69
70    /// A DataFusion error.
71    #[error(transparent)]
72    DataFusion(#[from] DataFusionError),
73}
74
75impl EngineError {
76    /// Builds an [`EngineError::Unsupported`].
77    pub fn unsupported(feature: &'static str, detail: impl Into<String>) -> Self {
78        Self::Unsupported {
79            feature,
80            detail: detail.into(),
81        }
82    }
83
84    /// Builds an [`EngineError::Execution`].
85    pub fn execution(detail: impl Into<String>) -> Self {
86        Self::Execution(detail.into())
87    }
88
89    /// Builds an [`EngineError::Plan`].
90    pub fn plan(detail: impl Into<String>) -> Self {
91        Self::Plan(detail.into())
92    }
93
94    /// Builds an [`EngineError::Format`].
95    pub fn format(detail: impl Into<String>) -> Self {
96        Self::Format(detail.into())
97    }
98
99    /// Builds an [`EngineError::Device`].
100    pub fn device(backend: BackendKind, detail: impl Into<String>) -> Self {
101        Self::Device {
102            backend,
103            detail: detail.into(),
104        }
105    }
106
107    /// Builds an [`EngineError::Allocation`].
108    pub fn allocation(bytes: usize, align: usize, detail: impl Into<String>) -> Self {
109        Self::Allocation {
110            bytes,
111            align,
112            detail: detail.into(),
113        }
114    }
115
116    /// Returns `true` for [`EngineError::Unsupported`], the signal operators use
117    /// to fall back to the CPU path.
118    pub fn is_unsupported(&self) -> bool {
119        matches!(self, Self::Unsupported { .. })
120    }
121}
122
123impl From<EngineError> for DataFusionError {
124    fn from(err: EngineError) -> Self {
125        match err {
126            EngineError::DataFusion(inner) => inner,
127            other => DataFusionError::External(Box::new(other)),
128        }
129    }
130}
131
132#[cfg(test)]
133#[allow(clippy::unwrap_used, clippy::expect_used)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn unsupported_is_detectable() {
139        let err = EngineError::unsupported("metal.hash_join", "not in v1");
140        assert!(err.is_unsupported());
141        assert_eq!(err.to_string(), "unsupported: metal.hash_join (not in v1)");
142    }
143
144    #[test]
145    fn datafusion_round_trip_unwraps_inner_error() {
146        let inner = DataFusionError::Plan("boom".to_owned());
147        let wrapped = EngineError::from(inner);
148        let back: DataFusionError = wrapped.into();
149        assert!(matches!(back, DataFusionError::Plan(ref m) if m == "boom"));
150    }
151
152    #[test]
153    fn other_errors_become_external() {
154        let back: DataFusionError = EngineError::format("truncated footer").into();
155        assert!(matches!(back, DataFusionError::External(_)));
156        assert!(back.to_string().contains("truncated footer"));
157    }
158
159    #[test]
160    fn io_errors_convert() {
161        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
162        let err: EngineError = io.into();
163        assert!(matches!(err, EngineError::Io(_)));
164    }
165}