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}