Skip to main content

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