This is an automated email from the ASF dual-hosted git repository.
mobuchowski pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new d8d78647229 Propagate OpenLineage context to Databricks job runs
(#72643)
d8d78647229 is described below
commit d8d7864722975bb3ad979016b7d9f45146a0ae27
Author: Kacper Muda <[email protected]>
AuthorDate: Wed Sep 9 15:30:37 2026 +0200
Propagate OpenLineage context to Databricks job runs (#72643)
---
providers/databricks/docs/operators/run_now.rst | 29 +++
providers/databricks/docs/operators/submit_run.rst | 16 ++
.../providers/databricks/operators/databricks.py | 93 ++++++++-
.../providers/databricks/utils/openlineage.py | 101 ++++++++++
.../unit/databricks/operators/test_databricks.py | 211 +++++++++++++++++++++
.../unit/databricks/utils/test_openlineage.py | 140 ++++++++++++++
providers/openlineage/docs/spark.rst | 6 +
7 files changed, 586 insertions(+), 10 deletions(-)
diff --git a/providers/databricks/docs/operators/run_now.rst
b/providers/databricks/docs/operators/run_now.rst
index 8681934e2b6..5e706e15dba 100644
--- a/providers/databricks/docs/operators/run_now.rst
+++ b/providers/databricks/docs/operators/run_now.rst
@@ -81,6 +81,35 @@ disable this parameter forwarding behavior.
# job_parameters={"env": "staging", "batch_size": "42"}
# i.e. the same dict, passed straight through to the run-now request body.
+OpenLineage parent job information
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Set ``openlineage_inject_parent_job_info=True`` to add the standardized
OpenLineage context to the
+run's ``job_parameters`` under the ``OPENLINEAGE_CONTEXT`` key. The JSON value
contains the Airflow
+task as the parent job, with ``jobType`` set to ``BATCH/AIRFLOW/TASK``. When
the current Airflow Dag
+is the root job, its ``jobType`` is set to ``BATCH/AIRFLOW/DAG``. For a root
inherited through
+``DagRun.conf``, the job type is copied from
``conf["openlineage"]["rootParentJobType"]`` when that
+mapping is present; otherwise, the root job has no ``jobType`` facet. This
follows the context format
+introduced in `OpenLineage #4682
<https://github.com/OpenLineage/OpenLineage/pull/4682>`_.
+
+``OPENLINEAGE_CONTEXT`` is a Databricks job parameter, not an operating-system
environment variable.
+The Databricks task must expose the parameter to the downstream OpenLineage
integration through its
+own configuration channel (for example, ``spark.openlineage.context`` for
OpenLineage Spark or the
+``OPENLINEAGE_CONTEXT`` environment variable for dbt).
+
+The option defaults to the ``openlineage.spark_inject_parent_job_info``
configuration value. Existing
+OpenLineage context job parameters are preserved. Injection is skipped when a
legacy parameter slot
+such as ``notebook_params`` or ``spark_submit_params`` is used because
Databricks does not allow those
+slots to be combined with ``job_parameters``.
+
+.. code-block:: python
+
+ run_now = DatabricksRunNowOperator(
+ task_id="run_now",
+ job_id=123,
+ openlineage_inject_parent_job_info=True,
+ )
+
Durable execution
^^^^^^^^^^^^^^^^^
diff --git a/providers/databricks/docs/operators/submit_run.rst
b/providers/databricks/docs/operators/submit_run.rst
index 0e85b7dd8f6..f5a88056c43 100644
--- a/providers/databricks/docs/operators/submit_run.rst
+++ b/providers/databricks/docs/operators/submit_run.rst
@@ -138,6 +138,22 @@ or ``tasks`` argument.
# i.e. the same dict, copied into the task's dict-shaped parameter slot.
+OpenLineage parent context
+--------------------------
+
+Set ``openlineage_inject_parent_job_info=True`` to inject the standardized
OpenLineage context into
+the dict-shaped parameter slot of each supported task. The context is passed
under the
+``OPENLINEAGE_CONTEXT`` key and includes the Airflow task as a
``BATCH/AIRFLOW/TASK`` parent. The root
+job is marked as ``BATCH/AIRFLOW/DAG`` when it is the current Airflow Dag. For
a root inherited
+through ``DagRun.conf``, its job type is copied from
+``conf["openlineage"]["rootParentJobType"]`` when available and otherwise
omitted.
+
+For tasks with a ``new_cluster``, the operator also retains the existing
injection into
+``new_cluster.spark_conf``. Tasks that only accept positional list parameters
+(``spark_jar_task``, ``spark_python_task``, or ``spark_submit_task``) receive
the context only when
+their Spark configuration can be injected.
+
+
Examples
--------
diff --git
a/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
b/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
index 009c803899a..4ce3d890d2e 100644
---
a/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
+++
b/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
@@ -337,6 +337,20 @@ def _inject_airflow_params_into_task(task: dict, params:
dict) -> None:
task_def[field] = dict(params)
+def _inject_openlineage_context_into_task_parameters(task: dict, context:
Context) -> None:
+ """Inject OpenLineage context into each dict-shaped parameter field
supported by a task."""
+ from airflow.providers.databricks.utils.openlineage import (
+ inject_openlineage_context_into_databricks_job_parameters,
+ )
+
+ for task_key, field in _DICT_PARAM_FIELD_BY_TASK.items():
+ task_def = task.get(task_key)
+ if isinstance(task_def, dict):
+ task_def[field] =
inject_openlineage_context_into_databricks_job_parameters(
+ job_parameters=task_def.get(field) or {}, context=context
+ )
+
+
def _coerce_json_to_dict(json: Any) -> dict[str, Any]:
if json is None:
return {}
@@ -731,8 +745,8 @@ class DatabricksSubmitRunOperator(ResumableJobMixin,
BaseOperator):
.. seealso::
https://docs.databricks.com/dev-tools/api/latest/jobs.html#operation/JobsRunsSubmit
:param openlineage_inject_parent_job_info: If True, injects OpenLineage
parent job information
- into the ``new_cluster`` ``spark_conf`` so the Spark job emits a
``parentRunFacet`` linking
- back to the Airflow task. Defaults to the
+ into dict-shaped task parameters and the ``new_cluster``
``spark_conf`` so the Databricks
+ job can emit a ``parentRunFacet`` linking back to the Airflow task.
Defaults to the
``openlineage.spark_inject_parent_job_info`` config value.
:param openlineage_inject_transport_info: If True, injects OpenLineage
transport configuration
into the ``new_cluster`` ``spark_conf`` so the Spark job sends OL
events to the same backend
@@ -947,12 +961,30 @@ class DatabricksSubmitRunOperator(ResumableJobMixin,
BaseOperator):
_inject_airflow_params_into_task(json, params_dump)
if self.openlineage_inject_parent_job_info or
self.openlineage_inject_transport_info:
- self.log.info("Automatic injection of OpenLineage information into
Spark properties is enabled.")
+ self.log.info("Automatic injection of OpenLineage information is
enabled.")
json =
self._inject_openlineage_properties_into_databricks_job(json, context)
return cast("dict[str, Any]", normalise_json_content(json))
def _inject_openlineage_properties_into_databricks_job(self, json: dict,
context: Context) -> dict:
+ if self.openlineage_inject_parent_job_info:
+ try:
+ context_json = copy.deepcopy(json)
+ tasks = context_json.get("tasks")
+ if isinstance(tasks, list):
+ for task in tasks:
+ if isinstance(task, dict):
+
_inject_openlineage_context_into_task_parameters(task, context)
+ else:
+
_inject_openlineage_context_into_task_parameters(context_json, context)
+ json = context_json
+ except Exception as e:
+ self.log.warning(
+ "An error occurred while trying to inject OpenLineage
context. "
+ "Databricks task parameters have not been modified by
OpenLineage.",
+ exc_info=e,
+ )
+
try:
from airflow.providers.databricks.utils.openlineage import (
inject_openlineage_properties_into_databricks_job,
@@ -1227,6 +1259,10 @@ class DatabricksRunNowOperator(ResumableJobMixin,
BaseOperator):
:param do_xcom_push: Whether we should push run_id and run_page_url to
xcom.
:param wait_for_termination: if we should wait for termination of the job
run. ``True`` by default.
:param deferrable: Run operator in the deferrable mode.
+ :param openlineage_inject_parent_job_info: If True, injects the
standardized OpenLineage parent-run
+ context into the ``OPENLINEAGE_CONTEXT`` job parameter so Databricks
tasks can link their
+ OpenLineage events back to the Airflow task. Defaults to the
+ ``openlineage.spark_inject_parent_job_info`` config value.
:param repair_run: Repair the databricks run in case of failure.
:param databricks_repair_reason_new_settings: A dict of reason and
new_settings JSON object for which
to repair the run. `None` by default. `None` means to repair at
all cases with existing job
@@ -1300,6 +1336,9 @@ class DatabricksRunNowOperator(ResumableJobMixin,
BaseOperator):
do_xcom_push: bool = True,
wait_for_termination: bool = True,
deferrable: bool = conf.getboolean("operators", "default_deferrable",
fallback=False),
+ openlineage_inject_parent_job_info: bool = conf.getboolean(
+ "openlineage", "spark_inject_parent_job_info", fallback=False
+ ),
repair_run: bool = False,
databricks_repair_reason_new_settings: dict[str, Any] | None = None,
cancel_previous_runs: bool = False,
@@ -1332,6 +1371,7 @@ class DatabricksRunNowOperator(ResumableJobMixin,
BaseOperator):
self.databricks_retry_args = databricks_retry_args
self.wait_for_termination = wait_for_termination
self.deferrable = deferrable
+ self.openlineage_inject_parent_job_info =
openlineage_inject_parent_job_info
self.repair_run = repair_run
self.databricks_repair_reason_new_settings =
databricks_repair_reason_new_settings or {}
self.cancel_previous_runs = cancel_previous_runs
@@ -1379,13 +1419,13 @@ class DatabricksRunNowOperator(ResumableJobMixin,
BaseOperator):
def execute(self, context: Context):
if self.deferrable:
- json = self._prepare_run_now_json()
+ json = self._prepare_run_now_json(context)
self.run_id = self._hook.run_now(json)
_handle_deferrable_databricks_operator_execution(self, self._hook,
self.log, context)
else:
return self.execute_resumable(context)
- def _build_run_now_payload(self) -> dict[str, Any]:
+ def _build_run_now_payload(self, context: Context) -> dict[str, Any]:
# Utility to build the run payload: merge, validate, resolve job_name
-> job_id, inject params.
# Kept separate from cancel_previous_runs so the reconnect path can
rebuild the payload
# (for repair_run) without re-cancelling the run it is reconnecting to.
@@ -1404,6 +1444,9 @@ class DatabricksRunNowOperator(ResumableJobMixin,
BaseOperator):
json["job_id"] = job_id
del json["job_name"]
+ return self._inject_run_now_job_parameters(json, context)
+
+ def _inject_run_now_job_parameters(self, json: dict[str, Any], context:
Context) -> dict[str, Any]:
if (
self.forward_dag_params
and not json.get("job_parameters")
@@ -1412,10 +1455,40 @@ class DatabricksRunNowOperator(ResumableJobMixin,
BaseOperator):
):
json["job_parameters"] = dict(self.params)
+ if self.openlineage_inject_parent_job_info:
+ if any(k in json for k in
_RUN_NOW_PARAM_SLOTS_CONFLICTING_WITH_JOB_PARAMETERS):
+ self.log.info(
+ "Skipping OpenLineage parent job information injection
because the Databricks "
+ "run uses a legacy parameter slot that cannot be combined
with job_parameters."
+ )
+ else:
+ json =
self._inject_openlineage_properties_into_databricks_job(json, context)
+
return json
- def _prepare_run_now_json(self) -> dict[str, Any]:
- json = self._build_run_now_payload()
+ def _inject_openlineage_properties_into_databricks_job(
+ self, json: dict[str, Any], context: Context
+ ) -> dict[str, Any]:
+ try:
+ from airflow.providers.databricks.utils.openlineage import (
+ inject_openlineage_context_into_databricks_job_parameters,
+ )
+
+ json = dict(json)
+ json["job_parameters"] =
inject_openlineage_context_into_databricks_job_parameters(
+ job_parameters=json.get("job_parameters", {}), context=context
+ )
+ return json
+ except Exception as e:
+ self.log.warning(
+ "An error occurred while trying to inject OpenLineage context.
"
+ "Databricks job parameters have not been modified by
OpenLineage.",
+ exc_info=e,
+ )
+ return json
+
+ def _prepare_run_now_json(self, context: Context) -> dict[str, Any]:
+ json = self._build_run_now_payload(context)
if self.cancel_previous_runs:
if (job_id := json.get("job_id")) is None:
raise ValueError(
@@ -1427,7 +1500,7 @@ class DatabricksRunNowOperator(ResumableJobMixin,
BaseOperator):
return json
def submit_job(self, context: Context) -> int:
- json = self._prepare_run_now_json()
+ json = self._prepare_run_now_json(context)
# Set run_id the instant the run exists so on_kill can cancel it even
if the worker dies
# before polling begins.
self.run_id = self._hook.run_now(json)
@@ -1470,7 +1543,7 @@ class DatabricksRunNowOperator(ResumableJobMixin,
BaseOperator):
# in the poll helper). _build_run_now_payload resolves job_name ->
job_id but omits
# cancel_previous_runs, which would otherwise cancel the run we are
reconnecting to.
if not getattr(self, "_merged_json", None):
- self._merged_json = self._build_run_now_payload()
+ self._merged_json = self._build_run_now_payload(context)
# The run already exists here (fresh submit logged in submit_job, or
reconnect logged by the
# mixin), so the poll helper must not announce a submission.
_handle_databricks_operator_execution(self, self._hook, self.log,
context, announce_submission=False)
@@ -1509,7 +1582,7 @@ class DatabricksRunNowOperator(ResumableJobMixin,
BaseOperator):
# of reading a mutated self.json: on a deferral resume this is
a fresh process, so any
# value written to self.json in execute() is gone.
_get_merged_json() also recovers a
# job_parameters supplied via the named ``job_parameters=``
argument, not only inside json=.
- merged = self._get_merged_json()
+ merged =
self._inject_run_now_job_parameters(self._get_merged_json(), context)
if "job_parameters" in merged:
repair_json["job_parameters"] = merged["job_parameters"]
self._hook.repair_run(repair_json)
diff --git
a/providers/databricks/src/airflow/providers/databricks/utils/openlineage.py
b/providers/databricks/src/airflow/providers/databricks/utils/openlineage.py
index 706afa5df09..d241db12c94 100644
--- a/providers/databricks/src/airflow/providers/databricks/utils/openlineage.py
+++ b/providers/databricks/src/airflow/providers/databricks/utils/openlineage.py
@@ -21,6 +21,7 @@ import datetime
import json
import logging
from typing import TYPE_CHECKING, Any
+from uuid import UUID
import requests
@@ -41,6 +42,12 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
+_AIRFLOW_DAG_JOB_TYPE = {
+ "processingType": "BATCH",
+ "integration": "AIRFLOW",
+ "jobType": "DAG",
+}
+
def _get_parent_run_facet(task_instance):
"""
@@ -356,6 +363,100 @@ def _is_openlineage_provider_accessible() -> bool:
return True
+def _get_dag_run_conf(task_instance) -> dict[str, Any]:
+ dag_run = getattr(task_instance, "dag_run", None)
+ if dag_run is None:
+ dag_run = task_instance.get_template_context()["dag_run"]
+ conf = getattr(dag_run, "conf", None)
+ return conf if isinstance(conf, dict) else {}
+
+
+def _has_valid_job_identifiers(openlineage_conf: dict[str, Any], keys:
tuple[str, str, str]) -> bool:
+ run_id, namespace, name = (openlineage_conf.get(key) for key in keys)
+ if not all((run_id, namespace, name)):
+ return False
+ try:
+ UUID(str(run_id))
+ except ValueError:
+ return False
+ return True
+
+
+def _get_root_job_type(task_instance) -> dict[str, Any] | None:
+ openlineage_conf = _get_dag_run_conf(task_instance).get("openlineage")
+ if not isinstance(openlineage_conf, dict):
+ return dict(_AIRFLOW_DAG_JOB_TYPE)
+
+ has_inherited_root = _has_valid_job_identifiers(
+ openlineage_conf,
+ ("rootParentRunId", "rootParentJobNamespace", "rootParentJobName"),
+ ) or _has_valid_job_identifiers(
+ openlineage_conf,
+ ("parentRunId", "parentJobNamespace", "parentJobName"),
+ )
+ if not has_inherited_root:
+ return dict(_AIRFLOW_DAG_JOB_TYPE)
+
+ root_job_type = openlineage_conf.get("rootParentJobType")
+ return copy.deepcopy(root_job_type) if isinstance(root_job_type, dict)
else None
+
+
+def _build_openlineage_parent_run_context(task_instance) -> dict[str, Any]:
+ """Build the standardized OpenLineage parent-run context for a task
instance."""
+ parent_run_facet = _get_parent_run_facet(task_instance)
+ root_job: dict[str, Any] = {
+ "namespace": parent_run_facet.root.job.namespace,
+ "name": parent_run_facet.root.job.name,
+ }
+ if root_job_type := _get_root_job_type(task_instance):
+ root_job["facets"] = {"jobType": root_job_type}
+
+ return {
+ "parent": {
+ "run": {"runId": parent_run_facet.run.runId},
+ "job": {
+ "namespace": parent_run_facet.job.namespace,
+ "name": parent_run_facet.job.name,
+ "facets": {
+ "jobType": {
+ "processingType": "BATCH",
+ "integration": "AIRFLOW",
+ "jobType": "TASK",
+ }
+ },
+ },
+ "root": {
+ "run": {"runId": parent_run_facet.root.run.runId},
+ "job": root_job,
+ },
+ }
+ }
+
+
+def inject_openlineage_context_into_databricks_job_parameters(job_parameters:
dict, context: Context) -> dict:
+ """Inject the standardized OpenLineage context into Databricks job
parameters."""
+ if any(key in job_parameters for key in ("OPENLINEAGE_CONTEXT",
"spark.openlineage.context")):
+ log.info(
+ "OpenLineage context is already present in Databricks job
parameters. Skipping the injection."
+ )
+ return job_parameters
+
+ if not _is_openlineage_provider_accessible():
+ log.warning(
+ "Could not access OpenLineage provider for automatic OpenLineage
context injection into "
+ "Databricks job parameters. No action will be performed."
+ )
+ return job_parameters
+
+ log.debug("Injecting OpenLineage context into Databricks job parameters.")
+ return {
+ **job_parameters,
+ "OPENLINEAGE_CONTEXT": json.dumps(
+ _build_openlineage_parent_run_context(context["ti"]),
separators=(",", ":")
+ ),
+ }
+
+
def _extract_new_clusters_from_databricks_job(job: dict) -> list[dict]:
"""
Collect every ``new_cluster`` definition that can carry Spark properties
in a Databricks job.
diff --git
a/providers/databricks/tests/unit/databricks/operators/test_databricks.py
b/providers/databricks/tests/unit/databricks/operators/test_databricks.py
index 990e15f1098..d05be38716d 100644
--- a/providers/databricks/tests/unit/databricks/operators/test_databricks.py
+++ b/providers/databricks/tests/unit/databricks/operators/test_databricks.py
@@ -1712,6 +1712,217 @@ class
TestDatabricksSubmitRunOperatorOpenLineageInjection:
assert submitted["new_cluster"]["spark_conf"]["spark.executor.memory"]
== "8g"
assert
submitted["new_cluster"]["spark_conf"]["spark.openlineage.parentJobNamespace"]
== "ns"
+ @mock.patch(
+
"airflow.providers.databricks.utils.openlineage.inject_openlineage_context_into_databricks_job_parameters",
+ autospec=True,
+ )
+ @mock.patch(
+
"airflow.providers.databricks.utils.openlineage.inject_openlineage_properties_into_databricks_job",
+ autospec=True,
+ )
+ def test_injects_context_into_task_parameters_in_addition_to_spark_conf(
+ self, mock_spark_inject, mock_context_inject
+ ):
+ mock_context_inject.side_effect = lambda job_parameters, context: {
+ **job_parameters,
+ "OPENLINEAGE_CONTEXT": "context",
+ }
+ mock_spark_inject.side_effect = lambda job, context,
inject_parent_job_info, inject_transport_info: {
+ **job,
+ "new_cluster": {
+ **job["new_cluster"],
+ "spark_conf": {"spark.openlineage.context": "spark-context"},
+ },
+ }
+ context = {"ti": object()}
+ op = DatabricksSubmitRunOperator(
+ task_id=TASK_ID,
+ new_cluster=NEW_CLUSTER,
+ notebook_task={"notebook_path": "/Users/me/notebook"},
+ openlineage_inject_parent_job_info=True,
+ )
+
+ result = op._prepare_submit_json(context)
+
+ assert result["notebook_task"]["base_parameters"] ==
{"OPENLINEAGE_CONTEXT": "context"}
+ assert result["new_cluster"]["spark_conf"] ==
{"spark.openlineage.context": "spark-context"}
+ mock_context_inject.assert_called_once_with(job_parameters={},
context=context)
+ mock_spark_inject.assert_called_once()
+ spark_call = mock_spark_inject.call_args.kwargs
+ assert spark_call["context"] == context
+ assert spark_call["inject_parent_job_info"] is True
+ assert spark_call["inject_transport_info"] is False
+ assert spark_call["job"]["notebook_task"]["base_parameters"] ==
{"OPENLINEAGE_CONTEXT": "context"}
+
+ @mock.patch(
+
"airflow.providers.databricks.utils.openlineage.inject_openlineage_context_into_databricks_job_parameters",
+ autospec=True,
+ )
+ @mock.patch(
+
"airflow.providers.databricks.utils.openlineage.inject_openlineage_properties_into_databricks_job",
+ autospec=True,
+ )
+ def test_injects_context_into_supported_tasks_without_new_cluster(
+ self, mock_spark_inject, mock_context_inject
+ ):
+ mock_context_inject.side_effect = lambda job_parameters, context: {
+ **job_parameters,
+ "OPENLINEAGE_CONTEXT": "context",
+ }
+ mock_spark_inject.side_effect = lambda job, context,
inject_parent_job_info, inject_transport_info: (
+ job
+ )
+ context = {"ti": object()}
+ op = DatabricksSubmitRunOperator(
+ task_id=TASK_ID,
+ tasks=[
+ {"task_key": "notebook", "notebook_task": {"notebook_path":
"/notebook"}},
+ {"task_key": "wheel", "python_wheel_task": {"package_name":
"package"}},
+ {"task_key": "jar", "spark_jar_task": {"main_class_name":
"Main"}},
+ ],
+ openlineage_inject_parent_job_info=True,
+ )
+
+ result = op._prepare_submit_json(context)
+
+ assert result["tasks"][0]["notebook_task"]["base_parameters"] ==
{"OPENLINEAGE_CONTEXT": "context"}
+ assert result["tasks"][1]["python_wheel_task"]["named_parameters"] == {
+ "OPENLINEAGE_CONTEXT": "context"
+ }
+ assert "OPENLINEAGE_CONTEXT" not in
result["tasks"][2]["spark_jar_task"]
+ assert mock_context_inject.call_count == 2
+ mock_spark_inject.assert_called_once()
+
+ @mock.patch(
+
"airflow.providers.databricks.utils.openlineage.inject_openlineage_context_into_databricks_job_parameters",
+ autospec=True,
+ side_effect=RuntimeError("context generation failed"),
+ )
+ @mock.patch(
+
"airflow.providers.databricks.utils.openlineage.inject_openlineage_properties_into_databricks_job",
+ autospec=True,
+ )
+ def test_context_injection_failure_does_not_block_spark_conf_injection(
+ self, mock_spark_inject, mock_context_inject
+ ):
+ mock_spark_inject.side_effect = lambda job, context,
inject_parent_job_info, inject_transport_info: (
+ job
+ )
+ op = DatabricksSubmitRunOperator(
+ task_id=TASK_ID,
+ notebook_task={"notebook_path": "/Users/me/notebook"},
+ new_cluster=NEW_CLUSTER,
+ openlineage_inject_parent_job_info=True,
+ )
+
+ result = op._prepare_submit_json({"ti": object()})
+
+ assert "base_parameters" not in result["notebook_task"]
+ mock_context_inject.assert_called_once()
+ mock_spark_inject.assert_called_once()
+
+
+class TestDatabricksRunNowOperatorOpenLineageInjection:
+ @mock.patch(
+ "airflow.providers.databricks.utils.openlineage."
+ "inject_openlineage_context_into_databricks_job_parameters",
+ autospec=True,
+ )
+
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook",
autospec=True)
+ def test_inject_openlineage_context_into_job_parameters(self,
db_mock_class, mock_inject):
+ mock_inject.side_effect = lambda job_parameters, context: {
+ **job_parameters,
+ "OPENLINEAGE_CONTEXT":
'{"parent":{"job":{"facets":{"jobType":{"jobType":"TASK"}}}}}',
+ }
+ op = DatabricksRunNowOperator(
+ durable=False,
+ task_id=TASK_ID,
+ job_id=JOB_ID,
+ wait_for_termination=False,
+ openlineage_inject_parent_job_info=True,
+ )
+ db_mock = db_mock_class.return_value
+ db_mock.run_now.return_value = RUN_ID
+ context = {"ti": MagicMock(spec=["stats_tags", "xcom_push"],
stats_tags={})}
+
+ op.execute(context)
+
+ mock_inject.assert_called_once_with(job_parameters={}, context=context)
+ submitted = db_mock.run_now.call_args.args[0]
+ assert submitted["job_parameters"] == {
+ "OPENLINEAGE_CONTEXT":
'{"parent":{"job":{"facets":{"jobType":{"jobType":"TASK"}}}}}'
+ }
+
+ @mock.patch(
+ "airflow.providers.databricks.utils.openlineage."
+ "inject_openlineage_context_into_databricks_job_parameters",
+ autospec=True,
+ side_effect=RuntimeError("context generation failed"),
+ )
+
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook",
autospec=True)
+ def test_injection_failure_leaves_job_parameters_unchanged(self,
db_mock_class, mock_inject):
+ op = DatabricksRunNowOperator(
+ durable=False,
+ task_id=TASK_ID,
+ job_id=JOB_ID,
+ wait_for_termination=False,
+ openlineage_inject_parent_job_info=True,
+ )
+ db_mock = db_mock_class.return_value
+ db_mock.run_now.return_value = RUN_ID
+
+ op.execute({"ti": MagicMock(spec=["stats_tags", "xcom_push"],
stats_tags={})})
+
+ mock_inject.assert_called_once()
+ submitted = db_mock.run_now.call_args.args[0]
+ assert "job_parameters" not in submitted
+
+ @mock.patch(
+ "airflow.providers.databricks.utils.openlineage."
+ "inject_openlineage_context_into_databricks_job_parameters",
+ autospec=True,
+ )
+
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook",
autospec=True)
+ def test_does_not_inject_parent_job_info_when_disabled(self,
db_mock_class, mock_inject):
+ op = DatabricksRunNowOperator(
+ durable=False,
+ task_id=TASK_ID,
+ job_id=JOB_ID,
+ wait_for_termination=False,
+ openlineage_inject_parent_job_info=False,
+ )
+ db_mock = db_mock_class.return_value
+ db_mock.run_now.return_value = RUN_ID
+
+ op.execute({"ti": MagicMock(spec=["stats_tags", "xcom_push"],
stats_tags={})})
+
+ mock_inject.assert_not_called()
+
+ @mock.patch(
+ "airflow.providers.databricks.utils.openlineage."
+ "inject_openlineage_context_into_databricks_job_parameters",
+ autospec=True,
+ )
+
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook",
autospec=True)
+ def test_skips_injection_with_legacy_parameter_slots(self, db_mock_class,
mock_inject):
+ op = DatabricksRunNowOperator(
+ durable=False,
+ task_id=TASK_ID,
+ job_id=JOB_ID,
+ notebook_params={"input": "value"},
+ wait_for_termination=False,
+ openlineage_inject_parent_job_info=True,
+ )
+ db_mock = db_mock_class.return_value
+ db_mock.run_now.return_value = RUN_ID
+
+ op.execute({"ti": MagicMock(spec=["stats_tags", "xcom_push"],
stats_tags={})})
+
+ mock_inject.assert_not_called()
+ submitted = db_mock.run_now.call_args.args[0]
+ assert submitted["notebook_params"] == {"input": "value"}
+ assert "job_parameters" not in submitted
+
@pytest.mark.skipif(
not AIRFLOW_V_3_3_PLUS, reason="task_state_store (durable execution)
requires Airflow 3.3+"
diff --git
a/providers/databricks/tests/unit/databricks/utils/test_openlineage.py
b/providers/databricks/tests/unit/databricks/utils/test_openlineage.py
index cefa881c7ec..7517a160370 100644
--- a/providers/databricks/tests/unit/databricks/utils/test_openlineage.py
+++ b/providers/databricks/tests/unit/databricks/utils/test_openlineage.py
@@ -18,6 +18,8 @@ from __future__ import annotations
import copy
import datetime
+import json
+from types import SimpleNamespace
from unittest import mock
import pytest
@@ -40,6 +42,7 @@ from airflow.providers.databricks.utils.openlineage import (
_process_data_from_api,
_run_api_call,
emit_openlineage_events_for_databricks_queries,
+ inject_openlineage_context_into_databricks_job_parameters,
inject_openlineage_properties_into_databricks_job,
)
from airflow.providers.openlineage.conf import namespace
@@ -1203,6 +1206,143 @@ def
test_emit_openlineage_events_with_old_openlineage_provider(mock_version):
OL_UTILS = "airflow.providers.databricks.utils.openlineage"
[email protected](
+ ("dag_run_conf", "expected_root_job_type"),
+ [
+ pytest.param(
+ {},
+ {"processingType": "BATCH", "integration": "AIRFLOW", "jobType":
"DAG"},
+ id="current-dag-is-root",
+ ),
+ pytest.param(
+ {
+ "openlineage": {
+ "rootParentRunId": "11111111-1111-1111-1111-111111111111",
+ "rootParentJobNamespace": "external_namespace",
+ "rootParentJobName": "external_job",
+ }
+ },
+ None,
+ id="inherited-root-without-job-type",
+ ),
+ pytest.param(
+ {
+ "openlineage": {
+ "rootParentRunId": "11111111-1111-1111-1111-111111111111",
+ "rootParentJobNamespace": "external_namespace",
+ "rootParentJobName": "external_job",
+ "rootParentJobType": {
+ "processingType": "STREAMING",
+ "integration": "CUSTOM",
+ "jobType": "PIPELINE",
+ },
+ }
+ },
+ {"processingType": "STREAMING", "integration": "CUSTOM",
"jobType": "PIPELINE"},
+ id="inherited-root-with-job-type",
+ ),
+ pytest.param(
+ {
+ "openlineage": {
+ "parentRunId": "22222222-2222-2222-2222-222222222222",
+ "parentJobNamespace": "external_namespace",
+ "parentJobName": "external_job",
+ "rootParentJobType": {
+ "processingType": "BATCH",
+ "integration": "DBT",
+ "jobType": "JOB",
+ },
+ }
+ },
+ {"processingType": "BATCH", "integration": "DBT", "jobType":
"JOB"},
+ id="parent-is-inherited-root-with-job-type",
+ ),
+ pytest.param(
+ {
+ "openlineage": {
+ "rootParentRunId": "invalid-run-id",
+ "rootParentJobNamespace": "external_namespace",
+ "rootParentJobName": "external_job",
+ }
+ },
+ {"processingType": "BATCH", "integration": "AIRFLOW", "jobType":
"DAG"},
+ id="invalid-inherited-root-falls-back-to-current-dag",
+ ),
+ ],
+)
[email protected](f"{OL_UTILS}._get_parent_run_facet", autospec=True)
[email protected](f"{OL_UTILS}._is_openlineage_provider_accessible", autospec=True,
return_value=True)
+def test_inject_openlineage_context_into_job_parameters(
+ mock_accessible, mock_parent, dag_run_conf, expected_root_job_type
+):
+ context = {"ti":
SimpleNamespace(dag_run=SimpleNamespace(conf=dag_run_conf))}
+ parent_run_facet = SimpleNamespace(
+ run=SimpleNamespace(runId="run_id"),
+ job=SimpleNamespace(namespace="namespace", name="dag_id.task_id"),
+ root=SimpleNamespace(
+ run=SimpleNamespace(runId="root_run_id"),
+ job=SimpleNamespace(namespace="namespace", name="dag_id"),
+ ),
+ )
+ mock_parent.return_value = parent_run_facet
+ job_parameters = {"input": "value"}
+
+ result =
inject_openlineage_context_into_databricks_job_parameters(job_parameters,
context)
+
+ assert result["input"] == "value"
+ context_value = json.loads(result["OPENLINEAGE_CONTEXT"])
+ expected_root_job = {
+ "namespace": "namespace",
+ "name": "dag_id",
+ }
+ if expected_root_job_type is not None:
+ expected_root_job["facets"] = {"jobType": expected_root_job_type}
+
+ assert context_value == {
+ "parent": {
+ "run": {"runId": "run_id"},
+ "job": {
+ "namespace": "namespace",
+ "name": "dag_id.task_id",
+ "facets": {
+ "jobType": {"processingType": "BATCH", "integration":
"AIRFLOW", "jobType": "TASK"}
+ },
+ },
+ "root": {
+ "run": {"runId": "root_run_id"},
+ "job": expected_root_job,
+ },
+ }
+ }
+ assert job_parameters == {"input": "value"}
+ mock_parent.assert_called_once_with(context["ti"])
+ mock_accessible.assert_called_once_with()
+
+
[email protected](f"{OL_UTILS}._get_parent_run_facet", autospec=True)
[email protected](f"{OL_UTILS}._is_openlineage_provider_accessible", autospec=True,
return_value=True)
+def
test_inject_openlineage_context_preserves_existing_parameters(mock_accessible,
mock_parent):
+ job_parameters = {"OPENLINEAGE_CONTEXT": "manual_context", "input":
"value"}
+
+ result =
inject_openlineage_context_into_databricks_job_parameters(job_parameters,
{"ti": object()})
+
+ assert result == job_parameters
+ mock_accessible.assert_not_called()
+ mock_parent.assert_not_called()
+
+
[email protected](f"{OL_UTILS}._get_parent_run_facet", autospec=True)
[email protected](f"{OL_UTILS}._is_openlineage_provider_accessible", autospec=True,
return_value=False)
+def test_inject_openlineage_context_provider_inaccessible(mock_accessible,
mock_parent):
+ job_parameters = {"input": "value"}
+
+ result =
inject_openlineage_context_into_databricks_job_parameters(job_parameters,
{"ti": object()})
+
+ assert result == job_parameters
+ mock_accessible.assert_called_once_with()
+ mock_parent.assert_not_called()
+
+
def test_extract_new_clusters_from_databricks_job():
top_cluster = {"spark_version": "13.3.x-scala2.12"}
task_cluster = {"spark_version": "14.3.x-scala2.12"}
diff --git a/providers/openlineage/docs/spark.rst
b/providers/openlineage/docs/spark.rst
index a324508b55c..d7c9b12b845 100644
--- a/providers/openlineage/docs/spark.rst
+++ b/providers/openlineage/docs/spark.rst
@@ -100,6 +100,7 @@ Automatic injection is supported for the following
operators:
- :class:`~airflow.providers.amazon.aws.operators.glue.GlueJobOperator`
- :class:`~airflow.providers.apache.livy.operators.livy.LivyOperator`
-
:class:`~airflow.providers.apache.spark.operators.spark_submit.SparkSubmitOperator`
+-
:class:`~airflow.providers.databricks.operators.databricks.DatabricksRunNowOperator`
-
:class:`~airflow.providers.databricks.operators.databricks.DatabricksSubmitRunOperator`
-
:class:`~airflow.providers.google.cloud.operators.dataproc.DataprocCreateBatchOperator`
-
:class:`~airflow.providers.google.cloud.operators.dataproc.DataprocInstantiateInlineWorkflowTemplateOperator`
@@ -125,6 +126,11 @@ Automatic injection is supported for the following
operators:
:class:`~airflow.providers.amazon.aws.operators.emr.EmrServerlessStartJobOperator`
inject parent and transport
properties through their ``spark-defaults`` configuration.
+
:class:`~airflow.providers.databricks.operators.databricks.DatabricksRunNowOperator`
injects the standardized
+ ``OPENLINEAGE_CONTEXT`` through Databricks job parameters. The
+
:class:`~airflow.providers.databricks.operators.databricks.DatabricksSubmitRunOperator`
supports the same
+ context in dict-shaped task parameters and retains its Spark
``spark_conf`` injection for Spark jobs.
+
.. _options:spark_inject_parent_job_info: