This is an automated email from the ASF dual-hosted git repository.
Lee-W 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 4138878208c Use EXISTS instead of a fan-out join in asset orphanage
(#75404)
4138878208c is described below
commit 4138878208cc61e2fea420c4eda9a41a744a5ea3
Author: Aboulfoul <[email protected]>
AuthorDate: Mon Oct 26 10:43:34 2026 +0100
Use EXISTS instead of a fan-out join in asset orphanage (#75404)
Co-authored-by: Wei Lee <[email protected]>
---
.../src/airflow/jobs/scheduler_job_runner.py | 43 +++++----------
airflow-core/tests/unit/jobs/test_scheduler_job.py | 61 ++++++++++++++++++++++
2 files changed, 75 insertions(+), 29 deletions(-)
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 82ea45fcb03..01c80d53379 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -35,7 +35,6 @@ from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from sqlalchemy import (
- CTE,
Text,
and_,
case,
@@ -2025,6 +2024,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
timers.call_regular_interval(
conf.getfloat("scheduler", "parsing_cleanup_interval"),
self._update_asset_orphanage,
+ non_fatal=True,
)
timers.call_regular_interval(
conf.getfloat("scheduler", "parsing_cleanup_interval"),
@@ -4219,37 +4219,24 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
:seealso: :meth:`AssetModelOperation.activate_assets_if_possible`.
"""
- # Group assets into orphaned=True and orphaned=False groups.
- orphaned = (
- (
- func.count(DagScheduleAssetReference.dag_id)
- + func.count(TaskOutletAssetReference.dag_id)
- + func.count(TaskInletAssetReference.dag_id)
- # An asset materialized from an ``AssetAlias`` outlet is
referenced only through
- # the alias association, not the schedule/outlet/inlet tables.
Count it so it is
- # activated (and shown in the UI) instead of being treated as
orphaned. See #58058.
- + func.count(alias_association_table.c.alias_id)
- )
- == 0
- ).label("orphaned")
- asset_reference_query = (
- select(AssetModel)
- .outerjoin(DagScheduleAssetReference)
- .outerjoin(TaskOutletAssetReference)
- .outerjoin(TaskInletAssetReference)
- .outerjoin(alias_association_table,
alias_association_table.c.asset_id == AssetModel.id)
- .group_by(AssetModel.id)
+ referenced = or_(
+ exists().where(DagScheduleAssetReference.asset_id ==
AssetModel.id),
+ exists().where(TaskOutletAssetReference.asset_id == AssetModel.id),
+ exists().where(TaskInletAssetReference.asset_id == AssetModel.id),
+ # An asset materialized from an ``AssetAlias`` outlet is
referenced only through
+ # the alias association, not the schedule/outlet/inlet tables.
Treat it as referenced so it
+ # is activated (and shown in the UI) instead of being orphaned.
See #58058.
+ exists().where(alias_association_table.c.asset_id ==
AssetModel.id),
)
-
- orphan_query = asset_reference_query.having(orphaned).cte()
- activate_query = asset_reference_query.having(~orphaned).cte()
+ orphan_query = select(AssetModel.name,
AssetModel.uri).where(~referenced).subquery()
+ activate_query = select(AssetModel.name,
AssetModel.uri).where(referenced).subquery()
self._orphan_unreferenced_assets(orphan_query, session=session)
self._activate_referenced_assets(activate_query, session=session)
self._cleanup_orphaned_asset_state_store(session=session)
@staticmethod
- def _orphan_unreferenced_assets(assets_query: CTE, *, session: Session) ->
None:
+ def _orphan_unreferenced_assets(assets_query: Subquery, *, session:
Session) -> None:
deleted_orphaned_assets = session.execute(
delete(AssetActive).where(
exists().where(
@@ -4261,7 +4248,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
stats.gauge("asset.orphaned", max(getattr(deleted_orphaned_assets,
"rowcount", 0), 0))
@staticmethod
- def _activate_referenced_assets(assets_query: CTE, *, session: Session) ->
None:
+ def _activate_referenced_assets(assets_query: Subquery, *, session:
Session) -> None:
active_assets_query = select(AssetActive.name, AssetActive.uri).join(
assets_query,
and_(AssetActive.name == assets_query.c.name, AssetActive.uri ==
assets_query.c.uri),
@@ -4304,9 +4291,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
),
)
.where(
- ~active_assets_query.where(
- and_(AssetActive.name == AssetModel.name,
AssetActive.uri == AssetModel.uri)
- ).exists()
+ ~exists().where(AssetActive.name == AssetModel.name,
AssetActive.uri == AssetModel.uri)
)
)
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 13399201cf3..aa2a9c7c6d3 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -10262,6 +10262,67 @@ class TestSchedulerJob:
assert asset in active
assert asset not in orphaned
+ def test_asset_orphaning_asset_referenced_from_every_table(self,
dag_maker, session):
+ self.job_runner = SchedulerJobRunner(job=Job())
+
+ shared = Asset(uri="test://shared", name="shared", group="test_group")
+ dropped = Asset(uri="test://dropped", name="dropped",
group="test_group")
+ schedule_only = Asset(uri="test://schedule_only",
name="schedule_only", group="test_group")
+ outlet_only = Asset(uri="test://outlet_only", name="outlet_only",
group="test_group")
+ inlet_only = Asset(uri="test://inlet_only", name="inlet_only",
group="test_group")
+
+ for i in range(3):
+ with dag_maker(dag_id=f"consumer-{i}", schedule=[shared],
session=session):
+ BashOperator(task_id="task", bash_command="echo 1")
+ with dag_maker(dag_id="schedule-only", schedule=[schedule_only],
session=session):
+ BashOperator(task_id="task", bash_command="echo 1")
+ with dag_maker(dag_id="producer", schedule=None, session=session):
+ for i in range(3):
+ BashOperator(task_id=f"produce-{i}", bash_command="echo 1",
outlets=[shared, dropped])
+ BashOperator(task_id=f"read-{i}", bash_command="echo 1",
inlets=[shared])
+ BashOperator(task_id="outlet-only", bash_command="echo 1",
outlets=[outlet_only])
+ BashOperator(task_id="inlet-only", bash_command="echo 1",
inlets=[inlet_only])
+
+ # Attach aliases: one to ``shared`` and one to an asset with no other
reference.
+ alias_only = AssetModel(uri="test://alias_only", name="alias_only",
group="test_group")
+ alias_only.aliases.append(AssetAliasModel(name="alias_for_alias_only",
group="asset"))
+ shared_model = session.scalar(select(AssetModel).where(AssetModel.name
== "shared"))
+ shared_model.aliases.append(AssetAliasModel(name="alias_for_shared",
group="asset"))
+ session.add(alias_only)
+ session.flush()
+
+ self.job_runner._update_asset_orphanage(session=session)
+ session.flush()
+ orphaned, active = self._find_assets_activation(session)
+ assert {a.name for a in active} == {
+ "shared",
+ "dropped",
+ "schedule_only",
+ "outlet_only",
+ "inlet_only",
+ "alias_only",
+ }
+ assert orphaned == []
+
+ with dag_maker(dag_id="producer", schedule=None, session=session):
+ for i in range(3):
+ BashOperator(task_id=f"produce-{i}", bash_command="echo 1",
outlets=[shared])
+ BashOperator(task_id=f"read-{i}", bash_command="echo 1",
inlets=[shared])
+ BashOperator(task_id="outlet-only", bash_command="echo 1",
outlets=[outlet_only])
+ BashOperator(task_id="inlet-only", bash_command="echo 1",
inlets=[inlet_only])
+
+ self.job_runner._update_asset_orphanage(session=session)
+ session.flush()
+ orphaned, active = self._find_assets_activation(session)
+ assert {a.name for a in active} == {
+ "shared",
+ "schedule_only",
+ "outlet_only",
+ "inlet_only",
+ "alias_only",
+ }
+ assert [a.name for a in orphaned] == ["dropped"]
+
def test_asset_orphaning_ignore_orphaned_assets(self, dag_maker, session):
self.job_runner = SchedulerJobRunner(job=Job())