This is an automated email from the ASF dual-hosted git repository.
kaxil 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 ef0ff5cfe2b Preload source_aliases in process_executor_events (#65422)
ef0ff5cfe2b is described below
commit ef0ff5cfe2bf46c853f47e676537b0d5c9ce4399
Author: Pablo Seibelt <[email protected]>
AuthorDate: Thu Apr 30 20:24:59 2026 -0300
Preload source_aliases in process_executor_events (#65422)
PR #56916 added a _eager_load_dag_run_for_validation helper that
returns two loader options so DRDataModel.model_validate can walk
DagRun.consumed_asset_events[*].asset and source_aliases without
triggering lazy loads on a detached ORM instance. The helper's
docstring example applies both loaders, and _adopt_or_reset_orphaned_tasks
applies both. SchedulerJobRunner.process_executor_events only applies
asset_loader and discards alias_loader, leaving
AssetEvent.source_aliases lazy-loaded.
When Celery/Kubernetes executor reports a failed-while-queued event
for an asset-triggered DagRun whose consumed AssetEvent has a
non-empty source_aliases collection, SQLAlchemy detaches the
AssetEvent by the time pydantic walks the DagRun, and source_aliases
raises DetachedInstanceError wrapped as a pydantic ValidationError.
The exception escapes _run_scheduler_loop, the scheduler exits,
which orphans in-flight task instances. The next scheduler's
_adopt_or_reset_orphaned_tasks picks them up and replays the same
validation path, crashing again.
Apply alias_loader alongside asset_loader at this call site.
Extend test_process_executor_events_with_asset_events so the
AssetEvent now carries a non-empty source_aliases collection and
the assertion verifies the alias survives into the callback payload.
Co-authored-by: Claude <[email protected]>
---
airflow-core/newsfragments/65422.bugfix.rst | 1 +
.../src/airflow/jobs/scheduler_job_runner.py | 3 ++-
airflow-core/tests/unit/jobs/test_scheduler_job.py | 21 +++++++++++++++------
3 files changed, 18 insertions(+), 7 deletions(-)
diff --git a/airflow-core/newsfragments/65422.bugfix.rst
b/airflow-core/newsfragments/65422.bugfix.rst
new file mode 100644
index 00000000000..efb484da723
--- /dev/null
+++ b/airflow-core/newsfragments/65422.bugfix.rst
@@ -0,0 +1 @@
+Fix scheduler crash on asset-triggered DagRuns by eager-loading
``AssetEvent.source_aliases`` in ``SchedulerJobRunner.process_executor_events``.
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index ccf02817e7a..5076ab1608a 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -1276,12 +1276,13 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
filter_for_tis = TI.filter_for_tis(tis_with_right_state)
if filter_for_tis is None:
return len(event_buffer)
- asset_loader, _ = _eager_load_dag_run_for_validation()
+ asset_loader, alias_loader = _eager_load_dag_run_for_validation()
query = (
select(TI)
.where(filter_for_tis)
.options(selectinload(TI.dag_model))
.options(asset_loader)
+ .options(alias_loader)
.options(joinedload(TI.dag_run).selectinload(DagRun.created_dag_version))
.options(joinedload(TI.dag_version))
)
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 529c9c3a3a4..53c64e0ea2d 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -860,11 +860,15 @@ class TestSchedulerJob:
Test that _process_executor_events handles asset events without
DetachedInstanceError.
Regression test for scheduler crashes when task callbacks are built
with
- consumed_asset_events that weren't eager-loaded.
+ consumed_asset_events that weren't eager-loaded. Exercises both
+ ``AssetEvent.asset`` and ``AssetEvent.source_aliases`` so that missing
+ either loader option on the TI query surfaces as a
``DetachedInstanceError``
+ when the callback's ``DRDataModel`` is built.
"""
asset1 = Asset(uri="test://asset1", name="test_asset_executor",
group="test_group")
asset_model = AssetModel(name=asset1.name, uri=asset1.uri,
group=asset1.group)
- session.add(asset_model)
+ asset_alias = AssetAliasModel(name="test_alias_executor",
group="test_group")
+ session.add_all([asset_model, asset_alias])
session.flush()
with dag_maker(dag_id="test_executor_events_with_assets",
schedule=[asset1], fileloc="/test_path1/"):
@@ -876,7 +880,9 @@ class TestSchedulerJob:
dr = dag_maker.create_dagrun()
- # Create asset event and attach to dag run
+ # Create asset event with an attached source alias so the lazy-loaded
+ # AssetEvent.source_aliases relationship is non-empty and must be
+ # eager-loaded to survive a detached ORM instance in the callback.
asset_event = AssetEvent(
asset_id=asset_model.id,
source_task_id="upstream_task",
@@ -884,6 +890,7 @@ class TestSchedulerJob:
source_run_id="upstream_run",
source_map_index=-1,
)
+ asset_event.source_aliases.append(asset_alias)
session.add(asset_event)
session.flush()
dr.consumed_asset_events.append(asset_event)
@@ -907,12 +914,14 @@ class TestSchedulerJob:
ti1.refresh_from_db(session=session)
assert ti1.state == State.FAILED
- # Verify callback was created with asset event data
+ # Verify callback was created with asset event data including aliases
self.job_runner.executor.callback_sink.send.assert_called_once()
callback_request =
self.job_runner.executor.callback_sink.send.call_args.args[0]
assert callback_request.context_from_server is not None
- assert
len(callback_request.context_from_server.dag_run.consumed_asset_events) == 1
- assert
callback_request.context_from_server.dag_run.consumed_asset_events[0].asset.uri
== asset1.uri
+ events =
callback_request.context_from_server.dag_run.consumed_asset_events
+ assert len(events) == 1
+ assert events[0].asset.uri == asset1.uri
+ assert [alias.name for alias in events[0].source_aliases] ==
[asset_alias.name]
@pytest.mark.usefixtures("testing_dag_bundle")
def test_schedule_dag_run_with_asset_event(self, session: Session,
dag_maker: DagMaker):