This is an automated email from the ASF dual-hosted git repository.
hussein-awala 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 d561f37ea54 Fix OpenLineage task events lost for Dags with mapped
tasks (#75860)
d561f37ea54 is described below
commit d561f37ea5471f4e67bd7e5924aea425ded0bec4
Author: Kacper Muda <[email protected]>
AuthorDate: Thu Oct 29 09:18:13 2026 +0100
Fix OpenLineage task events lost for Dags with mapped tasks (#75860)
* Fix OpenLineage task events lost for Dags with mapped tasks
The listener pickles its facets to send them to a process pool. Info objects
kept the serialized Dag and other build-time objects as attributes, and a
serialized Dag with a mapped operator can't be pickled, so task events
emitted
from the scheduler or API server (e.g. FAIL after an executor-reported
failure)
were silently dropped. Full task info also included the stored operator's
Dag
and methodtools caches, which can't be pickled either.
Co-Authored-By: Claude <[email protected]>
* Ignore test-defined InfoJsonEncodable subclasses in pickling test
Other tests define local subclasses, which stay listed by __subclasses__
when
they run first in the same process, as in the provider compatibility jobs.
Co-Authored-By: Claude <[email protected]>
* Keep only JSON-native values in OpenLineage full task info
Full task info copies operator attributes, and any of them may reach live
Airflow objects that can't be pickled. A mapped task fed from an upstream
task's output does, through its expand input, so its events were still
dropped when full task info was on.
Co-Authored-By: Claude <[email protected]>
* Redact OpenLineage info objects by their dict items
The redactor sent every JSON-serializable object with a `__dict__` to a
branch that only masks attributes. For `InfoJsonEncodable` dict subclasses
that skipped the emitted items, so full task info leaked rendered templated
fields and Dag run `conf` leaked secrets. Dicts now go to the base masker.
Operator attribute values that aren't JSON-native no longer become their
`repr`, which could show secret fields: attrs objects become dicts, so they
stay maskable by key name, and unknown objects get the client's
`<<non-serializable: ...>>` placeholder.
Co-Authored-By: Claude <[email protected]>
---------
Co-authored-by: Claude <[email protected]>
---
.../airflow/providers/openlineage/utils/utils.py | 61 ++++++++---
.../tests/unit/openlineage/plugins/test_utils.py | 48 +++++++--
.../tests/unit/openlineage/utils/test_utils.py | 118 +++++++++++++++++++--
3 files changed, 192 insertions(+), 35 deletions(-)
diff --git
a/providers/openlineage/src/airflow/providers/openlineage/utils/utils.py
b/providers/openlineage/src/airflow/providers/openlineage/utils/utils.py
index 320a324da8d..e7d6303bda3 100644
--- a/providers/openlineage/src/airflow/providers/openlineage/utils/utils.py
+++ b/providers/openlineage/src/airflow/providers/openlineage/utils/utils.py
@@ -22,6 +22,8 @@ import json
import logging
from collections.abc import Callable, Collection, Mapping
from contextlib import suppress
+from decimal import Decimal
+from enum import Enum
from functools import wraps
from importlib import metadata
from typing import TYPE_CHECKING, Any, Literal
@@ -891,7 +893,9 @@ class InfoJsonEncodable(dict):
self,
**{field: InfoJsonEncodable._cast_basic_types(getattr(self,
field)) for field in self._fields},
)
- del self.obj
+ # Drop the build-time attributes, keeping the dict items set above.
Attributes may hold live Airflow
+ # objects that can't be pickled for the listener's process pool, which
would drop the event.
+ self.__dict__.clear()
@staticmethod
def _cast_basic_types(value):
@@ -951,8 +955,13 @@ class InfoJsonEncodable(dict):
"nor is defined as an attrs class."
)
for field, val in obj_fields.items():
+ # Keys that aren't identifiers are set by libraries rather
than being attributes, e.g. the
+ # unpicklable ``__wire|<class>|<method>`` caches of
``methodtools.lru_cache``.
+ if not field.isidentifier():
+ continue
if field not in self._fields and field not in self.excludes
and field not in self.renames:
- setattr(self, field, val)
+ # Any attribute may hold live Airflow objects, even nested
ones that can't be pickled
+ setattr(self, field,
_convert_to_json_safe(self._cast_basic_types(val), field))
self._fields.append(field)
@@ -1491,20 +1500,12 @@ class TaskInfo(InfoJsonEncodable):
def __init__(self, obj, runtime_assets: list[tuple[Asset, AssetAlias |
None]] | None = None):
self._runtime_assets = runtime_assets # Will not be included in final
dict, not in self._fields.
super().__init__(obj)
- # ``inlets`` / ``outlets`` are a Python repr string that consumers
cannot reliably parse
- # (``extra`` can hold quotes, ``Decimal``, ``True``, ...). Expose them
as real lists too.
- # Set after ``super().__init__`` so ``_cast_basic_types`` does not
turn them into strings.
- for field in ("inlets", "outlets"):
- if hasattr(self, field):
- self[f"{field}_list"] =
_convert_to_json_safe_list(getattr(self, field), field)
def _extend_fields(self) -> None:
- if not self._runtime_assets:
- return
- if not hasattr(self, "outlets"): # Should never happen, just in case
+ if self._runtime_assets and not hasattr(self, "outlets"): # Should
never happen, just in case
self.outlets = []
self._fields.append("outlets")
- for asset, alias in self._runtime_assets:
+ for asset, alias in self._runtime_assets or ():
# Append runtime-resolved outlets (alias resolutions + dynamic
asset events).
# Inlets are not touched - Airflow has no dynamic inlets.
self.outlets.append(
@@ -1512,6 +1513,12 @@ class TaskInfo(InfoJsonEncodable):
if alias is None
else AssetInfo(asset, asset_type="asset_event_from_alias",
source_alias=alias.name)
)
+ # ``inlets`` / ``outlets`` are a Python repr string that consumers
cannot reliably parse
+ # (``extra`` can hold quotes, ``Decimal``, ``True``, ...). Expose them
as real lists too.
+ # Set as items rather than fields so ``_cast_basic_types`` does not
turn them into strings.
+ for field in ("inlets", "outlets"):
+ if hasattr(self, field):
+ self[f"{field}_list"] = _convert_to_json_safe(getattr(self,
field), field)
class TaskInfoComplete(TaskInfo):
@@ -1522,6 +1529,9 @@ class TaskInfoComplete(TaskInfo):
"_BaseOperator__instantiated",
"_BaseOperator__init_kwargs", # Causes recursion error on AF3,
nothing useful there
"_dag",
+ "dag",
+ "expand_input",
+ "op_kwargs_expand_input",
"_hook",
"_log",
"_outlets",
@@ -2540,7 +2550,9 @@ class OpenLineageRedactor(SecretsMasker):
),
)
return item
- if is_json_serializable(item) and hasattr(item, "__dict__"):
+ # Dicts go to the base masker, which walks their items; the
attributes of a dict
+ # subclass (e.g. InfoJsonEncodable) aren't what gets emitted.
+ if is_json_serializable(item) and hasattr(item, "__dict__")
and not isinstance(item, dict):
for dict_key, subval in item.__dict__.items():
if type(subval).__name__ == "Proxy":
return "<<non-redactable: Proxy>>"
@@ -2562,16 +2574,31 @@ class OpenLineageRedactor(SecretsMasker):
return item
-def _convert_to_json_safe_list(value: list, name: str) -> list | None:
+def _get_json_native_form(value: Any) -> Any:
+ # Attrs objects become dicts so the redactor can still mask fields by
their names, e.g. ``password``.
+ # Other objects are not cast with ``str()``, as their ``repr`` may show
such fields unmasked.
+ if attrs.has(type(value)):
+ return attrs.asdict(value, recurse=False)
+ if isinstance(value, Enum):
+ return value.value
+ if isinstance(value, (set, frozenset)):
+ return list(value)
+ if isinstance(value, (datetime.date, datetime.time, datetime.timedelta,
Decimal, UUID)):
+ return str(value)
+ return f"<<non-serializable: {type(value).__qualname__}>>"
+
+
+def _convert_to_json_safe(value: Any, name: str) -> Any:
"""
Return ``value`` with only JSON-native types.
- Non-serializable values are cast with ``str()``; dict keys JSON can't
encode (e.g. tuples) are dropped.
+ Non-serializable values are converted with ``_get_json_native_form``; dict
keys JSON can't encode
+ (e.g. tuples) are dropped.
"""
try:
- return json.loads(json.dumps(value, default=str, skipkeys=True))
+ return json.loads(json.dumps(value, default=_get_json_native_form,
skipkeys=True))
except (TypeError, ValueError) as e:
- log.warning("Unable to serialize task `%s` to a JSON list: %s", name,
e)
+ log.warning("Unable to convert `%s` to JSON-native types: %s", name, e)
return None
diff --git a/providers/openlineage/tests/unit/openlineage/plugins/test_utils.py
b/providers/openlineage/tests/unit/openlineage/plugins/test_utils.py
index 88006cf74dc..02ab5a8f0d8 100644
--- a/providers/openlineage/tests/unit/openlineage/plugins/test_utils.py
+++ b/providers/openlineage/tests/unit/openlineage/plugins/test_utils.py
@@ -20,6 +20,7 @@ import datetime
import json
import uuid
from json import JSONEncoder
+from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
@@ -33,8 +34,10 @@ from airflow.providers.common.compat.sdk import timezone
from airflow.providers.openlineage.plugins.facets import AirflowDebugRunFacet
from airflow.providers.openlineage.utils.utils import (
DagInfo,
+ DagRunInfo,
InfoJsonEncodable,
OpenLineageRedactor,
+ TaskInfoComplete,
_get_all_packages_installed,
_is_name_redactable,
get_airflow_debug_facet,
@@ -303,6 +306,31 @@ def test_redact_with_exclusions(monkeypatch):
assert redacted_nested == NestedMixined("***", NestedMixined("passwd",
None))
[email protected]_redact
+def test_redact_masks_items_of_info_json_encodables():
+ sm = SecretsMasker()
+ if AIRFLOW_V_3_1_PLUS:
+ sm.sensitive_variables_fields = list(DEFAULT_SENSITIVE_FIELDS)
+ sm.add_mask("s3cr3tvalue")
+ redactor = OpenLineageRedactor.from_masker(sm)
+
+ @define
+ class Creds:
+ user: str
+ password: str
+
+ task = BashOperator(task_id="t", bash_command="curl -u user:s3cr3tvalue x")
+ task.creds = Creds(user="u", password="unregistered")
+ dag_run = SimpleNamespace(
+ dag_id="d", run_id="r", conf={"password": "unregistered", "cmd": "x
s3cr3tvalue"}
+ )
+
+ redacted_task = redactor.redact(TaskInfoComplete(task))
+ assert redacted_task["bash_command"] == "curl -u user:*** x"
+ assert redacted_task["creds"] == {"user": "u", "password": "***"}
+ assert redactor.redact(DagRunInfo(dag_run))["conf"] == {"password": "***",
"cmd": "x ***"}
+
+
def test_get_fully_qualified_class_name():
from airflow.providers.openlineage.plugins.adapter import
OpenLineageAdapter
@@ -486,7 +514,7 @@ def test_serialize_timetable_complex_with_alias():
}
if AIRFLOW_V_3_4_PLUS:
expected["batch_asset_events"] = True
- assert dag_info.timetable == expected
+ assert dag_info["timetable"] == expected
@pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="This test checks
serialization only in 3.0 conditions")
@@ -504,7 +532,7 @@ def test_serialize_timetable_single_asset():
}
if AIRFLOW_V_3_4_PLUS:
expected["batch_asset_events"] = False
- assert dag_info.timetable == expected
+ assert dag_info["timetable"] == expected
@pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="This test checks
serialization only in 3.0 conditions")
@@ -522,7 +550,7 @@ def test_serialize_timetable_list_of_assets():
}
if AIRFLOW_V_3_4_PLUS:
expected["batch_asset_events"] = True
- assert dag_info.timetable == expected
+ assert dag_info["timetable"] == expected
@pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="This test checks
serialization only in 3.0 conditions")
@@ -581,7 +609,7 @@ def
test_serialize_timetable_with_complex_logical_condition():
}
if AIRFLOW_V_3_4_PLUS:
expected["batch_asset_events"] = True
- assert dag_info.timetable == expected
+ assert dag_info["timetable"] == expected
@pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="This test checks
serialization only in 3.0 conditions")
@@ -655,7 +683,7 @@ def
test_serialize_timetable_with_dataset_or_time_schedule():
}
if AIRFLOW_V_3_4_PLUS:
expected["batch_asset_events"] = True
- assert dag_info.timetable == expected
+ assert dag_info["timetable"] == expected
@pytest.mark.skipif(
@@ -677,7 +705,7 @@ def test_serialize_timetable_2_10_complex_with_alias():
dag.timetable = DatasetTriggeredTimetable(asset)
dag_info = DagInfo(dag)
- assert dag_info.timetable == {
+ assert dag_info["timetable"] == {
"dataset_condition": {
"__type": DagAttributeTypes.DATASET_ANY,
"objects": [
@@ -703,7 +731,7 @@ def test_serialize_timetable_2_10_complex_with_alias():
def test_serialize_timetable_2_10_single_asset():
dag = DAG(dag_id="test", start_date=datetime.datetime(2025, 1, 1),
schedule=Asset("a"))
dag_info = DagInfo(dag)
- assert dag_info.timetable == {
+ assert dag_info["timetable"] == {
"dataset_condition": {"__type": DagAttributeTypes.DATASET, "uri": "a",
"extra": None}
}
@@ -715,7 +743,7 @@ def test_serialize_timetable_2_10_single_asset():
def test_serialize_timetable_2_10_list_of_assets():
dag = DAG(dag_id="test", start_date=datetime.datetime(2025, 1, 1),
schedule=[Asset("a"), Asset("b")])
dag_info = DagInfo(dag)
- assert dag_info.timetable == {
+ assert dag_info["timetable"] == {
"dataset_condition": {
"__type": DagAttributeTypes.DATASET_ALL,
"objects": [
@@ -738,7 +766,7 @@ def
test_serialize_timetable_2_10_with_complex_logical_condition():
& (Asset("ds3") | Asset("ds4", extra={"another_extra": 345})),
)
dag_info = DagInfo(dag)
- assert dag_info.timetable == {
+ assert dag_info["timetable"] == {
"dataset_condition": {
"__type": DagAttributeTypes.DATASET_ALL,
"objects": [
@@ -779,7 +807,7 @@ def
test_serialize_timetable_2_10_with_dataset_or_time_schedule():
),
)
dag_info = DagInfo(dag)
- assert dag_info.timetable == {
+ assert dag_info["timetable"] == {
"timetable": {
"__type": "airflow.timetables.trigger.CronTriggerTimetable",
"__var": {"expression": "0 0 * 3 *", "timezone": "UTC",
"interval": 0.0},
diff --git a/providers/openlineage/tests/unit/openlineage/utils/test_utils.py
b/providers/openlineage/tests/unit/openlineage/utils/test_utils.py
index 9c9518b97bc..756d86fc7e0 100644
--- a/providers/openlineage/tests/unit/openlineage/utils/test_utils.py
+++ b/providers/openlineage/tests/unit/openlineage/utils/test_utils.py
@@ -20,12 +20,15 @@ from __future__ import annotations
import datetime
import enum
import pathlib
+import pickle
+import threading
from decimal import Decimal
from types import SimpleNamespace
from unittest import mock
from unittest.mock import MagicMock, PropertyMock, patch
from uuid import UUID, uuid4
+import attrs
import pendulum
import pytest
from openlineage.client.facet_v2 import parent_run
@@ -51,7 +54,7 @@ from airflow.providers.openlineage.utils.utils import (
TaskInstanceInfo,
TaskInstancePosition,
_build_labeled_edge_map,
- _convert_to_json_safe_list,
+ _convert_to_json_safe,
_extract_ol_info_from_asset_event,
_get_ol_job_dependencies_from_asset_events,
_get_openlineage_data_from_dagrun_conf,
@@ -2615,11 +2618,11 @@ def test_daginfo_timetable_summary():
# timetable is enough to get summary
dag.timetable = NullTimetable()
dag.timetable_summary = None
- assert DagInfo(dag).timetable_summary == "None"
+ assert DagInfo(dag)["timetable_summary"] == "None"
# but if summary is present, it's preferred
dag.timetable_summary = "explicit_summary"
- assert DagInfo(dag).timetable_summary == "explicit_summary"
+ assert DagInfo(dag)["timetable_summary"] == "explicit_summary"
@pytest.mark.skipif(AIRFLOW_V_3_0_PLUS, reason="Airflow 2 tests")
@@ -4642,6 +4645,87 @@ def
test_task_instance_info_of_stored_instance_without_task_does_not_raise():
assert "loop" not in info
+def _get_info_json_encodable_subclasses(cls: type[InfoJsonEncodable] =
InfoJsonEncodable):
+ for subclass in cls.__subclasses__():
+ # Leave out the subclasses other tests define
+ if subclass.__module__ == InfoJsonEncodable.__module__:
+ yield subclass
+ yield from _get_info_json_encodable_subclasses(subclass)
+
+
[email protected]_test
+def
test_info_json_encodables_of_stored_objects_pickle_to_their_items_only(dag_maker):
+ """
+ The scheduler and API server send facets to a process pool, while what
they are built from may not pickle.
+
+ A serialized Dag with a mapped operator doesn't, so no encodable may keep
it or any other built-from object.
+ """
+ asset = Asset("s3://bucket/key")
+ with dag_maker(serialized=True):
+ with TaskGroup("group"):
+ plain = EmptyOperator(task_id="plain", inlets=[asset],
outlets=[asset])
+ plain >> PythonOperator.partial(task_id="mapped",
python_callable=list).expand(op_kwargs=[{}, {}])
+ PythonOperator.partial(task_id="mapped_from_upstream",
python_callable=list).expand(
+ op_kwargs=plain.output
+ )
+ run = dag_maker.create_dagrun()
+ stored = next(ti for ti in run.task_instances if ti.task_id ==
"group.plain")
+ dag = stored.task.dag
+
+ infos = [
+ DagInfo(dag),
+ DagRunInfo(run),
+ TaskInstanceInfo(stored, dag=dag, dag_run=run),
+ AssetInfo(asset),
+ TaskInfo(stored.task, runtime_assets=[(asset, None)]),
+ TaskInfoComplete(stored.task),
+ TaskInfo(dag.get_task("mapped")),
+ TaskInfoComplete(dag.get_task("mapped")),
+ TaskInfo(dag.get_task("mapped_from_upstream")),
+ TaskInfoComplete(dag.get_task("mapped_from_upstream")),
+ TaskGroupInfo(stored.task.task_group),
+ ]
+
+ # A new encodable must be added here too
+ assert {type(info) for info in infos} ==
set(_get_info_json_encodable_subclasses())
+ for info in infos:
+ assert vars(info) == {}
+ unpickled = pickle.loads(pickle.dumps(info))
+ assert type(unpickled) is type(info)
+ assert unpickled == info
+
+
[email protected]_test
+def
test_task_info_complete_of_stored_mapped_task_leaves_out_its_expand_input(dag_maker):
+ with dag_maker(serialized=True):
+
+ @task
+ def produce():
+ return [1, 2]
+
+ @task
+ def consume(x):
+ return x
+
+ produced = produce()
+ consume.expand(x=produced)
+ PythonOperator.partial(task_id="mapped",
python_callable=list).expand(op_kwargs=produced)
+ dag = dag_maker.create_dagrun().task_instances[0].task.dag
+
+ for task_id in ("consume", "mapped"):
+ assert not {"expand_input", "op_kwargs_expand_input"} &
TaskInfoComplete(dag.get_task(task_id)).keys()
+
+
+def
test_task_info_complete_keeps_only_json_native_values_of_operator_attributes():
+ operator = BashOperator(task_id="t", bash_command="echo 1")
+ operator.custom = {"when": datetime.datetime(2026, 1, 2), "lock":
threading.Lock()}
+
+ info = TaskInfoComplete(operator)
+
+ assert info["custom"] == {"when": "2026-01-02 00:00:00", "lock":
"<<non-serializable: lock>>"}
+ pickle.dumps(info)
+
+
def test_task_instance_info_uses_the_position_it_is_given():
# The task instance's own loop and expansion differ, so the info can only
match the given position
ti = SimpleNamespace(
@@ -6085,6 +6169,16 @@ class _DatasetName(str, enum.Enum):
SYNC = "user_sync"
+class _Color(enum.Enum):
+ RED = 1
+
+
[email protected]
+class _Creds:
+ user: str
+ password: str
+
+
@pytest.mark.parametrize(
("value", "expected"),
[
@@ -6100,19 +6194,27 @@ class _DatasetName(str, enum.Enum):
pytest.param(
[{"nested": {"items": ["a", None, 1.0]}}], [{"nested": {"items":
["a", None, 1.0]}}], id="nested"
),
+ pytest.param([{"c": _Color.RED}], [{"c": 1}], id="enum_uses_value"),
+ pytest.param([{"s": {"a"}}], [{"s": ["a"]}], id="set"),
+ pytest.param(
+ [{"creds": _Creds("u", "p")}], [{"creds": {"user": "u",
"password": "p"}}], id="attrs_as_dict"
+ ),
+ pytest.param(
+ [{"lock": threading.Lock()}], [{"lock": "<<non-serializable:
lock>>"}], id="other_object"
+ ),
],
)
-def test_convert_to_json_safe_list(value, expected):
- assert _convert_to_json_safe_list(value, "outlets") == expected
+def test_convert_to_json_safe(value, expected):
+ assert _convert_to_json_safe(value, "outlets") == expected
@patch("airflow.providers.openlineage.utils.utils.log", autospec=True)
-def test_convert_to_json_safe_list_returns_none_on_failure(mock_log):
+def test_convert_to_json_safe_returns_none_on_failure(mock_log):
circular: list = []
circular.append(circular)
- assert _convert_to_json_safe_list(circular, "outlets") is None
+ assert _convert_to_json_safe(circular, "outlets") is None
mock_log.warning.assert_called_once_with(
- "Unable to serialize task `%s` to a JSON list: %s", "outlets", mock.ANY
+ "Unable to convert `%s` to JSON-native types: %s", "outlets", mock.ANY
)
assert str(mock_log.warning.call_args.args[2]) == "Circular reference
detected"