rjgoyln commented on code in PR #72668:
URL: https://github.com/apache/airflow/pull/72668#discussion_r3963871089


##########
airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py:
##########
@@ -89,6 +89,28 @@ def calculate(cls, finished_upstreams: 
Iterator[TaskInstance]) -> _UpstreamTISta
         )
 
 
+def _covers_expansion_one_to_one(task, counted_task_ids: Collection[str]) -> 
bool:
+    """
+    Whether the counted upstream instances map 1:1 onto this task's expansion.
+
+    Inferring instance removal from upstream state counts is only sound when 
this task expands
+    over exactly one XCom reference, that reference is the entire counted 
upstream set, and no
+    literal or mapped task group multiplies the length. Otherwise the task's 
expansion length
+    has no relation to the counts: comparing ``map_index`` against the success 
count would
+    remove valid instances (static lists, zips, multiple upstreams).
+    """
+    if task.get_closest_mapped_task_group() is not None:
+        return False
+    iter_deps = getattr(task, "iter_mapped_dependencies", None)
+    if iter_deps is None:
+        return False

Review Comment:
   Minor typing nit: could we use the existing `is_mapped(task)` pattern 
instead of `getattr()` here? It’s already used in 
`_iter_expansion_dependencies` and provides proper `TypeGuard` narrowing.
   
   Also, `task` is the only untyped parameter in this module, so annotating it 
as `Operator` would let mypy check the function body as well.
   



##########
airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py:
##########
@@ -432,9 +453,12 @@ def _evaluate_direct_relatives() -> Iterator[TIDepStatus]:
                         new_state = TaskInstanceState.UPSTREAM_FAILED
                     elif skipped:
                         new_state = TaskInstanceState.SKIPPED
-                    elif removed and success and ti.map_index > -1:
-                        if ti.map_index >= success:
-                            new_state = TaskInstanceState.REMOVED
+                    elif removed and ti.map_index > -1:
+                        # All counted (live) instances are successes here, so 
with a strict 1:1
+                        # mapping the success count is the ti's own 
post-shrink expansion length.
+                        if upstream_done and 
_covers_expansion_one_to_one(task, task.upstream_task_ids):
+                            if ti.map_index >= success:
+                                new_state = TaskInstanceState.REMOVED

Review Comment:
   One concern: dropping `and success` changes behavior, but this case is not 
mentioned or covered by a test.
   
   With an unmapped upstream whose single TI is `REMOVED`, 
`_covers_expansion_one_to_one()` can still return `True`, and `success == 0` 
makes `map_index >= success` true for every downstream instance. I reproduced 
this through the same scheduling-decision path:
   
   ```python
   @task
   def producer():
       return [1, 2, 3]
   
   @task
   def work(arg):
       return arg
   
   work.expand(arg=producer())
   # run producer, set its single ti to REMOVED, run the scheduling decision 
again
   ```
   
   `main` leaves all three `work` instances at `None`; this PR marks all three 
`REMOVED`.
   
   Removal may be the intended outcome since the expansion input is no longer 
resolvable, but this should be an explicit, tested decision. Could we either 
keep `and success`, or add a regression test documenting the intended behavior?



##########
airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py:
##########
@@ -89,6 +89,28 @@ def calculate(cls, finished_upstreams: 
Iterator[TaskInstance]) -> _UpstreamTISta
         )
 
 
+def _covers_expansion_one_to_one(task, counted_task_ids: Collection[str]) -> 
bool:
+    """
+    Whether the counted upstream instances map 1:1 onto this task's expansion.
+
+    Inferring instance removal from upstream state counts is only sound when 
this task expands
+    over exactly one XCom reference, that reference is the entire counted 
upstream set, and no
+    literal or mapped task group multiplies the length. Otherwise the task's 
expansion length
+    has no relation to the counts: comparing ``map_index`` against the success 
count would
+    remove valid instances (static lists, zips, multiple upstreams).
+    """
+    if task.get_closest_mapped_task_group() is not None:
+        return False
+    iter_deps = getattr(task, "iter_mapped_dependencies", None)
+    if iter_deps is None:
+        return False
+    referenced = [op.task_id for op in iter_deps()]
+    if len(referenced) != 1 or set(referenced) != set(counted_task_ids):
+        return False
+    expand_value = task._get_specified_expand_input().value
+    return not isinstance(expand_value, Mapping) or len(expand_value) == 1

