Skip to main content

hawdb_core/
error.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::RuntimeCapability;
16use std::fmt::{Display, Formatter};
17
18/// Descriptor admission failures are distinct from corrupt storage or a busy
19/// branch. Project quota counts belong to one engine resource domain; OS-limit
20/// failures can additionally report the process allowance when it is known.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum FileDescriptorError {
23    InvalidBudget {
24        limit: usize,
25    },
26    ConfigurationConflict {
27        configured: usize,
28        requested: usize,
29    },
30    BudgetExceeded {
31        requested: usize,
32        available: usize,
33        limit: usize,
34    },
35    OsLimit {
36        requested: usize,
37        os_code: Option<i32>,
38        soft: Option<u64>,
39        hard: Option<u64>,
40    },
41}
42
43impl Display for FileDescriptorError {
44    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::InvalidBudget { limit } => write!(formatter, "invalid file descriptor budget: {limit}"),
47            Self::ConfigurationConflict { configured, requested } => write!(formatter,
48                "project file descriptor budget conflict: configured {configured}, requested {requested}"),
49            Self::BudgetExceeded { requested, available, limit } => write!(formatter,
50                "project file descriptor budget exceeded: requested {requested}, available {available}, limit {limit}"),
51            Self::OsLimit { requested, os_code, soft, hard } => write!(formatter,
52                "operating system file descriptor limit: requested {requested}, soft {soft:?}, hard {hard:?}, OS code {os_code:?}"),
53        }
54    }
55}
56
57impl std::error::Error for FileDescriptorError {}
58
59/// The result resource whose selected read limit rejected delivery.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ReadBudgetResource {
62    Rows,
63    PayloadBytes,
64}
65
66/// Retains budget identity independently of the execution diagnostic text.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ReadBudgetError {
69    pub resource: ReadBudgetResource,
70    pub limit: usize,
71    pub message: String,
72}
73
74impl Display for ReadBudgetError {
75    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
76        formatter.write_str(&self.message)
77    }
78}
79
80impl std::error::Error for ReadBudgetError {}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum HawDBError {
84    Parse(String),
85    Semantic(String),
86    Storage(String),
87    FileDescriptors(FileDescriptorError),
88    StorageIntegrity(String),
89    Execution(String),
90    ReadBudgetExceeded(ReadBudgetError),
91    GraphExpansionCandidateLimitExceeded {
92        requested: usize,
93        limit: usize,
94    },
95    GraphExpansionPayloadLimitExceeded {
96        requested: usize,
97        limit: usize,
98    },
99    TransactionConflict {
100        read_epoch: u64,
101        committed_epoch: u64,
102        key: String,
103    },
104    AppendSequenceExhausted {
105        table: String,
106        watermark: i64,
107        requested: usize,
108    },
109    CapabilityUnavailable {
110        capability: RuntimeCapability,
111    },
112    BranchCommandUnsupported {
113        command: &'static str,
114        context: &'static str,
115    },
116    BranchBusy {
117        resource: &'static str,
118    },
119    /// An unleased pending create prevents a complete reclamation mark.
120    /// This identifies retained evidence, not authorization to discard it.
121    BranchReclamationBlocked {
122        branch_id: crate::Uuid,
123        catalog_revision: u64,
124        metadata_revision: u64,
125        cause: Box<HawDBError>,
126    },
127}
128
129impl Display for HawDBError {
130    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
131        match self {
132            HawDBError::Parse(message) => write!(f, "parse error: {message}"),
133            HawDBError::Semantic(message) => write!(f, "semantic error: {message}"),
134            HawDBError::Storage(message) => write!(f, "storage error: {message}"),
135            HawDBError::FileDescriptors(error) => Display::fmt(error, f),
136            HawDBError::StorageIntegrity(message) => {
137                write!(f, "storage integrity error: {message}")
138            }
139            HawDBError::Execution(message) => write!(f, "execution error: {message}"),
140            HawDBError::ReadBudgetExceeded(error) => write!(f, "execution error: {error}"),
141            HawDBError::GraphExpansionCandidateLimitExceeded { requested, limit } => write!(
142                f,
143                "graph expansion candidate_limit exceeded: requested {requested} rows, limit {limit} rows; configure ExecutionMemoryConfig.graph_expansion_budget.candidate_limit"
144            ),
145            HawDBError::GraphExpansionPayloadLimitExceeded { requested, limit } => write!(
146                f,
147                "graph expansion payload_byte_limit exceeded: requested {requested} bytes, limit {limit} bytes; configure ExecutionMemoryConfig.graph_expansion_budget.payload_byte_limit"
148            ),
149            HawDBError::TransactionConflict {
150                read_epoch,
151                committed_epoch,
152                key,
153            } => write!(
154                f,
155                "transaction conflict on {key}: transaction read epoch {read_epoch}, conflicting commit epoch {committed_epoch}"
156            ),
157            HawDBError::AppendSequenceExhausted {
158                table,
159                watermark,
160                requested,
161            } => write!(
162                f,
163                "append commit sequence exhausted for table {table}: watermark={watermark}, requested={requested}"
164            ),
165            HawDBError::CapabilityUnavailable { capability } => {
166                write!(f, "capability unavailable: {}", capability.as_str())
167            }
168            HawDBError::BranchCommandUnsupported { command, context } => {
169                write!(f, "{command} is unavailable in {context}")
170            }
171            HawDBError::BranchBusy { resource } => write!(f, "branch is busy: {resource}"),
172            HawDBError::BranchReclamationBlocked {
173                branch_id,
174                catalog_revision,
175                metadata_revision,
176                cause,
177            } => write!(
178                f,
179                "pending branch {branch_id} blocks reclamation at catalog revision {catalog_revision}, branch revision {metadata_revision}: {cause}"
180            ),
181        }
182    }
183}
184
185impl std::error::Error for HawDBError {
186    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
187        match self {
188            Self::FileDescriptors(error) => Some(error),
189            Self::ReadBudgetExceeded(error) => Some(error),
190            Self::BranchReclamationBlocked { cause, .. } => Some(cause.as_ref()),
191            _ => None,
192        }
193    }
194}
195
196impl HawDBError {
197    pub fn read_budget_exceeded(
198        resource: ReadBudgetResource,
199        limit: usize,
200        message: impl Into<String>,
201    ) -> Self {
202        Self::ReadBudgetExceeded(ReadBudgetError {
203            resource,
204            limit,
205            message: message.into(),
206        })
207    }
208
209    /// Preserve a descriptor rejection through nested storage error wrappers.
210    pub fn from_storage_error(error: impl std::error::Error + 'static) -> Self {
211        if let Some(error) = (&error as &dyn std::error::Error).downcast_ref::<Self>() {
212            return error.clone();
213        }
214        if let Some(resource) = file_descriptor_error(&error) {
215            Self::FileDescriptors(resource)
216        } else {
217            Self::Storage(error.to_string())
218        }
219    }
220
221    /// `from_storage_error`, prefixing a generic `Storage` message with
222    /// `context` and leaving a preserved typed cause (e.g. `FileDescriptors`)
223    /// unprefixed and unchanged.
224    pub fn from_storage_error_with_context(
225        error: impl std::error::Error + 'static,
226        context: &str,
227    ) -> Self {
228        match Self::from_storage_error(error) {
229            Self::Storage(message) => Self::Storage(format!("{context}: {message}")),
230            error => error,
231        }
232    }
233
234    pub const fn is_retryable_transaction_conflict(&self) -> bool {
235        matches!(self, Self::TransactionConflict { .. })
236    }
237}
238
239pub type Result<T> = std::result::Result<T, HawDBError>;
240
241impl From<std::io::Error> for HawDBError {
242    fn from(error: std::io::Error) -> Self {
243        Self::from_storage_error(error)
244    }
245}
246
247pub fn file_descriptor_error(
248    error: &(dyn std::error::Error + 'static),
249) -> Option<FileDescriptorError> {
250    let mut current = Some(error);
251    while let Some(error) = current {
252        if let Some(resource) = error.downcast_ref::<FileDescriptorError>() {
253            return Some(resource.clone());
254        }
255        if let Some(HawDBError::FileDescriptors(resource)) = error.downcast_ref::<HawDBError>() {
256            return Some(resource.clone());
257        }
258        if let Some(error) = error.downcast_ref::<std::io::Error>() {
259            if let Some(code) = error.raw_os_error()
260                && descriptor_os_limit(code)
261            {
262                return Some(FileDescriptorError::OsLimit {
263                    requested: 1,
264                    os_code: Some(code),
265                    soft: None,
266                    hard: None,
267                });
268            }
269            // io::Error::source can skip its boxed concrete error. Inspect it
270            // explicitly so typed capacity rejections survive each IO layer.
271            if let Some(inner) = error.get_ref()
272                && let Some(resource) = file_descriptor_error(inner)
273            {
274                return Some(resource);
275            }
276        }
277        current = error.source();
278    }
279    None
280}
281
282fn descriptor_os_limit(code: i32) -> bool {
283    #[cfg(unix)]
284    {
285        matches!(code, 23 | 24)
286    }
287    #[cfg(windows)]
288    {
289        code == 4
290    }
291    #[cfg(not(any(unix, windows)))]
292    {
293        let _ = code;
294        false
295    }
296}