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

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


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new 604bcece5be [v3-4-test] Fix dynamically mapped tasks never running on 
scheduled Dag runs (#74861) (#74918)
604bcece5be is described below

commit 604bcece5bec7e80ed5e26a1d01484dd8f2d843e
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Oct 18 11:28:59 2026 +0530

    [v3-4-test] Fix dynamically mapped tasks never running on scheduled Dag 
runs (#74861) (#74918)
    
    DynamicRegion.get_or_create_many() uses a SAVEPOINT (session.begin_nested())
    to safely insert dynamic-mapping regions. Releasing that SAVEPOINT 
dispatches
    the same before_commit event as a real commit, and the scheduler's
    CommitProhibitorGuard rejected it unconditionally, raising "UNEXPECTED
    COMMIT - THIS WILL BREAK HA LOCKS!" before the Dag run's task instances
    could be created.
    
    This only happened for a Dag run the scheduler created from its own
    schedule: the API server's Dag run creation runs outside the scheduler's
    guarded session, so the identical SAVEPOINT release there was harmless. A
    dynamically mapped task on a real (non-manually-triggered) schedule could
    therefore never run, with the same scheduling error repeating every loop.
    
    Releasing a savepoint ends neither the outer transaction nor its row locks,
    so it cannot break the HA locks this guard protects -- only the outer
    commit can, and that remains guarded.
    (cherry picked from commit f5038e678343787f676e1bb6f0923c06f4b4f4b3)
    
    Co-authored-by: Rahul Vats <[email protected]>
---
 airflow-core/src/airflow/utils/sqlalchemy.py       |  9 ++++++++-
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 21 +++++++++++++++++++++
 airflow-core/tests/unit/utils/test_sqlalchemy.py   | 20 ++++++++++++++++++++
 3 files changed, 49 insertions(+), 1 deletion(-)

diff --git a/airflow-core/src/airflow/utils/sqlalchemy.py 
b/airflow-core/src/airflow/utils/sqlalchemy.py
index 6b7db82b8ef..2d72ef319a8 100644
--- a/airflow-core/src/airflow/utils/sqlalchemy.py
+++ b/airflow-core/src/airflow/utils/sqlalchemy.py
@@ -690,7 +690,14 @@ class CommitProhibitorGuard:
     def __init__(self, session: Session):
         self.session = session
 
-    def _validate_commit(self, _):
+    def _validate_commit(self, session):
+        # before_commit fires for a SAVEPOINT release too, not just 
session.commit(). A
+        # savepoint release ends neither the outer transaction nor its row 
locks, so it can't
+        # break the HA locks this guard protects -- skip the check while one 
is open. This
+        # covers session.commit() and savepoint exits; nothing under this 
guard commits a
+        # transaction handle directly today, which would bypass this check.
+        if session.in_nested_transaction():
+            return
         if self.expected_commit:
             self.expected_commit = False
             return
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 858ffc87c4c..576cb59a898 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -6585,6 +6585,27 @@ class TestSchedulerJob:
         assert dr.start_date is None
         assert dr.creating_job_id == scheduler_job.id
 
+    def 
test_do_scheduling_creates_task_instances_for_scheduler_created_mapped_run(self,
 dag_maker, session):
+        """A Dag run the scheduler creates itself (not via 
dag_maker.create_dagrun()) for a
+        dynamically mapped task must get its mapped TaskInstances created.
+
+        Only reproducible through the real CommitProhibitorGuard installed by 
_do_scheduling:
+        other tests in this file exercise 
_create_dagruns_for_dags/_schedule_all_dag_runs
+        directly with the plain session cast as the guard 
(`cast("CommitProhibitorGuard",
+        session)`), which never installs the real before_commit listener.
+        """
+        with dag_maker(dag_id="test_scheduled_mapped_task", schedule="@once", 
session=session):
+            BashOperator.partial(task_id="mapped", 
bash_command="true").expand(env=[{"n": "1"}, {"n": "2"}])
+
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, 
executors=[self.null_exec])
+
+        self.job_runner._do_scheduling(session)
+
+        dr = session.scalars(select(DagRun).where(DagRun.dag_id == 
"test_scheduled_mapped_task")).one()
+        tis = dr.get_task_instances(session=session)
+        assert sorted(ti.map_index for ti in tis) == [0, 1]
+
     @pytest.mark.need_serialized_dag
     @conf_vars({("scheduler", "batch_asset_events"): "True"})
     def test_create_dag_runs_assets(self, session, dag_maker):
diff --git a/airflow-core/tests/unit/utils/test_sqlalchemy.py 
b/airflow-core/tests/unit/utils/test_sqlalchemy.py
index e5e883eac85..5cb11e8cb41 100644
--- a/airflow-core/tests/unit/utils/test_sqlalchemy.py
+++ b/airflow-core/tests/unit/utils/test_sqlalchemy.py
@@ -219,6 +219,26 @@ class TestSqlAlchemyUtils:
             other_session.execute(text("SELECT 1"))
             other_session.commit()
 
+    def test_prohibit_commit_allows_savepoint_release(self):
+        """A SAVEPOINT release (session.begin_nested()) dispatches the same 
before_commit
+        event as a real commit, but must not trip the guard: it ends neither 
the outer
+        transaction nor its row locks."""
+        with prohibit_commit(self.session):
+            self.session.execute(text("SELECT 1"))
+            with self.session.begin_nested():
+                self.session.execute(text("SELECT 1"))
+
+            # The guard must still catch a real commit after the savepoint 
released.
+            with pytest.raises(RuntimeError, match="UNEXPECTED COMMIT"):
+                self.session.commit()
+            self.session.rollback()
+
+            # ...and an explicit commit while still inside an open savepoint.
+            with pytest.raises(RuntimeError, match="UNEXPECTED COMMIT"):
+                with self.session.begin_nested():
+                    self.session.commit()
+            self.session.rollback()
+
     def teardown_method(self):
         self.session.close()
         settings.engine.dispose()

Reply via email to