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

vatsrahul1001 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 3b627816506 [v3-3-test] Stop skipping none_failed_min_one_success 
tasks in mapped task groups (#69377) (#70318)
3b627816506 is described below

commit 3b62781650608946ed1fe5a2ac5e03c8a66c687c
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Jul 24 14:53:57 2026 +0530

    [v3-3-test] Stop skipping none_failed_min_one_success tasks in mapped task 
groups (#69377) (#70318)
    
    (cherry picked from commit dd029a899537c61709226af4d4b0f514fb1c025c)
    
    Co-authored-by: Shahar Epstein <[email protected]>
---
 airflow-core/newsfragments/69377.bugfix.rst        |  1 +
 .../src/airflow/ti_deps/deps/trigger_rule_dep.py   | 28 +++++---
 .../tests/unit/models/test_mappedoperator.py       | 79 ++++++++++++++++++++++
 3 files changed, 98 insertions(+), 10 deletions(-)

diff --git a/airflow-core/newsfragments/69377.bugfix.rst 
b/airflow-core/newsfragments/69377.bugfix.rst
new file mode 100644
index 00000000000..bfbb5f560f6
--- /dev/null
+++ b/airflow-core/newsfragments/69377.bugfix.rst
@@ -0,0 +1 @@
+A task with the ``none_failed_min_one_success`` trigger rule inside a 
dynamically mapped task group is no longer skipped before the task group 
expands. Previously such a task was evaluated on its not-yet-expanded summary 
task instance, found no successful upstreams (because the upstream had already 
expanded away from ``map_index -1``), and was wrongly skipped instead of 
expanding and running.
diff --git a/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py 
b/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py
index 54d5a4d9309..fddfe97baed 100644
--- a/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py
+++ b/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py
@@ -37,7 +37,7 @@ if TYPE_CHECKING:
 
     from airflow.models.taskinstance import TaskInstance
     from airflow.serialization.definitions.mappedoperator import Operator
-    from airflow.serialization.definitions.taskgroup import 
SerializedMappedTaskGroup
+    from airflow.serialization.definitions.taskgroup import SerializedTaskGroup
     from airflow.ti_deps.dep_context import DepContext
     from airflow.ti_deps.deps.base_ti_dep import TIDepStatus
     from airflow.typing_compat import Unpack
@@ -152,7 +152,7 @@ class TriggerRuleDep(BaseTIDep):
 
             return get_mapped_ti_count(task, ti.run_id, session=session)
 
-        def _iter_expansion_dependencies(task_group: SerializedMappedTaskGroup 
| None) -> Iterator[str]:
+        def _iter_expansion_dependencies(task_group: SerializedTaskGroup | 
None) -> Iterator[str]:
             if is_mapped(task):
                 for op in task.iter_mapped_dependencies():
                     yield op.task_id
@@ -177,14 +177,22 @@ class TriggerRuleDep(BaseTIDep):
                 assert task.task_group
 
             # Only the not-yet-expanded summary ti (map_index < 0) needs the 
broad
-            # "depend on every upstream ti" behavior, so a fast-triggered rule
-            # (ONE_SUCCESS / ONE_FAILED / ONE_DONE) does not skip it before the
-            # mapped task group has expanded (see #34023). Once the ti is 
expanded,
-            # each instance must depend on the upstream instance(s) that share 
its
-            # map index, otherwise a single upstream failure would wrongly 
trigger
-            # every expanded instance (see #50210).
-            if is_mapped(task.task_group) and ti.map_index < 0:
-                is_fast_triggered = task.trigger_rule in (TR.ONE_SUCCESS, 
TR.ONE_FAILED, TR.ONE_DONE)
+            # "depend on every upstream ti" behavior, so a rule that can be 
satisfied
+            # by a subset of upstreams (ONE_SUCCESS / ONE_FAILED / ONE_DONE /
+            # NONE_FAILED_MIN_ONE_SUCCESS) does not skip it before the mapped 
task
+            # group has expanded (see #34023, #39801). Once the ti is 
expanded, each
+            # instance must depend on the upstream instance(s) that share its 
map
+            # index, otherwise a single upstream failure would wrongly trigger 
every
+            # expanded instance (see #50210). The task may be nested in plain 
task
+            # groups inside the mapped one (see #39801), so check the closest 
mapped
+            # ancestor rather than only the immediate parent group.
+            if ti.map_index < 0 and task.get_closest_mapped_task_group() is 
not None:
+                is_fast_triggered = task.trigger_rule in (
+                    TR.ONE_SUCCESS,
+                    TR.ONE_FAILED,
+                    TR.ONE_DONE,
+                    TR.NONE_FAILED_MIN_ONE_SUCCESS,
+                )
                 if is_fast_triggered and upstream_id not in set(
                     _iter_expansion_dependencies(task_group=task.task_group)
                 ):
diff --git a/airflow-core/tests/unit/models/test_mappedoperator.py 
b/airflow-core/tests/unit/models/test_mappedoperator.py
index e00a821e056..3539428be43 100644
--- a/airflow-core/tests/unit/models/test_mappedoperator.py
+++ b/airflow-core/tests/unit/models/test_mappedoperator.py
@@ -1709,6 +1709,85 @@ def 
test_one_failed_trigger_rule_runs_on_indirect_failure_in_mapped_task_group(d
     assert states["deliver_records.handle_failed_delivery"] == {0: "success", 
1: "success", 2: "success"}
 
 
+def 
test_none_failed_min_one_success_trigger_rule_expands_in_mapped_task_group(dag_maker):
+    """Regression test for #39801.
+
+    A task with the ``NONE_FAILED_MIN_ONE_SUCCESS`` trigger rule inside a 
dynamically
+    expanded task group must expand and run once its upstream (also inside the 
group)
+    succeeds, instead of being skipped prematurely at its unexpanded 
(map_index -1)
+    summary ti before the task group has expanded.
+    """
+    with 
dag_maker(dag_id="test_none_failed_min_one_success_in_mapped_task_group") as 
dag:
+
+        @task
+        def init():
+            return ["seize", "the", "day"]
+
+        @task_group(group_id="tg")
+        def tg(message):
+            @task
+            def imsleepy(message):
+                return message
+
+            @task(trigger_rule=TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS)
+            def imawake(message):
+                pass
+
+            imawake(imsleepy(message))
+
+        tg.expand(message=init())
+
+    dr = dag.test()
+
+    states: dict[str, dict[int, str | None]] = defaultdict(dict)
+    for ti in dr.get_task_instances():
+        states[ti.task_id][ti.map_index] = ti.state
+
+    assert states["tg.imsleepy"] == {0: "success", 1: "success", 2: "success"}
+    assert states["tg.imawake"] == {0: "success", 1: "success", 2: "success"}
+
+
+def 
test_none_failed_min_one_success_trigger_rule_expands_in_nested_task_group(dag_maker):
+    """Regression test for #39801, nested-group shape.
+
+    Same as above, but the ``NONE_FAILED_MIN_ONE_SUCCESS`` task lives in a 
plain task
+    group nested inside the mapped one (the exact shape of the issue's 
reproducer), so
+    its immediate ``task_group`` is not mapped — only an ancestor is.
+    """
+    with 
dag_maker(dag_id="test_none_failed_min_one_success_in_nested_task_group") as 
dag:
+
+        @task
+        def init():
+            return ["seize", "the", "day"]
+
+        @task_group(group_id="tg")
+        def tg(message):
+            @task
+            def imsleepy(message):
+                return message
+
+            @task_group(group_id="inner")
+            def inner(message):
+                @task(trigger_rule=TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS)
+                def imawake(message):
+                    pass
+
+                imawake(message)
+
+            inner(imsleepy(message))
+
+        tg.expand(message=init())
+
+    dr = dag.test()
+
+    states: dict[str, dict[int, str | None]] = defaultdict(dict)
+    for ti in dr.get_task_instances():
+        states[ti.task_id][ti.map_index] = ti.state
+
+    assert states["tg.imsleepy"] == {0: "success", 1: "success", 2: "success"}
+    assert states["tg.inner.imawake"] == {0: "success", 1: "success", 2: 
"success"}
+
+
 def test_mapped_operator_retry_delay_default(dag_maker):
     """
     Test that MappedOperator.retry_delay returns default value when not 
explicitly set.

Reply via email to