Skip to main content

datafusion_openlineage/
rule.rs

1//! Plan-carried lineage marker and its lowering into the terminal node.
2//!
3//! OpenLineage instrumentation has three concerns with different needs (see ADR
4//! 0005): lineage *extraction* needs the optimized `LogicalPlan`; the START event
5//! and orchestration context need `&SessionState` and are async; the terminal
6//! COMPLETE/FAIL node needs to sit at the physical root and observe execution.
7//! Only the [`QueryPlanner`] seam has `&SessionState`, so the planning-time work
8//! lives there — but the terminal node is installed the composable, DataFusion-
9//! idiomatic way: a registered [`ExtensionPlanner`] lowers a plan-carried marker
10//! into [`OpenLineageExec`], rather than the planner hand-wrapping the physical
11//! root.
12//!
13//! Flow, all under one `run_id`. First, [`OpenLineageQueryPlanner`] (the
14//! [`QueryPlanner`]) extracts lineage from the optimized logical plan, resolves
15//! the async [`LineageContextProvider`], emits START, builds the COMPLETE
16//! template, and wraps the *logical* plan in a [`LineageMarker`] carrying that
17//! template. Then physical planning lowers the marker via
18//! [`LineageExtensionPlanner`] into an [`OpenLineageExec`] at the root, which
19//! emits COMPLETE/FAIL at end of execution.
20//!
21//! A `LogicalPlan::Extension` requires a registered `ExtensionPlanner` (the
22//! default physical planner errors on unknown extension nodes), so the planner
23//! delegates physical planning to a [`DefaultPhysicalPlanner`] configured with
24//! [`LineageExtensionPlanner`].
25
26use std::cmp::Ordering;
27use std::fmt;
28use std::hash::{Hash, Hasher};
29use std::sync::Arc;
30
31use async_trait::async_trait;
32use datafusion::common::{DFSchemaRef, Result};
33use datafusion::dataframe::DataFrame;
34use datafusion::execution::context::{QueryPlanner, SessionContext, SessionState};
35use datafusion::logical_expr::{
36    Expr, Extension, InvariantLevel, LogicalPlan, UserDefinedLogicalNode,
37    UserDefinedLogicalNodeCore,
38};
39use datafusion::physical_plan::ExecutionPlan;
40use datafusion::physical_planner::{DefaultPhysicalPlanner, ExtensionPlanner, PhysicalPlanner};
41use uuid::Uuid;
42
43use crate::builder::{complete_event, fail_event, start_event};
44use crate::client::OpenLineageClient;
45use crate::config::OpenLineageConfig;
46use crate::context::{LineageContext, LineageContextProvider};
47use crate::event::RunEvent;
48use crate::exec::OpenLineageExec;
49use crate::extract::{QueryLineage, extract};
50
51tokio::task_local! {
52    /// Set while [`OpenLineageQueryPlanner::execute_ddl_with_lineage`] runs the DDL,
53    /// so the *nested* `create_physical_plan` that DataFusion triggers for the body
54    /// (e.g. `create_memory_table` collecting the CTAS SELECT) does not emit a
55    /// second, spurious run. Task-local rather than a shared flag so it is scoped to
56    /// this one execution's task tree and never suppresses a concurrent query on
57    /// another task. See [`suppressing_nested_lineage`].
58    static SUPPRESS_NESTED_LINEAGE: ();
59}
60
61/// True when the current task is inside an `execute_ddl_with_lineage` call, i.e.
62/// any `create_physical_plan` here is the nested body of a DDL run already being
63/// reported and must not emit its own events.
64fn nested_lineage_suppressed() -> bool {
65    SUPPRESS_NESTED_LINEAGE.try_with(|()| ()).is_ok()
66}
67
68// ---------------------------------------------------------------------------
69// The plan-carried marker.
70// ---------------------------------------------------------------------------
71
72/// A logical no-op wrapping the real plan, carrying the per-query lineage payload
73/// from [`OpenLineageQueryPlanner`] (which has `&SessionState`) to
74/// [`LineageExtensionPlanner`] (which installs the terminal node). Schema-
75/// transparent: it reports its input's schema so optimization and physical
76/// planning treat it as a pass-through.
77#[derive(Clone)]
78pub struct LineageMarker {
79    input: LogicalPlan,
80    /// COMPLETE event template, built at plan time; cloned into the terminal
81    /// [`OpenLineageExec`] at lowering and mutated into FAIL there on error.
82    complete: RunEvent,
83    client: OpenLineageClient,
84    producer: String,
85}
86
87impl fmt::Debug for LineageMarker {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.debug_struct("LineageMarker").finish_non_exhaustive()
90    }
91}
92
93// Identity is the run id plus the wrapped plan: enough to distinguish markers,
94// and the payload (client/template) is behavioral rather than structural.
95impl PartialEq for LineageMarker {
96    fn eq(&self, other: &Self) -> bool {
97        self.complete.run.run_id == other.complete.run.run_id && self.input == other.input
98    }
99}
100impl Eq for LineageMarker {}
101impl PartialOrd for LineageMarker {
102    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
103        self.complete
104            .run
105            .run_id
106            .partial_cmp(&other.complete.run.run_id)
107    }
108}
109impl Hash for LineageMarker {
110    fn hash<H: Hasher>(&self, state: &mut H) {
111        self.complete.run.run_id.hash(state);
112    }
113}
114
115impl UserDefinedLogicalNodeCore for LineageMarker {
116    fn name(&self) -> &str {
117        "LineageMarker"
118    }
119
120    fn inputs(&self) -> Vec<&LogicalPlan> {
121        vec![&self.input]
122    }
123
124    fn schema(&self) -> &DFSchemaRef {
125        self.input.schema()
126    }
127
128    fn check_invariants(&self, _check: InvariantLevel) -> Result<()> {
129        Ok(())
130    }
131
132    fn expressions(&self) -> Vec<Expr> {
133        vec![]
134    }
135
136    fn fmt_for_explain(&self, f: &mut fmt::Formatter) -> fmt::Result {
137        write!(f, "LineageMarker")
138    }
139
140    fn with_exprs_and_inputs(
141        &self,
142        _exprs: Vec<Expr>,
143        mut inputs: Vec<LogicalPlan>,
144    ) -> Result<Self> {
145        Ok(Self {
146            input: inputs.pop().expect("LineageMarker has one input"),
147            complete: self.complete.clone(),
148            client: self.client.clone(),
149            producer: self.producer.clone(),
150        })
151    }
152}
153
154// ---------------------------------------------------------------------------
155// Lowering: marker -> OpenLineageExec.
156// ---------------------------------------------------------------------------
157
158/// Lowers a [`LineageMarker`] into an [`OpenLineageExec`] at physical-planning
159/// time. Register it on the session's physical planner (see
160/// [`crate::session::instrument_session_state`]).
161#[derive(Debug, Default)]
162pub struct LineageExtensionPlanner;
163
164#[async_trait]
165impl ExtensionPlanner for LineageExtensionPlanner {
166    async fn plan_extension(
167        &self,
168        _planner: &dyn PhysicalPlanner,
169        node: &dyn UserDefinedLogicalNode,
170        _logical_inputs: &[&LogicalPlan],
171        physical_inputs: &[Arc<dyn ExecutionPlan>],
172        _session_state: &SessionState,
173    ) -> Result<Option<Arc<dyn ExecutionPlan>>> {
174        // Not our node: let another extension planner handle it.
175        let Some(marker) = node.as_any().downcast_ref::<LineageMarker>() else {
176            return Ok(None);
177        };
178        let inner = physical_inputs
179            .first()
180            .expect("LineageMarker has one physical input")
181            .clone();
182        Ok(Some(OpenLineageExec::new(
183            inner,
184            marker.client.clone(),
185            marker.complete.clone(),
186            marker.producer.clone(),
187        )))
188    }
189}
190
191// ---------------------------------------------------------------------------
192// The query planner: extract + START + inject the marker.
193// ---------------------------------------------------------------------------
194
195/// A [`QueryPlanner`] that emits OpenLineage events around a query.
196///
197/// It does the `&SessionState`-bound, async work — extract lineage, resolve
198/// context, emit START, mint the `run_id`, emit FAIL on a planning error — then
199/// hands off to physical planning by wrapping the logical plan in a
200/// [`LineageMarker`]. The registered [`LineageExtensionPlanner`] lowers that
201/// marker into the terminal [`OpenLineageExec`]. Built by
202/// [`crate::session::instrument_session_state`].
203pub struct OpenLineageQueryPlanner {
204    client: OpenLineageClient,
205    context: Arc<dyn LineageContextProvider>,
206    config: OpenLineageConfig,
207    /// Physical planner that knows how to lower [`LineageMarker`]; composes any
208    /// extension planners the host already had.
209    physical: Arc<DefaultPhysicalPlanner>,
210}
211
212impl OpenLineageQueryPlanner {
213    /// Build a planner whose physical planning lowers our marker plus
214    /// `extra_extension_planners` (any the host session already registered).
215    pub fn new(
216        client: OpenLineageClient,
217        context: Arc<dyn LineageContextProvider>,
218        config: OpenLineageConfig,
219        extra_extension_planners: Vec<Arc<dyn ExtensionPlanner + Send + Sync>>,
220    ) -> Self {
221        let mut planners: Vec<Arc<dyn ExtensionPlanner + Send + Sync>> =
222            vec![Arc::new(LineageExtensionPlanner)];
223        planners.extend(extra_extension_planners);
224        Self {
225            client,
226            context,
227            config,
228            physical: Arc::new(DefaultPhysicalPlanner::with_extension_planners(planners)),
229        }
230    }
231
232    /// Planning-time lineage work shared by the `QueryPlanner` path and the
233    /// `SessionContext`-level DDL path (see [`crate::session::OpenLineageSqlExt`]):
234    /// extract lineage from `plan`, resolve the async context, and — unless the
235    /// query touches no datasets — mint a `run_id` and emit START.
236    ///
237    /// Returns the per-query payload needed to emit the terminal event under the
238    /// same `run_id`, or `None` when lineage is suppressed (no inputs and no
239    /// outputs — `information_schema` introspection, `SET`/`SHOW`, metadata
240    /// probes), in which case no START fired and the caller must emit nothing.
241    async fn begin_lineage(
242        &self,
243        plan: &LogicalPlan,
244        session_state: &SessionState,
245    ) -> Option<(Uuid, QueryLineage, LineageContext)> {
246        // A `create_physical_plan` nested inside `execute_ddl_with_lineage` is the
247        // DDL body (e.g. the CTAS SELECT that `create_memory_table` collects); the
248        // enclosing DDL run already reports it, so emit nothing here.
249        if nested_lineage_suppressed() {
250            return None;
251        }
252
253        let mut lineage = extract(plan, &self.config);
254        let cx = self.context.context(session_state).await;
255        // The SQL text isn't recoverable from the plan; take it from the
256        // host-supplied context (absent on non-SQL paths, e.g. ingest).
257        lineage.sql = cx.sql.clone();
258
259        // Suppress lineage for queries that touch no datasets — information_schema
260        // introspection, `SET`/`SHOW`, metadata-RPC probes. They carry no input or
261        // output, so a START/COMPLETE pair only adds a dangling job node to the
262        // graph.
263        if lineage.inputs.is_empty() && lineage.outputs.is_empty() {
264            return None;
265        }
266
267        let run_id = cx.run_id.unwrap_or_else(Uuid::now_v7);
268        self.client
269            .emit(start_event(run_id, &lineage, &cx, &self.config));
270        Some((run_id, lineage, cx))
271    }
272
273    /// Execute a DDL-with-input statement (CTAS / CREATE VIEW) that DataFusion
274    /// runs *outside* the `QueryPlanner` hook, emitting lineage around it.
275    ///
276    /// `SessionContext::execute_logical_plan` dispatches these DDL variants to its
277    /// own `create_memory_table` / `create_view` before any `QueryPlanner` sees the
278    /// wrapper (it only ever plans the stripped SELECT body), so the planner path
279    /// captures the inputs but never the created table as an output. This runs
280    /// [`extract`] on the *full* DDL plan (so the output dataset, its schema, and
281    /// column lineage are captured), emits START, delegates the actual creation to
282    /// `execute_logical_plan` — reusing DataFusion's registration logic, including
283    /// every `if_not_exists` / `or_replace` branch — then emits COMPLETE on success
284    /// or FAIL on error, under the same `run_id`.
285    ///
286    /// `raw_sql` is folded into the lineage when the context provider didn't supply
287    /// SQL text, since this path *does* have the original statement in hand.
288    ///
289    /// COMPLETE here does not carry an `outputStatistics.rowCount`: DataFusion
290    /// materializes the CTAS body internally and hands back an empty result, so
291    /// there is no stream for an `OpenLineageExec` to count. The output edge,
292    /// schema, lifecycle, and column lineage are all present; runtime row stats for
293    /// this path are a documented follow-up.
294    pub(crate) async fn execute_ddl_with_lineage(
295        &self,
296        ctx: &SessionContext,
297        plan: LogicalPlan,
298        raw_sql: &str,
299    ) -> Result<DataFrame> {
300        let Some((run_id, mut lineage, cx)) = self.begin_lineage(&plan, &ctx.state()).await else {
301            // No datasets touched — nothing to report; just run it.
302            return ctx.execute_logical_plan(plan).await;
303        };
304        // This path has the SQL in hand even when the context provider omitted it.
305        if lineage.sql.is_none() {
306            lineage.sql = Some(raw_sql.to_string());
307        }
308
309        // Run the DDL with nested-lineage suppression set: `execute_logical_plan`
310        // dispatches CTAS/CREATE VIEW to `create_memory_table`/`create_view`, which
311        // collect the SELECT body back through *this* planner; without the guard
312        // that body would emit its own (input-only) run alongside this DDL run.
313        let result = SUPPRESS_NESTED_LINEAGE
314            .scope((), ctx.execute_logical_plan(plan))
315            .await;
316        match result {
317            Ok(df) => {
318                let mut event = complete_event(run_id, &lineage, &cx, &self.config);
319                // The template's `eventTime` was set above; refresh it to when the
320                // statement actually finished so run duration is meaningful (mirrors
321                // `OpenLineageExec::emit_terminal`).
322                event.event_time = chrono::Utc::now().to_rfc3339();
323                self.client.emit(event);
324                Ok(df)
325            }
326            Err(err) => {
327                self.client.emit(fail_event(
328                    run_id,
329                    &lineage,
330                    &cx,
331                    &self.config,
332                    &err.to_string(),
333                ));
334                Err(err)
335            }
336        }
337    }
338}
339
340impl fmt::Debug for OpenLineageQueryPlanner {
341    // `DefaultPhysicalPlanner` is not `Debug`, so don't try to print it.
342    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343        f.debug_struct("OpenLineageQueryPlanner")
344            .finish_non_exhaustive()
345    }
346}
347
348#[async_trait]
349impl QueryPlanner for OpenLineageQueryPlanner {
350    async fn create_physical_plan(
351        &self,
352        logical_plan: &LogicalPlan,
353        session_state: &SessionState,
354    ) -> Result<Arc<dyn ExecutionPlan>> {
355        // Extract lineage, resolve context, and emit START — or, when the query
356        // touches no datasets, plan straight through without a marker so no events
357        // fire.
358        let Some((run_id, lineage, cx)) = self.begin_lineage(logical_plan, session_state).await
359        else {
360            return self
361                .physical
362                .create_physical_plan(logical_plan, session_state)
363                .await;
364        };
365
366        // Carry the COMPLETE template into the physical phase via the plan itself;
367        // the extension planner lowers it into an OpenLineageExec at the root that
368        // emits COMPLETE/FAIL at end of execution, under this same run id.
369        let marker = LineageMarker {
370            input: logical_plan.clone(),
371            complete: complete_event(run_id, &lineage, &cx, &self.config),
372            client: self.client.clone(),
373            producer: self.config.producer.clone(),
374        };
375        let wrapped = LogicalPlan::Extension(Extension {
376            node: Arc::new(marker),
377        });
378
379        match self
380            .physical
381            .create_physical_plan(&wrapped, session_state)
382            .await
383        {
384            Ok(plan) => Ok(plan),
385            Err(err) => {
386                // Planning failed outright — no execution to observe, emit FAIL now.
387                self.client.emit(fail_event(
388                    run_id,
389                    &lineage,
390                    &cx,
391                    &self.config,
392                    &err.to_string(),
393                ));
394                Err(err)
395            }
396        }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use std::collections::hash_map::DefaultHasher;
403
404    use datafusion::logical_expr::LogicalPlanBuilder;
405
406    use super::*;
407    use crate::QueryLineage;
408    use crate::context::LineageContext;
409    use crate::transport::NoopTransport;
410
411    // `name`/`inputs`/`schema`/`expressions`/`with_exprs_and_inputs` exist on both
412    // `UserDefinedLogicalNodeCore` and the object-safe `UserDefinedLogicalNode`
413    // (blanket impl), so calls go through this alias to disambiguate.
414    use datafusion::logical_expr::UserDefinedLogicalNodeCore as NodeCore;
415
416    /// A `LineageMarker` over a trivial empty-relation plan, tagged with `run_id`.
417    /// The marker fields are private, so these tests live inline.
418    fn marker(run_id: Uuid) -> LineageMarker {
419        let input = LogicalPlanBuilder::empty(false).build().unwrap();
420        let config = OpenLineageConfig::default();
421        let complete = complete_event(
422            run_id,
423            &QueryLineage::default(),
424            &LineageContext::default(),
425            &config,
426        );
427        LineageMarker {
428            input,
429            complete,
430            client: OpenLineageClient::new(Arc::new(NoopTransport)),
431            producer: config.producer,
432        }
433    }
434
435    fn hash_of(m: &LineageMarker) -> u64 {
436        let mut h = DefaultHasher::new();
437        m.hash(&mut h);
438        h.finish()
439    }
440
441    // `OpenLineageClient::new` spawns a background drain, so these need a runtime.
442    #[tokio::test]
443    async fn node_core_is_schema_transparent_and_expr_free() {
444        let m = marker(Uuid::now_v7());
445        assert_eq!(NodeCore::name(&m), "LineageMarker");
446        assert_eq!(NodeCore::inputs(&m).len(), 1);
447        // Schema-transparent: it reports its single input's schema verbatim.
448        assert_eq!(NodeCore::schema(&m), NodeCore::inputs(&m)[0].schema());
449        assert!(NodeCore::expressions(&m).is_empty());
450        assert!(NodeCore::check_invariants(&m, InvariantLevel::Always).is_ok());
451        assert_eq!(format!("{m:?}"), "LineageMarker { .. }");
452    }
453
454    #[tokio::test]
455    async fn with_exprs_and_inputs_rebuilds_preserving_payload() {
456        let run_id = Uuid::now_v7();
457        let m = marker(run_id);
458        let new_input = LogicalPlanBuilder::empty(true).build().unwrap();
459        let rebuilt = NodeCore::with_exprs_and_inputs(&m, vec![], vec![new_input.clone()]).unwrap();
460        // The wrapped input swaps; the run-id payload is carried through.
461        assert_eq!(NodeCore::inputs(&rebuilt)[0], &new_input);
462        assert_eq!(rebuilt.complete.run.run_id, run_id);
463    }
464
465    #[tokio::test]
466    async fn identity_keys_on_run_id_and_input() {
467        let run_id = Uuid::now_v7();
468        // Same run id + same (empty) plan → equal, same hash, equal ordering.
469        let a = marker(run_id);
470        let b = marker(run_id);
471        assert_eq!(a, b);
472        assert_eq!(hash_of(&a), hash_of(&b));
473        assert_eq!(a.partial_cmp(&b), Some(Ordering::Equal));
474
475        // Different run id → not equal, ordering follows the run id.
476        let c = marker(Uuid::now_v7());
477        assert_ne!(a, c);
478        assert_eq!(
479            a.partial_cmp(&c),
480            a.complete.run.run_id.partial_cmp(&c.complete.run.run_id)
481        );
482    }
483}