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 cbe4028f546 manual state change should not use fork-execute model on
scheduler (#65677)
cbe4028f546 is described below
commit cbe4028f546719586d5b1c4bd9ab2ffe819d7e1e
Author: Maciej Obuchowski <[email protected]>
AuthorDate: Mon Apr 27 17:29:04 2026 +0200
manual state change should not use fork-execute model on scheduler (#65677)
* manual state change should not use fork-execute model on scheduler
Signed-off-by: Maciej Obuchowski <[email protected]>
* Fix mypy errors in _on_task_instance_manual_state_change
- Assigning isoformat() string to datetime-typed variable confused mypy
now that the assignment is in the outer method scope (previously inside
a nested closure where inference behaved differently).
- ti.operator is str | None; guard against None before .lower().
Signed-off-by: Maciej Obuchowski <[email protected]>
* code review updates
Signed-off-by: Maciej Obuchowski <[email protected]>
* statsd fix
Signed-off-by: Maciej Obuchowski <[email protected]>
* revert: keep `if not self.executor:` guard untouched in this PR
The four `if not self.executor:` → `if self._executor is None:` changes in
`1f2fb073e9` honored the spirit of potiuk's review nit but ignored his
explicit guidance: "Don't fix in this PR." The original guard was
unreachable as a log line but had a load-bearing side effect — accessing
the lazy `executor` property *creates* the pool, which is what
`submit_callable` then submits to. Replacing it with the side-effect-free
`self._executor is None` short-circuited before pool creation, breaking
`test_listener_on_dag_run_state_changes_configure_process_pool_size` in
CI. Reverting; cleanup is appropriate as a separate PR.
Signed-off-by: Maciej Obuchowski <[email protected]>
---------
Signed-off-by: Maciej Obuchowski <[email protected]>
---
.../providers/openlineage/plugins/listener.py | 112 +++++++++++++++------
.../unit/openlineage/plugins/test_listener.py | 55 +++++++++-
2 files changed, 133 insertions(+), 34 deletions(-)
diff --git
a/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
b/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
index ee1007fba61..69c9f779d3f 100644
---
a/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
+++
b/providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py
@@ -74,11 +74,37 @@ def _executor_initializer():
This function must be picklable, so it cannot be defined as an inner
method or local function.
Reconfigures the ORM engine to prevent issues that arise when multiple
processes interact with
- the Airflow database.
+ the Airflow database, and re-initializes ``Stats`` so that metrics emitted
from worker
+ processes (e.g. ``ol.event.size.*`` from
``_emit_manual_state_change_event``) are routed to
+ the configured statsd backend instead of being silently dropped by
``NoStatsLogger`` — the
+ parent's ``Stats.initialize(...)`` call from scheduler startup does not
propagate across the
+ spawn boundary.
"""
# This initializer is used only on the scheduler
# We can configure_orm regardless of the Airflow version, as DB access is
always allowed from scheduler.
settings.configure_orm()
+ try:
+ from airflow.observability.metrics import stats_utils
+
+ Stats.initialize(factory=stats_utils.get_stats_factory(Stats))
+ except ImportError:
+ # ``stats_utils`` lives under ``airflow.observability.metrics`` in
current Airflow; if the
+ # import path changes or is unavailable, fall through silently — gauge
calls will simply
+ # land on ``NoStatsLogger`` as before, which is no worse than current
behavior.
+ pass
+
+
+def _emit_manual_state_change_event(adapter_method, stats_key, **kwargs):
+ """
+ Emit an OL event via the given adapter method and record its serialized
size.
+
+ Module-level so it is picklable across the ProcessPoolExecutor boundary
used by
+ `_on_task_instance_manual_state_change` for scheduler-side "task state
changed
+ externally" emissions.
+ """
+ event = adapter_method(**kwargs)
+ Stats.gauge(stats_key, len(Serde.to_json(event).encode("utf-8")))
+ return event
class OpenLineageListener:
@@ -653,6 +679,17 @@ class OpenLineageListener:
ti_state: TaskInstanceState,
error: None | str | BaseException = None,
) -> None:
+ """
+ Emit an OL event from the scheduler when a TI transitions externally.
+
+ This path is only reached on the scheduler (``process_executor_events
->
+ handle_failure``, or manual UI/API state changes). Emission is routed
through
+ the same ``ProcessPoolExecutor`` the DAG-run listeners use rather than
through
+ ``_fork_execute``: the pool's ``_executor_initializer`` rebuilds the
ORM once
+ per worker, so the child never shares a pooled Postgres SSL connection
with
+ the scheduler, and bursts of external-state-change events no longer
produce a
+ fork-per-event.
+ """
self.log.debug("`_on_task_instance_manual_state_change` was called
with state: `%s`.", ti_state)
end_date = timezone.utcnow()
@@ -674,15 +711,25 @@ class OpenLineageListener:
)
return
- @print_warning(self.log)
- def on_state_change():
- date = dagrun.logical_date or dagrun.run_after
- parent_run_id = self.adapter.build_dag_run_id(
- dag_id=ti.dag_id,
- logical_date=date,
- clear_number=dagrun.clear_number,
- )
+ try:
+ if not self.executor:
+ self.log.debug("Executor has not started before
`_on_task_instance_manual_state_change`")
+ return
+
+ if ti_state == TaskInstanceState.FAILED:
+ adapter_method = self.adapter.fail_task
+ event_type = RunState.FAIL.value.lower()
+ elif ti_state in (TaskInstanceState.SUCCESS,
TaskInstanceState.SKIPPED):
+ adapter_method = self.adapter.complete_task
+ event_type = RunState.COMPLETE.value.lower()
+ else:
+ raise ValueError(f"Unsupported ti_state: `{ti_state}`.")
+ # Extract primitives from live ORM objects in the parent
(scheduler)
+ # before crossing the pool boundary. Passing ORM objects through
the pool
+ # pickler loses TaskGroup attributes and crashes event emission --
see
+ # the equivalent note in `on_dag_run_running` (listener.py ~868).
+ date = dagrun.logical_date or dagrun.run_after
task_uuid = self.adapter.build_task_instance_run_id(
dag_id=ti.dag_id,
task_id=ti.task_id,
@@ -690,29 +737,38 @@ class OpenLineageListener:
logical_date=date,
map_index=ti.map_index,
)
+ parent_run_id = self.adapter.build_dag_run_id(
+ dag_id=ti.dag_id,
+ logical_date=date,
+ clear_number=dagrun.clear_number,
+ )
- data_interval_start = dagrun.data_interval_start
+ # Mirror the pattern used in the other listener call sites: convert
+ # `datetime` to ISO-8601 string, but preserve any non-`datetime`
+ # value as-is in case a duck-typed caller already passed a string.
+ data_interval_start: str | datetime | None =
dagrun.data_interval_start
if isinstance(data_interval_start, datetime):
data_interval_start = data_interval_start.isoformat()
- data_interval_end = dagrun.data_interval_end
+ data_interval_end: str | datetime | None = dagrun.data_interval_end
if isinstance(data_interval_end, datetime):
data_interval_end = data_interval_end.isoformat()
- dag_tags, owners, doc, doc_type = None, None, None, None
- airflow_run_facet = {}
+ dag_tags: list | None = None
+ owners: list[str] | None = None
+ doc: str | None = None
+ doc_type: str | None = None
+ airflow_run_facet: dict = {}
if task: # on scheduler, we should have access to task
doc, doc_type = get_task_documentation(task)
dag = getattr(task, "dag")
if dag:
if not doc:
doc, doc_type = get_dag_documentation(dag)
-
dag_tags = dag.tags
owners = [x.strip() for x in (task if task.owner !=
"airflow" else dag).owner.split(",")]
-
airflow_run_facet = get_airflow_run_facet(dagrun, dag, ti,
task, task_uuid)
- adapter_kwargs = {
+ adapter_kwargs: dict = {
"run_id": task_uuid,
"job_name": get_job_name(ti),
"end_time": end_date.isoformat(),
@@ -733,23 +789,21 @@ class OpenLineageListener:
**get_airflow_debug_facet(),
},
}
-
if ti_state == TaskInstanceState.FAILED:
- event_type = RunState.FAIL.value.lower()
- redacted_event = self.adapter.fail_task(**adapter_kwargs,
error=error)
- elif ti_state in (TaskInstanceState.SUCCESS,
TaskInstanceState.SKIPPED):
- event_type = RunState.COMPLETE.value.lower()
- redacted_event = self.adapter.complete_task(**adapter_kwargs)
- else:
- raise ValueError(f"Unsupported ti_state: `{ti_state}`.")
+ adapter_kwargs["error"] = error
- operator_name = ti.operator.lower()
- Stats.gauge(
+ operator_name = (ti.operator or "unknown").lower()
+ self.submit_callable(
+ _emit_manual_state_change_event,
+ adapter_method,
f"ol.event.size.{event_type}.{operator_name}",
- len(Serde.to_json(redacted_event).encode("utf-8")),
+ **adapter_kwargs,
+ )
+ except BaseException as e:
+ self.log.warning(
+ "OpenLineage received exception in method
`_on_task_instance_manual_state_change`",
+ exc_info=e,
)
-
- self._execute(on_state_change, "on_state_change", use_fork=True)
def _execute(self, callable, callable_name: str, use_fork: bool = False):
if use_fork:
diff --git
a/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
b/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
index 608e789436e..1183ce91f60 100644
--- a/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
+++ b/providers/openlineage/tests/unit/openlineage/plugins/test_listener.py
@@ -85,6 +85,23 @@ def regular_call(self, callable, callable_name, use_fork):
callable()
+def direct_submit_call(self, callable, *args, **kwargs):
+ """Synchronous stand-in for ``OpenLineageListener.submit_callable``.
+
+ Bypasses the ``ProcessPoolExecutor`` so tests can assert against mocked
+ adapter methods without hitting pickling of ``unittest.mock.Mock``.
+ When the submitted callable is ``_emit_manual_state_change_event``, skip
+ its ``Stats.gauge`` side effect (which would try to ``Serde.to_json`` a
+ ``MagicMock`` return value) and invoke the adapter method directly.
+ """
+ from airflow.providers.openlineage.plugins.listener import
_emit_manual_state_change_event
+
+ if callable is _emit_manual_state_change_event:
+ adapter_method, _stats_key, *_ = args
+ return adapter_method(**kwargs)
+ return callable(*args, **kwargs)
+
+
class MockExecutor:
def __init__(self, *args, **kwargs):
self.submitted = False
@@ -1457,13 +1474,15 @@ class TestOpenLineageListenerAirflow3:
assert listener.adapter.fail_task.call_args.kwargs["job_description"]
== "Test DAG Description"
assert
listener.adapter.fail_task.call_args.kwargs["job_description_type"] ==
"text/plain"
+
@mock.patch("airflow.providers.openlineage.plugins.listener.OpenLineageListener._fork_execute")
@mock.patch("airflow.providers.openlineage.plugins.adapter.OpenLineageAdapter.emit")
@mock.patch("airflow.providers.openlineage.conf.debug_mode",
return_value=True)
@mock.patch("airflow.providers.openlineage.plugins.listener.get_airflow_debug_facet")
@mock.patch("airflow.providers.openlineage.plugins.listener.get_airflow_run_facet")
@mock.patch("airflow.providers.openlineage.plugins.listener.get_task_parent_run_facet")
@mock.patch(
-
"airflow.providers.openlineage.plugins.listener.OpenLineageListener._execute",
new=regular_call
+
"airflow.providers.openlineage.plugins.listener.OpenLineageListener.submit_callable",
+ new=direct_submit_call,
)
def
test_adapter_fail_task_is_called_with_proper_arguments_for_db_task_instance_model(
self,
@@ -1472,6 +1491,7 @@ class TestOpenLineageListenerAirflow3:
mock_debug_facet,
mock_debug_mode,
mock_emit,
+ mock_fork_execute,
time_machine,
):
"""Tests that the 'fail_task' method of the OpenLineageAdapter is
invoked with the correct arguments.
@@ -1482,6 +1502,7 @@ class TestOpenLineageListenerAirflow3:
time_machine.move_to(timezone.datetime(2023, 1, 3, 13, 1, 1),
tick=False)
listener, task_instance =
self._create_listener_and_task_instance(runtime_ti=False)
+ listener._executor = MagicMock() # satisfy `if not self.executor`
guard
mock_get_airflow_run_facet.return_value = {"airflow": 3}
mock_get_task_parent_run_facet.return_value = {"parent": 4}
mock_debug_facet.return_value = {"debug": "packages"}
@@ -1513,6 +1534,8 @@ class TestOpenLineageListenerAirflow3:
error=err,
)
listener.adapter.fail_task.assert_called_once_with(**expected_args)
+ # Regression guard: manual state-change emission must not go through
_fork_execute.
+ mock_fork_execute.assert_not_called()
expected_args["run_id"] = "9d3b14f7-de91-40b6-aeef-e887e2c7673e"
adapter = OpenLineageAdapter()
@@ -1644,15 +1667,23 @@ class TestOpenLineageListenerAirflow3:
assert
listener.adapter.complete_task.call_args.kwargs["job_description"] == "Test DAG
Description"
assert
listener.adapter.complete_task.call_args.kwargs["job_description_type"] ==
"text/plain"
+
@mock.patch("airflow.providers.openlineage.plugins.listener.OpenLineageListener._fork_execute")
@mock.patch("airflow.providers.openlineage.plugins.adapter.OpenLineageAdapter.emit")
@mock.patch("airflow.providers.openlineage.conf.debug_mode",
return_value=True)
@mock.patch("airflow.providers.openlineage.plugins.listener.get_airflow_debug_facet")
@mock.patch("airflow.providers.openlineage.plugins.listener.get_task_parent_run_facet")
@mock.patch(
-
"airflow.providers.openlineage.plugins.listener.OpenLineageListener._execute",
new=regular_call
+
"airflow.providers.openlineage.plugins.listener.OpenLineageListener.submit_callable",
+ new=direct_submit_call,
)
def
test_adapter_complete_task_is_called_with_proper_arguments_for_db_task_instance_model(
- self, mock_get_task_parent_run_facet, mock_debug_facet,
mock_debug_mode, mock_emit, time_machine
+ self,
+ mock_get_task_parent_run_facet,
+ mock_debug_facet,
+ mock_debug_mode,
+ mock_emit,
+ mock_fork_execute,
+ time_machine,
):
"""Tests that the 'complete_task' method of the OpenLineageAdapter is
called with the correct arguments.
@@ -1662,6 +1693,7 @@ class TestOpenLineageListenerAirflow3:
time_machine.move_to(timezone.datetime(2023, 1, 3, 13, 1, 1),
tick=False)
listener, task_instance =
self._create_listener_and_task_instance(runtime_ti=False)
+ listener._executor = MagicMock() # satisfy `if not self.executor`
guard
delattr(task_instance, "task") # Test api server path, where task is
not available
mock_get_task_parent_run_facet.return_value = {"parent": 4}
mock_debug_facet.return_value = {"debug": "packages"}
@@ -1691,6 +1723,8 @@ class TestOpenLineageListenerAirflow3:
},
)
assert calls[0][1] == expected_args
+ # Regression guard: manual state-change emission must not go through
_fork_execute.
+ mock_fork_execute.assert_not_called()
expected_args["run_id"] = "9d3b14f7-de91-40b6-aeef-e887e2c7673e"
adapter = OpenLineageAdapter()
@@ -1851,15 +1885,23 @@ class TestOpenLineageListenerAirflow3:
listener.extractor_manager.extract_metadata.assert_not_called()
listener.adapter.complete_task.assert_not_called()
+
@mock.patch("airflow.providers.openlineage.plugins.listener.OpenLineageListener._fork_execute")
@mock.patch("airflow.providers.openlineage.plugins.adapter.OpenLineageAdapter.emit")
@mock.patch("airflow.providers.openlineage.conf.debug_mode",
return_value=True)
@mock.patch("airflow.providers.openlineage.plugins.listener.get_airflow_debug_facet")
@mock.patch("airflow.providers.openlineage.plugins.listener.get_task_parent_run_facet")
@mock.patch(
-
"airflow.providers.openlineage.plugins.listener.OpenLineageListener._execute",
new=regular_call
+
"airflow.providers.openlineage.plugins.listener.OpenLineageListener.submit_callable",
+ new=direct_submit_call,
)
def
test_adapter_complete_task_is_called_with_proper_arguments_for_db_task_instance_model_on_skip(
- self, mock_get_task_parent_run_facet, mock_debug_facet,
mock_debug_mode, mock_emit, time_machine
+ self,
+ mock_get_task_parent_run_facet,
+ mock_debug_facet,
+ mock_debug_mode,
+ mock_emit,
+ mock_fork_execute,
+ time_machine,
):
"""Tests that the 'complete_task' method of the OpenLineageAdapter is
called with the correct arguments.
@@ -1869,6 +1911,7 @@ class TestOpenLineageListenerAirflow3:
time_machine.move_to(timezone.datetime(2023, 1, 3, 13, 1, 1),
tick=False)
listener, task_instance =
self._create_listener_and_task_instance(runtime_ti=False)
+ listener._executor = MagicMock() # satisfy `if not self.executor`
guard
delattr(task_instance, "task") # Test api server path, where task is
not available
mock_get_task_parent_run_facet.return_value = {"parent": 4}
mock_debug_facet.return_value = {"debug": "packages"}
@@ -1898,6 +1941,8 @@ class TestOpenLineageListenerAirflow3:
},
)
assert calls[0][1] == expected_args
+ # Regression guard: manual state-change emission must not go through
_fork_execute.
+ mock_fork_execute.assert_not_called()
expected_args["run_id"] = "9d3b14f7-de91-40b6-aeef-e887e2c7673e"
adapter = OpenLineageAdapter()