Skip to content

Commit 240027d

Browse files
committed
fixup! Run loop gates and advance iterations atomically
A gate decision that the pinned loop would reject (continue at the iteration limit, or a stop before the limit of a fixed-count loop) was accepted by set_xcom and only rewritten into a failure when the state update arrived. By then the task runner had already run the success callbacks, so a failed or retried gate never saw its failure or retry callbacks (T7.15). The three decision checks now live in validate_loop_decision, shared by complete_loop_gate and set_xcom, so the write is refused with a 400 inside execute() and goes through normal retry and failure handling; the state-update rewrite stays as a backstop and its tests now seed the invalid decision directly. Reading one slot of a mapped loop terminal went through resolve_current_producers, which hydrated every live producer row of the pass just to detect two producers at one index (T7.18). The candidate query is now built once and select_current_producer_ids fetches only id and region_index, sharing the ambiguity check with the full-row path. The resolver also passes the producer request fields to both functions explicitly instead of through attrs.asdict, so mypy checks them and a one-sided rename cannot slip through (T7.19). The first region of a loop was inserted with a plain add and flush, so two verify_integrity calls racing on the same run could fail on the unique slot key before the later task instance race handling applied; it now uses DynamicRegion.get_or_create, which reuses the winner's region (T7.21). count_loaded_task_instances registered list.append as a mapper load listener, which receives (target, context) and raised TypeError on the first loaded row, so its callers only passed while nothing loaded; it now filters in a real listener and has a positive-path test (T7.13). The retry test for the gate now asserts the DeleteXCom for the decision key so skipping the delete is caught (T7.22), and the unsuccessful gate test matches LoopMaxIterationsExceeded instead of a bare Exception (T7.17). The lock-order test for the loop decision write and gate completion paused the writer only after its first dag_run lock and seeded the same continue the writer wrote, so a set_xcom that locked in the reverse order, or a completion that consumed a stale decision, would still have passed (T7.14). The writer now pauses after its first locking statement of any table, observed at the engine so session.scalar locks are seen too, and sleeps briefly once completion has issued its first lock, so a reversed order deadlocks; the seeded decision is stop, which a fixed-count loop rejects if completion consumes it before the writer replaces it. The test is still timing-based by necessity: the short sleep stands in for observing the lock wait directly. When a Dag change gives a loop a different gate task id on a run that already has regions, verify_integrity raised from _create_initial_tasks and failed the whole run's integrity check even though only that loop was affected (T7.16). It now logs a warning and skips that loop so the run's other tasks and loops are still created; the loop keeps no gate and cannot advance, which the warning names. The loop_decision flag on SetXCom and the set_xcom route carried nothing the reserved decision key did not already carry, because the route required key == LOOP_DECISION_KEY inside the flagged branch and rejected every other reserved key outside it (T7.20). The route now takes the gate branch when the key is the decision key, the field is gone from SetXCom, the client, the request handler, the Cadwyn entry and the regenerated Task SDK, Go and TypeScript supervisor schemas. The loop result read test that asserted the pass's producers were loaded now asserts no producer row is loaded, which is what the earlier id-only selection guarantees.
1 parent 20a0322 commit 240027d

23 files changed

Lines changed: 328 additions & 155 deletions

File tree

