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