1use std::collections::BTreeMap;
4use std::fmt;
5
6use arrow_schema::Schema;
7use serde::{Deserialize, Serialize};
8
9use super::ScandalError;
10use super::plan::PlanDescription;
11
12pub const EXECUTION_OPTIONS_KEY: &str = "spiral.execution.options";
14
15pub const PROFILE_METADATA_KEY: &str = "spiral.execution.profiling";
17
18pub use super::api::ExecutionOptions;
19
20#[expect(
21 clippy::derivable_impls,
22 reason = "Defaults are SDK behavior on a contract-generated request type"
23)]
24impl Default for ExecutionOptions {
25 fn default() -> Self {
26 Self {
27 profile: false,
28 caller: None,
29 partition: None,
30 }
31 }
32}
33
34impl ExecutionOptions {
35 pub fn with_schema(&self, schema: &Schema) -> Result<Schema, ScandalError> {
37 self.validate()?;
38 let mut metadata = schema.metadata().clone();
39 metadata.insert(
40 EXECUTION_OPTIONS_KEY.into(),
41 serde_json::to_string(self).map_err(invalid)?,
42 );
43 Ok(schema.clone().with_metadata(metadata))
44 }
45
46 pub fn from_schema(schema: &Schema) -> Result<Self, ScandalError> {
48 let Some(value) = schema.metadata().get(EXECUTION_OPTIONS_KEY) else {
49 return Ok(Self::default());
50 };
51 if value.len() > 1024 {
52 return Err(invalid("execution options exceed 1 KiB"));
53 }
54 let options: Self = serde_json::from_str(value).map_err(invalid)?;
55 options.validate()?;
56 Ok(options)
57 }
58
59 pub(crate) fn validate(&self) -> Result<(), ScandalError> {
60 if self.caller.as_ref().is_some_and(|caller| {
61 caller.len() > 128
62 || !caller
63 .bytes()
64 .all(|c| c.is_ascii_alphanumeric() || b"-_.:".contains(&c))
65 }) {
66 return Err(invalid(
67 "execution caller must be an opaque identifier of at most 128 ASCII characters",
68 ));
69 }
70 Ok(())
71 }
72}
73
74#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct NodeProfile {
79 pub admission_wait_ns: Option<u64>,
81 pub measured: bool,
83 pub calls: u64,
85 pub input_rows: u64,
87 pub output_rows: u64,
89 pub input_bytes: u64,
91 pub output_bytes: u64,
93 pub elapsed_ns: u64,
95 pub ordered_wait_ns: u64,
97 pub io_calls: Option<u64>,
99 pub io_bytes: Option<u64>,
101 pub io_elapsed_ns: Option<u64>,
103 pub copied_bytes: Option<u64>,
105 pub device_ns: Option<u64>,
107 pub synchronization_ns: Option<u64>,
109 pub sessions_created: Option<u64>,
111 pub sessions_reused: Option<u64>,
113}
114
115#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub struct ExecutionProfile {
119 pub status: String,
121 pub operation: String,
123 pub caller: Option<String>,
125 pub partition: Option<u32>,
127 pub nodes: BTreeMap<String, NodeProfile>,
129 pub omitted_nodes: u64,
131 pub delivery_omissions: u64,
133 pub input_wait_ns: u64,
135 pub consumer_wait_ns: u64,
137 pub peak_in_flight_batches: Option<u64>,
139 pub peak_input_buffer_bytes: Option<u64>,
141 pub peak_delivery_reserved_bytes: Option<u64>,
143 pub peak_device_reserved_bytes: Option<u64>,
145 pub admission_wait_ns: Option<u64>,
147 pub failed_node: Option<String>,
149 pub error_code: Option<String>,
151 pub consumer_cancelled: bool,
153}
154
155impl fmt::Display for ExecutionProfile {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 write!(
158 f,
159 "analyze status={} operation={} input_wait_ns={} consumer_wait_ns={} peak_in_flight={} peak_input_buffer_bytes={}",
160 self.status,
161 self.operation,
162 self.input_wait_ns,
163 self.consumer_wait_ns,
164 self.peak_in_flight_batches
165 .map_or_else(|| "unknown".into(), |n| n.to_string()),
166 self.peak_input_buffer_bytes
167 .map_or_else(|| "unknown".into(), |n| n.to_string())
168 )?;
169 if let Some(caller) = &self.caller {
170 write!(f, " caller={caller}")?;
171 }
172 if let Some(partition) = self.partition {
173 write!(f, " partition={partition}")?;
174 }
175 if self.delivery_omissions > 0 {
176 write!(f, " delivery_omissions={}", self.delivery_omissions)?;
177 }
178 for (id, node) in &self.nodes {
179 if !node.measured {
180 write!(f, "\n {id} measurements=unknown")?;
181 continue;
182 }
183 write!(
184 f,
185 "\n {id} calls={} rows={}->{} bytes={}->{} host_elapsed_ns={} ordered_wait_ns={} device_ns={}",
186 node.calls,
187 node.input_rows,
188 node.output_rows,
189 node.input_bytes,
190 node.output_bytes,
191 node.elapsed_ns,
192 node.ordered_wait_ns,
193 node.device_ns
194 .map_or_else(|| "unknown".into(), |n| n.to_string())
195 )?;
196 if let Some(wait) = node.admission_wait_ns {
197 write!(f, " admission_wait_ns={wait}")?;
198 }
199 if let Some(calls) = node.io_calls {
200 write!(
201 f,
202 " io_calls={calls} io_bytes={} io_elapsed_ns={}",
203 node.io_bytes.unwrap_or(0),
204 node.io_elapsed_ns.unwrap_or(0)
205 )?;
206 }
207 }
208 if let Some(node) = &self.failed_node {
209 write!(f, "\n failed_node={node}")?;
210 }
211 Ok(())
212 }
213}
214
215fn invalid(error: impl fmt::Display) -> ScandalError {
216 ScandalError::InvalidArgument {
217 message: error.to_string(),
218 }
219}
220
221pub fn execution_error(plan: &PlanDescription) -> Option<ScandalError> {
223 let profile = plan.profile.as_ref().filter(|p| p.status == "failed")?;
224 let node = profile.failed_node.as_deref().unwrap_or("input");
225 let outputs = plan
226 .logical
227 .iter()
228 .filter_map(|n| n.properties.get("output"))
229 .cloned()
230 .collect::<Vec<_>>()
231 .join(",");
232 let message = format!(
233 "SPQL execution={} plan={} node={node} outputs=[{outputs}] failed",
234 plan.execution_id.as_deref().unwrap_or("unknown"),
235 plan.physical_id
236 );
237 Some(match profile.error_code.as_deref() {
238 Some("budget_exceeded") => ScandalError::BudgetExceeded { message },
239 Some("invalid_argument") => ScandalError::InvalidArgument { message },
240 Some("conflict") => ScandalError::Conflict { message },
241 Some("storage_unavailable") => ScandalError::StorageUnavailable { message },
242 Some("unavailable") => ScandalError::Unavailable { message },
243 Some("unauthenticated") => ScandalError::Unauthenticated { message },
244 Some("permission_denied") => ScandalError::PermissionDenied {
245 operation: "execute".into(),
246 resource: message,
247 },
248 Some("not_found") => ScandalError::NotFound { resource: message },
249 _ => ScandalError::Internal { message },
250 })
251}