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::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}