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        let mut event = self.complete.clone();
126        // The template's `eventTime` was set at plan time; refresh it to the
127        // moment execution actually ended so run duration is meaningful.
128        event.event_time = Utc::now().to_rfc3339();
129        if self.failed.load(Ordering::SeqCst) {
130            event.event_type = RunEventType::Fail;
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        } else {
144            self.attach_output_statistics(&mut event);
145            self.attach_input_statistics(&mut event);
146        }
147        self.client.emit(event);
148    }
149
150    /// Attach an `outputStatistics` facet to each output dataset of the COMPLETE
151    /// event.
152    ///
153    /// The row count comes from DataFusion's write-result `count` batch (the
154    /// authoritative rows-written signal, captured as the stream drained). Size
155    /// is taken from a `bytes_scanned`-style plan metric when available. Reads
156    /// (SELECT) have no output dataset, so this is a no-op there.
157    fn attach_output_statistics(&self, event: &mut RunEvent) {
158        if event.outputs.is_empty() {
159            return;
160        }
161
162        let row_count = self.rows_written.lock().unwrap().map(|n| n as i64);
163
164        // `bytes_scanned` is the closest widely-emitted size metric; absent for
165        // many plans, in which case `size` is simply omitted.
166        let size = self
167            .inner
168            .lock()
169            .unwrap()
170            .metrics()
171            .and_then(|m| m.aggregate_by_name().sum_by_name("bytes_scanned"))
172            .map(|v| v.as_usize() as i64);
173
174        if row_count.is_none() && size.is_none() {
175            return;
176        }
177
178        let stats = OutputStatisticsOutputDatasetFacet {
179            base: BaseFacet::new(&self.producer, OUTPUT_STATS_FACET),
180            row_count,
181            size,
182            file_count: None,
183        };
184        for output in &mut event.outputs {
185            let facets = output.output_facets.get_or_insert_with(Default::default);
186            facets.output_statistics = Some(stats.clone());
187        }
188    }
189
190    /// Attach an `inputStatistics` facet to the input dataset — but only when
191    /// there is exactly ONE input.
192    ///
193    /// Scan metrics (`output_rows`, `bytes_scanned`) live on the per-node
194    /// `MetricsSet` of the scan nodes, not the root, so we walk the executed
195    /// plan tree and aggregate them. With a single input that aggregate is
196    /// unambiguously that dataset's read stats. With multiple inputs we cannot
197    /// attribute a summed total to the right source without matching each scan
198    /// node back to its dataset — which needs location-based dataset naming
199    /// (object-store URL + symlinks). That is deferred; see the design doc, so
200    /// we skip rather than emit a misleading aggregate.
201    fn attach_input_statistics(&self, event: &mut RunEvent) {
202        if event.inputs.len() != 1 {
203            return;
204        }
205
206        let inner = self.inner.lock().unwrap().clone();
207        // A dataset scanned more than once (self-join, self-union, correlated
208        // subquery) collapses to a single input dataset, but the metric
209        // aggregate sums over every leaf scan and would over-count. Only a
210        // single leaf scan can be unambiguously attributed to the lone input,
211        // so skip rather than emit a doubled total.
212        if count_leaf_scans(&inner) != 1 {
213            tracing::trace!(
214                target: "openlineage",
215                "skipping inputStatistics: dataset scanned by multiple leaf nodes"
216            );
217            return;
218        }
219        let (rows, bytes) = aggregate_scan_metrics(&inner);
220        let row_count = rows.map(|n| n as i64);
221        let size = bytes.map(|n| n as i64);
222        if row_count.is_none() && size.is_none() {
223            return;
224        }
225
226        let stats = InputStatisticsInputDatasetFacet {
227            base: BaseFacet::new(&self.producer, INPUT_STATS_FACET),
228            row_count,
229            size,
230            file_count: None,
231        };
232        let facets = event.inputs[0]
233            .input_facets
234            .get_or_insert_with(Default::default);
235        facets.input_statistics = Some(stats);
236    }
237}
238
239/// Sum scan metrics across the whole executed plan tree.
240///
241/// `metrics()` is per-node, so we recurse. Returns aggregated
242/// (`output_rows`, `bytes_scanned`); either may be `None` if no node reported it.
243fn aggregate_scan_metrics(plan: &Arc<dyn ExecutionPlan>) -> (Option<usize>, Option<usize>) {
244    let mut rows: Option<usize> = None;
245    let mut bytes: Option<usize> = None;
246
247    if let Some(metrics) = plan.metrics() {
248        let metrics = metrics.aggregate_by_name();
249        // Only count rows from leaf scans: intermediate nodes also report
250        // `output_rows` and would double-count. Leaf-ness (no children) is the
251        // right discriminator — `bytes_scanned` is parquet-specific, so gating
252        // on it dropped `output_rows` for memory/CSV/JSON/custom sources.
253        if plan.children().is_empty() {
254            if let Some(r) = metrics.output_rows() {
255                *rows.get_or_insert(0) += r;
256            }
257            if let Some(b) = metrics.sum_by_name("bytes_scanned") {
258                *bytes.get_or_insert(0) += b.as_usize();
259            }
260        }
261    }
262
263    for child in plan.children() {
264        let (cr, cb) = aggregate_scan_metrics(child);
265        if let Some(r) = cr {
266            *rows.get_or_insert(0) += r;
267        }
268        if let Some(b) = cb {
269            *bytes.get_or_insert(0) += b;
270        }
271    }
272
273    (rows, bytes)
274}
275
276/// Count the leaf scan nodes in the executed plan tree.
277///
278/// Used to detect when a single input dataset is read by more than one scan
279/// (self-join/union/correlated subquery), where the summed scan-metric
280/// aggregate would over-count that dataset's read statistics.
281fn count_leaf_scans(plan: &Arc<dyn ExecutionPlan>) -> usize {
282    let children = plan.children();
283    if children.is_empty() {
284        return 1;
285    }
286    children.iter().map(|child| count_leaf_scans(child)).sum()
287}
288
289/// Wraps the root physical plan, emitting a terminal lineage event when
290/// execution finishes.
291pub struct OpenLineageExec {
292    inner: Arc<dyn ExecutionPlan>,
293    state: Arc<RunState>,
294}
295
296impl OpenLineageExec {
297    /// Wrap `inner`, emitting COMPLETE (or FAIL on error) once all partitions
298    /// finish. `complete` is the pre-built COMPLETE event (sharing the run id
299    /// used by START); `producer` builds the error facet on failure.
300    pub fn new(
301        inner: Arc<dyn ExecutionPlan>,
302        client: OpenLineageClient,
303        complete: RunEvent,
304        producer: String,
305    ) -> Arc<Self> {
306        let has_outputs = !complete.outputs.is_empty();
307        let state = Arc::new(RunState {
308            client,
309            complete,
310            producer,
311            has_outputs,
312            inner: std::sync::Mutex::new(inner.clone()),
313            // Initialized lazily on the first `execute()` from the partition
314            // count of the node that actually runs (which may differ from
315            // `inner` here after a `with_new_children` rewrite).
316            remaining: AtomicUsize::new(0),
317            init: std::sync::Once::new(),
318            failed: AtomicBool::new(false),
319            error: std::sync::Mutex::new(None),
320            rows_written: std::sync::Mutex::new(None),
321            emitted: AtomicBool::new(false),
322        });
323        Arc::new(Self { inner, state })
324    }
325
326    fn with_new_inner(&self, inner: Arc<dyn ExecutionPlan>) -> Arc<Self> {
327        // Keep the shared run state pointed at the node that will execute, so
328        // metrics are harvested from the right plan on completion.
329        *self.state.inner.lock().unwrap() = inner.clone();
330        Arc::new(Self {
331            inner,
332            state: self.state.clone(),
333        })
334    }
335}
336
337impl fmt::Debug for OpenLineageExec {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        f.debug_struct("OpenLineageExec").finish_non_exhaustive()
340    }
341}
342
343impl DisplayAs for OpenLineageExec {
344    fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        match t {
346            DisplayFormatType::Default | DisplayFormatType::Verbose => {
347                write!(f, "OpenLineageExec")
348            }
349            DisplayFormatType::TreeRender => write!(f, "OpenLineageExec"),
350        }
351    }
352}
353
354impl ExecutionPlan for OpenLineageExec {
355    fn name(&self) -> &str {
356        "OpenLineageExec"
357    }
358
359    // No `as_any` override: as of DataFusion 54 it is no longer an
360    // `ExecutionPlan` method — the trait now requires `Any`, so downcasting
361    // goes through `Any::downcast_ref` on `&dyn ExecutionPlan`, which resolves
362    // to *this* wrapper (not the inner plan). That is exactly the behavior the
363    // former override existed to guarantee: visitors can't downcast past the
364    // wrapper to the inner type and silently drop this lineage node.
365
366    fn properties(&self) -> &Arc<PlanProperties> {
367        self.inner.properties()
368    }
369
370    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
371        vec![&self.inner]
372    }
373
374    fn with_new_children(
375        self: Arc<Self>,
376        mut children: Vec<Arc<dyn ExecutionPlan>>,
377    ) -> Result<Arc<dyn ExecutionPlan>> {
378        // We wrap a single root; rewrap whatever child we're given so the node
379        // stays installed across optimizer/child rewrites.
380        let child = children.pop().unwrap_or_else(|| self.inner.clone());
381        Ok(self.with_new_inner(child))
382    }
383
384    fn metrics(&self) -> Option<MetricsSet> {
385        self.inner.metrics()
386    }
387
388    fn execute(
389        &self,
390        partition: usize,
391        context: Arc<TaskContext>,
392    ) -> Result<SendableRecordBatchStream> {
393        // Lazily fix the outstanding-partition count from the node that is
394        // actually executing, so `with_new_children` rewrites that change the
395        // partitioning don't desync the counter (reading `self.properties()`,
396        // which delegates to `inner`, rather than the count captured at
397        // construction). `init_partitions` floors the count at 1 so that if a
398        // plan reporting zero partitions is nonetheless executed, the terminal
399        // event still fires exactly once. (A zero-partition plan that is never
400        // executed emits nothing — correctly, there was no execution.)
401        self.state
402            .init_partitions(self.properties().output_partitioning().partition_count());
403
404        // An execute-time error (e.g. object-store auth / credential vending)
405        // means this partition's stream never exists, so its `TrackedStream`
406        // would never run its terminal path. Record the failure and settle the
407        // partition here before propagating, or the run is stuck RUNNING with
408        // no COMPLETE/FAIL ever emitted.
409        let inner = match self.inner.execute(partition, context) {
410            Ok(inner) => inner,
411            Err(err) => {
412                self.state.record_error(err.to_string());
413                self.state.partition_finished();
414                return Err(err);
415            }
416        };
417        Ok(Box::pin(TrackedStream {
418            schema: inner.schema(),
419            inner,
420            state: self.state.clone(),
421            done: false,
422        }))
423    }
424}
425
426/// Wraps a partition's stream, recording errors and signalling completion on
427/// terminal (exhaustion, error, or drop).
428struct TrackedStream {
429    schema: SchemaRef,
430    inner: SendableRecordBatchStream,
431    state: Arc<RunState>,
432    done: bool,
433}
434
435impl TrackedStream {
436    fn finish(&mut self) {
437        if !self.done {
438            self.done = true;
439            self.state.partition_finished();
440        }
441    }
442}
443
444impl Stream for TrackedStream {
445    type Item = Result<RecordBatch>;
446
447    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
448        match Pin::new(&mut self.inner).poll_next(cx) {
449            Poll::Ready(Some(Ok(batch))) => {
450                // Only sniff for a write-result `count` batch when this run
451                // actually writes (has an output dataset); otherwise a read
452                // returning a lone `UInt64 count` column would be misread as
453                // rows-written.
454                if self.state.has_outputs
455                    && let Some(rows) = write_count(&batch)
456                {
457                    self.state.record_rows_written(rows);
458                }
459                Poll::Ready(Some(Ok(batch)))
460            }
461            Poll::Ready(Some(Err(e))) => {
462                self.state.record_error(e.to_string());
463                self.finish();
464                Poll::Ready(Some(Err(e)))
465            }
466            Poll::Ready(None) => {
467                self.finish();
468                Poll::Ready(None)
469            }
470            Poll::Pending => Poll::Pending,
471        }
472    }
473}
474
475impl RecordBatchStream for TrackedStream {
476    fn schema(&self) -> SchemaRef {
477        self.schema.clone()
478    }
479}
480
481impl Drop for TrackedStream {
482    fn drop(&mut self) {
483        // A stream dropped before exhaustion means the partition was cancelled
484        // or abandoned: count it as a failure for the run.
485        if !self.done {
486            self.state
487                .record_error("query stream dropped before completion".to_string());
488            self.finish();
489        }
490    }
491}
492
493/// Recognize DataFusion's write-result batch — a single `count` UInt64 column
494/// whose value is the number of rows written — and return that count.
495fn write_count(batch: &RecordBatch) -> Option<u64> {
496    use datafusion::arrow::array::{Array, UInt64Array};
497
498    if batch.num_columns() != 1 || batch.schema().field(0).name() != "count" {
499        return None;
500    }
501    let counts = batch.column(0).as_any().downcast_ref::<UInt64Array>()?;
502    Some(counts.iter().flatten().sum())
503}