kaxil commented on code in PR #74349:
URL: https://github.com/apache/airflow/pull/74349#discussion_r4222295977
##########
airflow-core/src/airflow/serialization/definitions/dag.py:
##########
@@ -1290,11 +1299,23 @@ def clear(
if count == 0:
return 0
- clear_task_instances(
- list(tis),
- session,
+ clear_task_instances_for_runs(
Review Comment:
The dry run above returns `tis_result`, but once a gate is among `tis` this
archives more than that: `clear_loop_task_instances` archives every live TI at
a later loop position, including ones `exclude_task_ids` left out, and the
returned count is still `len(tis)`. `airflow tasks clear` builds its
confirmation prompt from the dry run, so users confirm a list that doesn't show
the later passes that get archived. Could the dry run include the archive set
from `select_loop_clear_scope`, and the scope respect `exclude_task_ids`?
##########
airflow-core/src/airflow/models/taskinstance.py:
##########
@@ -424,10 +430,50 @@ def clear_task_instances(
:param run_on_latest_version: whether to run on latest serialized DAG and
Bundle version.
A run with no version of its own uses the latest either way, since
there is nothing
else for it to run on; a task instance with no version joins its run's.
+ :param whole_task_keys: ``(dag_id, run_id, task_id)`` of tasks cleared as
a whole. A pre-region
+ mapped expansion of such a task is archived and regenerated in a new
region.
+ :param superseded_ti_ids: executions whose generated work is replaced
rather than retried; they
+ are archived in place instead of receiving a successor.
:meta private:
"""
from airflow.exceptions import AirflowClearRunningTaskException
+ from airflow.models.dagrun import DagRun
+
+ if tis:
+ run_keys = {(ti.dag_id, ti.run_id) for ti in tis}
+ session.scalars(
+ select(DagRun.id)
+ .where(tuple_(DagRun.dag_id, DagRun.run_id).in_(run_keys))
+ .order_by(DagRun.dag_id, DagRun.run_id)
+ .with_for_update()
+ ).all()
+
+ legacy_keys = {
+ (ti.dag_id, ti.run_id, ti.task_id)
+ for ti in tis
+ if ti.region_id == SENTINEL_REGION_ID
+ and ti.region_index >= 0
+ and (ti.dag_id, ti.run_id, ti.task_id) in whole_task_keys
+ }
+ if legacy_keys:
+ existing_keys = {
+ (region.dag_id, region.run_id, region.node_id)
+ for region in session.scalars(
+ select(DynamicRegion).where(
+ tuple_(DynamicRegion.dag_id, DynamicRegion.run_id,
DynamicRegion.node_id).in_(
+ legacy_keys
+ ),
+ DynamicRegion.parent_region_id.is_(None),
+ DynamicRegion.forked_from_region_id.is_(None),
+ )
+ )
+ }
+ session.add_all(
Review Comment:
When the legacy expansion is regenerated in this new region,
`expand_mapped_task` seeds try numbers from
`get_last_try_numbers(region_id=<new region>)`, which never sees the archived
sentinel rows, so the new tries start at 1 again. A top-level map region has an
empty log position and keeps `map_index=region_index`, so the new attempt 1
writes to the same `map_index=i/attempt=1.log` as the archived attempt 1.
That's an ordinary whole-task clear of a mapped task created before the
upgrade, no loops involved. Could the seed also take the max try from the
sentinel region for the same task?
##########
airflow-core/src/airflow/models/dagrun.py:
##########
@@ -1901,6 +1903,100 @@ def _emit_duration_stats_for_finished_state(self):
tags={**self.stats_tags, "dag_id": self.dag_id},
)
+ def _reconcile_legacy_expansions(
+ self, *, session: Session, dag_version_id: UUID | None = None
+ ) -> list[TI]:
+ from airflow.models.task_coordinates import TaskCoordinateResolver
+
+ if not self.get_dag().has_dynamic_nodes:
+ return []
+
+ pending = (
+ select(DynamicRegion)
+ .where(
+ DynamicRegion.dag_id == self.dag_id,
+ DynamicRegion.run_id == self.run_id,
+ DynamicRegion.parent_region_id.is_(None),
+ DynamicRegion.forked_from_region_id.is_(None),
+ ~select(TI.id)
+ .where(
+ TI.working_set.is_(True),
+ TI.dag_id == DynamicRegion.dag_id,
+ TI.run_id == DynamicRegion.run_id,
+ TI.region_id == DynamicRegion.id,
+ )
+ .exists(),
+ ~select(TI.id)
+ .where(
+ TI.working_set.is_(True),
+ TI.dag_id == DynamicRegion.dag_id,
+ TI.run_id == DynamicRegion.run_id,
+ TI.task_id == DynamicRegion.node_id,
+ TI.region_id == SENTINEL_REGION_ID,
+ )
+ .exists(),
+ select(TI.id)
+ .where(
+ TI.working_set.is_(None),
+ TI.dag_id == DynamicRegion.dag_id,
+ TI.run_id == DynamicRegion.run_id,
+ TI.task_id == DynamicRegion.node_id,
+ TI.region_id == SENTINEL_REGION_ID,
+ TI.archived_reason == "superseded",
+ )
+ .exists(),
+ )
+ .execution_options(include_all_attempts=True)
+ )
+ regions = session.scalars(pending).all()
+ if not regions:
+ return []
+ session.execute(
+ select(DagRun)
+ .where(DagRun.id == self.id)
+ .with_for_update()
+ .execution_options(populate_existing=True)
Review Comment:
Sessions are `autoflush=False`, so when `clear_task_instances` calls
`dr.verify_integrity` on the latest-version path, the new `clear_number`,
`queued_at`, `state` and `start_date` on that run are still unflushed, and this
`populate_existing` lock reloads them from the row. On a whole-task clear of a
pre-region mapped task this query does match, because the clear has already
flushed the new top-level region and archived the sentinel rows. So a finished
run cleared with `run_on_latest_version` (or one with no
`created_dag_version_id`, which always takes that path) stays SUCCESS or FAILED
with cleared TIs that the scheduler never picks up. Locking with
`select(DagRun.id)` like the other lock sites, or flushing before the lock,
would avoid it.
##########
airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py:
##########
@@ -963,42 +994,162 @@ def _collect_relatives(run_id: str, direction:
Literal["upstream", "downstream"]
*((t, m) for t, m in mapped_tasks_tuples if t not in
normal_task_ids),
]
- task_instances: Sequence[TI]
- if dag_run_id is not None and not (past or future):
- # Use run_id-based clearing when we have a specific dag_run_id and not
using past/future
- task_instances = dag.clear(
- dry_run=True,
- task_ids=task_markers_to_clear,
- run_id=dag_run_id,
- session=session,
- run_on_latest_version=resolved_run_on_latest,
- only_failed=body.only_failed,
- only_running=body.only_running,
- )
- else:
- # Use date-based clearing when no dag_run_id or when past/future is
specified
- task_instances = dag.clear(
+ clear_markers = task_markers_to_clear
+ if loop_aware and task_markers_to_clear is not None:
+ clear_markers = [marker if isinstance(marker, str) else marker[0] for
marker in task_markers_to_clear]
+
+ def select_candidates() -> Sequence[TI]:
+ if body.task_instance_ids is not None:
+ return session.scalars(
+ select(TI).where(
+ TI.dag_id == dag_id,
+ TI.run_id == dag_run_id,
+ TI.working_set.is_(True),
+ TI.id.in_(body.task_instance_ids),
+ )
+ ).all()
+ if dag_run_id is not None and not (past or future):
+ return dag.clear(
+ dry_run=True,
+ task_ids=clear_markers,
+ run_id=dag_run_id,
+ session=session,
+ run_on_latest_version=resolved_run_on_latest,
+ only_failed=body.only_failed and not loop_aware,
+ only_running=body.only_running and not loop_aware,
+ )
+ return dag.clear(
dry_run=True,
- task_ids=task_markers_to_clear,
+ task_ids=clear_markers,
start_date=body.start_date,
end_date=body.end_date,
session=session,
run_on_latest_version=resolved_run_on_latest,
- only_failed=body.only_failed,
- only_running=body.only_running,
+ only_failed=body.only_failed and not loop_aware,
+ only_running=body.only_running and not loop_aware,
)
- if not dry_run:
+ task_instances = select_candidates()
+ if body.task_instance_ids is not None and {ti.id for ti in task_instances}
!= set(body.task_instance_ids):
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND, "Selected task execution is not current
in this DAG run"
+ )
+
+ if task_instances:
+ if not dry_run:
+ run_keys = {(ti.dag_id, ti.run_id) for ti in task_instances}
+ session.scalars(
+ select(DagRun)
+ .where(tuple_(DagRun.dag_id, DagRun.run_id).in_(run_keys))
+ .order_by(DagRun.dag_id, DagRun.run_id)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ ).all()
+ if body.task_instance_ids is None:
+ task_instances = [ti for ti in select_candidates() if
(ti.dag_id, ti.run_id) in run_keys]
+ task_instances = session.scalars(
+ select(TI)
+ .where(TI.id.in_([ti.id for ti in task_instances]))
+ .execution_options(populate_existing=True)
+ ).all()
+
+ scopes: list[LoopClearScope] = []
+ if loop_aware and task_markers_to_clear is not None:
Review Comment:
`dag.clear` adds the setup and teardown tasks of the selection through
`partial_subset`, but this filter keeps only TIs whose task_id was in the
request, and `select_loop_clear_scope` doesn't add them back. Since
`loop_aware` is true for any Dag that contains a loop, clearing an ordinary
task with a setup/teardown pair in such a Dag now re-runs it without its setup
and teardown. Could the filter keep the ids `dag.clear` added, or the scope
pull in the relevant setup/teardown?
##########
airflow-ctl/src/airflowctl/api/datamodels/generated.py:
##########
@@ -291,6 +302,27 @@ class ClearTaskInstancesBody(BaseModel):
title="Task Group Id",
),
] = None
+ task_instance_ids: Annotated[
Review Comment:
`airflowctl tasks clear --task-instance-ids` comes through as a plain
string: the annotation is the `TaskInstanceIds` root model, which
`_python_type_from_string` maps to `str`, and `_apply_datamodel_defaults` only
splits `task_ids`, so pydantic rejects it with "Input should be a valid list".
`--whole-expansion-ids` has to be a subset of it, so neither is usable from the
CLI. Could both get the same comma/JSON handling as `task_ids`?
##########
airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py:
##########
@@ -57,6 +57,8 @@ class TaskInstanceResponse(BaseModel):
dag_id: str
run_id: str = Field(alias="dag_run_id")
map_index: int
+ region_id: UUID
Review Comment:
With these required, every ordinary TI in the REST API reports the zero UUID
as its `region_id`, while the execution API maps the sentinel to `None` through
`get_public_region`. Could these be `UUID | None` / `int | None` and go through
`get_public_region` before the generated clients ship? Otherwise every client
has to know that the zero UUID means "no region".
##########
airflow-core/src/airflow/ti_deps/deps/loop_archival_dep.py:
##########
@@ -0,0 +1,30 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from airflow.ti_deps.deps.base_ti_dep import BaseTIDep
+
+
+class LoopArchivalDep(BaseTIDep):
+ """A rerun gate waits for the generated work it replaces to terminate."""
+
+ NAME = "Loop archival"
+ IS_TASK_DEP = True
+
+ def _get_dep_statuses(self, ti, dep_context, session):
+ if ti.waits_for_loop_archival(session=session):
+ yield self._failing_status(reason="Superseded loop executions are
still terminating")
Review Comment:
`_are_premature_tis` still sets only the retry and reschedule flags, so
`update_state` can't tell this wait apart from a dependency that will never be
met. With a body like `prepare >> process` and `process[3]` running, clearing
the gate of iteration 1 can still fail the run with `all_tasks_deadlocked`
before the worker acks the restart.
##########
airflow-core/src/airflow/models/dagrun.py:
##########
@@ -2794,10 +2907,18 @@ def _flush_ti_buffer(*, drain: bool = False) -> int:
while len(ti_carry) >= _TI_CHUNK_SIZE:
slice_tis = ti_carry[:_TI_CHUNK_SIZE]
Review Comment:
These slices still cut across runs, and the buffer query has no ordering. A
gate of one run in this slice archives that run's later passes that are still
waiting in `ti_carry`, and the next slice then fails with "An archived task
instance cannot be cleared" and rolls back the whole partition clear. Flushing
whole runs per call would avoid it.
##########
airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py:
##########
@@ -896,7 +913,7 @@ def post_clear_task_instances(
error_message = f"Dag Run id {dag_run_id} not found in dag
{dag_id}"
raise HTTPException(status.HTTP_404_NOT_FOUND, error_message)
# Get the specific dag version:
- dag = get_dag_for_run(dag_bag, dag_run, session)
+ dag = get_dag_for_run_or_latest_version(dag_bag, dag_run, dag_id,
session)
Review Comment:
This still picks the run's creation version, where `get_dag_for_run` used to
fall back to the latest one for unversioned bundles. On the default dags folder
the scheduler moves a running run's unfinished TIs to the latest version and
`verify_integrity` adds new tasks, so after `a >> b` becomes `a >> b >> c`
mid-run, clearing `b` with downstream misses `c`, and a newly added
`task_group_id` returns 404.
##########
airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py:
##########
@@ -2255,6 +2255,40 @@ def test_clear_dag_run_dry_run(self, test_client,
session, body, dag_run_id, exp
)
assert logs == 0
+ @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+ def test_clear_dag_run_dry_run_reports_loop_coordinates(self, test_client,
dag_maker, session):
+ @task_group
+ def body():
+ EmptyOperator(task_id="work")
+
+ with dag_maker("dry_run_loop", serialized=True):
+ loop = create_loop(body, max_iterations=3)
+ run = dag_maker.create_dagrun(run_id="dry_run_loop_run")
+ region =
session.scalar(select(DynamicRegion).where(DynamicRegion.node_id ==
loop.group_id))
+ later = TaskInstance(
+ task=dag_maker.dag.get_task("body.work"),
+ run_id=run.run_id,
+ dag_version_id=run.created_dag_version_id,
+ region_id=region.id,
+ region_index=1,
+ )
+ later.state = State.SUCCESS
+ session.add(later)
+ session.commit()
+
+ response = test_client.post(
+ f"/dags/{run.dag_id}/dagRuns/{run.run_id}/clear", json={"dry_run":
True, "only_failed": False}
+ )
+
+ assert response.status_code == 200, response.text
+ work = [row for row in response.json()["task_instances"] if
row["task_id"] == "body.work"]
+ assert {row["region_index"] for row in work} == {0, 1}
+ assert {row["map_index"] for row in work} == {-1}
+ assert all(
+ row["loop_iterations"] == [{"loop_id": loop.group_id, "iteration":
row["region_index"]}]
Review Comment:
`TaskInstanceResponse` still has no `loop_iterations` field at this commit,
so this raises `KeyError`. It can move to the PR that adds the field.
##########
airflow-core/src/airflow/api_fastapi/core_api/services/public/task_instances.py:
##########
@@ -545,13 +806,92 @@ def handle_bulk_create(
}
)
+ def _handle_regional_bulk(
+ self, action: MutationAction, results: BulkActionResponse
+ ) -> tuple[MutationAction, set[tuple[str, str, str, int]], set[tuple[str,
str, str]]]:
+ regional = [
+ entity
+ for entity in action.entities
+ if isinstance(entity, BulkTaskInstanceBody)
+ and (entity.region_id is not None or entity.region_index is not
None)
+ ]
+ deleting = isinstance(action, BulkDeleteAction)
+ specific, whole = self._categorize_entities(
+ action.entities, results, method="DELETE" if deleting else "PUT",
action_name=action.action.value
+ )
+ keys = {key[:3] for key in specific} | whole
+ run_keys = {key[:2] for key in keys}
+ for entity in action.entities:
+ if isinstance(entity, BulkTaskInstanceBody) and
(entity.include_future or entity.include_past):
+ dag_id, run_id, task_id, _ =
self._extract_task_identifiers(entity)
+ if (dag_id, run_id, task_id) in keys:
+ dag = get_latest_version_of_dag(self.dag_bag, dag_id,
self.session)
+ run_keys.update(
+ (dag_id, selected_run)
+ for selected_run in get_run_ids(
+ dag, run_id, entity.include_future,
entity.include_past, session=self.session
+ )
+ )
+ self.session.scalars(
+ select(DagRun)
+ .where(
+ tuple_(DagRun.dag_id, DagRun.run_id).in_(run_keys),
+ )
+ .order_by(DagRun.dag_id, DagRun.run_id)
+ .with_for_update()
+ ).all()
+ for entity in regional:
+ dag_id, run_id, task_id, map_index =
self._extract_task_identifiers(entity)
+ if (dag_id, run_id, task_id) not in keys:
+ continue
+ try:
+ dag, tis, data = _patch_ti_validate_request(
+ dag_id,
+ run_id,
+ task_id,
+ self.dag_bag,
+ entity,
+ self.session,
+ map_index,
+ getattr(action, "update_mask", None),
+ )
+ if deleting:
+ for ti in tis:
+ self.session.delete(ti)
Review Comment:
This still deletes only the live row, so the archived tries of a loop pass
(and the logs and XCom keyed by their attempt ids) stay behind, unlike the
non-regional path that goes through `TI.delete_attempts`.
##########
airflow-core/src/airflow/models/taskinstance.py:
##########
@@ -570,11 +620,297 @@ def get_cached_latest_dag_version(dag_id: str) ->
DagVersion | None:
if dr.created_dag_version_id:
_pin_versionless_tis_to_run_version(dr,
dr.created_dag_version_id, session)
for ti in tis:
- ti.context_carrier = new_task_run_carrier(ti.dag_run.context_carrier)
+ if ti.working_set is True:
+ ti.context_carrier =
new_task_run_carrier(ti.dag_run.context_carrier)
session.flush()
return cleared
[email protected](frozen=True)
+class LoopClearScope:
+ """Live executions to retry and generated executions to archive."""
+
+ retry_ids: frozenset[UUID]
+ archive_ids: frozenset[UUID]
+
+
+def select_loop_clear_scope(
+ selected: Collection[TaskInstance],
+ *,
+ whole_expansion_ids: Collection[UUID] = (),
+ upstream: bool = False,
+ downstream: bool = True,
+ later_loop_iterations: bool = True,
+ session: Session,
+) -> LoopClearScope:
+ """Select loop clear executions; mutation callers must hold the DagRun
lock."""
+ from airflow.models.dagbag import DBDagBag
+ from airflow.models.task_coordinates import TaskCoordinateResolver,
enclosing_loop
+ from airflow.serialization.definitions.mappedoperator import
get_mapped_ti_count
+
+ if not selected:
+ return LoopClearScope(frozenset(), frozenset())
+ runs = {(ti.dag_id, ti.run_id) for ti in selected}
+ if len(runs) != 1:
+ raise ValueError("Loop clear selection must belong to one DagRun")
+ dag_id, run_id = runs.pop()
+ live = {
+ ti.id: ti
+ for ti in session.scalars(
+ select(TaskInstance)
+ .where(
+ TaskInstance.dag_id == dag_id,
+ TaskInstance.run_id == run_id,
+ TaskInstance.working_set.is_(True),
+ )
+ .execution_options(populate_existing=True)
+ )
+ }
+ selected_ids = {ti.id for ti in selected}
+ if not selected_ids <= live.keys():
+ raise ValueError("Loop clear selection contains executions that are no
longer live")
+ if not set(whole_expansion_ids) <= selected_ids:
+ raise ValueError("Whole-task selection requires an explicitly selected
execution")
+ resolver = TaskCoordinateResolver(DBDagBag(), session)
+ regions = load_region_ancestry(
+ {ti.region_id for ti in live.values()}, dag_id=dag_id, run_id=run_id,
session=session
+ )
+ retry = {ti_id: live[ti_id] for ti_id in selected_ids}
+ for ti in tuple(retry.values()):
+ task = resolver.get_task(dag_id, run_id, ti.task_id,
dag_version_id=ti.dag_version_id)
+ group = enclosing_loop(task)
+ if group is not None and loop_position(regions, ti.region_id,
ti.region_index, group.node_id) is None:
+ raise ValueError("Loop clear selection requires an execution
inside its pinned loop")
+ if ti.id in whole_expansion_ids:
+ if not task.get_needs_expansion():
+ raise ValueError("Whole-expansion selection requires a mapped
task")
+ retry.update(
+ (other.id, other)
+ for other in resolver.resolve(dag_id=dag_id, run_id=run_id,
task_id=ti.task_id, caller=ti)
+ )
+ for is_upstream, enabled in ((True, upstream), (False, downstream)):
+ if not enabled:
+ continue
+ for ti in tuple(retry.values()):
+ task = resolver.get_task(dag_id, run_id, ti.task_id,
dag_version_id=ti.dag_version_id)
+ contexts = resolver.producer_contexts(ti)
+ count = (
+ get_mapped_ti_count(task, run_id, session=session,
producer_contexts=contexts)
+ if task.get_needs_expansion() and ti.region_index >= 0
+ else None
+ )
+ for relative in task.get_flat_relatives(upstream=is_upstream):
Review Comment:
This still walks relatives once per selected TI, with `producer_contexts`,
`get_mapped_ti_count` and a `resolve` each time, all under the DagRun lock. Any
Dag that contains a loop takes this path, so clearing a 1000-wide mapped task
with downstream there is thousands of queries returning the same rows.
Expanding relatives once per `(task_id, region_id, dag_version_id)` when the
task and relative share no mapped group would collapse most of it.
##########
airflow-core/src/airflow/models/dagrun.py:
##########
@@ -1468,6 +1468,8 @@ def recalculate(self) -> _UnfinishedStates:
@provide_session
def task_instance_scheduling_decisions(self, *, session: Session =
NEW_SESSION) -> TISchedulingDecision:
tis = self.get_task_instances(session=session, state=State.task_states)
+ reconciled = self._reconcile_legacy_expansions(session=session)
Review Comment:
The `has_dynamic_nodes` guard helps, but any Dag with a mapped task still
runs this three-subquery `dynamic_region` probe on every scheduling pass of
every run, and it only finds work after a whole-task clear of a pre-region
expansion. Could it run from `clear_task_instances` and `complete_restart`,
where that state is created, with `verify_integrity` as the backstop?
##########
airflow-core/src/airflow/jobs/scheduler_job_runner.py:
##########
@@ -1543,10 +1543,36 @@ def process_executor_events(
return len(event_buffer)
# Check state of finished tasks
+ candidates = session.execute(
+ select(TI.id, TI.dag_id, TI.run_id).where(
+ TI.working_set.is_(True), TI.id.in_([key.id for key in
tis_with_right_state])
+ )
+ ).all()
+ run_query = (
+ select(DagRun.dag_id, DagRun.run_id)
+ .where(
+ tuple_(DagRun.dag_id, DagRun.run_id).in_(
+ {(dag_id, run_id) for _, dag_id, run_id in candidates}
+ )
+ )
+ .order_by(DagRun.dag_id, DagRun.run_id)
+ )
+ locked_runs = set(
+ session.execute(with_row_locks(run_query, of=DagRun,
session=session, skip_locked=True))
Review Comment:
This takes a `skip_locked` DagRun lock for every run with a pending executor
event, for every Dag, and puts events for runs it can't lock back in the
buffer. In HA, a run that another scheduler is examining
(`get_running_dag_runs_to_examine` holds the same row lock) gets its task
success/failure handling pushed to a later loop, and the heartbeat scan below
does the same. Should this be limited to runs with regional TIs, or is the
delay meant to apply to every Dag?
##########
airflow-core/src/airflow/api_fastapi/core_api/services/public/task_instances.py:
##########
@@ -197,33 +221,110 @@ def _patch_ti_validate_request(
session: SessionDep,
map_index: int | None = -1,
update_mask: list[str] | None = None,
+ *,
+ lock: bool = True,
) -> tuple[SerializedDAG, list[TI], dict]:
+ _validate_region_selection(body)
dag = get_latest_version_of_dag(dag_bag, dag_id, session)
- if not dag.has_task(task_id):
+ if lock:
+ _lock_patch_runs(dag, dag_run_id, body, session)
+ if body.region_id is None and not dag.has_task(task_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, f"Task '{task_id}' not
found in Dag '{dag_id}'")
query = (
select(TI)
.where(TI.dag_id == dag_id, TI.run_id == dag_run_id, TI.task_id ==
task_id)
.options(joinedload(TI.rendered_task_instance_fields))
)
- if map_index is not None:
- query = query.where(TI.map_index == map_index)
- else:
- query = query.order_by(TI.map_index)
-
+ if body.region_id is None:
+ resolver = TaskCoordinateResolver(dag_bag, session)
+ for version in session.scalars(
Review Comment:
This still returns a null `dag_version_id` for TIs from runs that predate
Dag versioning, and `get_task(..., dag_version_id=None)` raises a bare
`ValueError("Pinned Dag ... not found")` when the run has no
`created_dag_version_id` either. Nothing on the PATCH or bulk path catches it,
so a note or state change on such a TI is a 500. Filtering with
`TI.dag_version_id.is_not(None)` here would skip them.
--
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]