Skip to main content

cudf/core/error/
mod.rs

1//! Error contract and diagnostic structures for `cudf`.
2//!
3//! `Error` stores one authoritative failure identity: [`ErrorCode`]. Both
4//! [`ErrorKind`] and [`ErrorStatus`] are pure projections of `code`; they are
5//! never stored independently, and there are no override APIs.
6//!
7//! The public surface is read-only: construction and mutation helpers are
8//! crate-internal (`pub(crate)`).
9
10use std::{borrow::Cow, error::Error as StdError, ffi::CStr, fmt, ptr::NonNull};
11
12use rapids_interop::{InteropError, InteropErrorKind};
13
14use crate::{core::memory::ReservationUsage, runtime};
15
16mod ffi;
17
18pub mod classification;
19pub mod code;
20pub mod facts;
21#[cfg(any(test, feature = "test-hooks"))]
22pub(crate) mod testing_hooks;
23
24pub use classification::{
25    CudfAllocationAttribution, CudfErrorClassification, allocation_attribution, classify_cudf_error,
26};
27pub use code::{ErrorCode, ErrorKind, ErrorStatus, kind_for_code, status_for_code};
28pub(crate) use facts::{
29    DeviceIdFact, FACT_ACTUAL_COUNT, FACT_ACTUAL_ROWS, FACT_COLUMN_INDEX, FACT_COLUMN_NAME,
30    FACT_CONTROL_PLANE_SCALAR_BYTES, FACT_DISTINCT_KEYS_COUNT, FACT_EXECUTION_BINDING_ID, FACT_EXPECTED_COUNT,
31    FACT_EXPECTED_ROWS, FACT_KEYS_COUNT, FACT_LEFT_DEVICE, FACT_LEFT_TYPE_ID, FACT_RIGHT_DEVICE, FACT_RIGHT_TYPE_ID,
32    FACT_TABLE_COLUMN_COUNT, FACT_TABLE_ROW_COUNT, FACT_TYPE_ID, FACT_VALIDATION_DEVICE, FACT_VALIDATION_REASON,
33    FACT_VALIDATION_STREAM, FactKey, IntoFactValue,
34};
35pub use facts::{FactKeyName, FactValue, Facts};
36pub use ffi::{CudfErrorDetail, CudfErrorFailureSource};
37
38type BoxedSourceError = Box<dyn StdError + Send + Sync + 'static>;
39
40/// A cudf error. See the module-level docs for the contract.
41pub struct Error {
42    inner: Box<ErrorInner>,
43}
44
45struct ErrorInner {
46    code: ErrorCode,
47    operation: &'static str,
48    message: String,
49    facts: Facts,
50    details: Vec<(&'static str, String)>,
51    budget_exhaustion: Option<BudgetExhaustionDetail>,
52    ffi_detail: Option<CudfErrorDetail>,
53    call_chain: Vec<&'static str>,
54    source: Option<BoxedSourceError>,
55}
56
57/// Budget scope an operation ran in when it failed, attached by budget-token
58/// execution.
59///
60/// The member usage is a snapshot of the scope the error passed through; it
61/// does not prove that the query cap rejected an allocation. `usage` is absent
62/// when the scope could not be sampled.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct BudgetExhaustionDetail {
65    boundary: Cow<'static, str>,
66    operation: &'static str,
67    usage: Option<ReservationUsage>,
68}
69
70impl BudgetExhaustionDetail {
71    pub(crate) fn new(
72        boundary: impl Into<Cow<'static, str>>,
73        operation: &'static str,
74        usage: Option<ReservationUsage>,
75    ) -> Self {
76        Self { boundary: boundary.into(), operation, usage }
77    }
78
79    /// Stage boundary label of the budget request.
80    pub fn boundary(&self) -> &str {
81        &self.boundary
82    }
83
84    /// Budgeted operation the error passed through.
85    pub fn operation(&self) -> &'static str {
86        self.operation
87    }
88
89    /// Member counters, including the allocation limit the allocator enforced.
90    pub fn usage(&self) -> Option<&ReservationUsage> {
91        self.usage.as_ref()
92    }
93}
94
95impl Error {
96    /// Create a new error with the full contract: a leaf `code`, a cudf
97    /// boundary `operation`, and a primary `message`.
98    ///
99    /// `operation` must be a non-empty `&'static str`. `message` must be
100    /// non-empty. These are programmer errors inside cudf. Violations panic;
101    /// the constructor does not return `Result`.
102    pub(crate) fn new_with(code: ErrorCode, operation: &'static str, message: impl Into<String>) -> Self {
103        let message = message.into();
104        assert!(!operation.is_empty(), "cudf internal error: operation label must not be empty");
105        assert!(!message.is_empty(), "cudf internal error: message must not be empty");
106        Self {
107            inner: Box::new(ErrorInner {
108                code,
109                operation,
110                message,
111                facts: Facts::new(),
112                details: Vec::new(),
113                budget_exhaustion: None,
114                ffi_detail: None,
115                call_chain: Vec::new(),
116                source: None,
117            }),
118        }
119    }
120
121    /// Inserts a typed fact. A duplicate key keeps the leaf error's value.
122    #[must_use]
123    pub(crate) fn with_fact<T>(mut self, key: FactKey<T>, value: T) -> Self
124    where T: IntoFactValue {
125        self.inner.facts.insert(key.name(), value.into_fact_value());
126        self
127    }
128
129    /// Appends the canonical column-name fact.
130    #[must_use]
131    pub(crate) fn with_column(self, name: impl Into<Cow<'static, str>>) -> Self {
132        self.with_fact(FACT_COLUMN_NAME, name.into())
133    }
134
135    /// Appends a diagnostic detail. Repeated keys are allowed.
136    ///
137    /// Debug-asserts if `key` is a canonical fact snake_case name; such keys
138    /// must be written via [`with_fact`](Self::with_fact), never through the
139    /// detail path.
140    #[must_use]
141    pub(crate) fn with_detail(mut self, key: &'static str, value: impl Into<String>) -> Self {
142        assert_canonical_key_not_used_as_detail(key);
143        self.inner.details.push((key, value.into()));
144        self
145    }
146
147    /// Appends a typed fact only when that canonical fact is absent.
148    #[must_use]
149    pub(crate) fn with_fact_if_absent<T>(self, key: FactKey<T>, value: T) -> Self
150    where T: IntoFactValue {
151        if self.fact_value(key.name()).is_some() { self } else { self.with_fact(key, value) }
152    }
153
154    /// Appends a diagnostic detail only when that detail key is
155    /// absent.
156    #[must_use]
157    pub(crate) fn with_detail_if_absent(self, key: &'static str, value: impl Into<String>) -> Self {
158        if self.detail_value(key).is_some() { self } else { self.with_detail(key, value) }
159    }
160
161    /// Appends several diagnostic details.
162    ///
163    /// Debug-asserts if any `key` is a canonical fact snake_case name. See
164    /// [`with_detail`](Self::with_detail).
165    #[must_use]
166    pub(crate) fn with_details<I>(mut self, details: I) -> Self
167    where I: IntoIterator<Item = (&'static str, String)> {
168        let details = details.into_iter();
169        self.inner.details.reserve(details.size_hint().0);
170        for (key, value) in details {
171            assert_canonical_key_not_used_as_detail(key);
172            self.inner.details.push((key, value));
173        }
174        self
175    }
176
177    /// Attaches typed budget-exhaustion counters if absent.
178    #[must_use]
179    pub(crate) fn with_budget_exhaustion_if_absent(mut self, detail: BudgetExhaustionDetail) -> Self {
180        if self.inner.budget_exhaustion.is_none() {
181            self.inner.budget_exhaustion = Some(detail);
182        }
183        self
184    }
185
186    /// Attaches the copied native shim payload.
187    #[must_use]
188    pub(crate) fn with_ffi_detail(mut self, detail: CudfErrorDetail) -> Self {
189        self.inner.ffi_detail = Some(detail);
190        self
191    }
192
193    /// Attaches a source error.
194    #[must_use]
195    pub(crate) fn with_source(mut self, src: impl Into<BoxedSourceError>) -> Self {
196        self.inner.source = Some(src.into());
197        self
198    }
199
200    /// Re-anchors this error at a new boundary.
201    ///
202    /// The previous primary operation is appended to `call_chain` as-is with
203    /// no deduplication. All other fields are preserved.
204    #[must_use]
205    pub(crate) fn reanchor(mut self, operation: &'static str) -> Self {
206        assert!(!operation.is_empty(), "cudf internal error: operation label must not be empty");
207        self.inner.call_chain.push(self.inner.operation);
208        self.inner.operation = operation;
209        self
210    }
211
212    /// Re-anchors this error only when the requested operation
213    /// differs from the current anchor.
214    #[must_use]
215    pub(crate) fn reanchor_if_needed(self, operation: &'static str) -> Self {
216        if self.operation() == operation { self } else { self.reanchor(operation) }
217    }
218
219    /// The stable machine-readable failure identity.
220    pub fn code(&self) -> ErrorCode {
221        self.inner.code
222    }
223
224    /// The coarse failure category (pure projection of `code`).
225    pub fn kind(&self) -> ErrorKind {
226        kind_for_code(self.inner.code)
227    }
228
229    /// The retry semantic (pure projection of `code`).
230    pub fn status(&self) -> ErrorStatus {
231        status_for_code(self.inner.code)
232    }
233
234    /// The cudf boundary at which this error was surfaced.
235    pub fn operation(&self) -> &'static str {
236        self.inner.operation
237    }
238
239    /// The one-line primary failure description.
240    pub fn message(&self) -> &str {
241        &self.inner.message
242    }
243
244    /// Canonical typed facts.
245    pub fn facts(&self) -> &Facts {
246        &self.inner.facts
247    }
248
249    /// The value of a canonical fact, if present.
250    pub fn fact_value(&self, key: FactKeyName) -> Option<&FactValue> {
251        self.inner.facts.get(key)
252    }
253
254    /// Open-ended diagnostic annotations (may contain repeated keys).
255    pub fn details(&self) -> &[(&'static str, String)] {
256        &self.inner.details
257    }
258
259    /// The first detail value recorded for `key`, if any.
260    pub fn detail_value(&self, key: &str) -> Option<&str> {
261        self.inner.details.iter().find_map(|(k, v)| (*k == key).then_some(v.as_str()))
262    }
263
264    /// All detail values recorded for `key`, in insertion order.
265    pub fn detail_values<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'a str> + 'a {
266        self.inner.details.iter().filter_map(move |(k, v)| (*k == key).then_some(v.as_str()))
267    }
268
269    /// Typed budget-exhaustion counters attached by a hard budget token.
270    pub fn budget_exhaustion(&self) -> Option<&BudgetExhaustionDetail> {
271        self.inner.budget_exhaustion.as_ref()
272    }
273
274    /// The native shim payload copied at the failed FFI boundary, if present.
275    pub fn ffi_detail(&self) -> Option<&CudfErrorDetail> {
276        self.inner.ffi_detail.as_ref()
277    }
278
279    /// Ordered trail of prior primary operations this error has been
280    /// re-anchored from. No deduplication.
281    pub fn call_chain(&self) -> &[&'static str] {
282        &self.inner.call_chain
283    }
284
285    /// Underlying error cause, if any.
286    pub fn source_error(&self) -> Option<&(dyn StdError + 'static)> {
287        self.inner.source.as_deref().map(|source| source as &(dyn StdError + 'static))
288    }
289
290    /// Stable diagnostic string for logs and query-engine adapters.
291    ///
292    /// This formats the existing error contract without introducing parallel
293    /// stored kind/status fields or string-only categories. Callers that need
294    /// policy decisions should keep using [`code`](Self::code),
295    /// [`kind`](Self::kind), [`status`](Self::status), and
296    /// [`facts`](Self::facts) directly.
297    pub fn diagnostic_string(&self) -> String {
298        let mut out = format!(
299            "code={} kind={} status={} operation={} message={}",
300            self.code(),
301            self.kind(),
302            self.status(),
303            self.operation(),
304            diagnostic_quote(self.message()),
305        );
306
307        out.push_str(" facts={");
308        for (index, (key, value)) in self.facts().iter().enumerate() {
309            if index > 0 {
310                out.push_str(", ");
311            }
312            out.push_str(key.as_snake_case());
313            out.push('=');
314            out.push_str(&value.to_string());
315        }
316        out.push('}');
317
318        out.push_str(" details=[");
319        for (index, (key, value)) in self.details().iter().enumerate() {
320            if index > 0 {
321                out.push_str(", ");
322            }
323            out.push_str(key);
324            out.push('=');
325            out.push_str(&diagnostic_quote(value));
326        }
327        out.push(']');
328
329        if let Some(detail) = self.ffi_detail() {
330            out.push_str(" ffi_detail={");
331            out.push_str("status=");
332            out.push_str(&detail.status.to_string());
333            out.push_str(", failure_source=");
334            out.push_str(&detail.failure_source.to_string());
335            out.push_str(", domain_id=");
336            out.push_str(&detail.domain_id.to_string());
337            out.push_str(", member_id=");
338            out.push_str(&detail.member_id.to_string());
339            out.push_str(", generation=");
340            out.push_str(&detail.generation.to_string());
341            out.push_str(", requested_bytes=");
342            out.push_str(&detail.requested_bytes.to_string());
343            out.push_str(", alignment=");
344            out.push_str(&detail.alignment.to_string());
345            out.push_str(", cuda_error=");
346            out.push_str(&detail.cuda_error.to_string());
347            out.push_str(", message=");
348            out.push_str(&diagnostic_quote(&detail.message));
349            out.push('}');
350        }
351
352        out.push_str(" call_chain=[");
353        for (index, operation) in self.call_chain().iter().enumerate() {
354            if index > 0 {
355                out.push_str(" -> ");
356            }
357            out.push_str(operation);
358        }
359        out.push(']');
360
361        out.push_str(" source=");
362        match self.source_error() {
363            Some(source) => out.push_str(&diagnostic_quote(&source.to_string())),
364            None => out.push_str("none"),
365        }
366
367        out
368    }
369
370    // Status and identity helpers are exact aliases only.
371
372    /// Equivalent to `status() == Temporary`.
373    pub fn is_retryable(&self) -> bool {
374        self.status() == ErrorStatus::Temporary
375    }
376
377    /// Equivalent to `status() == Temporary`.
378    pub fn is_temporary(&self) -> bool {
379        self.status() == ErrorStatus::Temporary
380    }
381
382    /// Equivalent to `status() == Permanent`.
383    pub fn is_permanent(&self) -> bool {
384        self.status() == ErrorStatus::Permanent
385    }
386
387    /// Equivalent to `status() == Persistent`.
388    pub fn is_persistent(&self) -> bool {
389        self.status() == ErrorStatus::Persistent
390    }
391
392    /// Equivalent to `code() == ErrorCode::OutOfMemory`.
393    ///
394    /// This is the single domestic OOM leaf. Foreign resource exhaustion is a
395    /// different leaf and does not alias here.
396    pub fn is_oom(&self) -> bool {
397        self.inner.code == ErrorCode::OutOfMemory
398    }
399}
400
401impl fmt::Display for Error {
402    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403        // Output is "{kind} ({status}) at {operation}: {message}", then "; k: v" for each
404        // detail, then "; source: {src}" when a source is present.
405        write!(f, "{} ({}) at {}: {}", self.kind(), self.status(), self.inner.operation, self.inner.message)?;
406        for (k, v) in &self.inner.details {
407            write!(f, "; {}: {}", k, v)?;
408        }
409        if let Some(src) = &self.inner.source {
410            write!(f, "; source: {}", src)?;
411        }
412        Ok(())
413    }
414}
415
416impl fmt::Debug for Error {
417    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
418        writeln!(f, "code: {}", self.inner.code)?;
419        writeln!(f, "kind: {} (derived)", self.kind())?;
420        writeln!(f, "status: {}", self.status())?;
421        writeln!(f, "operation: {}", self.inner.operation)?;
422        writeln!(f, "call_chain: {:?}", self.inner.call_chain)?;
423        writeln!(f, "facts: {}", self.inner.facts)?;
424        if let Some(detail) = &self.inner.ffi_detail {
425            writeln!(f, "ffi_detail: {:?}", detail)?;
426        }
427        for (k, v) in &self.inner.details {
428            writeln!(f, "{}: {}", k, v)?;
429        }
430        writeln!(f, "message: {}", self.inner.message)?;
431        writeln!(f, "source: {:?}", self.inner.source)?;
432        Ok(())
433    }
434}
435
436impl StdError for Error {
437    fn source(&self) -> Option<&(dyn StdError + 'static)> {
438        self.source_error()
439    }
440}
441
442/// Convenience alias.
443pub type Result<T> = std::result::Result<T, Error>;
444
445/// Reject canonical fact snake_case names on the detail write path.
446///
447/// Canonical snake_case keys are reserved for `facts` and must never appear
448/// in `details`. The writer checks this in debug builds, where a violation (a
449/// crate-internal bug) panics; release builds keep the detail.
450fn assert_canonical_key_not_used_as_detail(key: &'static str) {
451    debug_assert!(
452        FactKeyName::from_snake_case(key).is_none(),
453        "cudf internal error: canonical fact key `{key}` written through the detail path; \
454         use `with_fact` instead",
455    );
456}
457
458fn diagnostic_quote(input: &str) -> String {
459    let mut out = String::with_capacity(input.len() + 2);
460    out.push('"');
461    for ch in input.chars() {
462        out.extend(ch.escape_default());
463    }
464    out.push('"');
465    out
466}
467
468pub(crate) fn invariant_violation(operation: &'static str, message: impl Into<String>) -> Error {
469    Error::new_with(ErrorCode::InvariantViolation, operation, message)
470}
471
472/// Build an error describing an interior null byte.
473///
474/// Emits the leaf code [`ErrorCode::NullUnexpected`]. See that variant's
475/// doc comment for the full contract: `NullUnexpected` covers both
476/// SQL-level null values (mask bit null where not allowed) and interior
477/// null bytes in string inputs (a `\0` byte in the middle of a string
478/// that cannot survive a C null-terminated contract). Downstreams that
479/// key on this code handle both by treating the input as unclean.
480pub(crate) fn interior_null_byte_error(
481    operation: &'static str,
482    resource: &'static str,
483    message: &'static str,
484) -> Error {
485    Error::new_with(ErrorCode::NullUnexpected, operation, message)
486        .with_detail("resource", resource)
487        .with_detail("reason", "interior_null_byte")
488}
489
490/// Build a `ColumnLengthMismatch` error for a table-like row-count contract.
491pub(crate) fn row_count_mismatch_error(
492    operation: &'static str,
493    column_index: usize,
494    actual_rows: usize,
495    expected_rows: usize,
496) -> Error {
497    Error::new_with(
498        ErrorCode::ColumnLengthMismatch,
499        operation,
500        format!("column {column_index} has {actual_rows} rows but column 0 has {expected_rows} rows"),
501    )
502    .with_fact(FACT_COLUMN_INDEX, column_index)
503    .with_fact(FACT_ACTUAL_ROWS, actual_rows)
504    .with_fact(FACT_EXPECTED_ROWS, expected_rows)
505}
506
507/// Build an `UnsupportedOperation` error for a value that exceeded a
508/// hard numeric bound.
509pub(crate) fn limit_validation_error(
510    operation: &'static str,
511    message: impl Into<String>,
512    resource: &'static str,
513    value_key: &'static str,
514    limit: &'static str,
515    value: usize,
516    remedy: impl Into<String>,
517) -> Error {
518    let reason = match limit {
519        "i32::MAX" => "exceeds_i32_max",
520        "i64::MAX" => "exceeds_i64_max",
521        _ => "exceeds_supported_range",
522    };
523
524    let err = Error::new_with(ErrorCode::UnsupportedOperation, operation, message);
525    let err = match FactKeyName::from_snake_case(value_key) {
526        Some(FactKeyName::ColumnIndex) => err.with_fact(FACT_COLUMN_INDEX, value),
527        Some(FactKeyName::ExpectedRows) => err.with_fact(FACT_EXPECTED_ROWS, value),
528        Some(FactKeyName::ActualRows) => err.with_fact(FACT_ACTUAL_ROWS, value),
529        Some(FactKeyName::ExpectedCount) => err.with_fact(FACT_EXPECTED_COUNT, value),
530        Some(FactKeyName::ActualCount) => err.with_fact(FACT_ACTUAL_COUNT, value),
531        Some(FactKeyName::TableRowCount) => err.with_fact(FACT_TABLE_ROW_COUNT, value),
532        Some(FactKeyName::TableColumnCount) => err.with_fact(FACT_TABLE_COLUMN_COUNT, value),
533        Some(FactKeyName::KeysCount) => err.with_fact(FACT_KEYS_COUNT, value),
534        Some(FactKeyName::DistinctKeysCount) => err.with_fact(FACT_DISTINCT_KEYS_COUNT, value),
535        _ => err.with_detail(value_key, value.to_string()),
536    };
537
538    err.with_detail("resource", resource).with_detail("reason", reason).with_detail("remedy", remedy.into())
539}
540
541/// Build a `ForeignInvariantViolation` with the canonical
542/// `resource/reason/remedy` contract details.
543pub(crate) fn foreign_invariant_violation(
544    operation: &'static str,
545    message: impl Into<String>,
546    resource: &'static str,
547    reason: &'static str,
548    remedy: impl Into<String>,
549) -> Error {
550    Error::new_with(ErrorCode::ForeignInvariantViolation, operation, message)
551        .with_detail("resource", resource)
552        .with_detail("reason", reason)
553        .with_detail("remedy", remedy.into())
554}
555
556/// Fill in missing foreign-contract details on invariant-shaped errors
557/// without overwriting existing payload.
558#[must_use]
559pub(crate) fn annotate_foreign_invariant_contract_if_absent(
560    mut err: Error,
561    resource: &'static str,
562    reason: &'static str,
563    remedy: impl Into<String>,
564) -> Error {
565    if matches!(err.code(), ErrorCode::ForeignInvariantViolation | ErrorCode::InvariantViolation) {
566        let remedy = remedy.into();
567        err = err.with_detail_if_absent("resource", resource);
568        err = err.with_detail_if_absent("reason", reason);
569        err = err.with_detail_if_absent("remedy", remedy);
570    }
571    err
572}
573
574/// Build an `UnsupportedOperation` error for a range-bound overflow.
575pub(crate) fn range_limit_error(
576    operation: &'static str,
577    field: &'static str,
578    limit: &'static str,
579    value: usize,
580) -> Error {
581    limit_validation_error(
582        operation,
583        format!("{field} exceeds {limit}"),
584        field,
585        field,
586        limit,
587        value,
588        format!("reduce {field} to fit within {limit}"),
589    )
590}
591
592/// Build an `UnsupportedOperation` error for a row-range bound overflow.
593pub(crate) fn row_range_limit_error(operation: &'static str, bound: &'static str, value: usize) -> Error {
594    let (message, key, reason) = match bound {
595        "start" => ("row range start exceeds i32::MAX", "start_row", "start_exceeds_i32_max"),
596        "end" => ("row range end exceeds i32::MAX", "end_row", "end_exceeds_i32_max"),
597        _ => ("row range bound exceeds i32::MAX", "row_bound", "bound_exceeds_i32_max"),
598    };
599
600    Error::new_with(ErrorCode::UnsupportedOperation, operation, message)
601        .with_detail(key, value.to_string())
602        .with_detail("resource", "row_range")
603        .with_detail("reason", reason)
604        .with_detail("remedy", "reduce row range bounds to fit within i32::MAX")
605}
606
607/// Build an `UnsupportedOperation` error for a row-range end that overflows
608/// `usize`.
609pub(crate) fn row_range_overflow_error(operation: &'static str, start: usize, len: usize) -> Error {
610    let end_row = (start as u128) + (len as u128);
611
612    Error::new_with(ErrorCode::UnsupportedOperation, operation, "row range end overflowed usize")
613        .with_detail("start_row", start.to_string())
614        .with_detail("end_row", end_row.to_string())
615        .with_detail("resource", "row_range")
616        .with_detail("reason", "end_overflowed_usize")
617        .with_detail("remedy", "reduce row range bounds to fit within usize")
618}
619
620/// Ensure a raw FFI pointer is non-null. On null, returns an
621/// `InvariantViolation` error stating the FFI contract was violated.
622pub(crate) fn require_non_null<T>(ptr: *mut T, operation: &'static str, target: &'static str) -> Result<NonNull<T>> {
623    NonNull::new(ptr).ok_or_else(|| {
624        Error::new_with(
625            ErrorCode::InvariantViolation,
626            operation,
627            format!("FFI returned null {target} handle on success"),
628        )
629        .with_detail("target", target)
630    })
631}
632
633/// Convert a `usize` to an `i32`, producing a range-limit error on overflow.
634pub(crate) fn usize_to_i32(value: usize, operation: &'static str, field: &'static str) -> Result<i32> {
635    i32::try_from(value).map_err(|_| range_limit_error(operation, field, "i32::MAX", value))
636}
637
638fn leaf_code_for_status(status: cudf_sys::cudf_status_t) -> ErrorCode {
639    match status {
640        cudf_sys::CUDF_ERROR_INVALID_ARGUMENT | cudf_sys::CUDF_ERROR_INVALID_OPERATION | cudf_sys::CUDF_ERROR_TYPE => {
641            ErrorCode::ForeignInvalidArgument
642        },
643        cudf_sys::CUDF_ERROR_INVARIANT_VIOLATION => ErrorCode::ForeignInvariantViolation,
644        cudf_sys::CUDF_ERROR_RUNTIME_BINDING_MISMATCH => ErrorCode::RuntimeBindingMismatch,
645        cudf_sys::CUDF_ERROR_CUDA => ErrorCode::CudaRuntime,
646        cudf_sys::CUDF_ERROR_FATAL_CUDA => ErrorCode::FatalCuda,
647        cudf_sys::CUDF_ERROR_OUT_OF_MEMORY => ErrorCode::OutOfMemory,
648        cudf_sys::CUDF_ERROR_SIZE_OVERFLOW => ErrorCode::SizeOverflow,
649        cudf_sys::CUDF_ERROR_UNSUPPORTED => ErrorCode::UnsupportedOperation,
650        cudf_sys::CUDF_ERROR_IO => ErrorCode::IoFailure,
651        cudf_sys::CUDF_ERROR_NULL => ErrorCode::NullUnexpected,
652        cudf_sys::CUDF_ERROR_INDEX => ErrorCode::IndexOutOfBounds,
653        _ => ErrorCode::ForeignInvariantViolation,
654    }
655}
656
657#[cfg(any(test, feature = "test-hooks"))]
658fn error_for_ffi_status(status: cudf_sys::cudf_status_t, operation: &'static str, message: String) -> Error {
659    error_for_ffi_status_with_detail(status, operation, message, None)
660}
661
662fn error_for_ffi_status_with_detail(
663    status: cudf_sys::cudf_status_t,
664    operation: &'static str,
665    message: String,
666    ffi_detail: Option<CudfErrorDetail>,
667) -> Error {
668    let error = Error::new_with(leaf_code_for_status(status), operation, message);
669    match ffi_detail {
670        Some(detail) => error.with_ffi_detail(detail),
671        None => error,
672    }
673}
674
675fn read_ffi_last_error_message() -> String {
676    // SAFETY: cudf_last_error_message returns either null or a pointer to a
677    // thread-local static C string that remains valid until the next FFI
678    // call on this thread. The pointee is copied into an owned String below.
679    unsafe {
680        let ptr = cudf_sys::cudf_last_error_message();
681        if ptr.is_null() {
682            String::from("(no error message)")
683        } else {
684            CStr::from_ptr(ptr).to_string_lossy().into_owned()
685        }
686    }
687}
688
689fn read_ffi_error(status: cudf_sys::cudf_status_t) -> (String, Option<CudfErrorDetail>) {
690    // SAFETY: the native getter returns a pointer to a fixed, thread-local
691    // record that remains valid until the next shim call. Copy the entire
692    // payload before returning to the caller.
693    unsafe {
694        let detail =
695            cudf_sys::cudf_last_error_detail().as_ref().map(|detail| CudfErrorDetail::from_raw(detail, status));
696        match detail {
697            Some(detail) if !detail.message.is_empty() => (detail.message.clone(), Some(detail)),
698            Some(detail) => (String::from("(no error message)"), Some(detail)),
699            None => (read_ffi_last_error_message(), None),
700        }
701    }
702}
703
704/// Convert a C shim status into a `Result<()>` anchored at the caller's
705/// operation label.
706pub(crate) fn check_status_in(status: cudf_sys::cudf_status_t, operation: &'static str) -> Result<()> {
707    if status == cudf_sys::CUDF_SUCCESS {
708        return Ok(());
709    }
710    // Copy the native record before any health-reporting path can make
711    // another shim call on this thread and replace the TLS payload.
712    let (message, ffi_detail) = read_ffi_error(status);
713    if status == cudf_sys::CUDF_ERROR_FATAL_CUDA
714        && let Some(device) = runtime::device_guard::current_entered_device()
715    {
716        runtime::report_device_fault(
717            device,
718            runtime::DeviceFaultKind::FatalDeviceOperation,
719            operation,
720            Some(runtime::DeviceFaultNativeStatus::Cudf(status)),
721        );
722    }
723    Err(error_for_ffi_status_with_detail(status, operation, message, ffi_detail))
724}
725
726/// Convert a C shim status without inferring device ownership from the
727/// thread-local entry stack.
728///
729/// Device-aware runtime boundaries use this after reporting through their
730/// explicit owner-device path, so one native failure produces one health
731/// record and no second generic TLS-derived record.
732pub(crate) fn check_status_in_without_device_report(
733    status: cudf_sys::cudf_status_t,
734    operation: &'static str,
735) -> Result<()> {
736    if status == cudf_sys::CUDF_SUCCESS {
737        return Ok(());
738    }
739    let (message, ffi_detail) = read_ffi_error(status);
740    Err(error_for_ffi_status_with_detail(status, operation, message, ffi_detail))
741}
742
743/// Convert a C shim status, attaching diagnostic details on failure.
744///
745/// Attach non-canonical diagnostic details supplied by FFI boundaries.
746///
747/// Canonical facts should use typed fact helpers; this helper is reserved for
748/// annotations that are useful for diagnosis but are not part of the stable
749/// machine-readable error identity.
750pub(crate) fn check_status_in_with_details<I>(
751    status: cudf_sys::cudf_status_t,
752    operation: &'static str,
753    details: I,
754) -> Result<()>
755where
756    I: IntoIterator<Item = (&'static str, String)>,
757{
758    check_status_in(status, operation).map_err(|err| err.with_details(details))
759}
760
761fn leaf_code_for_interop_kind(kind: InteropErrorKind) -> ErrorCode {
762    match kind {
763        InteropErrorKind::NotFound => ErrorCode::ForeignInvalidArgument,
764        InteropErrorKind::UnsupportedType => ErrorCode::UnsupportedDtype,
765        InteropErrorKind::InvalidView => ErrorCode::ForeignInvalidArgument,
766        InteropErrorKind::ExecutionBindingUnavailable => ErrorCode::ExecutionBindingUnavailable,
767        InteropErrorKind::ReadinessUnavailable => ErrorCode::ReadinessUnavailable,
768        InteropErrorKind::UnsupportedLayout => ErrorCode::UnsupportedLayout,
769        InteropErrorKind::PlacementUnsupported | InteropErrorKind::ProfileUnsupported => {
770            ErrorCode::UnsupportedOperation
771        },
772        InteropErrorKind::MixedDeviceUnsupported => ErrorCode::CrossDeviceInputs,
773        InteropErrorKind::ExecutionBindingMismatch | InteropErrorKind::ReadinessMismatch => {
774            ErrorCode::RuntimeBindingMismatch
775        },
776    }
777}
778
779/// Translate an [`InteropError`] into a cudf [`Error`] at the
780/// caller-supplied cudf boundary `operation`.
781///
782/// The translated error always uses the caller-supplied cudf operation
783/// label as its anchor. Foreign metadata is preserved as follows:
784///
785/// - `operation` is the caller-supplied cudf constant, never the foreign
786///   layer's operation label.
787/// - The foreign layer's operation label goes into `details["foreign_operation"]`.
788/// - Foreign `called` breadcrumbs go into `details["foreign_called"]` (may
789///   repeat).
790/// - The inner `InteropError` is preserved as the standard source chain, so
791///   any cudf `Error` nested inside remains reachable via
792///   [`find_cudf_error_in_chain`].
793pub(crate) fn from_interop_in(error: InteropError, operation: &'static str) -> Error {
794    let code = leaf_code_for_interop_kind(error.kind());
795    let message = error.message().to_string();
796
797    let mut out = Error::new_with(code, operation, message);
798
799    for (key, value) in error.context() {
800        match *key {
801            "operation" => {
802                out = out.with_detail("foreign_operation", value.clone());
803            },
804            "called" => {
805                out = out.with_detail("foreign_called", value.clone());
806            },
807            other => {
808                out = attach_foreign_context_entry(out, other, value);
809            },
810        }
811    }
812
813    out.with_source(error)
814}
815
816/// Attach a foreign-context entry to `err`.
817///
818/// Canonical snake_case fact keys like `left_device`, `right_device`, and
819/// `type_id` are not allowed in `details`. Foreign context comes in as weakly
820/// typed `(key, String)` pairs, so this helper:
821///
822/// - Promotes recognised device-role breadcrumbs to [`DeviceIdFact`] facts
823///   when the value parses as an `i32`, falling back to a non-canonical
824///   `foreign_<role>_device_raw` detail otherwise.
825/// - Rewrites any other key that collides with a canonical snake_case fact
826///   name onto a `foreign_<name>` detail, preserving the diagnostic without
827///   violating the overlap rule.
828/// - Keeps every remaining breadcrumb on the detail path unchanged.
829fn attach_foreign_context_entry(err: Error, key: &'static str, value: &str) -> Error {
830    match foreign_device_role(key) {
831        Some(ForeignDeviceRole::Left) => {
832            attach_foreign_device_fact(err, FACT_LEFT_DEVICE, "foreign_left_device_raw", value)
833        },
834        Some(ForeignDeviceRole::Right) => {
835            attach_foreign_device_fact(err, FACT_RIGHT_DEVICE, "foreign_right_device_raw", value)
836        },
837        None => match foreign_context_detail_key(key) {
838            Some(rewritten) => err.with_detail(rewritten, value.to_string()),
839            None => err.with_detail(key, value.to_string()),
840        },
841    }
842}
843
844fn attach_foreign_device_fact(
845    err: Error,
846    fact_key: FactKey<DeviceIdFact>,
847    fallback_detail_key: &'static str,
848    value: &str,
849) -> Error {
850    match value.parse::<i32>() {
851        Ok(device_id) if err.fact_value(fact_key.name()).is_none() => err.with_fact(fact_key, DeviceIdFact(device_id)),
852        _ => err.with_detail(fallback_detail_key, value.to_string()),
853    }
854}
855
856#[derive(Debug, Clone, Copy, PartialEq, Eq)]
857enum ForeignDeviceRole {
858    Left,
859    Right,
860}
861
862/// Collapse the nine raw foreign device-role keys into canonical left/right
863/// roles.
864fn foreign_device_role(key: &str) -> Option<ForeignDeviceRole> {
865    match key {
866        "producer_device" | "expected_device" | "descriptor_device" | "release_device" => Some(ForeignDeviceRole::Left),
867        "runtime_device" | "actual_device" | "event_device" | "stream_device" | "consumer_device" => {
868            Some(ForeignDeviceRole::Right)
869        },
870        _ => None,
871    }
872}
873
874/// Rewrite any foreign context key whose string form collides with a
875/// canonical fact snake_case name. Returning `None` means the key is safe to
876/// keep as-is on the detail path.
877fn foreign_context_detail_key(key: &'static str) -> Option<&'static str> {
878    FactKeyName::from_snake_case(key)?;
879    Some(match key {
880        "column_name" => "foreign_column_name",
881        "column_index" => "foreign_column_index",
882        "expected_rows" => "foreign_expected_rows",
883        "actual_rows" => "foreign_actual_rows",
884        "expected_count" => "foreign_expected_count",
885        "actual_count" => "foreign_actual_count",
886        "left_type_id" => "foreign_left_type_id",
887        "right_type_id" => "foreign_right_type_id",
888        "left_device" => "foreign_left_device_raw",
889        "right_device" => "foreign_right_device_raw",
890        "table_row_count" => "foreign_table_row_count",
891        "table_column_count" => "foreign_table_column_count",
892        "keys_count" => "foreign_keys_count",
893        "distinct_keys_count" => "foreign_distinct_keys_count",
894        "type_id" => "foreign_type_id",
895        "execution_binding_id" => "foreign_execution_binding_id",
896        "validation_reason" => "foreign_validation_reason",
897        "validation_device" => "foreign_validation_device",
898        "validation_stream" => "foreign_validation_stream",
899        "control_plane_scalar_bytes" => "foreign_control_plane_scalar_bytes",
900        // Unreachable unless a new canonical key is added. The fallback avoids
901        // a panic inside the translator.
902        _ => "foreign_canonical_context",
903    })
904}
905
906/// Walk the `source()` chain looking for a nested cudf [`Error`].
907pub fn find_cudf_error_in_chain<'a>(error: &'a (dyn StdError + 'static)) -> Option<&'a Error> {
908    let mut current = Some(error);
909    while let Some(err) = current {
910        if let Some(cudf_error) = err.downcast_ref::<Error>() {
911            return Some(cudf_error);
912        }
913        current = err.source();
914    }
915    None
916}
917
918#[cfg(test)]
919mod size_overflow_tests {
920    use super::*;
921
922    #[test]
923    fn native_size_overflow_status_maps_to_permanent_resource_error() {
924        let error = error_for_ffi_status(
925            cudf_sys::CUDF_ERROR_SIZE_OVERFLOW,
926            "cudf::test.size_overflow",
927            "synthetic size overflow".to_string(),
928        );
929
930        assert_eq!(error.code(), ErrorCode::SizeOverflow);
931        assert_eq!(error.kind(), ErrorKind::ResourceExhausted);
932        assert_eq!(error.status(), ErrorStatus::Permanent);
933        assert!(!error.is_retryable());
934    }
935}
936
937#[cfg(test)]
938mod ffi_detail_tests {
939    use super::*;
940
941    #[test]
942    fn ffi_detail_is_retained_as_typed_error_payload() {
943        let detail = CudfErrorDetail {
944            status: cudf_sys::CUDF_ERROR_OUT_OF_MEMORY,
945            failure_source: CudfErrorFailureSource::ReservationLimit,
946            domain_id: 41,
947            member_id: 3,
948            generation: 9,
949            requested_bytes: 4096,
950            alignment: 256,
951            cuda_error: -1,
952            message: "query memory domain grant exceeded".to_string(),
953        };
954        let error = error_for_ffi_status_with_detail(
955            cudf_sys::CUDF_ERROR_OUT_OF_MEMORY,
956            "testing::ffi_detail",
957            detail.message.clone(),
958            Some(detail.clone()),
959        );
960
961        assert_eq!(error.code(), ErrorCode::OutOfMemory);
962        assert_eq!(error.ffi_detail(), Some(&detail));
963        assert!(error.diagnostic_string().contains("failure_source=RESERVATION_LIMIT"));
964    }
965}