Review Comment:
   One important correctness point: the helper also needs to verify that the 
referenced upstream itself needs expansion. Without that, an unmapped upstream 
returning a list can look like a 1:1 mapping even though it has only one TI and 
produces multiple downstream instances.
   
   I'd suggest using `get_needs_expansion()` rather than `is_mapped()`, since a 
plain task inside a mapped task group can still legitimately produce one XCom 
per instance.
   
   Combined with the typing point above:
   
   ```python
   def _covers_expansion_one_to_one(task: Operator, counted_task_ids: 
Collection[str]) -> bool:
       """Whether the counted upstream instances map 1:1 onto this task's 
expansion."""
       from airflow.serialization.definitions.mappedoperator import is_mapped
   
       if task.get_closest_mapped_task_group() is not None or not 
is_mapped(task):
           return False
       referenced = list(task.iter_mapped_dependencies())
       if len(referenced) != 1 or {op.task_id for op in referenced} != 
set(counted_task_ids):
           return False
       if not referenced[0].get_needs_expansion():
           return False
       expand_value = task._get_specified_expand_input().value
       return not isinstance(expand_value, Mapping) or len(expand_value) == 1
   ```
   
   I tested this version locally: ruff is clean, `ti_deps` passes, and the 
unmapped-upstream case below keeps main's behavior.



##########
airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py:
##########
@@ -318,9 +340,8 @@ def _evaluate_setup_constraint(
                     new_state = TaskInstanceState.UPSTREAM_FAILED
                 elif skipped:
                     new_state = TaskInstanceState.SKIPPED
-                elif removed and success and ti.map_index > -1:
-                    if ti.map_index >= success:
-                        new_state = TaskInstanceState.REMOVED
+                # No REMOVED inference here: a task can only expand over a 
direct upstream,
+                # and this branch counts indirect setups only.

Review Comment:
   One test coverage concern: this deletion is not currently covered by a test 
that fails without it.
   
   I restored the removed branch and ran `airflow-core/tests/unit/ti_deps/`: 
323 passed, with no test detecting the difference. 
`test_setup_constraint_mapped_task_upstream_removed_and_success` still passes, 
but through `_evaluate_direct_relatives` — `setup_3` expands 1:1 over 
`setup_2`, so it no longer exercises the deleted branch.
   
   The deletion itself looks sound to me, but since this changes behavior, 
could we add a regression test for a non-1:1 downstream (e.g. mapped over a 
literal list) with indirect setups? That would ensure the test fails without 
this change.



##########
airflow-core/tests/unit/models/test_taskinstance.py:
##########
@@ -1246,8 +1246,8 @@ def test_check_task_dependencies(
             ["all_success", _UpstreamTIStates(2, 0, 0, 0, 0, 0, 0, 0), True, 
None, False],
             ["all_success", _UpstreamTIStates(2, 0, 1, 0, 0, 0, 0, 0), True, 
State.UPSTREAM_FAILED, False],
             ["all_success", _UpstreamTIStates(2, 1, 0, 0, 0, 0, 0, 0), True, 
State.SKIPPED, False],
-            # ti.map_index >= success
-            ["all_success", _UpstreamTIStates(3, 0, 0, 0, 2, 0, 0, 0), True, 
State.REMOVED, True],
+            # 1:1 mapped upstream shrank: map_index >= success once the 
upstream is done
+            ["all_success", _UpstreamTIStates(3, 0, 0, 0, 2, 5, 0, 0), True, 
State.REMOVED, True],

Review Comment:
   One small test coverage point: this replaces the existing row rather than 
adding a new one, so we lose coverage of the other side of the new 
`upstream_done` gate.
   
   Could we keep both cases? I checked that keeping the old `done=0` row passes 
as written, and it now specifically verifies that no removal inference happens 
before the upstream is complete.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to