This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new f474e0ea6c7 [v3-3-test] Honor catchup for historical asset events in 
asset-triggered Dags (#68749) (#69224)
f474e0ea6c7 is described below

commit f474e0ea6c765f71bb0341620faa92591d2cf960
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Jul 3 19:21:12 2026 +0200

    [v3-3-test] Honor catchup for historical asset events in asset-triggered 
Dags (#68749) (#69224)
    
    (cherry picked from commit ff10b2e3e19c9a6c1a262ebd32ef65b968b39424)
    
    Co-authored-by: Shahar Epstein <[email protected]>
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 airflow-core/newsfragments/68749.bugfix.rst        |   1 +
 .../src/airflow/jobs/scheduler_job_runner.py       |  14 ++-
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 123 +++++++++++++++++----
 3 files changed, 113 insertions(+), 25 deletions(-)

diff --git a/airflow-core/newsfragments/68749.bugfix.rst 
b/airflow-core/newsfragments/68749.bugfix.rst
new file mode 100644
index 00000000000..592b875b2f5
--- /dev/null
+++ b/airflow-core/newsfragments/68749.bugfix.rst
@@ -0,0 +1 @@
+Asset-triggered Dags now honor ``catchup`` for historical asset events. With 
``catchup`` off (the default), a newly added asset-triggered Dag no longer 
consumes events recorded before it started scheduling on those assets; its 
first run is bounded at the moment the schedule reference was created instead 
of reprocessing the entire backlog. With ``catchup`` on, the backlog is still 
replayed (#39456).
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 2f94d480eb6..355f693db0a 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -2581,6 +2581,18 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                 .cte()
             )
 
+            # A first asset-triggered run has no previous run to floor the 
event window. With
+            # catchup off, floor it at when the Dag started scheduling on its 
assets so the
+            # backlog is skipped; with catchup on, only date.min applies and 
the backlog replays.
+            event_window_floor: list[Any] = [cte.c.previous_dag_run_run_after]
+            if not dag.catchup:
+                event_window_floor.append(
+                    select(func.min(DagScheduleAssetReference.created_at))
+                    .where(DagScheduleAssetReference.dag_id == dag.dag_id)
+                    .scalar_subquery()
+                )
+            event_window_floor.append(date.min)
+
             asset_events = list(
                 session.scalars(
                     select(AssetEvent)
@@ -2598,7 +2610,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                             ),
                         ),
                         AssetEvent.timestamp <= triggered_date,
-                        AssetEvent.timestamp > 
func.coalesce(cte.c.previous_dag_run_run_after, date.min),
+                        AssetEvent.timestamp > 
func.coalesce(*event_window_floor),
                     )
                     .order_by(AssetEvent.timestamp.asc(), AssetEvent.id.asc())
                 )
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index e2d5977eb86..d7b08e662b1 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -65,6 +65,7 @@ from airflow.models.asset import (
     AssetEvent,
     AssetModel,
     AssetPartitionDagRun,
+    DagScheduleAssetReference,
     PartitionedAssetKeyLog,
 )
 from airflow.models.backfill import Backfill, BackfillDagRun, 
