datafusion_openlineage/exec.rs
1//! Physical-plan wrapper that emits COMPLETE / FAIL at end of execution.
2//!
3//! [`crate::rule::OpenLineageQueryPlanner`] emits START at plan time and carries
4//! a COMPLETE template through the plan in a [`crate::rule::LineageMarker`];
5//! [`crate::rule::LineageExtensionPlanner`] lowers that marker into an
6//! [`OpenLineageExec`] at the physical root. This node observes the result
7//! streams and emits exactly one terminal event once every output partition has
8//! finished:
9//!
10//! - COMPLETE when all partitions drain successfully;
11//! - FAIL (with an `errorMessage` run facet) if any partition yields an error
12//! or is dropped before its stream is exhausted (e.g. a cancelled query).
13//!
14//! Completion is tracked with a `Drop`-based guard so cancellation is handled
15//! without special-casing, and the terminal event fires under the *same*
16//! `runId` the START used.
17
18use std::any::Any;
19use std::fmt;
20use std::pin::Pin;
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
23use std::task::{Context, Poll};
24
25use chrono::Utc;
26use datafusion::arrow::array::RecordBatch;
27use datafusion::arrow::datatypes::SchemaRef;
28use datafusion::error::Result;
29use datafusion::execution::{RecordBatchStream, SendableRecordBatchStream, TaskContext};
30use datafusion::physical_plan::metrics::MetricsSet;
31use datafusion::physical_plan::{DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties};
32use futures::Stream;
33
34use crate::client::OpenLineageClient;
35use crate::event::{RunEvent, RunEventType};
36use crate::facets::{
37 BaseFacet, ErrorMessageRunFacet, InputStatisticsInputDatasetFacet,
38 OutputStatisticsOutputDatasetFacet,
39};
40
41const ERROR_FACET: &str = "1-0-1/ErrorMessageRunFacet.json";
42const OUTPUT_STATS_FACET: &str = "1-0-2/OutputStatisticsOutputDatasetFacet.json";
43const INPUT_STATS_FACET: &str = "1-0-0/InputStatisticsInputDatasetFacet.json";
44
45/// Shared completion state across the partitions of one query run.
46///
47/// The `Mutex` fields are locked with `.unwrap()`: the only way to poison one
48/// is to panic while holding it, which the short critical sections here never
49/// do (they touch plain data, never call back into user code). Recovering from
50/// poisoning is therefore intentionally not handled.
51struct RunState {
52 client: OpenLineageClient,
53 /// COMPLETE event template (cloned and mutated into FAIL on error).
54 complete: RunEvent,
55 producer: String,
56 /// The wrapped plan, read for native runtime metrics on completion. Tracked
57 /// through `with_new_children` rewrites so metrics come from the node that
58 /// actually executed.
59 inner: std::sync::Mutex<Arc<dyn ExecutionPlan>>,
60 /// Outstanding partitions yet to finish. Initialized lazily from the
61 /// executing node's partition count on the first `execute()` (see
62 /// [`RunState::init_partitions`]) so it stays correct across
63 /// `with_new_children` rewrites that change the partitioning.
64 remaining: AtomicUsize,
65 /// Guards one-time initialization of `remaining`.
66 init: std::sync::Once,
67 /// Set if any partition observed an error or was dropped early.
68 failed: AtomicBool,
69 /// First error message observed, for the FAIL facet.
70 error: std::sync::Mutex<Option<String>>,
71 /// Whether this run has an output dataset. The write-result `count`-batch
72 /// sniffing in [`TrackedStream`] only applies to writes, so it is gated on
73 /// this: a read whose result happens to be a single `UInt64` `count` column
74 /// must not be mistaken for a rows-written signal. Mirrors the
75 /// `outputs`-non-empty guard in [`RunState::attach_output_statistics`].
76 has_outputs: bool,
77 /// Rows written, summed from DataFusion's write-result `count` batches
78 /// (`Some` once any write count is observed).
79 rows_written: std::sync::Mutex<Option<u64>>,
80 /// Guards against emitting more than once (e.g. zero-partition plans).
81 emitted: AtomicBool,
82}
83
84impl RunState {
85 /// Initialize the outstanding-partition counter from the count of
86 /// partitions that will actually execute. Called on every `execute()`;
87 /// the `Once` ensures only the first call wins, so concurrent partition
88 /// executes observe a stable total. `count` is the executing node's
89 /// `output_partitioning().partition_count()`.
90 fn init_partitions(&self, count: usize) {
91 self.init.call_once(|| {
92 // A plan may report zero partitions; guard so we still emit once.
93 self.remaining.store(count.max(1), Ordering::SeqCst);
94 });
95 }
96
97 fn record_error(&self, message: String) {
98 self.failed.store(true, Ordering::SeqCst);
99 let mut slot = self.error.lock().unwrap();
100 if slot.is_none() {
101 *slot = Some(message);
102 }
103 }
104
105 /// Accumulate rows written, observed from a write-result `count` batch.
106 fn record_rows_written(&self, rows: u64) {
107 let mut slot = self.rows_written.lock().unwrap();
108 *slot = Some(slot.unwrap_or(0) + rows);
109 }
110
111 /// Mark one partition finished; emit the terminal event when the last one
112 /// completes. Safe to call once per partition (including from `Drop`).
113 fn partition_finished(&self) {
114 // `remaining` starts at the partition count; the partition that brings
115 // it to zero emits.
116 if self.remaining.fetch_sub(1, Ordering::SeqCst) != 1 {
117 return;
118 }
119 self.emit_terminal();
120 }
121
122 fn emit_terminal(&self) {
123 if self.emitted.swap(true, Ordering::SeqCst) {
124 return;
125 }
126 if self.failed.load(Ordering::SeqCst) {
127 let mut event = self.complete.clone();
128 event.event_type = RunEventType::Fail;
129 // The template's `eventTime` was set at plan time; refresh it to the
130 // moment execution actually ended so run duration is meaningful.
131 event.event_time = Utc::now().to_rfc3339();
132 let message = self
133 .error
134 .lock()
135 .unwrap()
136 .clone()
137 .unwrap_or_else(|| "query failed".to_string());
138 event.run.facets.error_message = Some(ErrorMessageRunFacet {
139 base: BaseFacet::new(&self.producer, ERROR_FACET),
140 message,
141 programming_language: "Rust".to_string(),
142 stack_trace: None,
143 });
144 self.client.emit(event);
145 } else {
146 let mut event = self.complete.clone();
147 // The template's `eventTime` was set at plan time; refresh it to the
148 // moment execution actually ended so run duration is meaningful.
149 event.event_time = Utc::now().to_rfc3339();
150 self.attach_output_statistics(&mut event);
151 self.attach_input_statistics(&mut event);
152 self.client.emit(event);
153 }
154 }
155
156 /// Attach an `outputStatistics` facet to each output dataset of the COMPLETE
157 /// event.
158 ///
159 /// The row count comes from DataFusion's write-result `count` batch (the
160 /// authoritative rows-written signal, captured as the stream drained). Size
161 /// is taken from a `bytes_scanned`-style plan metric when available. Reads
162 /// (SELECT) have no output dataset, so this is a no-op there.
163 fn attach_output_statistics(&self, event: &mut RunEvent) {
164 if event.outputs.is_empty() {
165 return;
166 }
167
168 let row_count = self.rows_written.lock().unwrap().map(|n| n as i64);
169
170 // `bytes_scanned` is the closest widely-emitted size metric; absent for
171 // many plans, in which case `size` is simply omitted.
172 let size = self
173 .inner
174 .lock()
175 .unwrap()
176 .metrics()
177 .and_then(|m| m.aggregate_by_name().sum_by_name("bytes_scanned"))
178 .map(|v| v.as_usize() as i64);
179
180 if row_count.is_none() && size.is_none() {
181 return;
182 }
183
184 let stats = OutputStatisticsOutputDatasetFacet {
185 base: BaseFacet::new(&self.producer, OUTPUT_STATS_FACET),
186 row_count,
187 size,
188 file_count: None,
189 };
190 for output in &mut event.outputs {
191 let facets = output.output_facets.get_or_insert_with(Default::default);
192 facets.output_statistics = Some(stats.clone());
193 }
194 }
195
196 /// Attach an `inputStatistics` facet to the input dataset — but only when
197 /// there is exactly ONE input.
198 ///
199 /// Scan metrics (`output_rows`, `bytes_scanned`) live on the per-node
200 /// `MetricsSet` of the scan nodes, not the root, so we walk the executed
201 /// plan tree and aggregate them. With a single input that aggregate is
202 /// unambiguously that dataset's read stats. With multiple inputs we cannot
203 /// attribute a summed total to the right source without matching each scan
204 /// node back to its dataset — which needs location-based dataset naming
205 /// (object-store URL + symlinks). That is deferred; see the design doc, so
206 /// we skip rather than emit a misleading aggregate.
207 fn attach_input_statistics(&self, event: &mut RunEvent) {
208 if event.inputs.len() != 1 {
209 return;
210 }
211
212 let inner = self.inner.lock().unwrap().clone();
213 let (rows, bytes) = aggregate_scan_metrics(&inner);
214 let row_count = rows.map(|n| n as i64);
215 let size = bytes.map(|n| n as i64);
216 if row_count.is_none() && size.is_none() {
217 return;
218 }
219
220 let stats = InputStatisticsInputDatasetFacet {
221 base: BaseFacet::new(&self.producer, INPUT_STATS_FACET),
222 row_count,
223 size,
224 file_count: None,
225 };
226 let facets = event.inputs[0]
227 .input_facets
228 .get_or_insert_with(Default::default);
229 facets.input_statistics = Some(stats);
230 }
231}
232
233/// Sum scan metrics across the whole executed plan tree.
234///
235/// `metrics()` is per-node, so we recurse. Returns aggregated
236/// (`output_rows`, `bytes_scanned`); either may be `None` if no node reported it.
237fn aggregate_scan_metrics(plan: &Arc<dyn ExecutionPlan>) -> (Option<usize>, Option<usize>) {
238 let mut rows: Option<usize> = None;
239 let mut bytes: Option<usize> = None;
240
241 if let Some(metrics) = plan.metrics() {
242 let metrics = metrics.aggregate_by_name();
243 // Only count rows from leaf scans, identified by a `bytes_scanned`
244 // metric; intermediate nodes also report `output_rows` and would
245 // double-count.
246 if let Some(b) = metrics.sum_by_name("bytes_scanned") {
247 *bytes.get_or_insert(0) += b.as_usize();
248 if let Some(r) = metrics.output_rows() {
249 *rows.get_or_insert(0) += r;
250 }
251 }
252 }
253
254 for child in plan.children() {
255 let (cr, cb) = aggregate_scan_metrics(child);
256 if let Some(r) = cr {
257 *rows.get_or_insert(0) += r;
258 }
259 if let Some(b) = cb {
260 *bytes.get_or_insert(0) += b;
261 }
262 }
263
264 (rows, bytes)
265}
266
267/// Wraps the root physical plan, emitting a terminal lineage event when
268/// execution finishes.
269pub struct OpenLineageExec {
270 inner: Arc<dyn ExecutionPlan>,
271 state: Arc<RunState>,
272}
273
274impl OpenLineageExec {
275 /// Wrap `inner`, emitting COMPLETE (or FAIL on error) once all partitions
276 /// finish. `complete` is the pre-built COMPLETE event (sharing the run id
277 /// used by START); `producer` builds the error facet on failure.
278 pub fn new(
279 inner: Arc<dyn ExecutionPlan>,
280 client: OpenLineageClient,
281 complete: RunEvent,
282 producer: String,
283 ) -> Arc<Self> {
284 let has_outputs = !complete.outputs.is_empty();
285 let state = Arc::new(RunState {
286 client,
287 complete,
288 producer,
289 has_outputs,
290 inner: std::sync::Mutex::new(inner.clone()),
291 // Initialized lazily on the first `execute()` from the partition
292 // count of the node that actually runs (which may differ from
293 // `inner` here after a `with_new_children` rewrite).
294 remaining: AtomicUsize::new(0),
295 init: std::sync::Once::new(),
296 failed: AtomicBool::new(false),
297 error: std::sync::Mutex::new(None),
298 rows_written: std::sync::Mutex::new(None),
299 emitted: AtomicBool::new(false),
300 });
301 Arc::new(Self { inner, state })
302 }
303
304 fn with_new_inner(&self, inner: Arc<dyn ExecutionPlan>) -> Arc<Self> {
305 // Keep the shared run state pointed at the node that will execute, so
306 // metrics are harvested from the right plan on completion.
307 *self.state.inner.lock().unwrap() = inner.clone();
308 Arc::new(Self {
309 inner,
310 state: self.state.clone(),
311 })
312 }
313}
314
315impl fmt::Debug for OpenLineageExec {
316 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317 f.debug_struct("OpenLineageExec").finish_non_exhaustive()
318 }
319}
320
321impl DisplayAs for OpenLineageExec {
322 fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323 match t {
324 DisplayFormatType::Default | DisplayFormatType::Verbose => {
325 write!(f, "OpenLineageExec")
326 }
327 DisplayFormatType::TreeRender => write!(f, "OpenLineageExec"),
328 }
329 }
330}
331
332impl ExecutionPlan for OpenLineageExec {
333 fn name(&self) -> &str {
334 "OpenLineageExec"
335 }
336
337 fn as_any(&self) -> &dyn Any {
338 // Return *this* wrapper, per the `ExecutionPlan::as_any` contract.
339 // Returning the inner plan's `as_any` let visitors downcast the wrapper
340 // to the inner type and rewrite its children directly, silently
341 // dropping this lineage node from the plan.
342 self
343 }
344
345 fn properties(&self) -> &Arc<PlanProperties> {
346 self.inner.properties()
347 }
348
349 fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
350 vec![&self.inner]
351 }
352
353 fn with_new_children(
354 self: Arc<Self>,
355 mut children: Vec<Arc<dyn ExecutionPlan>>,
356 ) -> Result<Arc<dyn ExecutionPlan>> {
357 // We wrap a single root; rewrap whatever child we're given so the node
358 // stays installed across optimizer/child rewrites.
359 let child = children.pop().unwrap_or_else(|| self.inner.clone());
360 Ok(self.with_new_inner(child))
361 }
362
363 fn metrics(&self) -> Option<MetricsSet> {
364 self.inner.metrics()
365 }
366
367 fn execute(
368 &self,
369 partition: usize,
370 context: Arc<TaskContext>,
371 ) -> Result<SendableRecordBatchStream> {
372 // Lazily fix the outstanding-partition count from the node that is
373 // actually executing, so `with_new_children` rewrites that change the
374 // partitioning don't desync the counter (reading `self.properties()`,
375 // which delegates to `inner`, rather than the count captured at
376 // construction). `init_partitions` floors the count at 1 so that if a
377 // plan reporting zero partitions is nonetheless executed, the terminal
378 // event still fires exactly once. (A zero-partition plan that is never
379 // executed emits nothing — correctly, there was no execution.)
380 self.state
381 .init_partitions(self.properties().output_partitioning().partition_count());
382
383 // An execute-time error (e.g. object-store auth / credential vending)
384 // means this partition's stream never exists, so its `TrackedStream`
385 // would never run its terminal path. Record the failure and settle the
386 // partition here before propagating, or the run is stuck RUNNING with
387 // no COMPLETE/FAIL ever emitted.
388 let inner = match self.inner.execute(partition, context) {
389 Ok(inner) => inner,
390 Err(err) => {
391 self.state.record_error(err.to_string());
392 self.state.partition_finished();
393 return Err(err);
394 }
395 };
396 Ok(Box::pin(TrackedStream {
397 schema: inner.schema(),
398 inner,
399 state: self.state.clone(),
400 done: false,
401 }))
402 }
403}
404
405/// Wraps a partition's stream, recording errors and signalling completion on
406/// terminal (exhaustion, error, or drop).
407struct TrackedStream {
408 schema: SchemaRef,
409 inner: SendableRecordBatchStream,
410 state: Arc<RunState>,
411 done: bool,
412}
413
414impl TrackedStream {
415 fn finish(&mut self) {
416 if !self.done {
417 self.done = true;
418 self.state.partition_finished();
419 }
420 }
421}
422
423impl Stream for TrackedStream {
424 type Item = Result<RecordBatch>;
425
426 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
427 match Pin::new(&mut self.inner).poll_next(cx) {
428 Poll::Ready(Some(Ok(batch))) => {
429 // Only sniff for a write-result `count` batch when this run
430 // actually writes (has an output dataset); otherwise a read
431 // returning a lone `UInt64 count` column would be misread as
432 // rows-written.
433 if self.state.has_outputs
434 && let Some(rows) = write_count(&batch)
435 {
436 self.state.record_rows_written(rows);
437 }
438 Poll::Ready(Some(Ok(batch)))
439 }
440 Poll::Ready(Some(Err(e))) => {
441 self.state.record_error(e.to_string());
442 self.finish();
443 Poll::Ready(Some(Err(e)))
444 }
445 Poll::Ready(None) => {
446 self.finish();
447 Poll::Ready(None)
448 }
449 Poll::Pending => Poll::Pending,
450 }
451 }
452}
453
454impl RecordBatchStream for TrackedStream {
455 fn schema(&self) -> SchemaRef {
456 self.schema.clone()
457 }
458}
459
460impl Drop for TrackedStream {
461 fn drop(&mut self) {
462 // A stream dropped before exhaustion means the partition was cancelled
463 // or abandoned: count it as a failure for the run.
464 if !self.done {
465 self.state
466 .record_error("query stream dropped before completion".to_string());
467 self.finish();
468 }
469 }
470}
471
472/// Recognize DataFusion's write-result batch — a single `count` UInt64 column
473/// whose value is the number of rows written — and return that count.
474fn write_count(batch: &RecordBatch) -> Option<u64> {
475 use datafusion::arrow::array::{Array, UInt64Array};
476
477 if batch.num_columns() != 1 || batch.schema().field(0).name() != "count" {
478 return None;
479 }
480 let counts = batch.column(0).as_any().downcast_ref::<UInt64Array>()?;
481 Some(
482 (0..counts.len())
483 .filter(|i| counts.is_valid(*i))
484 .map(|i| counts.value(i))
485 .sum(),
486 )
487}