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