ReprocessBehavior, _create_backfill
@@ -5501,51 +5502,59 @@ class TestSchedulerJob:
 
         with dag_maker(dag_id="assets-1", start_date=timezone.utcnow(), 
session=session):
             BashOperator(task_id="task", bash_command="echo 1", 
outlets=[asset1])
-        dr = dag_maker.create_dagrun(
+        dr1 = dag_maker.create_dagrun(
             run_id="run1",
             logical_date=(DEFAULT_DATE + timedelta(days=100)),
             data_interval=(DEFAULT_DATE + timedelta(days=10), DEFAULT_DATE + 
timedelta(days=11)),
         )
+        dr2 = dag_maker.create_dagrun(
+            run_id="run2",
+            logical_date=(DEFAULT_DATE + timedelta(days=101)),
+            data_interval=(DEFAULT_DATE + timedelta(days=5), DEFAULT_DATE + 
timedelta(days=6)),
+        )
 
         asset1_id = session.scalar(select(AssetModel.id).where(AssetModel.uri 
== asset1.uri))
 
+        # Consumer Dags are created before the events, so the events fall 
within their window.
+        with dag_maker(dag_id="assets-consumer-multiple", schedule=[asset1, 
asset2]):
+            pass
+        dag2 = dag_maker.dag
+        with dag_maker(dag_id="assets-consumer-single", schedule=[asset1]):
+            pass
+        dag3 = dag_maker.dag
+
+        base = session.scalar(
+            select(DagScheduleAssetReference.created_at).where(
+                DagScheduleAssetReference.dag_id == dag3.dag_id
+            )
+        )
         event1 = AssetEvent(
             asset_id=asset1_id,
             source_task_id="task",
-            source_dag_id=dr.dag_id,
-            source_run_id=dr.run_id,
+            source_dag_id=dr1.dag_id,
+            source_run_id=dr1.run_id,
             source_map_index=-1,
+            timestamp=base + timedelta(seconds=1),
         )
-        session.add(event1)
-
-        # Create a second event, creation time is more recent, but data 
interval is older
-        dr = dag_maker.create_dagrun(
-            run_id="run2",
-            logical_date=(DEFAULT_DATE + timedelta(days=101)),
-            data_interval=(DEFAULT_DATE + timedelta(days=5), DEFAULT_DATE + 
timedelta(days=6)),
-        )
-
         event2 = AssetEvent(
             asset_id=asset1_id,
             source_task_id="task",
-            source_dag_id=dr.dag_id,
-            source_run_id=dr.run_id,
+            source_dag_id=dr2.dag_id,
+            source_run_id=dr2.run_id,
             source_map_index=-1,
+            timestamp=base + timedelta(seconds=2),
         )
-        session.add(event2)
-
-        with dag_maker(dag_id="assets-consumer-multiple", schedule=[asset1, 
asset2]):
-            pass
-        dag2 = dag_maker.dag
-        with dag_maker(dag_id="assets-consumer-single", schedule=[asset1]):
-            pass
-        dag3 = dag_maker.dag
+        session.add_all([event1, event2])
 
         session = dag_maker.session
         session.add_all(
             [
-                AssetDagRunQueue(asset_id=asset1_id, 
target_dag_id=dag2.dag_id),
-                AssetDagRunQueue(asset_id=asset1_id, 
target_dag_id=dag3.dag_id),
+                AssetDagRunQueue(
+                    asset_id=asset1_id, target_dag_id=dag2.dag_id, 
created_at=base + timedelta(hours=1)
+                ),
+                AssetDagRunQueue(
+                    asset_id=asset1_id, target_dag_id=dag3.dag_id, 
created_at=base + timedelta(hours=1)
+                ),
             ]
         )
         session.flush()
@@ -5591,6 +5600,72 @@ class TestSchedulerJob:
 
         assert created_run.creating_job_id == scheduler_job.id
 
+    @pytest.mark.need_serialized_dag
+    @pytest.mark.parametrize(
+        ("catchup", "expects_old_event"),
+        [
+            pytest.param(False, False, id="catchup-off-ignores-backlog"),
+            pytest.param(True, True, id="catchup-on-consumes-backlog"),
+        ],
+    )
+    def test_new_asset_triggered_dag_backlog_gated_by_catchup(
+        self, catchup, expects_old_event, session, dag_maker
+    ):
+        """Reproduces #39456: catchup gates whether a new asset-triggered Dag 
replays the
+        pre-creation backlog. With catchup off (the default) it only consumes 
events after it
+        started scheduling on the asset; with catchup on it replays the full 
history."""
+        asset = Asset(uri="test://asset-historical", name="hist_asset", 
group="test_group")
+
+        # Producer Dag + run that the asset events are sourced from.
+        with dag_maker(dag_id="historical-producer", 
start_date=timezone.utcnow(), session=session):
+            BashOperator(task_id="task", bash_command="echo 1", 
outlets=[asset])
+        producer_run = dag_maker.create_dagrun(run_id="producer-run")
+
+        asset_id = session.scalar(select(AssetModel.id).where(AssetModel.uri 
== asset.uri))
+
+        # Consumer Dag created now; its schedule reference's created_at is the 
cut-off.
+        with dag_maker(dag_id="historical-consumer", schedule=[asset], 
catchup=catchup):
+            pass
+        consumer_dag = dag_maker.dag
+        reference_created_at = session.scalar(
+            select(DagScheduleAssetReference.created_at).where(
+                DagScheduleAssetReference.dag_id == consumer_dag.dag_id
+            )
+        )
+
+        def _make_event(timestamp):
+            return AssetEvent(
+                asset_id=asset_id,
+                source_task_id="task",
+                source_dag_id=producer_run.dag_id,
+                source_run_id=producer_run.run_id,
+                source_map_index=-1,
+                timestamp=timestamp,
+            )
+
+        old_event = _make_event(reference_created_at - timedelta(days=1))
+        new_event = _make_event(reference_created_at + timedelta(seconds=1))
+        session.add_all([old_event, new_event])
+        # Trigger time after both events so neither is excluded by the upper 
bound.
+        session.add(
+            AssetDagRunQueue(
+                asset_id=asset_id,
+                target_dag_id=consumer_dag.dag_id,
+                created_at=reference_created_at + timedelta(hours=1),
+            )
+        )
+        session.flush()
+
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, 
executors=[self.null_exec])
+        with create_session() as session:
+            self.job_runner._create_dagruns_for_dags(session, session)
+
+        created_run = session.scalars(select(DagRun).where(DagRun.dag_id == 
consumer_dag.dag_id)).one()
+        assert created_run.state == State.QUEUED
+        expected = {new_event.id} | ({old_event.id} if expects_old_event else 
set())
+        assert {e.id for e in created_run.consumed_asset_events} == expected
+
     @pytest.mark.need_serialized_dag
     def test_create_dag_runs_asset_alias_with_asset_event_attached(self, 
session, dag_maker):
         """

Reply via email to