This is an automated email from the ASF dual-hosted git repository.
shahar1 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 8a065ef7131 Add OpenLineage parent info to EMR Spark steps (#70182)
8a065ef7131 is described below
commit 8a065ef7131d0f53d253bb5c341714f76e0970cc
Author: Maciej Obuchowski <[email protected]>
AuthorDate: Thu Jul 23 13:25:35 2026 +0200
Add OpenLineage parent info to EMR Spark steps (#70182)
---
providers/amazon/docs/operators/emr/emr.rst | 17 +++
.../airflow/providers/amazon/aws/operators/emr.py | 41 ++++++
.../amazon/aws/operators/test_emr_add_steps.py | 137 +++++++++++++++++++++
providers/openlineage/docs/spark.rst | 38 ++++++
4 files changed, 233 insertions(+)
diff --git a/providers/amazon/docs/operators/emr/emr.rst
b/providers/amazon/docs/operators/emr/emr.rst
index 2d63fafcc26..426ddf604ba 100644
--- a/providers/amazon/docs/operators/emr/emr.rst
+++ b/providers/amazon/docs/operators/emr/emr.rst
@@ -118,6 +118,23 @@ available in your deployment.
:start-after: [START howto_operator_emr_add_steps]
:end-before: [END howto_operator_emr_add_steps]
+OpenLineage parent job information
+""""""""""""""""""""""""""""""""""
+
+For Spark steps launched through ``command-runner.jar`` with ``spark-submit``
or ``run-example``,
+:class:`~airflow.providers.amazon.aws.operators.emr.EmrAddStepsOperator` can
inject OpenLineage parent job
+information into the Spark arguments. This links OpenLineage events emitted by
the Spark application to the
+Airflow task that submitted the step.
+
+Enable injection globally with the ``[openlineage]
spark_inject_parent_job_info`` configuration option, or for
+one operator by passing ``openlineage_inject_parent_job_info=True``. The
operator preserves manually configured
+``spark.openlineage.parent*`` properties and does not modify non-Spark steps.
The Spark application must still
+have the OpenLineage Spark integration installed and enabled.
+
+See the `OpenLineage Spark automatic injection documentation
+<https://airflow.apache.org/docs/apache-airflow-providers-openlineage/stable/spark.html#automatic-injection>`__
+for configuration details and an example.
+
.. _howto/operator:EmrTerminateJobFlowOperator:
Terminate an EMR job flow
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/operators/emr.py
b/providers/amazon/src/airflow/providers/amazon/aws/operators/emr.py
index c1e5ee91556..aab67520149 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/emr.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/emr.py
@@ -18,6 +18,7 @@
from __future__ import annotations
import ast
+import copy
import warnings
from collections.abc import Sequence
from datetime import timedelta
@@ -60,6 +61,7 @@ from airflow.providers.amazon.aws.utils.waiter_with_logging
import wait
from airflow.providers.amazon.version_compat import NOTSET, ArgNotSet
from airflow.providers.common.compat.openlineage.utils.spark import (
inject_parent_job_information_into_emr_serverless_properties,
+ inject_parent_job_information_into_spark_properties,
inject_transport_information_into_emr_serverless_properties,
)
from airflow.providers.common.compat.sdk import AirflowException, conf
@@ -100,6 +102,9 @@ class EmrAddStepsOperator(AwsBaseOperator[EmrHook]):
:param deferrable: If True, the operator will wait asynchronously for the
job to complete.
This implies waiting for completion. This mode requires aiobotocore
module to be installed.
(default: False)
+ :param openlineage_inject_parent_job_info: If True, injects OpenLineage
parent job information
+ into Spark steps so the Spark job emits a ``parentRunFacet`` linking
back to the Airflow task.
+ Defaults to the ``openlineage.spark_inject_parent_job_info`` config
value.
"""
aws_hook_class = EmrHook
@@ -130,6 +135,9 @@ class EmrAddStepsOperator(AwsBaseOperator[EmrHook]):
waiter_max_attempts: int = 60,
execution_role_arn: str | None = None,
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
+ ),
**kwargs,
):
if not exactly_one(job_flow_id is None, job_flow_name is None):
@@ -146,6 +154,36 @@ class EmrAddStepsOperator(AwsBaseOperator[EmrHook]):
self.waiter_max_attempts = waiter_max_attempts
self.execution_role_arn = execution_role_arn
self.deferrable = deferrable
+ self.openlineage_inject_parent_job_info =
openlineage_inject_parent_job_info
+
+ def _inject_openlineage_parent_job_information(self, steps: list[dict],
context: Context) -> list[dict]:
+ parent_job_information =
inject_parent_job_information_into_spark_properties({}, context)
+ if not parent_job_information:
+ return steps
+
+ parent_conf = [
+ argument
+ for key, value in parent_job_information.items()
+ for argument in ("--conf", f"{key}={value}")
+ ]
+ result = copy.deepcopy(steps)
+ for step in result:
+ hadoop_jar_step = step.get("HadoopJarStep", {})
+ arguments = hadoop_jar_step.get("Args", [])
+ if hadoop_jar_step.get("Jar", "").rsplit("/", 1)[-1] !=
"command-runner.jar" or not arguments:
+ continue
+ command = arguments[0].rsplit("/", 1)[-1]
+ if command not in {"run-example", "spark-submit"}:
+ continue
+ if any("spark.openlineage.parent" in argument for argument in
arguments):
+ self.log.info(
+ "Some OpenLineage properties with parent job information
are already present "
+ "in EMR Spark step arguments. Skipping injection for step
`%s`.",
+ step.get("Name", ""),
+ )
+ continue
+ hadoop_jar_step["Args"] = [arguments[0], *parent_conf,
*arguments[1:]]
+ return result
def execute(self, context: Context) -> list[str]:
job_flow_id = self.job_flow_id or self.hook.get_cluster_id_by_name(
@@ -181,6 +219,9 @@ class EmrAddStepsOperator(AwsBaseOperator[EmrHook]):
steps = self.steps
if isinstance(steps, str):
steps = ast.literal_eval(steps)
+ if self.openlineage_inject_parent_job_info:
+ self.log.info("Injecting OpenLineage parent job information into
EMR Spark steps.")
+ steps = self._inject_openlineage_parent_job_information(steps,
context)
step_ids = self.hook.add_job_flow_steps(
job_flow_id=job_flow_id,
steps=steps,
diff --git
a/providers/amazon/tests/unit/amazon/aws/operators/test_emr_add_steps.py
b/providers/amazon/tests/unit/amazon/aws/operators/test_emr_add_steps.py
index e52eb36e7bb..7a8d6b3892e 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_emr_add_steps.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_emr_add_steps.py
@@ -323,3 +323,140 @@ class TestEmrAddStepsOperator:
steps=self._config,
)
validate_template_fields(op)
+
+ @patch(
+
"airflow.providers.amazon.aws.operators.emr.inject_parent_job_information_into_spark_properties",
+ autospec=True,
+ )
+ def test_inject_openlineage_parent_job_information(self, mock_inject,
mocked_hook_client):
+ mock_inject.return_value = {
+ "spark.openlineage.parentRunId": "parent-run-id",
+ "spark.openlineage.parentJobName": "test_dag_id.test_task",
+ }
+ mocked_hook_client.add_job_flow_steps.return_value =
ADD_STEPS_SUCCESS_RETURN
+ steps = [
+ {
+ "Name": "spark-submit",
+ "HadoopJarStep": {
+ "Jar": "command-runner.jar",
+ "Args": ["spark-submit", "--deploy-mode", "cluster",
"s3://bucket/job.py"],
+ },
+ },
+ {
+ "Name": "run-example",
+ "HadoopJarStep": {
+ "Jar": "command-runner.jar",
+ "Args": ["/usr/lib/spark/bin/run-example", "SparkPi",
"10"],
+ },
+ },
+ {
+ "Name": "existing-parent-information",
+ "HadoopJarStep": {
+ "Jar": "command-runner.jar",
+ "Args": [
+ "spark-submit",
+ "--conf",
+ "spark.openlineage.parentRunId=existing-run-id",
+ "s3://bucket/job.py",
+ ],
+ },
+ },
+ {
+ "Name": "non-spark",
+ "HadoopJarStep": {
+ "Jar": "command-runner.jar",
+ "Args": ["bash", "-c", "echo done"],
+ },
+ },
+ {
+ "Name": "custom-jar",
+ "HadoopJarStep": {
+ "Jar": "s3://bucket/job.jar",
+ "Args": ["spark-submit", "application-argument"],
+ },
+ },
+ {
+ "Name": "no-arguments",
+ "HadoopJarStep": {"Jar": "command-runner.jar"},
+ },
+ ]
+ context = MagicMock(spec=dict)
+ operator = EmrAddStepsOperator(
+ task_id="test_task",
+ job_flow_id="j-8989898989",
+ aws_conn_id="aws_default",
+ steps=steps,
+ openlineage_inject_parent_job_info=True,
+ dag=DAG("test_dag_id", schedule=None, default_args=self.args),
+ )
+
+ operator.execute(context)
+
+ submitted_steps =
mocked_hook_client.add_job_flow_steps.call_args.kwargs["Steps"]
+ assert submitted_steps[0]["HadoopJarStep"]["Args"] == [
+ "spark-submit",
+ "--conf",
+ "spark.openlineage.parentRunId=parent-run-id",
+ "--conf",
+ "spark.openlineage.parentJobName=test_dag_id.test_task",
+ "--deploy-mode",
+ "cluster",
+ "s3://bucket/job.py",
+ ]
+ assert submitted_steps[1]["HadoopJarStep"]["Args"] == [
+ "/usr/lib/spark/bin/run-example",
+ "--conf",
+ "spark.openlineage.parentRunId=parent-run-id",
+ "--conf",
+ "spark.openlineage.parentJobName=test_dag_id.test_task",
+ "SparkPi",
+ "10",
+ ]
+ assert submitted_steps[2:] == steps[2:]
+ assert operator.steps == steps
+ mock_inject.assert_called_once_with({}, context)
+
+ @pytest.mark.parametrize(
+ ("enabled", "parent_job_information", "expected_call_count"),
+ [
+ pytest.param(False, {"spark.openlineage.parentRunId":
"parent-run-id"}, 0, id="disabled"),
+ pytest.param(True, {}, 1, id="no-parent-information"),
+ ],
+ )
+ @patch(
+
"airflow.providers.amazon.aws.operators.emr.inject_parent_job_information_into_spark_properties",
+ autospec=True,
+ )
+ def test_does_not_inject_openlineage_parent_job_information(
+ self,
+ mock_inject,
+ enabled,
+ parent_job_information,
+ expected_call_count,
+ mocked_hook_client,
+ ):
+ mock_inject.return_value = parent_job_information
+ mocked_hook_client.add_job_flow_steps.return_value =
ADD_STEPS_SUCCESS_RETURN
+ steps = [
+ {
+ "Name": "spark-submit",
+ "HadoopJarStep": {
+ "Jar": "command-runner.jar",
+ "Args": ["spark-submit", "s3://bucket/job.py"],
+ },
+ }
+ ]
+ operator = EmrAddStepsOperator(
+ task_id="test_task",
+ job_flow_id="j-8989898989",
+ aws_conn_id="aws_default",
+ steps=steps,
+ openlineage_inject_parent_job_info=enabled,
+ dag=DAG("test_dag_id", schedule=None, default_args=self.args),
+ )
+
+ operator.execute(MagicMock(spec=dict))
+
+ submitted_steps =
mocked_hook_client.add_job_flow_steps.call_args.kwargs["Steps"]
+ assert submitted_steps == steps
+ assert mock_inject.call_count == expected_call_count
diff --git a/providers/openlineage/docs/spark.rst
b/providers/openlineage/docs/spark.rst
index 6e8647bddc9..a324508b55c 100644
--- a/providers/openlineage/docs/spark.rst
+++ b/providers/openlineage/docs/spark.rst
@@ -94,6 +94,9 @@ to manually configure these properties in every Spark
operator.
Automatic injection is supported for the following operators:
+- :class:`~airflow.providers.amazon.aws.operators.emr.EmrAddStepsOperator`
+- :class:`~airflow.providers.amazon.aws.operators.emr.EmrContainerOperator`
+-
:class:`~airflow.providers.amazon.aws.operators.emr.EmrServerlessStartJobOperator`
- :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`
@@ -110,6 +113,18 @@ Automatic injection is supported for the following
operators:
job itself must still have the Spark OpenLineage integration (the
``OpenLineageSparkListener``) enabled for
lineage to be emitted.
+.. note::
+
+ :class:`~airflow.providers.amazon.aws.operators.emr.EmrAddStepsOperator`
supports parent job information
+ injection for classic EMR steps that use ``command-runner.jar`` to launch
``spark-submit`` or ``run-example``.
+ It adds each property as a separate ``--conf key=value`` argument before
the existing Spark arguments.
+ Steps with manually configured ``spark.openlineage.parent*`` properties
and non-Spark steps are left unchanged.
+ Automatic transport injection is not supported for this operator.
+
+ :class:`~airflow.providers.amazon.aws.operators.emr.EmrContainerOperator`
and
+
:class:`~airflow.providers.amazon.aws.operators.emr.EmrServerlessStartJobOperator`
inject parent and transport
+ properties through their ``spark-defaults`` configuration.
+
.. _options:spark_inject_parent_job_info:
@@ -217,6 +232,29 @@ This allows you to customize the injection behavior for
specific operators while
},
)
+For a classic EMR Spark step, enable parent job information injection on
+:class:`~airflow.providers.amazon.aws.operators.emr.EmrAddStepsOperator`:
+
+.. code-block:: python
+
+ from airflow.providers.amazon.aws.operators.emr import EmrAddStepsOperator
+
+ EmrAddStepsOperator(
+ task_id="submit_spark_step",
+ job_flow_id="j-1234567890",
+ steps=[
+ {
+ "Name": "process-data",
+ "ActionOnFailure": "CONTINUE",
+ "HadoopJarStep": {
+ "Jar": "command-runner.jar",
+ "Args": ["spark-submit",
"s3://example-bucket/jobs/process_data.py"],
+ },
+ }
+ ],
+ openlineage_inject_parent_job_info=True,
+ )
+
Complete Example