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}