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

shahar1 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 b43705cccee Decide min_one_success trigger rules per index in mapped 
task groups (#75417)
b43705cccee is described below

commit b43705cccee66d6ef54c919ed1740efd3154d7ed
Author: Shahar Epstein <[email protected]>
AuthorDate: Fri Oct 23 19:50:17 2026 +0300

    Decide min_one_success trigger rules per index in mapped task groups 
(#75417)
    
    With an upstream outside the group, the placeholder of an
    all_done_min_one_success task decided the whole task from that upstream
    alone. A none_failed_min_one_success task failed every index when one
    upstream index failed.
    
    For both rules, the placeholder now hands the decision to the expanded
    instances once an in-group upstream has expanded. Each index is then
    judged by its own upstreams.
---
 .../src/airflow/ti_deps/deps/trigger_rule_dep.py   | 32 +++++---
 .../tests/unit/models/test_mappedoperator.py       | 92 +++++++++++++++++++---
 2 files changed, 102 insertions(+), 22 deletions(-)

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 9ccc4adcb13..8ed5d25e425 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
@@ -195,20 +195,18 @@ class TriggerRuleDep(BaseTIDep):
 
             # Only the not-yet-expanded summary ti (map_index < 0) needs the 
broad
             # "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.
+            # by a subset of upstreams (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). 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 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)
@@ -422,6 +420,7 @@ class TriggerRuleDep(BaseTIDep):
             success_setup = upstream_states.success_setup
             skipped_setup = upstream_states.skipped_setup
 
+            task_id_counts: list[tuple[str, int]] | None = None
             # Optimization: Don't need to hit the database if all upstreams are
             # "simple" tasks (no task or task group mapping involved).
             if not any(t.get_needs_expansion() for t in 
upstream_tasks.values()):
@@ -431,7 +430,6 @@ class TriggerRuleDep(BaseTIDep):
                 # Regional selections share counts only for identical producer 
UUID sets;
                 # legacy runs can share by task IDs. Expansion invalidates 
both forms.
                 cache_key: tuple[str, str, frozenset[str | UUID]] | None = None
-                task_id_counts: list[tuple[str, int]] | None = None
                 if task.get_closest_mapped_task_group() is None:
                     scope: frozenset[str | UUID] = (
                         frozenset(
@@ -459,6 +457,20 @@ class TriggerRuleDep(BaseTIDep):
                 upstream = sum(count for _, count in task_id_counts)
                 upstream_setup = sum(c for t, c in task_id_counts if 
upstream_tasks[t].is_setup)
 
+            # Once an in-group upstream has expanded, the summary ti of a task 
in a mapped task
+            # group no longer sees it and would judge every index by the 
outside upstreams alone.
+            # For these two rules the expanded tis decide instead. The cost: 
an outside failure seen
+            # after the expansion gives one upstream_failed ti per index 
instead of one in total,
+            # which is why other rules still decide here.
+            if (
+                map_index < 0
+                and trigger_rule in (TR.ALL_DONE_MIN_ONE_SUCCESS, 
TR.NONE_FAILED_MIN_ONE_SUCCESS)
+                and task_id_counts is not None
+                and len(task_id_counts) < len(upstream_tasks)
+                and task.get_closest_mapped_task_group() is not None
+            ):
+                return
+
             upstream_done = done >= upstream
 
             changed = False
diff --git a/airflow-core/tests/unit/models/test_mappedoperator.py 
b/airflow-core/tests/unit/models/test_mappedoperator.py
index a6b89e03978..807b0aa2587 100644
--- a/airflow-core/tests/unit/models/test_mappedoperator.py
+++ b/airflow-core/tests/unit/models/test_mappedoperator.py
@@ -1956,15 +1956,16 @@ def 
test_short_circuit_skips_later_tasks_in_task_group_mapped_over_upstream_outp
         assert states[task_id] == {0: "success", 1: "skipped"}
 
 
-def 
test_none_failed_min_one_success_trigger_rule_expands_in_mapped_task_group(dag_maker):
-    """Regression test for #39801.
[email protected](
+    "trigger_rule", [TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS, 
TriggerRule.ALL_DONE_MIN_ONE_SUCCESS]
+)
+def test_min_one_success_trigger_rule_expands_in_mapped_task_group(dag_maker, 
trigger_rule):
+    """A ``*_MIN_ONE_SUCCESS`` task in a mapped task group runs once its 
in-group upstream succeeds.
 
-    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.
+    It must not be skipped or marked upstream_failed prematurely at its 
unexpanded
+    (map_index -1) summary ti.
     """
-    with 
dag_maker(dag_id="test_none_failed_min_one_success_in_mapped_task_group") as 
dag:
+    with dag_maker(dag_id=f"test_{trigger_rule.value}_in_mapped_task_group") 
as dag:
 
         @task
         def init():
@@ -1976,7 +1977,7 @@ def 
test_none_failed_min_one_success_trigger_rule_expands_in_mapped_task_group(d
             def imsleepy(message):
                 return message
 
-            @task(trigger_rule=TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS)
+            @task(trigger_rule=trigger_rule)
             def imawake(message):
                 pass
 
@@ -1994,14 +1995,17 @@ def 
test_none_failed_min_one_success_trigger_rule_expands_in_mapped_task_group(d
     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):
[email protected](
+    "trigger_rule", [TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS, 
TriggerRule.ALL_DONE_MIN_ONE_SUCCESS]
+)
+def test_min_one_success_trigger_rule_expands_in_nested_task_group(dag_maker, 
trigger_rule):
     """Regression test for #39801, nested-group shape.
 
-    Same as above, but the ``NONE_FAILED_MIN_ONE_SUCCESS`` task lives in a 
plain task
+    Same as above, but the ``*_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:
+    with dag_maker(dag_id=f"test_{trigger_rule.value}_in_nested_task_group") 
as dag:
 
         @task
         def init():
@@ -2015,7 +2019,7 @@ def 
test_none_failed_min_one_success_trigger_rule_expands_in_nested_task_group(d
 
             @task_group(group_id="inner")
             def inner(message):
-                @task(trigger_rule=TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS)
+                @task(trigger_rule=trigger_rule)
                 def imawake(message):
                     pass
 
@@ -2035,6 +2039,70 @@ def 
test_none_failed_min_one_success_trigger_rule_expands_in_nested_task_group(d
     assert states["tg.inner.imawake"] == {0: "success", 1: "success", 2: 
"success"}
 
 
[email protected](
+    ("trigger_rule", "outside_outcome", "inner_outcome", "expected_state"),
+    [
+        (TriggerRule.ALL_DONE_MIN_ONE_SUCCESS, None, "skip", "skipped"),
+        (TriggerRule.ALL_DONE_MIN_ONE_SUCCESS, None, "fail", 
"upstream_failed"),
+        (TriggerRule.ALL_DONE_MIN_ONE_SUCCESS, "fail", "success", "success"),
+        (TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS, None, "fail", 
"upstream_failed"),
+        (TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS, "skip", "fail", 
"upstream_failed"),
+    ],
+)
+def test_min_one_success_decides_per_index_in_mapped_task_group(
+    dag_maker, trigger_rule, outside_outcome, inner_outcome, expected_state
+):
+    """A ``*_MIN_ONE_SUCCESS`` task in a mapped group is evaluated per 
expanded index.
+
+    The middle index of its in-group upstream ends with ``inner_outcome``; an 
optional
+    upstream outside the group ends with ``outside_outcome``.
+    """
+    with dag_maker(
+        
dag_id=f"test_{trigger_rule.value}_outside_{outside_outcome}_inner_{inner_outcome}"
+    ) as dag:
+
+        @task
+        def init():
+            return ["seize", "the", "day"]
+
+        @task
+        def outside():
+            if outside_outcome == "skip":
+                raise AirflowSkipException
+            raise ValueError("boom")
+
+        outside_task = outside() if outside_outcome else None
+
+        @task_group(group_id="tg")
+        def tg(message):
+            @task
+            def imsleepy(message):
+                if message == "the":
+                    if inner_outcome == "skip":
+                        raise AirflowSkipException
+                    if inner_outcome == "fail":
+                        raise ValueError("boom")
+                return message
+
+            @task(trigger_rule=trigger_rule)
+            def imawake(message):
+                pass
+
+            awake = imawake(imsleepy(message))
+            if outside_task is not None:
+                outside_task >> awake
+
+        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.imawake"] == {0: "success", 1: expected_state, 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