Skip to main content

algeon_datafusion/
error.rs

1//! One error taxonomy for the DataFusion adapter boundary.
2//!
3//! Adapter errors classify failures from DataFusion planning/execution,
4//! native execution, cuDF calls made directly by the adapter, and native
5//! engine errors wrapped at the adapter boundary. The stable
6//! machine-readable [`ErrorCode`] is the contract; the human message is
7//! diagnostic only. Native errors keep their original classification in
8//! `native_*` facts while this layer maps them into adapter-level
9//! [`ErrorCode`] values suitable for DataFusion.
10//!
11//! The boundary rules this module enforces are owned by
12//! `website/docs/design/errors.mdx`:
13//!
14//! - An [`Error`] crosses into DataFusion once, as `DataFusionError::External`,
15//!   through `From`. There is no conversion back; `find_algeon_error` walks
16//!   whatever DataFusion wrapped around it.
17//! - A native `QueryError` is converted when it reaches the adapter, through
18//!   `from_native`. `kind` and `status` are derived from `code`, never chosen
19//!   per site.
20//! - A candidate decline is a report outcome, never an error from here.
21//! - Facts are raw unless a producer opts them into the log-safe projection.
22//!   Caller schema names travel as [`ClientDetail`], outside `message()`,
23//!   logs and `Debug`, because the Flight server logs the message verbatim.
24//! - Ordinary DataFusion errors keep their identity; `classify` assigns them a
25//!   server code without making them Algeon errors.
26
27use std::{
28    collections::{BTreeMap, BTreeSet},
29    error::Error as StdError,
30    fmt,
31    sync::Arc,
32};
33
34use datafusion::{
35    common::{DataFusionError, SchemaError},
36    logical_expr::{DdlStatement, LogicalPlan},
37    object_store::Error as ObjectStoreError,
38};
39use query_engine::object_store_error::query_error_code_for_object_store_error;
40use query_planning::error::QueryError;
41use query_runtime::native_error::{cudf_message_fact, query_error_code_for_cudf_error};
42
43pub(crate) mod client;
44mod code;
45
46use code::code_for_native_error;
47pub use code::{ErrorCode, ErrorKind, ErrorStatus};
48
49type BoxedSourceError = Box<dyn StdError + Send + Sync + 'static>;
50
51const NATIVE_FACT_COLLISION_KEYS: &str = "native_fact_collision_keys";
52/// Byte counts of a native memory failure. Numeric values contain no caller
53/// text and are included in retained query diagnostics.
54const NATIVE_MEMORY_FACT_KEYS: [&str; 4] =
55    ["requested_allocation_bytes", "budget_bytes", "budget_peak_bytes", "budget_current_bytes"];
56const FACT_COLLISION_KEYS: &str = "fact_collision_keys";
57const EMPTY_FACT_KEY: &str = "<empty>";
58const FALLBACK_OPERATION: &str = "algeon_datafusion.error.unspecified_operation";
59const FALLBACK_MESSAGE: &str = "Algeon DataFusion adapter error without a message";
60
61/// Adapter result type used before conversion to `DataFusionError`.
62pub type Result<T> = std::result::Result<T, Error>;
63
64const MAX_SOURCE_CHAIN_DEPTH: usize = 32;
65const MAX_SOURCE_CHAIN_MESSAGE_BYTES: usize = 512;
66const MAX_SOURCE_CHAIN_BYTES: usize = 2_048;
67const MAX_LOG_CAUSE_BYTES: usize = 512;
68const MAX_LOG_SOURCE_PATH_BYTES: usize = 1_024;
69const MAX_ALGEON_ERROR_TRAVERSAL_DEPTH: usize = 64;
70const MAX_ALGEON_ERROR_TRAVERSAL_NODES: usize = 256;
71const MAX_LOG_SAFE_FACT_COUNT: usize = 16;
72const MAX_LOG_SAFE_FACT_VALUE_BYTES: usize = 256;
73const MAX_LOG_SAFE_FACTS_RENDERED_BYTES: usize = 2_048;
74pub(crate) const ERROR_FACTS_TRUNCATED_KEY: &str = "error_facts_truncated";
75
76/// Caller schema detail an error names.
77///
78/// Because these values contain caller column identifiers and schema types
79/// (nested types print field names), they are excluded from operational logs,
80/// [`fmt::Debug`], and [`Error::message`]. The Flight status text, the Flight
81/// metadata allowlist, and query-stats capture read them from here.
82#[derive(Clone, PartialEq, Eq)]
83pub enum ClientDetail {
84    /// A column whose Arrow type the configured GPU execution requirement
85    /// cannot execute.
86    UnsupportedColumn {
87        /// Caller column identifier rejected by the execution requirement.
88        name: String,
89        /// Display form of the column's Arrow type, including nested field names.
90        data_type: String,
91        /// Semantic input role used in client-visible diagnostic facts.
92        role: &'static str,
93    },
94    /// A relation field whose Arrow type a GPU function rejects.
95    RelationField {
96        /// Caller relation-field identifier rejected by a GPU function.
97        field: String,
98        /// Display form of the field's Arrow type.
99        data_type: String,
100    },
101    /// A field a rejection names: absent from its relation or named by
102    /// native validation.
103    Field {
104        /// Caller field identifier named by the rejection.
105        name: String,
106    },
107    /// A column native validation or execution names.
108    Column {
109        /// Caller column identifier named by native validation or execution.
110        name: String,
111    },
112    /// An input ORDER BY key that is not an output column of its input.
113    OrderingKey {
114        /// ORDER BY expression that could not bind to the input's output columns.
115        name: String,
116    },
117    /// The plan element that keeps the configured GPU execution requirement
118    /// from holding, as one line that can quote the caller's SQL and
119    /// identifiers.
120    BlockingSubject {
121        /// Client-visible plan-element description, which may include SQL literals and identifiers.
122        text: String,
123    },
124}
125
126impl ClientDetail {
127    /// The detail as wire facts, under the keys query stats and Flight
128    /// metadata carry.
129    pub fn entries(&self) -> Vec<(&'static str, &str)> {
130        match self {
131            Self::UnsupportedColumn { name, data_type, role } => vec![
132                ("unsupported_column", name.as_str()),
133                ("unsupported_column_type", data_type.as_str()),
134                ("unsupported_column_role", role),
135            ],
136            Self::RelationField { field, data_type } => {
137                vec![("field", field.as_str()), ("actual_type", data_type.as_str())]
138            },
139            Self::Field { name } => vec![("field", name.as_str())],
140            Self::Column { name } => vec![("column", name.as_str())],
141            Self::OrderingKey { name } => vec![("key", name.as_str())],
142            Self::BlockingSubject { text } => vec![("blocking_subject", text.as_str())],
143        }
144    }
145
146    /// The wire fact `key` of this detail.
147    pub fn value(&self, key: &str) -> Option<&str> {
148        self.entries().into_iter().find_map(|(entry, value)| (entry == key).then_some(value))
149    }
150}
151
152impl fmt::Debug for ClientDetail {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        match self {
155            Self::UnsupportedColumn { role, .. } => {
156                f.debug_struct("UnsupportedColumn").field("role", role).finish_non_exhaustive()
157            },
158            Self::RelationField { .. } => f.debug_struct("RelationField").finish_non_exhaustive(),
159            Self::Field { .. } => f.debug_struct("Field").finish_non_exhaustive(),
160            Self::Column { .. } => f.debug_struct("Column").finish_non_exhaustive(),
161            Self::OrderingKey { .. } => f.debug_struct("OrderingKey").finish_non_exhaustive(),
162            Self::BlockingSubject { .. } => f.debug_struct("BlockingSubject").finish_non_exhaustive(),
163        }
164    }
165}
166
167/// DataFusion error inspection helpers provided by algeon-datafusion.
168pub trait DataFusionErrorExt {
169    /// Finds the first Algeon [`Error`] in this DataFusion error tree.
170    ///
171    /// Traversal is deterministic depth-first pre-order. Generic
172    /// [`std::error::Error::source`] links are followed, while every
173    /// [`DataFusionError::Collection`] branch is visited in collection order.
174    /// The first match in that order is returned. Traversal is bounded
175    /// defensively by both depth and total node count. A `None` result only
176    /// means no Algeon error was found within that budget; it does not prove
177    /// that branches beyond the budget contain no Algeon identity.
178    fn find_algeon_error(&self) -> Option<&Error>;
179}
180
181impl DataFusionErrorExt for DataFusionError {
182    fn find_algeon_error(&self) -> Option<&Error> {
183        let mut visited_nodes = 0;
184        find_algeon_error_in(self, 0, &mut visited_nodes)
185    }
186}
187
188fn find_algeon_error_in<'a>(
189    error: &'a (dyn StdError + 'static),
190    depth: usize,
191    visited_nodes: &mut usize,
192) -> Option<&'a Error> {
193    if *visited_nodes >= MAX_ALGEON_ERROR_TRAVERSAL_NODES {
194        return None;
195    }
196    *visited_nodes += 1;
197
198    if let Some(error) = error.downcast_ref::<Error>() {
199        return Some(error);
200    }
201    if depth >= MAX_ALGEON_ERROR_TRAVERSAL_DEPTH {
202        return None;
203    }
204
205    if let Some(DataFusionError::Collection(errors)) = error.downcast_ref::<DataFusionError>() {
206        return errors.iter().find_map(|error| find_algeon_error_in(error, depth + 1, visited_nodes));
207    }
208
209    error.source().and_then(|source| find_algeon_error_in(source, depth + 1, visited_nodes))
210}
211
212/// The statement whose DataFusion error [`classify`] maps.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub(crate) enum FailedStatement {
215    /// `DROP TABLE` or `DROP VIEW`. Neither reads table data, and DataFusion
216    /// 55 reports every failure of both, including a catalog that cannot
217    /// deregister the object, as its missing-object `Execution` error.
218    DropTableOrView,
219    /// Any other statement, or a failure before a statement was planned.
220    Other,
221}
222
223impl FailedStatement {
224    #[cfg_attr(
225        not(feature = "server"),
226        expect(dead_code, reason = "only the Flight statement boundary names a DDL statement")
227    )]
228    pub(crate) fn of(plan: &LogicalPlan) -> Self {
229        match plan {
230            LogicalPlan::Ddl(DdlStatement::DropTable(_) | DdlStatement::DropView(_)) => Self::DropTableOrView,
231            _ => Self::Other,
232        }
233    }
234}
235
236/// Classifies a DataFusion error into the adapter code the Flight server reports.
237///
238/// An Algeon error anywhere in the tree takes precedence. Otherwise a typed
239/// object-store error in the source chain maps to its source code, and the
240/// deepest DataFusion error reached decides classification alongside the failing
241/// statement. Traversal visits at most `MAX_SOURCE_CHAIN_DEPTH + 1` layers,
242/// ensuring deep wrapper trees yield a defined code.
243pub(crate) fn classify(error: &DataFusionError, statement: FailedStatement) -> ErrorCode {
244    if let Some(adapter) = error.find_algeon_error() {
245        return adapter.code();
246    }
247    let mut deepest = error;
248    let mut object_store = None;
249    let mut layer: Option<&(dyn StdError + 'static)> = Some(error);
250    for _ in 0..=MAX_SOURCE_CHAIN_DEPTH {
251        let Some(current) = layer else { break };
252        if let Some(datafusion) = current.downcast_ref::<DataFusionError>() {
253            deepest = datafusion;
254        } else if let Some(shared) = current.downcast_ref::<Arc<DataFusionError>>() {
255            deepest = shared.as_ref();
256        } else if object_store.is_none() {
257            object_store = current.downcast_ref::<ObjectStoreError>();
258        }
259        layer = current.source();
260    }
261    if let Some(object_store) = object_store {
262        return code_for_native_error(query_error_code_for_object_store_error(object_store));
263    }
264    match deepest {
265        DataFusionError::SQL(_, _) => ErrorCode::SqlParse,
266        DataFusionError::Plan(_) | DataFusionError::Configuration(_) => ErrorCode::DataFusionPlanning,
267        DataFusionError::SchemaError(schema, _) => match schema.as_ref() {
268            SchemaError::FieldNotFound { .. } => ErrorCode::FieldNotFound,
269            SchemaError::AmbiguousReference { .. }
270            | SchemaError::DuplicateQualifiedField { .. }
271            | SchemaError::DuplicateUnqualifiedField { .. } => ErrorCode::DataFusionPlanning,
272        },
273        DataFusionError::NotImplemented(_) => ErrorCode::UnsupportedOperator,
274        DataFusionError::ResourcesExhausted(_) => ErrorCode::HostMemoryExhausted,
275        DataFusionError::ExecutionJoin(join) if join.is_cancelled() => ErrorCode::Cancelled,
276        DataFusionError::Execution(_) if statement == FailedStatement::DropTableOrView => ErrorCode::ObjectNotFound,
277        // Other execution errors may include reader internals, so they stay
278        // behind the generic `Internal` client surface.
279        _ => ErrorCode::Internal,
280    }
281}
282
283/// Machine-readable facts attached to an [`Error`].
284///
285/// Fact keys are static strings so they can be treated as a documented contract
286/// by tests, reports, and callers. Values are stringified with [`ToString`].
287/// Iteration is sorted by key because facts are stored in a [`BTreeMap`].
288/// Raw lookup and iteration preserve every value. [`fmt::Debug`]
289/// exposes only facts explicitly approved through [`Error::with_log_safe_fact`],
290/// using the same sanitized and bounded projection as server observability.
291///
292/// Empty keys are ignored and duplicate inserts preserve the first value and
293/// disclosure choice. Rejected keys are reported deterministically under
294/// `fact_collision_keys`. The adapter reserves `cudf_*` for cuDF
295/// classification and `native_*` for wrapped `query-engine` errors;
296/// colliding native facts are reported under `native_fact_collision_keys`.
297#[derive(Clone, Default, PartialEq, Eq)]
298pub struct Facts {
299    values: BTreeMap<&'static str, FactValue>,
300    collision_keys: BTreeSet<&'static str>,
301}
302
303#[derive(Clone, PartialEq, Eq)]
304struct FactValue {
305    value: String,
306    log_safe: bool,
307}
308
309/// Sanitized, bounded facts approved for logs and retained diagnostics.
310pub struct LogSafeFactProjection {
311    entries: Vec<(&'static str, String)>,
312    truncated: bool,
313}
314
315impl Facts {
316    /// Creates an empty fact set.
317    pub fn new() -> Self {
318        Self::default()
319    }
320
321    /// Returns the string value for `key` when present.
322    pub fn get(&self, key: &str) -> Option<&str> {
323        self.values.get(key).map(|fact| fact.value.as_str())
324    }
325
326    /// Iterates facts in sorted key order.
327    pub fn iter(&self) -> impl Iterator<Item = (&'static str, &str)> {
328        self.values.iter().map(|(key, fact)| (*key, fact.value.as_str()))
329    }
330
331    fn insert(&mut self, key: &'static str, value: String) {
332        self.insert_with_log_safety(key, value, false);
333    }
334
335    /// Inserts a fact approved by the caller for log disclosure.
336    ///
337    /// Empty or duplicate keys follow [`Facts`] collision rules. The reserved
338    /// truncation marker is retained as raw data and cannot mark itself log-safe.
339    pub fn insert_log_safe(&mut self, key: &'static str, value: String) {
340        // The truncation marker is owned by the projection. A caller value under
341        // this key is stored raw and never marked log-safe. Such a value cannot
342        // spoof the synthetic retained marker.
343        self.insert_with_log_safety(key, value, key != ERROR_FACTS_TRUNCATED_KEY);
344    }
345
346    fn insert_with_log_safety(&mut self, key: &'static str, value: String, log_safe: bool) {
347        if key.is_empty() {
348            self.record_collision(EMPTY_FACT_KEY);
349            return;
350        }
351        if key == FACT_COLLISION_KEYS || self.values.contains_key(key) {
352            self.record_collision(key);
353            return;
354        }
355        self.values.insert(key, FactValue { value, log_safe });
356    }
357
358    fn record_collision(&mut self, key: &'static str) {
359        self.collision_keys.insert(key);
360        let value = self.collision_keys.iter().copied().collect::<Vec<_>>().join(",");
361        self.values.insert(FACT_COLLISION_KEYS, FactValue { value, log_safe: true });
362    }
363
364    /// Returns only disclosure-approved facts after sanitizing and applying count and byte bounds.
365    ///
366    /// The projection records truncation without modifying the stored raw facts.
367    pub fn log_safe_projection(&self) -> LogSafeFactProjection {
368        let safe_facts = self
369            .values
370            .iter()
371            .filter(|(key, fact)| **key != ERROR_FACTS_TRUNCATED_KEY && fact.log_safe)
372            .map(|(key, fact)| (*key, fact.value.as_str()))
373            .collect::<Vec<_>>();
374        let mut truncated = safe_facts.len() > MAX_LOG_SAFE_FACT_COUNT;
375        let mut entries = Vec::with_capacity(safe_facts.len().min(MAX_LOG_SAFE_FACT_COUNT));
376        let mut rendered_bytes = 0usize;
377
378        for (key, raw_value) in safe_facts.into_iter().take(MAX_LOG_SAFE_FACT_COUNT) {
379            let sanitized = sanitize_log_text(raw_value);
380            let (mut value, value_truncated) = truncate_with_marker(&sanitized, MAX_LOG_SAFE_FACT_VALUE_BYTES);
381            truncated |= value_truncated;
382
383            let separator_bytes = usize::from(!entries.is_empty()) * 2;
384            let fixed_bytes = separator_bytes.saturating_add(key.len()).saturating_add(1);
385            let Some(value_budget) = MAX_LOG_SAFE_FACTS_RENDERED_BYTES
386                .checked_sub(rendered_bytes)
387                .and_then(|remaining| remaining.checked_sub(fixed_bytes))
388            else {
389                truncated = true;
390                break;
391            };
392            let (total_capped_value, total_truncated) = truncate_with_marker(&value, value_budget);
393            value = total_capped_value;
394            truncated |= total_truncated;
395            rendered_bytes = rendered_bytes.saturating_add(fixed_bytes).saturating_add(value.len());
396            entries.push((key, value));
397            if total_truncated {
398                break;
399            }
400        }
401
402        LogSafeFactProjection { entries, truncated }
403    }
404
405    /// Retained entries for query-caller-visible capture (query stats).
406    ///
407    /// Combines the log-safe operational projection with the error's
408    /// [`ClientDetail`]. Detail values are raw caller schema identifiers: they
409    /// are sanitized and bounded the same way as log-safe values. Their presence
410    /// here discloses them to the query caller and does not claim log safety.
411    /// A single `error_facts_truncated` marker covers truncation in either
412    /// projection. Operational logs and [`fmt::Debug`] keep using
413    /// [`Self::log_safe_projection`] without the detail.
414    pub fn retained_capture_entries(&self, client_detail: Option<&ClientDetail>) -> Vec<(String, String)> {
415        let safe = self.log_safe_projection();
416        let mut truncated = safe.truncated;
417        let mut entries =
418            safe.entries.iter().map(|(key, value)| ((*key).to_string(), value.clone())).collect::<Vec<_>>();
419        for (key, raw) in client_detail.map(ClientDetail::entries).unwrap_or_default() {
420            let (value, value_truncated) = truncate_with_marker(&sanitize_log_text(raw), MAX_LOG_SAFE_FACT_VALUE_BYTES);
421            truncated |= value_truncated;
422            entries.push((key.to_string(), value));
423        }
424        if truncated {
425            entries.push((ERROR_FACTS_TRUNCATED_KEY.to_string(), "true".to_string()));
426        }
427        entries.sort_by(|left, right| left.0.cmp(&right.0));
428        entries
429    }
430}
431
432impl fmt::Debug for Facts {
433    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
434        let projection = self.log_safe_projection();
435        f.debug_struct("Facts")
436            .field("values", &projection.entries)
437            .field(ERROR_FACTS_TRUNCATED_KEY, &projection.truncated)
438            .finish()
439    }
440}
441
442impl LogSafeFactProjection {
443    /// Copies sanitized entries for retained diagnostics, adding
444    /// `error_facts_truncated=true` when any bound shortened the projection.
445    pub fn retained_entries(&self) -> Vec<(String, String)> {
446        let mut entries =
447            self.entries.iter().map(|(key, value)| ((*key).to_string(), value.clone())).collect::<Vec<_>>();
448        if self.truncated {
449            entries.push((ERROR_FACTS_TRUNCATED_KEY.to_string(), "true".to_string()));
450            entries.sort_by(|left, right| left.0.cmp(&right.0));
451        }
452        entries
453    }
454
455    /// Formats the sanitized entries as comma-separated `key=value` pairs.
456    ///
457    /// Returns `None` when no approved entries remain.
458    pub fn rendered(&self) -> Option<String> {
459        (!self.entries.is_empty())
460            .then(|| self.entries.iter().map(|(key, value)| format!("{key}={value}")).collect::<Vec<_>>().join(", "))
461    }
462
463    /// Returns whether sanitization bounds omitted or shortened any approved fact.
464    pub fn truncated(&self) -> bool {
465        self.truncated
466    }
467}
468
469fn sanitize_log_text(value: &str) -> String {
470    let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
471    if normalized.split_whitespace().any(is_credential_token) {
472        return "<redacted-credential>".to_string();
473    }
474    normalized
475        .split_whitespace()
476        .map(|token| if token.contains("://") { "<redacted-uri>" } else { token })
477        .collect::<Vec<_>>()
478        .join(" ")
479}
480
481fn is_credential_token(token: &str) -> bool {
482    let token = token
483        .trim_matches(|ch: char| matches!(ch, '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '"' | '\''))
484        .to_ascii_lowercase();
485    let key = token.split_once(['=', ':']).map_or(token.as_str(), |(key, _)| key);
486    let normalized_key = key.chars().filter(|ch| !matches!(ch, '-' | '_' | '.')).collect::<String>();
487    matches!(
488        normalized_key.as_str(),
489        "authorization"
490            | "proxyauthorization"
491            | "cookie"
492            | "setcookie"
493            | "password"
494            | "passwd"
495            | "apikey"
496            | "accesskey"
497            | "secret"
498            | "token"
499            | "clientsecret"
500            | "refreshtoken"
501            | "awssecretaccesskey"
502            | "secretaccesskey"
503    )
504}
505
506fn truncate_with_marker(value: &str, max_bytes: usize) -> (String, bool) {
507    if value.len() <= max_bytes {
508        return (value.to_string(), false);
509    }
510    const MARKER: &str = "...";
511    if max_bytes <= MARKER.len() {
512        return (MARKER[..max_bytes].to_string(), true);
513    }
514    let mut end = max_bytes - MARKER.len();
515    while !value.is_char_boundary(end) {
516        end -= 1;
517    }
518    (format!("{}{MARKER}", &value[..end]), true)
519}
520
521/// Structured adapter error with stable classification and facts.
522///
523/// [`fmt::Display`] renders only the human-facing [`Self::message`].
524/// Machine policy must use the typed accessors (`code`, `kind`, `status`,
525/// `operation`, fact lookups, `find_algeon_error`), never parsed text.
526/// [`fmt::Debug`] renders diagnostic text containing only log-safe facts and
527/// omits the underlying causal chain.
528pub struct Error {
529    code: ErrorCode,
530    operation: &'static str,
531    message: String,
532    facts: Facts,
533    /// Boxed so an `Error` without detail stays small; the detail is present
534    /// on strict rejections and schema validation failures.
535    client_detail: Option<Box<ClientDetail>>,
536    /// Set at most once; later `with_source` calls keep the first cause.
537    source: Option<BoxedSourceError>,
538}
539
540/// Human diagnostics derived from an error source chain.
541///
542/// Retained query diagnostics should keep machine-readable fields (`code`,
543/// `kind`, `status`, `operation`, facts) separate from human root-cause text.
544/// This formatter walks `std::error::Error::source()` and renders each project
545/// error by its human message. Structured `Display` output is not used. Each
546/// layer's text is capped at `MAX_SOURCE_CHAIN_MESSAGE_BYTES` and the joined
547/// chain at `MAX_SOURCE_CHAIN_BYTES`, both with `...` markers; the in-process
548/// error is never rewritten. Server-side logs should use [`ErrorLogDiagnostics`].
549#[derive(Debug, Clone, PartialEq, Eq)]
550pub struct ErrorSourceDiagnostics {
551    chain: Option<String>,
552    root_cause: Option<String>,
553}
554
555/// Bounded source diagnostics for privileged server-side logs.
556///
557/// This projection keeps only the deepest cause. Consumers that log the
558/// boundary error's message separately may omit an identical cause. Full
559/// source chains remain available through [`ErrorSourceDiagnostics`] for
560/// retained diagnostics.
561/// Local source paths are preserved for incident response; URI-shaped paths
562/// and credential-bearing messages remain redacted.
563#[derive(Debug, Clone, PartialEq, Eq)]
564pub struct ErrorLogDiagnostics {
565    cause: Option<String>,
566    source_path: Option<String>,
567    truncated: bool,
568}
569
570impl ErrorLogDiagnostics {
571    /// Builds log diagnostics from `error` and all of its sources.
572    ///
573    /// Each layer's text is truncated to the log cause budget before retention,
574    /// bounding rendered message sizes across arbitrary foreign `Display`
575    /// implementations. A foreign implementation can still spend memory
576    /// producing its own message; that cost cannot be bounded externally.
577    pub fn from_error(error: &(dyn StdError + 'static)) -> Self {
578        let mut current = Some(error);
579        let mut cause = None;
580        let mut diagnostics = Self { cause: None, source_path: None, truncated: false };
581        for _ in 0..MAX_SOURCE_CHAIN_DEPTH {
582            let Some(error) = current else {
583                break;
584            };
585            if diagnostics.source_path.is_none() {
586                diagnostics.set_source_path(diagnostic_source_path(error));
587            }
588            let (message, message_truncated) =
589                truncate_with_marker(&log_diagnostic_error_message(error), MAX_LOG_CAUSE_BYTES);
590            diagnostics.truncated |= message_truncated;
591            if !message.is_empty() {
592                cause = Some(message);
593            }
594            current = error.source();
595        }
596        let depth_truncated = current.is_some();
597        let (cause, message_truncated) = match cause {
598            Some(cause) => {
599                let (cause, truncated) = truncate_with_marker(&cause, MAX_LOG_CAUSE_BYTES);
600                (Some(cause), truncated)
601            },
602            None => (None, false),
603        };
604        diagnostics.cause = cause;
605        diagnostics.truncated |= depth_truncated || message_truncated;
606        diagnostics
607    }
608
609    /// Deepest bounded and redacted cause message.
610    pub fn cause(&self) -> Option<&str> {
611        self.cause.as_deref()
612    }
613
614    /// Input path associated with the failure, when present.
615    pub fn source_path(&self) -> Option<&str> {
616        self.source_path.as_deref()
617    }
618
619    /// Whether traversal or any projected diagnostic exceeded its log budget.
620    pub fn truncated(&self) -> bool {
621        self.truncated
622    }
623
624    fn set_source_path(&mut self, path: Option<&str>) {
625        let Some(path) = path.map(str::trim).filter(|path| !path.is_empty()) else {
626            return;
627        };
628        let path = if path.contains("://") { "<redacted-uri>" } else { path };
629        let (path, truncated) = truncate_with_marker(path, MAX_LOG_SOURCE_PATH_BYTES);
630        self.source_path = Some(path);
631        self.truncated |= truncated;
632    }
633}
634
635impl ErrorSourceDiagnostics {
636    /// Builds diagnostics from `error` and all of its sources.
637    pub fn from_error(error: &(dyn StdError + 'static)) -> Self {
638        Self::from_iter(Some(error))
639    }
640
641    /// Builds diagnostics from an optional source, excluding the wrapper that
642    /// already has explicit machine fields at the current boundary.
643    pub fn from_source(source: Option<&(dyn StdError + 'static)>) -> Self {
644        Self::from_iter(source)
645    }
646
647    /// Full source chain text, with adjacent duplicate messages removed.
648    pub fn chain(&self) -> Option<&str> {
649        self.chain.as_deref()
650    }
651
652    /// Deepest source message.
653    pub fn root_cause(&self) -> Option<&str> {
654        self.root_cause.as_deref()
655    }
656
657    /// Deepest source message when it adds information beyond the full chain.
658    pub fn root_cause_when_distinct(&self) -> Option<&str> {
659        match (self.chain(), self.root_cause()) {
660            (Some(chain), Some(root_cause)) if chain != root_cause => Some(root_cause),
661            _ => None,
662        }
663    }
664
665    fn from_iter(mut current: Option<&(dyn StdError + 'static)>) -> Self {
666        let mut messages = Vec::new();
667        let mut chain_bytes = 0usize;
668        let mut chain_dropped = false;
669        for _ in 0..MAX_SOURCE_CHAIN_DEPTH {
670            let Some(error) = current else {
671                break;
672            };
673            // Truncate each layer's own text before retaining it: an extreme
674            // but supported foreign `Display` must not make every projection
675            // unbounded. Markers keep the cut observable; the in-process error
676            // itself is never rewritten.
677            let (message, _) = truncate_with_marker(&diagnostic_error_message(error), MAX_SOURCE_CHAIN_MESSAGE_BYTES);
678            if !message.is_empty() && messages.last() != Some(&message) {
679                chain_bytes = chain_bytes.saturating_add(message.len());
680                if chain_bytes > MAX_SOURCE_CHAIN_BYTES {
681                    chain_dropped = true;
682                    break;
683                }
684                messages.push(message);
685            }
686            current = error.source();
687        }
688        let root_cause = messages.last().cloned();
689        let chain = (!messages.is_empty()).then(|| {
690            let joined = messages.join(": ");
691            let (mut capped, truncated) = truncate_with_marker(&joined, MAX_SOURCE_CHAIN_BYTES);
692            if chain_dropped && !truncated {
693                capped = truncate_with_marker(&format!("{capped} ..."), MAX_SOURCE_CHAIN_BYTES).0;
694            }
695            capped
696        });
697        Self { chain, root_cause }
698    }
699}
700
701impl Error {
702    /// Creates an adapter error with a stable operation id and message.
703    ///
704    /// `operation` should be a stable dotted identifier such as
705    /// `algeon_datafusion.native_exec.execute`. `message` is human-facing
706    /// diagnostic text. Add machine-readable context with [`Self::with_fact`].
707    pub fn new(code: ErrorCode, operation: &'static str, message: impl Into<String>) -> Self {
708        let message = message.into();
709        let operation = if operation.is_empty() { FALLBACK_OPERATION } else { operation };
710        let message = if message.is_empty() { FALLBACK_MESSAGE.to_string() } else { message };
711        Self { code, operation, message, facts: Facts::new(), client_detail: None, source: None }
712    }
713
714    /// Adds one machine-readable fact.
715    ///
716    /// `key` must be a non-empty static string and should be treated as part of
717    /// the diagnostic contract once exposed. `value` is stringified with
718    /// [`ToString`]. Empty keys are ignored. Duplicate keys preserve the first
719    /// value and are recorded under `fact_collision_keys`. Facts added here
720    /// remain raw and are hidden from logs, retained diagnostics, and
721    /// [`Facts`]'s [`fmt::Debug`] output unless the caller instead makes the explicit
722    /// [`Self::with_log_safe_fact`] disclosure choice.
723    #[must_use]
724    pub fn with_fact(mut self, key: &'static str, value: impl ToString) -> Self {
725        self.facts.insert(key, value.to_string());
726        self
727    }
728
729    /// Adds a fact that may be projected into logs and retained diagnostics.
730    ///
731    /// Opts a fact into log and diagnostic projection under the disclosure policy.
732    /// This method differs from [`Self::with_fact`] by permitting external visibility,
733    /// and callers must restrict usage to bounded operational classifications that omit
734    /// credentials, identifiers, paths, query literals, and sensitive payloads.
735    /// Projections apply defensive redaction for URI and credential-like tokens
736    /// alongside size limits, but sanitization does not replace careful classification.
737    /// External or arbitrary facts must use [`Self::with_fact`] to remain hidden by default.
738    ///
739    /// Caller schema details (for example strict-rejection column names) are
740    /// a [`ClientDetail`], never log-safe facts. The reserved
741    /// `error_facts_truncated` key remains available through raw typed lookup,
742    /// but cannot be opted into the projection; only the projection itself may
743    /// emit that retained marker.
744    /// Empty and duplicate keys follow [`Self::with_fact`]'s first-write
745    /// collision policy.
746    #[must_use]
747    pub fn with_log_safe_fact(mut self, key: &'static str, value: impl ToString) -> Self {
748        self.facts.insert_log_safe(key, value.to_string());
749        self
750    }
751
752    /// Attaches the caller schema detail this error names, when none is
753    /// attached yet.
754    #[must_use]
755    pub fn with_client_detail(mut self, detail: ClientDetail) -> Self {
756        self.client_detail.get_or_insert_with(|| Box::new(detail));
757        self
758    }
759
760    /// Replaces this error's fact set with an existing set, preserving its disclosure flags and collision evidence.
761    pub fn with_preserved_facts(mut self, facts: Facts) -> Self {
762        self.facts = facts;
763        self
764    }
765
766    /// Attaches the source error when no source is already present.
767    ///
768    /// A second source is discarded so error decoration cannot replace the
769    /// original causal chain.
770    #[must_use]
771    pub fn with_source<E>(mut self, source: E) -> Self
772    where E: StdError + Send + Sync + 'static {
773        if self.source.is_none() {
774            self.source = Some(Box::new(source));
775        }
776        self
777    }
778
779    /// Attaches an already boxed source error when no source is already present.
780    ///
781    /// A second source is discarded so error decoration cannot replace the
782    /// original causal chain.
783    #[must_use]
784    pub fn with_boxed_source(mut self, source: Box<dyn StdError + Send + Sync + 'static>) -> Self {
785        if self.source.is_none() {
786            self.source = Some(source);
787        }
788        self
789    }
790
791    /// Wraps a `cudf::Error` at the adapter boundary.
792    ///
793    /// `operation` should be the stable dotted id for the adapter operation that
794    /// called cuDF, and `message` should describe the failure for humans. cuDF
795    /// classification and a URI-redacted message are preserved as `cudf_*`
796    /// facts.
797    pub fn from_cudf(operation: &'static str, message: impl Into<String>, source: cudf::Error) -> Self {
798        // Server I/O lifecycle wraps cuDF errors without a native QueryError. The
799        // adapter derives the code from the runtime's single cuDF classification,
800        // so only one mapping table exists.
801        let code = code_for_native_error(query_error_code_for_cudf_error(&source));
802        let mut error = Self::new(code, operation, message)
803            .with_log_safe_fact("cudf_code", source.code())
804            .with_log_safe_fact("cudf_kind", source.kind())
805            .with_log_safe_fact("cudf_status", source.status())
806            .with_log_safe_fact("cudf_operation", source.operation());
807        if let Some(detail) = source.ffi_detail() {
808            error = error
809                .with_log_safe_fact("failure_source", detail.failure_source)
810                .with_log_safe_fact("requested_allocation_bytes", detail.requested_bytes)
811                .with_log_safe_fact("allocation_alignment", detail.alignment)
812                .with_log_safe_fact("cuda_status", detail.cuda_error);
813        }
814        match cudf_message_fact(&source) {
815            Some(cudf_message) => error.with_fact("cudf_message", cudf_message),
816            None => error,
817        }
818        .with_source(source)
819    }
820
821    /// Wraps a native-engine error at the DataFusion adapter boundary.
822    ///
823    /// The adapter maps the native code into the closest [`ErrorCode`] and
824    /// preserves the native classification under reserved facts: `native_code`,
825    /// `native_kind`, `native_status`, `native_operation`, and the native
826    /// diagnostic text under `native_message`. Native facts are copied
827    /// unprefixed when they do not collide with adapter facts. If a native fact
828    /// would overwrite an adapter/native classification fact, the adapter
829    /// preserves the existing fact, skips the colliding native fact, and records
830    /// the skipped keys in `native_fact_collision_keys`.
831    ///
832    /// `operation` should be the stable dotted id for the adapter operation, and
833    /// `message` should be human-facing adapter context.
834    pub(crate) fn from_native(operation: &'static str, message: impl Into<String>, source: QueryError) -> Self {
835        let code = code_for_native_error(source.code());
836        let mut error = Self::new(code, operation, message)
837            .with_log_safe_fact("native_code", source.code())
838            .with_log_safe_fact("native_kind", source.kind())
839            .with_log_safe_fact("native_status", source.status())
840            .with_log_safe_fact("native_operation", source.operation())
841            .with_fact("native_message", source.message());
842        let mut collision_keys = Vec::new();
843        for (key, value) in source.wire_facts() {
844            if key == NATIVE_FACT_COLLISION_KEYS || error.facts.get(key).is_some() {
845                collision_keys.push(key);
846                continue;
847            }
848            let value = value.into_owned();
849            if NATIVE_MEMORY_FACT_KEYS.contains(&key) && value.parse::<u64>().is_ok() {
850                error.facts.insert_log_safe(key, value);
851            } else {
852                error.facts.insert(key, value);
853            }
854        }
855        if !collision_keys.is_empty() {
856            error.facts.insert_log_safe(NATIVE_FACT_COLLISION_KEYS, collision_keys.join(","));
857        }
858        error.with_source(source)
859    }
860
861    /// [`Self::from_native`] whose adapter message also carries the native
862    /// message and the allowlisted facts below.
863    ///
864    /// Used at boundaries whose consumers print only the outermost message
865    /// (backend construction, device probing, composed admission), preserving
866    /// the cause and actionable capacity numbers. Schema identifiers such as
867    /// column and field names are isolated into [`ClientDetail`].
868    pub(crate) fn from_native_with_blocking_diagnostics(
869        operation: &'static str,
870        message: impl Into<String>,
871        source: QueryError,
872    ) -> Self {
873        // `field` and `column` name the caller's schema, so they travel as
874        // client detail and stay out of the logged message.
875        const CLIENT_FACTS: &[&str] = &[
876            "device_ordinal",
877            "stage",
878            "phase",
879            "allocation_limit_bytes",
880            "physical_total_bytes",
881            "configured_limit_bytes",
882            "owned_capacity_bytes",
883            "usable_capacity_bytes",
884            "managed_capacity_bytes",
885            "backend_reserve_bytes",
886            "limit_bytes",
887            "fallback_bytes",
888            "schedule_reason",
889            "sort_rejection_reason",
890            "reason",
891            "validation_reason",
892            "relation_role",
893            "training_rows",
894            "non_finite_count",
895            "null_count",
896            "remedy",
897            "occurrence_id",
898            "node_id",
899            "input_node_id",
900            "left_rows",
901            "right_rows",
902            "output_rows",
903            "input_row_multiplicity",
904            "producer_row_count_upper_bound",
905            "consumer_row_count_upper_bound",
906            "max_compatible_managed_capacity_bytes",
907            "minimum_compatible_required_floor_bytes",
908            "minimum_compatible_dominant_owner",
909            "minimum_compatible_dominant_owner_role",
910            "minimum_compatible_dominant_owner_bytes",
911            "minimum_compatible_dominant_owner_occurrence",
912        ];
913        let mut message = message.into();
914        message.push_str(": ");
915        message.push_str(source.message());
916        let wire = source.wire_facts().collect::<BTreeMap<_, _>>();
917        let facts = CLIENT_FACTS
918            .iter()
919            .filter_map(|key| wire.get(key).map(|value| format!("{key}={value}")))
920            .collect::<Vec<_>>();
921        if !facts.is_empty() {
922            message.push_str(" [");
923            message.push_str(&facts.join(", "));
924            message.push(']');
925        }
926        let detail = match (wire.get("column"), wire.get("field")) {
927            (Some(column), _) => Some(ClientDetail::Column { name: column.to_string() }),
928            (None, Some(field)) => Some(ClientDetail::Field { name: field.to_string() }),
929            (None, None) => None,
930        };
931        let error = Self::from_native(operation, message, source);
932        match detail {
933            Some(detail) => error.with_client_detail(detail),
934            None => error,
935        }
936    }
937
938    /// Returns the stable machine-readable adapter error code.
939    pub fn code(&self) -> ErrorCode {
940        self.code
941    }
942
943    /// Returns the coarse category derived from [`Self::code`].
944    pub fn kind(&self) -> ErrorKind {
945        self.code.kind()
946    }
947
948    /// Returns the retry/remediation status derived from [`Self::code`].
949    pub fn status(&self) -> ErrorStatus {
950        self.code.status()
951    }
952
953    /// Returns the stable dotted operation id that produced the error.
954    pub fn operation(&self) -> &'static str {
955        self.operation
956    }
957
958    /// Returns the human-facing diagnostic message.
959    pub fn message(&self) -> &str {
960        &self.message
961    }
962
963    /// Returns the sorted machine-readable fact set.
964    pub fn facts(&self) -> &Facts {
965        &self.facts
966    }
967
968    /// Returns one fact value by key.
969    pub fn fact_value(&self, key: &str) -> Option<&str> {
970        self.facts.get(key)
971    }
972
973    /// Caller schema detail this error names.
974    pub fn client_detail(&self) -> Option<&ClientDetail> {
975        self.client_detail.as_deref()
976    }
977
978    /// Returns the underlying source error when one was attached.
979    pub fn source_error(&self) -> Option<&(dyn StdError + 'static)> {
980        self.source.as_deref().map(|source| source as &(dyn StdError + 'static))
981    }
982}
983
984impl fmt::Display for Error {
985    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
986        f.write_str(&self.message)
987    }
988}
989
990impl fmt::Debug for Error {
991    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
992        let source = self.source.as_ref().map(|_| "<source omitted>");
993        f.debug_struct("Error")
994            .field("code", &self.code)
995            .field("kind", &self.kind())
996            .field("status", &self.status())
997            .field("operation", &self.operation)
998            .field("message", &self.message)
999            .field("facts", &self.facts)
1000            .field("source", &source)
1001            .finish()
1002    }
1003}
1004
1005impl StdError for Error {
1006    fn source(&self) -> Option<&(dyn StdError + 'static)> {
1007        self.source_error()
1008    }
1009}
1010
1011// Adapter errors cross the DataFusion boundary as `External` so callers can
1012// downcast the boxed source back to `algeon_datafusion::Error` and inspect code,
1013// kind, status, operation, facts, and source.
1014impl From<Error> for DataFusionError {
1015    fn from(error: Error) -> Self {
1016        Self::External(Box::new(error))
1017    }
1018}
1019
1020fn diagnostic_error_message(error: &(dyn StdError + 'static)) -> String {
1021    if let Some(error) = error.downcast_ref::<Error>() {
1022        return normalize_diagnostic_message(error.message());
1023    }
1024    if let Some(error) = error.downcast_ref::<QueryError>() {
1025        return normalize_diagnostic_message(error.message());
1026    }
1027    if let Some(error) = error.downcast_ref::<DataFusionError>() {
1028        return datafusion_error_human_message(error);
1029    }
1030    normalize_diagnostic_message(&error.to_string())
1031}
1032
1033fn log_diagnostic_error_message(error: &(dyn StdError + 'static)) -> String {
1034    let message = if let Some(error) = error.downcast_ref::<cudf::Error>() {
1035        normalize_diagnostic_message(error.message())
1036    } else {
1037        diagnostic_error_message(error)
1038    };
1039    sanitize_log_text(&message)
1040}
1041
1042fn diagnostic_source_path<'a>(error: &'a (dyn StdError + 'static)) -> Option<&'a str> {
1043    if let Some(error) = error.downcast_ref::<Error>() {
1044        return error.fact_value("path");
1045    }
1046    error.downcast_ref::<QueryError>().and_then(|error| error.fact_value("path"))
1047}
1048
1049/// Returns human-facing text for the current DataFusion error layer.
1050///
1051/// DataFusion renders SQL parser errors with `Debug` in
1052/// [`DataFusionError::message`]. This extractor uses the parser's `Display`
1053/// representation so Rust enum syntax does not leak into diagnostics.
1054pub fn datafusion_error_human_message(error: &DataFusionError) -> String {
1055    match error {
1056        DataFusionError::Context(message, _) => normalize_diagnostic_message(message),
1057        DataFusionError::Diagnostic(diagnostic, _) => normalize_diagnostic_message(&diagnostic.message),
1058        DataFusionError::External(source) => diagnostic_error_message(source.as_ref()),
1059        DataFusionError::SQL(parser, _) => normalize_diagnostic_message(&parser.to_string()),
1060        DataFusionError::Collection(errors) => match errors.first() {
1061            Some(first) => {
1062                let first = datafusion_error_human_message(first);
1063                normalize_diagnostic_message(&format!("DataFusion error collection ({} errors): {first}", errors.len()))
1064            },
1065            None => "DataFusion error collection (0 errors)".to_string(),
1066        },
1067        DataFusionError::Shared(source) => datafusion_error_human_message(source.as_ref()),
1068        _ => normalize_diagnostic_message(&error.message()),
1069    }
1070}
1071
1072fn normalize_diagnostic_message(message: &str) -> String {
1073    message.split_whitespace().collect::<Vec<_>>().join(" ")
1074}
1075
1076#[cfg(test)]
1077mod tests;