‎airflow-core/src/airflow/api_fastapi/execution_api/routes/xcoms.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from airflow.api_fastapi.execution_api.security import CurrentTIToken
4141
from airflow.api_fastapi.execution_api.versions.v2026_10_30 import IdentifyArchivedTaskStateUpdates
4242
from airflow.exceptions import TaskNotFound
43-
from airflow.models.dagrun import DagRun
43+
from airflow.models.dagrun import DagRun, InvalidLoopDecision, validate_loop_decision
4444
from airflow.models.dynamic_region import (
4545
LOOP_DECISION_KEY,
4646
LOOP_XCOM_PREFIX,
@@ -582,7 +582,6 @@ def set_xcom(
582582
),
583583
] = None,
584584
map_index: Annotated[int, Query()] = -1,
585-
loop_decision: bool = False,
586585
dag_result: Annotated[bool, Query(description="Whether this XCom is a dag result")] = False,
587586
mapped_length: Annotated[
588587
int | None, Query(ge=0, description="Number of mapped tasks this value expands into")
@@ -594,11 +593,10 @@ def set_xcom(
594593

595594
region_id: UUID | None = None
596595
region_index: int | None = None
597-
if loop_decision:
596+
if key == LOOP_DECISION_KEY:
598597
caller = session.get(TaskInstance, token.id)
599598
if (
600-
key != LOOP_DECISION_KEY
601-
or value not in ("continue", "stop")
599+
value not in ("continue", "stop")
602600
or mapped_length is not None
603601
or dag_result
604602
or map_index != -1
@@ -624,6 +622,10 @@ def set_xcom(
624622
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Loop decisions require the pinned loop gate")
625623
if caller.state != TaskInstanceState.RUNNING or caller.working_set is not True:
626624
raise HTTPException(status.HTTP_409_CONFLICT, "Loop gate is no longer running")
625+
try:
626+
validate_loop_decision(value, caller.region_index, loop[0])
627+
except InvalidLoopDecision as error:
628+
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error
627629
region_id, region_index = caller.region_id, caller.region_index
628630
elif key.startswith(LOOP_XCOM_PREFIX):
629631
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Reserved loop XCom key")

‎airflow-core/src/airflow/models/dagrun.py‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,17 @@ class InvalidLoopDecision(ValueError):
151151
"""A gate cannot complete successfully with the available decision."""
152152

153153

154+
def validate_loop_decision(decision: str | None, region_index: int, group: SerializedLoopTaskGroup) -> None:
155+
"""Raise :class:`InvalidLoopDecision` unless ``decision`` is valid for the gate of pass ``region_index``."""
156+
at_limit = region_index + 1 >= group.max_iterations
157+
if decision not in ("continue", "stop"):
158+
raise InvalidLoopDecision("Successful loop gate requires a decision")
159+
if decision == "continue" and at_limit:
160+
raise InvalidLoopDecision("Loop cannot continue beyond its iteration limit")
161+
if not group.has_until and (decision == "stop") != at_limit:
162+
raise InvalidLoopDecision("Fixed-count loop decision does not match its iteration limit")
163+
164+
154165
class TISchedulingDecision(NamedTuple):
155166
"""Type of return for DagRun.task_instance_scheduling_decisions."""
156167

@@ -2246,13 +2257,7 @@ def complete_loop_gate(
22462257
session.delete(signal)
22472258
return
22482259
decision = signal.value if signal is not None else None
2249-
at_limit = gate.region_index + 1 >= group.max_iterations
2250-
if decision not in ("continue", "stop"):
2251-
raise InvalidLoopDecision("Successful loop gate requires a decision")
2252-
if decision == "continue" and at_limit:
2253-
raise InvalidLoopDecision("Loop cannot continue beyond its iteration limit")
2254-
if not group.has_until and (decision == "stop") != at_limit:
2255-
raise InvalidLoopDecision("Fixed-count loop decision does not match its iteration limit")
2260+
validate_loop_decision(decision, gate.region_index, group)
22562261
session.delete(signal)
22572262
if decision == "stop":
22582263
return
@@ -2315,10 +2320,16 @@ def _create_initial_tasks(
23152320
)
23162321
.limit(1)
23172322
):
2318-
raise ValueError(f"Loop {group_id!r} has regions but no live gate")
2319-
region = DynamicRegion(dag_id=self.dag_id, run_id=self.run_id, node_id=group_id)
2320-
session.add(region)
2321-
session.flush()
2323+
log.warning(
2324+
"skipping loop that has regions but no live gate",
2325+
dag_id=self.dag_id,
2326+
run_id=self.run_id,
2327+
loop=group_id,
2328+
)
2329+
continue
2330+
region = DynamicRegion.get_or_create(
2331+
dag_id=self.dag_id, run_id=self.run_id, node_id=loops[group_id].node_id, session=session
2332+
)
23222333
coordinates = [(region.id, 0)]
23232334
for region_id, region_index in coordinates:
23242335
yield from self._create_tasks(

‎airflow-core/src/airflow/models/dynamic_region.py‎

Lines changed: 87 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from collections.abc import Collection, Iterable
2222
from datetime import datetime
2323
from functools import partial
24-
from typing import TYPE_CHECKING
24+
from typing import TYPE_CHECKING, Any
2525
from uuid import UUID
2626

2727
import attrs
@@ -531,22 +531,20 @@ def _validate_producer_request(
531531
raise ValueError("Previous-iteration lookup requires a loop context")
532532

533533

534-
def resolve_current_producers(
534+
def _build_candidate_query(
535+
query: Select[Any],
535536
*,
536537
dag_id: str,
537538
run_id: str,
538539
task_id: str,
539540
is_mapped: bool,
540-
context: ProducerContext | None = None,
541-
map_indexes: int | Collection[int] | None = None,
542-
region_id: UUID | None = None,
543-
region_index: int | None = None,
541+
context: ProducerContext | None,
542+
map_indexes: int | Collection[int] | None,
543+
region_id: UUID | None,
544+
region_index: int | None,
544545
session: Session,
545-
) -> tuple[TaskInstance, ...]:
546-
"""Resolve the live producer task instances whose data the caller reads by task instance UUID."""
547-
from airflow.models.taskinstance import TaskInstance
548-
549-
_validate_producer_request(context=context, region_id=region_id, region_index=region_index)
546+
) -> Select[Any] | None:
547+
"""Narrow ``query`` to the candidate producers of the caller's scope, or ``None`` if there are none."""
550548
regions: dict[UUID, DynamicRegion] = {}
551549
position = None
552550
if context and region_id is None:
@@ -560,32 +558,74 @@ def resolve_current_producers(
560558
if context.previous_iteration:
561559
position = position[0], position[1] - 1
562560
if position[1] < 0:
563-
return ()
561+
return None
562+
563+
return _filter_producers(
564+
query,
565+
dag_id=dag_id,
566+
run_id=run_id,
567+
task_id=task_id,
568+
is_mapped=is_mapped,
569+
map_indexes=map_indexes,
570+
region_id=region_id,
571+
region_index=region_index,
572+
top_level_only=region_id is None and position is None,
573+
loop_pass=_load_loop_pass_regions(position, session=session) if position is not None else None,
574+
)
564575

565-
candidates = session.scalars(
566-
_filter_producers(
567-
select(TaskInstance),
568-
dag_id=dag_id,
569-
run_id=run_id,
570-
task_id=task_id,
571-
is_mapped=is_mapped,
572-
map_indexes=map_indexes,
573-
region_id=region_id,
574-
region_index=region_index,
575-
top_level_only=region_id is None and position is None,
576-
loop_pass=_load_loop_pass_regions(position, session=session) if position is not None else None,
577-
)
578-
).all()
579576

580-
selected: dict[int, TaskInstance] = {}
581-
for ti in candidates:
582-
public_index = ti.region_index if is_mapped else -1
583-
if public_index in selected:
577+
def _check_producers_unambiguous(
578+
region_indexes: Iterable[int], *, dag_id: str, run_id: str, task_id: str, is_mapped: bool
579+
) -> None:
580+
seen: set[int] = set()
581+
for region_index in region_indexes:
582+
public_index = region_index if is_mapped else -1
583+
if public_index in seen:
584584
raise AmbiguousProducerError(
585585
f"Multiple live producers for {dag_id}/{run_id}/{task_id} index {public_index}"
586586
)
587-
selected[public_index] = ti
588-
return tuple(selected[index] for index in sorted(selected))
587+
seen.add(public_index)
588+
589+
590+
def resolve_current_producers(
591+
*,
592+
dag_id: str,
593+
run_id: str,
594+
task_id: str,
595+
is_mapped: bool,
596+
context: ProducerContext | None = None,
597+
map_indexes: int | Collection[int] | None = None,
598+
region_id: UUID | None = None,
599+
region_index: int | None = None,
600+
session: Session,
601+
) -> tuple[TaskInstance, ...]:
602+
"""Resolve the live producer task instances whose data the caller reads by task instance UUID."""
603+
from airflow.models.taskinstance import TaskInstance
604+
605+
_validate_producer_request(context=context, region_id=region_id, region_index=region_index)
606+
query = _build_candidate_query(
607+
select(TaskInstance),
608+
dag_id=dag_id,
609+
run_id=run_id,
610+
task_id=task_id,
611+
is_mapped=is_mapped,
612+
context=context,
613+
map_indexes=map_indexes,
614+
region_id=region_id,
615+
region_index=region_index,
616+
session=session,
617+
)
618+
if query is None:
619+
return ()
620+
candidates = session.scalars(query).all()
621+
_check_producers_unambiguous(
622+
(ti.region_index for ti in candidates),
623+
dag_id=dag_id,
624+
run_id=run_id,
625+
task_id=task_id,
626+
is_mapped=is_mapped,
627+
)
628+
return tuple(sorted(candidates, key=lambda ti: ti.region_index if is_mapped else -1))
589629

590630

591631
def select_current_producer_ids(
@@ -632,14 +672,27 @@ def select_current_producer_ids(
632672
)
633673
single_region = not session.scalar(select(other_region.exists()))
634674
if not single_region:
635-
producers = resolve_current_producers(
675+
candidate_query = _build_candidate_query(
676+
select(TaskInstance.id, TaskInstance.region_index),
636677
dag_id=dag_id,
637678
run_id=run_id,
638679
task_id=task_id,
639680
is_mapped=is_mapped,
640681
context=context,
641682
map_indexes=map_indexes,
683+
region_id=None,
684+
region_index=None,
642685
session=session,
643686
)
644-
return select(TaskInstance.id).where(TaskInstance.id.in_([ti.id for ti in producers]))
687+
candidates = session.execute(candidate_query).all() if candidate_query is not None else []
688+
_check_producers_unambiguous(
689+
(candidate.region_index for candidate in candidates),
690+
dag_id=dag_id,
691+
run_id=run_id,
692+
task_id=task_id,
693+
is_mapped=is_mapped,
694+
)
695+
return select(TaskInstance.id).where(
696+
TaskInstance.id.in_([candidate.id for candidate in candidates])
697+
)
645698
return query

‎airflow-core/src/airflow/models/task_coordinates.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,17 @@ def resolve(
598598
).order_by(TaskInstance.region_index)
599599
)
600600
)
601-
return resolve_current_producers(**attrs.asdict(request, recurse=False), session=self.session)
601+
return resolve_current_producers(
602+
dag_id=request.dag_id,
603+
run_id=request.run_id,
604+
task_id=request.task_id,
605+
is_mapped=request.is_mapped,
606+
context=request.context,
607+
map_indexes=request.map_indexes,
608+
region_id=request.region_id,
609+
region_index=request.region_index,
610+
session=self.session,
611+
)
602612

603613
def select_skip_target_ids(
604614
self, *, caller: TaskInstance, task_id: str, map_indexes: int | None = None
@@ -681,4 +691,14 @@ def select_producer_ids(
681691
region_index=region_index,
682692
map_indexes=map_indexes,
683693
)
684-
return select_current_producer_ids(**attrs.asdict(request, recurse=False), session=self.session)
694+
return select_current_producer_ids(
695+
dag_id=request.dag_id,
696+
run_id=request.run_id,
697+
task_id=request.task_id,
698+
is_mapped=request.is_mapped,
699+
context=request.context,
700+
map_indexes=request.map_indexes,
701+
region_id=request.region_id,
702+
region_index=request.region_index,
703+
session=self.session,
704+
)

0 commit comments

Comments
 (0)