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