Skip to main content

query_planning/
triage.rs

1//! Shared triage vocabulary and compact diagnostic summaries.
2//!
3//! Planning, the runtime, and the adapter report all emit the same codes,
4//! verdicts, and severities, so one JSON consumer can read a failure from any
5//! of them. Every `as_str` label is a wire contract; the mapping from
6//! [`QueryErrorCode`] to [`TriageCode`] is owned by the error module.
7
8use serde_json::{Value, json};
9
10use crate::{
11    correlation::{CompiledTaskId, PlanNodeId, QueryId},
12    error::{QueryError, QueryErrorCode, triage_code_for_query_error},
13};
14
15/// Shared symptom/cause code used by planning, runtime, and report JSON.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17#[non_exhaustive]
18pub enum TriageCode {
19    /// Execution ended after a cancellation request.
20    Cancelled,
21    /// A supported native candidate was left on DataFusion by cost policy.
22    NotSelectedByCost,
23    /// The physical plan shape cannot be lowered as a native candidate.
24    UnsupportedPlanShape,
25    /// An operation has no supported native implementation.
26    UnsupportedOperator,
27    /// An expression cannot be evaluated by the native backend.
28    UnsupportedExpression,
29    /// A dtype or physical representation is unsupported.
30    UnsupportedType,
31    /// The source cannot satisfy the native reader contract.
32    UnsupportedSource,
33    /// Caller-supplied data or configuration violates a required constraint.
34    InvalidInput,
35    /// The plan violates an execution or IR invariant.
36    InvalidPlan,
37    /// Capability validation prevents bounded native execution.
38    CapabilityEnvelopeRejected,
39    /// Admission could not reserve the requested memory capacity.
40    MemoryReservationDenied,
41    /// Execution exhausted an enforced or available resource.
42    ResourceExhausted,
43    /// A bounded service queue or execution capacity is saturated.
44    ServiceOverloaded,
45    /// Source access or reading failed.
46    SourceFailure,
47    /// A native or external dependency reported a failure.
48    DependencyFailure,
49    /// Execution violated an ownership, provenance or consistency invariant.
50    InvariantViolation,
51    /// An internal failure has no more specific triage classification.
52    Internal,
53}
54
55impl TriageCode {
56    /// Stable snake-case triage code.
57    pub const fn as_str(self) -> &'static str {
58        match self {
59            Self::Cancelled => "cancelled",
60            Self::NotSelectedByCost => "not_selected_by_cost",
61            Self::UnsupportedPlanShape => "unsupported_plan_shape",
62            Self::UnsupportedOperator => "unsupported_operator",
63            Self::UnsupportedExpression => "unsupported_expression",
64            Self::UnsupportedType => "unsupported_type",
65            Self::UnsupportedSource => "unsupported_source",
66            Self::InvalidInput => "invalid_input",
67            Self::InvalidPlan => "invalid_plan",
68            // Compatibility wire label retained for existing report consumers.
69            Self::CapabilityEnvelopeRejected => "bounded_rejected",
70            Self::MemoryReservationDenied => "memory_reservation_denied",
71            Self::ResourceExhausted => "resource_exhausted",
72            Self::ServiceOverloaded => "service_overloaded",
73            Self::SourceFailure => "source_failure",
74            Self::DependencyFailure => "dependency_failure",
75            Self::InvariantViolation => "invariant_violation",
76            Self::Internal => "internal",
77        }
78    }
79}
80
81impl std::fmt::Display for TriageCode {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.write_str(self.as_str())
84    }
85}
86
87impl From<QueryErrorCode> for TriageCode {
88    fn from(code: QueryErrorCode) -> Self {
89        triage_code_for_query_error(code)
90    }
91}
92
93/// Query-level triage verdict.
94///
95/// `Failed` is reserved for a query that returned an error. A query that
96/// completed with warning signals is `Degraded`. A native candidate that ran on
97/// the CPU by cost is `NotSelected`, and that choice does not count as a
98/// failure.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub enum TriageVerdict {
101    /// The query completed without warning signals.
102    Healthy,
103    /// The query completed with warning signals.
104    Degraded,
105    /// The query returned an error.
106    Failed,
107    /// Cost policy retained CPU execution for a native candidate.
108    NotSelected,
109}
110
111impl TriageVerdict {
112    /// Stable snake-case verdict label.
113    pub(crate) const fn as_str(self) -> &'static str {
114        match self {
115            Self::Healthy => "healthy",
116            Self::Degraded => "degraded",
117            Self::Failed => "failed",
118            Self::NotSelected => "not_selected",
119        }
120    }
121}
122
123impl std::fmt::Display for TriageVerdict {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.write_str(self.as_str())
126    }
127}
128
129/// Triage signal severity.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
131pub enum TriageSeverity {
132    /// Informational evidence that does not itself indicate a failure.
133    Info,
134    /// A condition requiring attention without a query error.
135    Warning,
136    /// A signal associated with a failed query.
137    Error,
138}
139
140impl TriageSeverity {
141    /// Stable snake-case severity label.
142    pub(crate) const fn as_str(self) -> &'static str {
143        match self {
144            Self::Info => "info",
145            Self::Warning => "warning",
146            Self::Error => "error",
147        }
148    }
149}
150
151impl std::fmt::Display for TriageSeverity {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        f.write_str(self.as_str())
154    }
155}
156
157/// JSON evidence pointer into one diagnostic surface.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct TriageEvidenceRef {
160    pub(crate) query_id: Option<QueryId>,
161    pub(crate) plan_node_id: Option<PlanNodeId>,
162    pub(crate) compiled_task_id: Option<CompiledTaskId>,
163    pub(crate) surface: &'static str,
164    pub(crate) row_index: Option<usize>,
165}
166
167impl TriageEvidenceRef {
168    /// Builds an evidence reference for a named diagnostic surface.
169    pub fn new(surface: &'static str) -> Self {
170        Self { query_id: None, plan_node_id: None, compiled_task_id: None, surface, row_index: None }
171    }
172
173    /// Adds a query id.
174    pub fn with_query_id(mut self, query_id: impl Into<QueryId>) -> Self {
175        self.query_id = Some(query_id.into());
176        self
177    }
178
179    /// Adds a plan node id.
180    pub fn with_plan_node_id(mut self, plan_node_id: impl Into<PlanNodeId>) -> Self {
181        self.plan_node_id = Some(plan_node_id.into());
182        self
183    }
184
185    pub(crate) fn with_compiled_task_id(mut self, compiled_task_id: impl Into<CompiledTaskId>) -> Self {
186        self.compiled_task_id = Some(compiled_task_id.into());
187        self
188    }
189
190    /// Adds a row index inside the named surface.
191    pub fn with_row_index(mut self, row_index: usize) -> Self {
192        self.row_index = Some(row_index);
193        self
194    }
195
196    /// Serializes the evidence reference as named JSON.
197    pub(crate) fn to_json(&self) -> Value {
198        json!({
199            "query_id": self.query_id.as_ref().map(QueryId::as_str),
200            "plan_node_id": self.plan_node_id.as_ref().map(PlanNodeId::as_str),
201            "compiled_task_id": self.compiled_task_id.as_ref().map(CompiledTaskId::as_str),
202            "surface": self.surface,
203            "row_index": self.row_index,
204        })
205    }
206}
207
208/// One ranked triage signal.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct TriageSignal {
211    pub(crate) severity: TriageSeverity,
212    /// Machine-readable cause shared across planning and runtime diagnostics.
213    pub code: TriageCode,
214    pub(crate) message: String,
215    pub(crate) evidence: Option<TriageEvidenceRef>,
216}
217
218impl TriageSignal {
219    /// Builds a triage signal.
220    pub fn new(severity: TriageSeverity, code: TriageCode, message: impl Into<String>) -> Self {
221        Self { severity, code, message: message.into(), evidence: None }
222    }
223
224    /// Adds an evidence pointer.
225    pub fn with_evidence(mut self, evidence: TriageEvidenceRef) -> Self {
226        self.evidence = Some(evidence);
227        self
228    }
229
230    /// Serializes this signal as named JSON.
231    pub(crate) fn to_json(&self) -> Value {
232        json!({
233            "severity": self.severity.as_str(),
234            "code": self.code.as_str(),
235            "message": self.message,
236            "evidence": self.evidence.as_ref().map(TriageEvidenceRef::to_json),
237        })
238    }
239}
240
241/// Compact top-level diagnostic summary.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct TriageSummary {
244    /// Overall query outcome represented by this summary.
245    pub verdict: TriageVerdict,
246    /// Ordered diagnostic signals contributing to the summary.
247    pub signals: Vec<TriageSignal>,
248}
249
250impl TriageSummary {
251    /// Builds a summary from an explicit verdict and signal list.
252    ///
253    /// Signals are ordered by descending severity, and the sort is stable, so
254    /// signals of equal severity keep the order the caller supplied.
255    pub fn new(verdict: TriageVerdict, mut signals: Vec<TriageSignal>) -> Self {
256        signals.sort_by_key(|signal| std::cmp::Reverse(signal.severity));
257        Self { verdict, signals }
258    }
259
260    /// Builds a healthy empty summary.
261    pub fn healthy() -> Self {
262        Self::new(TriageVerdict::Healthy, Vec::new())
263    }
264
265    /// Serializes this summary as named JSON.
266    pub fn to_json(&self) -> Value {
267        json!({
268            "verdict": self.verdict.as_str(),
269            "signals": self.signals.iter().map(TriageSignal::to_json).collect::<Vec<_>>(),
270        })
271    }
272}
273
274impl QueryError {
275    /// Builds a compact triage summary for this error.
276    ///
277    /// Structured context takes precedence over facts. The error's own query id
278    /// and boundary node id are used first, and the `query_id` and `node_id`
279    /// facts fill in only when the context lacks them. The `compiled_task_id`
280    /// fact is used whenever it is present.
281    pub fn triage_summary(&self) -> TriageSummary {
282        let mut evidence = TriageEvidenceRef::new("query_error");
283        if let Some(query_id) = self.query_id().cloned().or_else(|| self.fact_value("query_id").map(QueryId::new)) {
284            evidence = evidence.with_query_id(query_id);
285        }
286        if let Some(node_id) = self.context().boundary.and_then(|boundary| boundary.node_id) {
287            evidence = evidence.with_plan_node_id(node_id);
288        } else if let Some(node_id) = self.fact_value("node_id") {
289            evidence = evidence.with_plan_node_id(node_id);
290        }
291        if let Some(compiled_task_id) = self.fact_value("compiled_task_id") {
292            evidence = evidence.with_compiled_task_id(compiled_task_id);
293        }
294        let signal = TriageSignal::new(TriageSeverity::Error, self.code().into(), self.message().to_string())
295            .with_evidence(evidence);
296        TriageSummary::new(TriageVerdict::Failed, vec![signal])
297    }
298}