|
20 | 20 | import asyncio |
21 | 21 | import itertools |
22 | 22 | import json |
23 | | -import operator |
24 | 23 | from typing import TYPE_CHECKING, Any |
25 | 24 |
|
26 | 25 | import attrs |
|
61 | 60 | from airflow.listeners.listener import get_listener_manager |
62 | 61 | from airflow.models.dagrun import DagRun, clear_partition_runs |
63 | 62 | from airflow.models.renderedtifields import load_legacy_rendered_fields |
| 63 | +from airflow.models.task_coordinates import enclosing_loop, public_map_index_expression |
64 | 64 | from airflow.models.taskinstance import TaskInstance |
65 | 65 | from airflow.models.xcom import XCOM_RETURN_KEY, XComModel, xcom_entity |
66 | 66 | from airflow.utils.session import create_session_async |
67 | 67 | from airflow.utils.state import State, TaskInstanceState |
68 | 68 |
|
69 | 69 | if TYPE_CHECKING: |
70 | | - from collections.abc import AsyncGenerator, Iterator |
| 70 | + from collections.abc import AsyncGenerator |
| 71 | + from uuid import UUID |
71 | 72 |
|
72 | 73 | from airflow.serialization.definitions.dag import SerializedDAG |
73 | 74 |
|
@@ -256,44 +257,90 @@ class DagRunWaiter: |
256 | 257 | interval: float |
257 | 258 | result_task_ids: list[str] | None |
258 | 259 |
|
| 260 | + def validate_result_selection(self, dag_run: DagRun, dag_bag: DagBagDep, *, session: Session) -> None: |
| 261 | + if self.result_task_ids == []: |
| 262 | + return |
| 263 | + query = select(TaskInstance.task_id, TaskInstance.dag_version_id).where( |
| 264 | + TaskInstance.dag_id == self.dag_id, TaskInstance.run_id == self.run_id |
| 265 | + ) |
| 266 | + if self.result_task_ids is not None: |
| 267 | + query = query.where(TaskInstance.task_id.in_(self.result_task_ids)) |
| 268 | + versions: dict[UUID | None, set[str]] = {} |
| 269 | + for task_id, version in session.execute(query.distinct()): |
| 270 | + versions.setdefault(version or dag_run.created_dag_version_id, set()).add(task_id) |
| 271 | + current_task_ids = {task_id for task_ids in versions.values() for task_id in task_ids} |
| 272 | + created_dag = ( |
| 273 | + dag_bag.get_dag(dag_run.created_dag_version_id, session=session) |
| 274 | + if dag_run.created_dag_version_id is not None |
| 275 | + else None |
| 276 | + ) |
| 277 | + if created_dag is not None: |
| 278 | + requested = ( |
| 279 | + set(self.result_task_ids) if self.result_task_ids is not None else set(created_dag.task_ids) |
| 280 | + ) |
| 281 | + versions.setdefault(dag_run.created_dag_version_id, set()).update(requested - current_task_ids) |
| 282 | + for version, task_ids in versions.items(): |
| 283 | + if version is None: |
| 284 | + continue |
| 285 | + dag = ( |
| 286 | + created_dag |
| 287 | + if version == dag_run.created_dag_version_id |
| 288 | + else dag_bag.get_dag(version, session=session) |
| 289 | + ) |
| 290 | + if dag is None: |
| 291 | + continue |
| 292 | + for task_id in task_ids: |
| 293 | + if not dag.has_task(task_id): |
| 294 | + continue |
| 295 | + task = dag.get_task(task_id) |
| 296 | + if self.result_task_ids is None and not task.returns_dag_result: |
| 297 | + continue |
| 298 | + if enclosing_loop(task) is not None: |
| 299 | + raise HTTPException( |
| 300 | + status.HTTP_409_CONFLICT, |
| 301 | + f"Cannot collect loop task {task_id!r} as a DagRun result; " |
| 302 | + "publish the result through a task outside the loop.", |
| 303 | + ) |
| 304 | + |
259 | 305 | async def _get_dag_run(self) -> DagRun: |
260 | 306 | async with create_session_async() as session: |
261 | 307 | return await session.scalar(select(DagRun).filter_by(dag_id=self.dag_id, run_id=self.run_id)) |
262 | 308 |
|
263 | 309 | async def _serialize_xcoms(self) -> dict[str, Any]: |
| 310 | + xcom_query = XComModel.get_many( |
| 311 | + run_id=self.run_id, |
| 312 | + key=XCOM_RETURN_KEY, |
| 313 | + dag_ids=self.dag_id, |
| 314 | + task_ids=self.result_task_ids, |
| 315 | + region_id=None, |
| 316 | + ) |
| 317 | + entity = xcom_entity(xcom_query) |
264 | 318 | if self.result_task_ids is None: # Return dag-author-specified results. |
265 | | - xcom_read = XComModel.get_many( |
266 | | - run_id=self.run_id, |
267 | | - key=XCOM_RETURN_KEY, |
268 | | - dag_ids=self.dag_id, |
269 | | - ) |
270 | | - entity = xcom_entity(xcom_read) |
271 | | - xcom_query = xcom_read.where(entity.dag_result.is_(True)) |
272 | | - else: # Explicitly API user-specified results. |
273 | | - xcom_read = XComModel.get_many( |
274 | | - run_id=self.run_id, |
275 | | - key=XCOM_RETURN_KEY, |
276 | | - task_ids=self.result_task_ids, |
277 | | - dag_ids=self.dag_id, |
278 | | - ) |
279 | | - xcom_query = xcom_read |
280 | | - entity = xcom_entity(xcom_read) |
| 319 | + xcom_query = xcom_query.where(entity.dag_result.is_(True)) |
281 | 320 | # XComModel.get_many() orders XCom by timestamp. Reset this to make |
282 | 321 | # mapped task results stable since execution order is not guaranteed. |
283 | | - xcom_query = xcom_query.order_by(None).order_by(entity.task_id, entity.map_index) |
| 322 | + xcom_query = ( |
| 323 | + xcom_query.add_columns(public_map_index_expression(entity)) |
| 324 | + .order_by(None) |
| 325 | + .order_by(entity.task_id, entity.region_index) |
| 326 | + ) |
284 | 327 | async with create_session_async() as session: |
285 | | - xcom_results = (await session.scalars(xcom_query)).all() |
286 | | - |
287 | | - def _group_xcoms(g: Iterator[XComModel | tuple[XComModel]]) -> Any: |
288 | | - entries = [row[0] if isinstance(row, tuple) else row for row in g] |
289 | | - if len(entries) == 1 and entries[0].map_index < 0: # Unpack non-mapped task xcom. |
290 | | - return entries[0].value |
291 | | - return [entry.value for entry in entries] # Task is mapped; return all xcoms in a list. |
292 | | - |
293 | | - return { |
294 | | - task_id: _group_xcoms(g) |
295 | | - for task_id, g in itertools.groupby(xcom_results, key=operator.attrgetter("task_id")) |
296 | | - } |
| 328 | + xcom_results = (await session.execute(xcom_query)).all() |
| 329 | + |
| 330 | + results = {} |
| 331 | + for task_id, group in itertools.groupby(xcom_results, key=lambda row: row[0].task_id): |
| 332 | + entries = list(group) |
| 333 | + if len({entry.region_id for entry, _ in entries}) > 1 or ( |
| 334 | + len(entries) > 1 and any(map_index < 0 for _, map_index in entries) |
| 335 | + ): |
| 336 | + raise ValueError( |
| 337 | + f"Ambiguous current producers for task {task_id!r} in DAG run {self.run_id!r}" |
| 338 | + ) |
| 339 | + if len(entries) == 1 and entries[0][1] < 0: |
| 340 | + results[task_id] = entries[0][0].value |
| 341 | + else: |
| 342 | + results[task_id] = [entry.value for entry, _ in entries] |
| 343 | + return results |
297 | 344 |
|
298 | 345 | async def _serialize_response(self, dag_run: DagRun) -> str: |
299 | 346 | resp = {"state": dag_run.state} |
|
0 commit comments