This is an automated email from the ASF dual-hosted git repository.
bbovenzi 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 f8e29160117 Add TI bulk actions endpoint (#50443)
f8e29160117 is described below
commit f8e29160117a91e2443b7f3f99cabc2485d10271
Author: Guan Ming(Wesley) Chiu <[email protected]>
AuthorDate: Fri May 30 00:35:52 2025 +0800
Add TI bulk actions endpoint (#50443)
---
.../core_api/datamodels/task_instances.py | 7 +
.../core_api/openapi/v2-rest-api-generated.yaml | 175 ++++++++++
.../core_api/routes/public/task_instances.py | 75 ++---
.../core_api/services/public/task_instances.py | 271 ++++++++++++++++
.../src/airflow/ui/openapi-gen/queries/common.ts | 3 +
.../src/airflow/ui/openapi-gen/queries/queries.ts | 44 +++
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 174 ++++++++++
.../ui/openapi-gen/requests/services.gen.ts | 30 ++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 87 +++++
.../core_api/routes/public/test_task_instances.py | 357 +++++++++++++++++++++
.../src/airflowctl/api/datamodels/generated.py | 79 ++++-
11 files changed, 1247 insertions(+), 55 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py
index 2eba2eeecf8..392fcc598df 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py
@@ -224,3 +224,10 @@ class PatchTaskInstanceBody(StrictBaseModel):
if ns not in valid_states:
raise ValueError(f"'{ns}' is not one of {valid_states}")
return ns
+
+
+class BulkTaskInstanceBody(PatchTaskInstanceBody, StrictBaseModel):
+ """Request body for bulk update, and delete task instances."""
+
+ task_id: str
+ map_index: int | None = None
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 2da6e38a273..3fbe6692b09 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -5568,6 +5568,58 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
+ patch:
+ tags:
+ - Task Instance
+ summary: Bulk Task Instances
+ description: Bulk update, and delete task instances.
+ operationId: bulk_task_instances
+ security:
+ - OAuth2PasswordBearer: []
+ parameters:
+ - name: dag_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Dag Id
+ - name: dag_run_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Dag Run Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BulkBody_BulkTaskInstanceBody_'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BulkResponse'
+ '401':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPExceptionResponse'
+ description: Unauthorized
+ '403':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPExceptionResponse'
+ description: Forbidden
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/list:
post:
tags:
@@ -7389,6 +7441,21 @@ components:
This structure helps users understand which key actions succeeded and
which
failed.'
+ BulkBody_BulkTaskInstanceBody_:
+ properties:
+ actions:
+ items:
+ oneOf:
+ - $ref:
'#/components/schemas/BulkCreateAction_BulkTaskInstanceBody_'
+ - $ref:
'#/components/schemas/BulkUpdateAction_BulkTaskInstanceBody_'
+ - $ref:
'#/components/schemas/BulkDeleteAction_BulkTaskInstanceBody_'
+ type: array
+ title: Actions
+ additionalProperties: false
+ type: object
+ required:
+ - actions
+ title: BulkBody[BulkTaskInstanceBody]
BulkBody_ConnectionBody_:
properties:
actions:
@@ -7434,6 +7501,28 @@ components:
required:
- actions
title: BulkBody[VariableBody]
+ BulkCreateAction_BulkTaskInstanceBody_:
+ properties:
+ action:
+ type: string
+ const: create
+ title: Action
+ description: The action to be performed on the entities.
+ entities:
+ items:
+ $ref: '#/components/schemas/BulkTaskInstanceBody'
+ type: array
+ title: Entities
+ description: A list of entities to be created.
+ action_on_existence:
+ $ref: '#/components/schemas/BulkActionOnExistence'
+ default: fail
+ additionalProperties: false
+ type: object
+ required:
+ - action
+ - entities
+ title: BulkCreateAction[BulkTaskInstanceBody]
BulkCreateAction_ConnectionBody_:
properties:
action:
@@ -7500,6 +7589,28 @@ components:
- action
- entities
title: BulkCreateAction[VariableBody]
+ BulkDeleteAction_BulkTaskInstanceBody_:
+ properties:
+ action:
+ type: string
+ const: delete
+ title: Action
+ description: The action to be performed on the entities.
+ entities:
+ items:
+ type: string
+ type: array
+ title: Entities
+ description: A list of entity id/key to be deleted.
+ action_on_non_existence:
+ $ref: '#/components/schemas/BulkActionNotOnExistence'
+ default: fail
+ additionalProperties: false
+ type: object
+ required:
+ - action
+ - entities
+ title: BulkDeleteAction[BulkTaskInstanceBody]
BulkDeleteAction_ConnectionBody_:
properties:
action:
@@ -7599,6 +7710,70 @@ components:
Fields are populated in the response only if the respective action was
part
of the request, else are set None.'
+ BulkTaskInstanceBody:
+ properties:
+ new_state:
+ anyOf:
+ - $ref: '#/components/schemas/TaskInstanceState'
+ - type: 'null'
+ note:
+ anyOf:
+ - type: string
+ maxLength: 1000
+ - type: 'null'
+ title: Note
+ include_upstream:
+ type: boolean
+ title: Include Upstream
+ default: false
+ include_downstream:
+ type: boolean
+ title: Include Downstream
+ default: false
+ include_future:
+ type: boolean
+ title: Include Future
+ default: false
+ include_past:
+ type: boolean
+ title: Include Past
+ default: false
+ task_id:
+ type: string
+ title: Task Id
+ map_index:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Map Index
+ additionalProperties: false
+ type: object
+ required:
+ - task_id
+ title: BulkTaskInstanceBody
+ description: Request body for bulk update, and delete task instances.
+ BulkUpdateAction_BulkTaskInstanceBody_:
+ properties:
+ action:
+ type: string
+ const: update
+ title: Action
+ description: The action to be performed on the entities.
+ entities:
+ items:
+ $ref: '#/components/schemas/BulkTaskInstanceBody'
+ type: array
+ title: Entities
+ description: A list of entities to be updated.
+ action_on_non_existence:
+ $ref: '#/components/schemas/BulkActionNotOnExistence'
+ default: fail
+ additionalProperties: false
+ type: object
+ required:
+ - action
+ - entities
+ title: BulkUpdateAction[BulkTaskInstanceBody]
BulkUpdateAction_ConnectionBody_:
properties:
action:
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
index d14ab18c95c..79a0f977916 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
+++
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/task_instances.py
@@ -21,8 +21,6 @@ from typing import Annotated, Literal, cast
import structlog
from fastapi import Depends, HTTPException, Query, status
-from fastapi.exceptions import RequestValidationError
-from pydantic import ValidationError
from sqlalchemy import or_, select
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.selectable import Select
@@ -51,7 +49,9 @@ from airflow.api_fastapi.common.parameters import (
float_range_filter_factory,
)
from airflow.api_fastapi.common.router import AirflowRouter
+from airflow.api_fastapi.core_api.datamodels.common import BulkBody,
BulkResponse
from airflow.api_fastapi.core_api.datamodels.task_instances import (
+ BulkTaskInstanceBody,
ClearTaskInstancesBody,
PatchTaskInstanceBody,
TaskDependencyCollectionResponse,
@@ -63,11 +63,14 @@ from airflow.api_fastapi.core_api.datamodels.task_instances
import (
)
from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
from airflow.api_fastapi.core_api.security import GetUserDep,
ReadableTIFilterDep, requires_access_dag
+from airflow.api_fastapi.core_api.services.public.task_instances import (
+ BulkTaskInstanceService,
+ _patch_ti_validate_request,
+)
from airflow.api_fastapi.logging.decorators import action_logging
from airflow.exceptions import TaskNotFound
from airflow.listeners.listener import get_listener_manager
from airflow.models import Base, DagRun
-from airflow.models.dag import DAG
from airflow.models.taskinstance import TaskInstance as TI,
clear_task_instances
from airflow.models.taskinstancehistory import TaskInstanceHistory as TIH
from airflow.ti_deps.dep_context import DepContext
@@ -728,54 +731,6 @@ def post_clear_task_instances(
)
-def _patch_ti_validate_request(
- dag_id: str,
- dag_run_id: str,
- task_id: str,
- dag_bag: DagBagDep,
- body: PatchTaskInstanceBody,
- session: SessionDep,
- map_index: int | None = -1,
- update_mask: list[str] | None = Query(None),
-) -> tuple[DAG, list[TI], dict]:
- dag = dag_bag.get_dag(dag_id)
- if not dag:
- raise HTTPException(status.HTTP_404_NOT_FOUND, f"DAG {dag_id} not
found")
-
- if 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)
- .join(TI.dag_run)
- .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)
-
- tis = session.scalars(query).all()
-
- err_msg_404 = (
- f"The Task Instance with dag_id: `{dag_id}`, run_id: `{dag_run_id}`,
task_id: `{task_id}` and map_index: `{map_index}` was not found",
- )
- if len(tis) == 0:
- raise HTTPException(status.HTTP_404_NOT_FOUND, err_msg_404)
-
- fields_to_update = body.model_fields_set
- if update_mask:
- fields_to_update = fields_to_update.intersection(update_mask)
- else:
- try:
- PatchTaskInstanceBody.model_validate(body)
- except ValidationError as e:
- raise RequestValidationError(errors=e.errors())
-
- return dag, list(tis), body.model_dump(include=fields_to_update,
by_alias=True)
-
-
@task_instances_router.patch(
task_instances_prefix + "/{task_id}/dry_run",
responses=create_openapi_http_exception_doc(
@@ -835,6 +790,24 @@ def patch_task_instance_dry_run(
)
+@task_instances_router.patch(
+ task_instances_prefix,
+ dependencies=[Depends(requires_access_dag(method="PUT",
access_entity=DagAccessEntity.TASK_INSTANCE))],
+)
+def bulk_task_instances(
+ request: BulkBody[BulkTaskInstanceBody],
+ session: SessionDep,
+ dag_id: str,
+ dag_bag: DagBagDep,
+ dag_run_id: str,
+ user: GetUserDep,
+) -> BulkResponse:
+ """Bulk update, and delete task instances."""
+ return BulkTaskInstanceService(
+ session=session, request=request, dag_id=dag_id,
dag_run_id=dag_run_id, dag_bag=dag_bag, user=user
+ ).handle_request()
+
+
@task_instances_router.patch(
task_instances_prefix + "/{task_id}",
responses=create_openapi_http_exception_doc(
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/task_instances.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/task_instances.py
new file mode 100644
index 00000000000..a99e802278f
--- /dev/null
+++
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/task_instances.py
@@ -0,0 +1,271 @@
+# 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
+
+import structlog
+from fastapi import HTTPException, Query, status
+from fastapi.exceptions import RequestValidationError
+from pydantic import ValidationError
+from sqlalchemy import select
+from sqlalchemy.orm import joinedload
+from sqlalchemy.orm.session import Session
+
+from airflow.api_fastapi.common.dagbag import DagBagDep
+from airflow.api_fastapi.common.db.common import SessionDep
+from airflow.api_fastapi.core_api.datamodels.common import (
+ BulkActionNotOnExistence,
+ BulkActionResponse,
+ BulkBody,
+ BulkCreateAction,
+ BulkDeleteAction,
+ BulkUpdateAction,
+)
+from airflow.api_fastapi.core_api.datamodels.task_instances import
BulkTaskInstanceBody, PatchTaskInstanceBody
+from airflow.api_fastapi.core_api.security import GetUserDep
+from airflow.api_fastapi.core_api.services.public.common import BulkService
+from airflow.listeners.listener import get_listener_manager
+from airflow.models.dag import DAG
+from airflow.models.taskinstance import TaskInstance as TI
+from airflow.utils.state import TaskInstanceState
+
+log = structlog.get_logger(__name__)
+
+
+def _patch_ti_validate_request(
+ dag_id: str,
+ dag_run_id: str,
+ task_id: str,
+ dag_bag: DagBagDep,
+ body: PatchTaskInstanceBody,
+ session: SessionDep,
+ map_index: int | None = -1,
+ update_mask: list[str] | None = Query(None),
+) -> tuple[DAG, list[TI], dict]:
+ dag = dag_bag.get_dag(dag_id)
+ if not dag:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, f"DAG {dag_id} not
found")
+
+ if 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)
+ .join(TI.dag_run)
+ .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)
+
+ tis = session.scalars(query).all()
+
+ err_msg_404 = (
+ f"The Task Instance with dag_id: `{dag_id}`, run_id: `{dag_run_id}`,
task_id: `{task_id}` and map_index: `{map_index}` was not found",
+ )
+ if len(tis) == 0:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, err_msg_404)
+
+ fields_to_update = body.model_fields_set
+ if update_mask:
+ fields_to_update = fields_to_update.intersection(update_mask)
+ else:
+ try:
+ PatchTaskInstanceBody.model_validate(body)
+ except ValidationError as e:
+ raise RequestValidationError(errors=e.errors())
+
+ return dag, list(tis), body.model_dump(include=fields_to_update,
by_alias=True)
+
+
+class BulkTaskInstanceService(BulkService[BulkTaskInstanceBody]):
+ """Service for handling bulk operations on task instances."""
+
+ def __init__(
+ self,
+ session: Session,
+ request: BulkBody[BulkTaskInstanceBody],
+ dag_id: str,
+ dag_run_id: str,
+ dag_bag: DagBagDep,
+ user: GetUserDep,
+ ):
+ super().__init__(session, request)
+ self.dag_id = dag_id
+ self.dag_run_id = dag_run_id
+ self.dag_bag = dag_bag
+ self.user = user
+
+ def categorize_task_instances(
+ self, task_ids: set[tuple[str, int]]
+ ) -> tuple[dict[tuple[str, int], TI], set[tuple[str, int]], set[tuple[str,
int]]]:
+ """
+ Categorize the given task_ids into matched_task_keys and
not_found_task_keys based on existing task_ids.
+
+ :param task_ids: set of task_ids
+ :return: tuple of (task_instances_map, matched_task_keys,
not_found_task_keys)
+ """
+ query = select(TI).where(
+ TI.dag_id == self.dag_id,
+ TI.run_id == self.dag_run_id,
+ TI.task_id.in_([task_id for task_id, _ in task_ids]),
+ )
+ task_instances = self.session.scalars(query).all()
+ task_instances_map = {
+ (ti.task_id, ti.map_index if ti.map_index is not None else -1): ti
for ti in task_instances
+ }
+ matched_task_keys = {(task_id, map_index) for (task_id, map_index) in
task_instances_map.keys()}
+ not_found_task_keys = {(task_id, map_index) for task_id, map_index in
task_ids} - matched_task_keys
+ return task_instances_map, matched_task_keys, not_found_task_keys
+
+ def _patch_task_instance_state(
+ self,
+ dag: DAG,
+ task_instance_body: BulkTaskInstanceBody,
+ data: dict,
+ ) -> None:
+ map_indexes = None if task_instance_body.map_index is None else
[task_instance_body.map_index]
+
+ updated_tis = dag.set_task_instance_state(
+ task_id=task_instance_body.task_id,
+ run_id=self.dag_run_id,
+ map_indexes=map_indexes,
+ state=data["new_state"],
+ upstream=task_instance_body.include_upstream,
+ downstream=task_instance_body.include_downstream,
+ future=task_instance_body.include_future,
+ past=task_instance_body.include_past,
+ commit=True,
+ session=self.session,
+ )
+ if not updated_tis:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ f"Task id {task_instance_body.task_id} is already in
{data['new_state']} state",
+ )
+ for ti in updated_tis:
+ try:
+ if data["new_state"] == TaskInstanceState.SUCCESS:
+ get_listener_manager().hook.on_task_instance_success(
+ previous_state=None, task_instance=ti
+ )
+ elif data["new_state"] == TaskInstanceState.FAILED:
+ get_listener_manager().hook.on_task_instance_failed(
+ previous_state=None,
+ task_instance=ti,
+ error=f"TaskInstance's state was manually set to
`{TaskInstanceState.FAILED}`.",
+ )
+ except Exception:
+ log.exception("error calling listener")
+
+ def _patch_task_instance_note(self, task_instance_body:
BulkTaskInstanceBody, tis: list[TI]) -> None:
+ for ti in tis:
+ if task_instance_body.note is not None:
+ if ti.task_instance_note is None:
+ ti.note = (task_instance_body.note, self.user.get_id())
+ else:
+ ti.task_instance_note.content = task_instance_body.note
+ ti.task_instance_note.user_id = self.user.get_id()
+
+ def handle_bulk_create(
+ self, action: BulkCreateAction[BulkTaskInstanceBody], results:
BulkActionResponse
+ ) -> None:
+ results.errors.append(
+ {
+ "error": "Task instances bulk create is not supported",
+ "status_code": status.HTTP_405_METHOD_NOT_ALLOWED,
+ }
+ )
+
+ def handle_bulk_update(
+ self, action: BulkUpdateAction[BulkTaskInstanceBody], results:
BulkActionResponse
+ ) -> None:
+ """Bulk Update Task Instances."""
+ to_update_task_keys = {
+ (task_instance.task_id, task_instance.map_index if
task_instance.map_index is not None else -1)
+ for task_instance in action.entities
+ }
+ _, _, not_found_task_keys =
self.categorize_task_instances(to_update_task_keys)
+
+ try:
+ for task_instance_body in action.entities:
+ task_key = (
+ task_instance_body.task_id,
+ task_instance_body.map_index if
task_instance_body.map_index is not None else -1,
+ )
+
+ if task_key in not_found_task_keys:
+ if action.action_on_non_existence ==
BulkActionNotOnExistence.FAIL:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"The Task Instance with dag_id:
`{self.dag_id}`, run_id: `{self.dag_run_id}`, task_id:
`{task_instance_body.task_id}` and map_index: `{task_instance_body.map_index}`
was not found",
+ )
+ if action.action_on_non_existence ==
BulkActionNotOnExistence.SKIP:
+ continue
+
+ dag, tis, data = _patch_ti_validate_request(
+ dag_id=self.dag_id,
+ dag_run_id=self.dag_run_id,
+ task_id=task_instance_body.task_id,
+ dag_bag=self.dag_bag,
+ body=task_instance_body,
+ session=self.session,
+ map_index=task_instance_body.map_index,
+ update_mask=None,
+ )
+
+ for key, _ in data.items():
+ if key == "new_state":
+ self._patch_task_instance_state(
+ dag=dag,
+ task_instance_body=task_instance_body,
+ data=data,
+ )
+ elif key == "note":
+
self._patch_task_instance_note(task_instance_body=task_instance_body, tis=tis)
+
+ results.success.append(task_instance_body.task_id)
+ except ValidationError as e:
+ results.errors.append({"error": f"{e.errors()}"})
+ except HTTPException as e:
+ results.errors.append({"error": f"{e.detail}", "status_code":
e.status_code})
+
+ def handle_bulk_delete(
+ self, action: BulkDeleteAction[BulkTaskInstanceBody], results:
BulkActionResponse
+ ) -> None:
+ """Bulk delete task instances."""
+ to_delete_task_keys = set((task_id, -1) for task_id in action.entities)
+ _, matched_task_keys, not_found_task_keys =
self.categorize_task_instances(to_delete_task_keys)
+ not_found_task_ids = [task_id for task_id, _ in not_found_task_keys]
+
+ try:
+ if action.action_on_non_existence == BulkActionNotOnExistence.FAIL
and not_found_task_keys:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"The task instances with these task_ids:
{not_found_task_ids} were not found",
+ )
+
+ for task_id, _ in matched_task_keys:
+ existing_task_instance =
self.session.scalar(select(TI).where(TI.task_id == task_id).limit(1))
+ if existing_task_instance:
+ self.session.delete(existing_task_instance)
+ results.success.append(task_id)
+
+ except HTTPException as e:
+ results.errors.append({"error": f"{e.detail}", "status_code":
e.status_code})
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
index bafbac9fb23..8410dde0ba3 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -1912,6 +1912,9 @@ export type
TaskInstanceServicePatchTaskInstanceMutationResult = Awaited<
export type TaskInstanceServicePatchTaskInstanceByMapIndexMutationResult =
Awaited<
ReturnType<typeof TaskInstanceService.patchTaskInstanceByMapIndex>
>;
+export type TaskInstanceServiceBulkTaskInstancesMutationResult = Awaited<
+ ReturnType<typeof TaskInstanceService.bulkTaskInstances>
+>;
export type TaskInstanceServicePatchTaskInstanceDryRunByMapIndexMutationResult
= Awaited<
ReturnType<typeof TaskInstanceService.patchTaskInstanceDryRunByMapIndex>
>;
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index 19c242bd493..8e2e3a810a6 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -37,6 +37,7 @@ import {
} from "../requests/services.gen";
import {
BackfillPostBody,
+ BulkBody_BulkTaskInstanceBody_,
BulkBody_ConnectionBody_,
BulkBody_PoolBody_,
BulkBody_VariableBody_,
@@ -4174,6 +4175,49 @@ export const
useTaskInstanceServicePatchTaskInstanceByMapIndex = <
}) as unknown as Promise<TData>,
...options,
});
+/**
+ * Bulk Task Instances
+ * Bulk update, and delete task instances.
+ * @param data The data for the request.
+ * @param data.dagId
+ * @param data.dagRunId
+ * @param data.requestBody
+ * @returns BulkResponse Successful Response
+ * @throws ApiError
+ */
+export const useTaskInstanceServiceBulkTaskInstances = <
+ TData = Common.TaskInstanceServiceBulkTaskInstancesMutationResult,
+ TError = unknown,
+ TContext = unknown,
+>(
+ options?: Omit<
+ UseMutationOptions<
+ TData,
+ TError,
+ {
+ dagId: string;
+ dagRunId: string;
+ requestBody: BulkBody_BulkTaskInstanceBody_;
+ },
+ TContext
+ >,
+ "mutationFn"
+ >,
+) =>
+ useMutation<
+ TData,
+ TError,
+ {
+ dagId: string;
+ dagRunId: string;
+ requestBody: BulkBody_BulkTaskInstanceBody_;
+ },
+ TContext
+ >({
+ mutationFn: ({ dagId, dagRunId, requestBody }) =>
+ TaskInstanceService.bulkTaskInstances({ dagId, dagRunId, requestBody })
as unknown as Promise<TData>,
+ ...options,
+ });
/**
* Patch Task Instance Dry Run
* Update a task instance dry_run mode.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index e41c93b11f4..7ffda75f426 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -582,6 +582,32 @@ The response includes a list of successful keys and any
errors encountered durin
This structure helps users understand which key actions succeeded and which
failed.`,
} as const;
+export const $BulkBody_BulkTaskInstanceBody_ = {
+ properties: {
+ actions: {
+ items: {
+ oneOf: [
+ {
+ $ref:
"#/components/schemas/BulkCreateAction_BulkTaskInstanceBody_",
+ },
+ {
+ $ref:
"#/components/schemas/BulkUpdateAction_BulkTaskInstanceBody_",
+ },
+ {
+ $ref:
"#/components/schemas/BulkDeleteAction_BulkTaskInstanceBody_",
+ },
+ ],
+ },
+ type: "array",
+ title: "Actions",
+ },
+ },
+ additionalProperties: false,
+ type: "object",
+ required: ["actions"],
+ title: "BulkBody[BulkTaskInstanceBody]",
+} as const;
+
export const $BulkBody_ConnectionBody_ = {
properties: {
actions: {
@@ -660,6 +686,33 @@ export const $BulkBody_VariableBody_ = {
title: "BulkBody[VariableBody]",
} as const;
+export const $BulkCreateAction_BulkTaskInstanceBody_ = {
+ properties: {
+ action: {
+ type: "string",
+ const: "create",
+ title: "Action",
+ description: "The action to be performed on the entities.",
+ },
+ entities: {
+ items: {
+ $ref: "#/components/schemas/BulkTaskInstanceBody",
+ },
+ type: "array",
+ title: "Entities",
+ description: "A list of entities to be created.",
+ },
+ action_on_existence: {
+ $ref: "#/components/schemas/BulkActionOnExistence",
+ default: "fail",
+ },
+ },
+ additionalProperties: false,
+ type: "object",
+ required: ["action", "entities"],
+ title: "BulkCreateAction[BulkTaskInstanceBody]",
+} as const;
+
export const $BulkCreateAction_ConnectionBody_ = {
properties: {
action: {
@@ -741,6 +794,33 @@ export const $BulkCreateAction_VariableBody_ = {
title: "BulkCreateAction[VariableBody]",
} as const;
+export const $BulkDeleteAction_BulkTaskInstanceBody_ = {
+ properties: {
+ action: {
+ type: "string",
+ const: "delete",
+ title: "Action",
+ description: "The action to be performed on the entities.",
+ },
+ entities: {
+ items: {
+ type: "string",
+ },
+ type: "array",
+ title: "Entities",
+ description: "A list of entity id/key to be deleted.",
+ },
+ action_on_non_existence: {
+ $ref: "#/components/schemas/BulkActionNotOnExistence",
+ default: "fail",
+ },
+ },
+ additionalProperties: false,
+ type: "object",
+ required: ["action", "entities"],
+ title: "BulkDeleteAction[BulkTaskInstanceBody]",
+} as const;
+
export const $BulkDeleteAction_ConnectionBody_ = {
properties: {
action: {
@@ -867,6 +947,100 @@ Each action (if requested) is represented as a field
containing details about su
Fields are populated in the response only if the respective action was part of
the request, else are set None.`,
} as const;
+export const $BulkTaskInstanceBody = {
+ properties: {
+ new_state: {
+ anyOf: [
+ {
+ $ref: "#/components/schemas/TaskInstanceState",
+ },
+ {
+ type: "null",
+ },
+ ],
+ },
+ note: {
+ anyOf: [
+ {
+ type: "string",
+ maxLength: 1000,
+ },
+ {
+ type: "null",
+ },
+ ],
+ title: "Note",
+ },
+ include_upstream: {
+ type: "boolean",
+ title: "Include Upstream",
+ default: false,
+ },
+ include_downstream: {
+ type: "boolean",
+ title: "Include Downstream",
+ default: false,
+ },
+ include_future: {
+ type: "boolean",
+ title: "Include Future",
+ default: false,
+ },
+ include_past: {
+ type: "boolean",
+ title: "Include Past",
+ default: false,
+ },
+ task_id: {
+ type: "string",
+ title: "Task Id",
+ },
+ map_index: {
+ anyOf: [
+ {
+ type: "integer",
+ },
+ {
+ type: "null",
+ },
+ ],
+ title: "Map Index",
+ },
+ },
+ additionalProperties: false,
+ type: "object",
+ required: ["task_id"],
+ title: "BulkTaskInstanceBody",
+ description: "Request body for bulk update, and delete task instances.",
+} as const;
+
+export const $BulkUpdateAction_BulkTaskInstanceBody_ = {
+ properties: {
+ action: {
+ type: "string",
+ const: "update",
+ title: "Action",
+ description: "The action to be performed on the entities.",
+ },
+ entities: {
+ items: {
+ $ref: "#/components/schemas/BulkTaskInstanceBody",
+ },
+ type: "array",
+ title: "Entities",
+ description: "A list of entities to be updated.",
+ },
+ action_on_non_existence: {
+ $ref: "#/components/schemas/BulkActionNotOnExistence",
+ default: "fail",
+ },
+ },
+ additionalProperties: false,
+ type: "object",
+ required: ["action", "entities"],
+ title: "BulkUpdateAction[BulkTaskInstanceBody]",
+} as const;
+
export const $BulkUpdateAction_ConnectionBody_ = {
properties: {
action: {
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index d5b88a7bf2b..cf819bbc4ee 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -134,6 +134,8 @@ import type {
PatchTaskInstanceByMapIndexResponse,
GetTaskInstancesData,
GetTaskInstancesResponse,
+ BulkTaskInstancesData,
+ BulkTaskInstancesResponse,
GetTaskInstancesBatchData,
GetTaskInstancesBatchResponse,
GetTaskInstanceTryDetailsData,
@@ -2337,6 +2339,34 @@ export class TaskInstanceService {
});
}
+ /**
+ * Bulk Task Instances
+ * Bulk update, and delete task instances.
+ * @param data The data for the request.
+ * @param data.dagId
+ * @param data.dagRunId
+ * @param data.requestBody
+ * @returns BulkResponse Successful Response
+ * @throws ApiError
+ */
+ public static bulkTaskInstances(data: BulkTaskInstancesData):
CancelablePromise<BulkTaskInstancesResponse> {
+ return __request(OpenAPI, {
+ method: "PATCH",
+ url: "/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances",
+ path: {
+ dag_id: data.dagId,
+ dag_run_id: data.dagRunId,
+ },
+ body: data.requestBody,
+ mediaType: "application/json",
+ errors: {
+ 401: "Unauthorized",
+ 403: "Forbidden",
+ 422: "Validation Error",
+ },
+ });
+ }
+
/**
* Get Task Instances Batch
* Get list of task instances.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 200008de784..924dfa45916 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -173,6 +173,14 @@ export type BulkActionResponse = {
}>;
};
+export type BulkBody_BulkTaskInstanceBody_ = {
+ actions: Array<
+ | BulkCreateAction_BulkTaskInstanceBody_
+ | BulkUpdateAction_BulkTaskInstanceBody_
+ | BulkDeleteAction_BulkTaskInstanceBody_
+ >;
+};
+
export type BulkBody_ConnectionBody_ = {
actions: Array<
BulkCreateAction_ConnectionBody_ | BulkUpdateAction_ConnectionBody_ |
BulkDeleteAction_ConnectionBody_
@@ -189,6 +197,18 @@ export type BulkBody_VariableBody_ = {
>;
};
+export type BulkCreateAction_BulkTaskInstanceBody_ = {
+ /**
+ * The action to be performed on the entities.
+ */
+ action: "create";
+ /**
+ * A list of entities to be created.
+ */
+ entities: Array<BulkTaskInstanceBody>;
+ action_on_existence?: BulkActionOnExistence;
+};
+
export type BulkCreateAction_ConnectionBody_ = {
/**
* The action to be performed on the entities.
@@ -225,6 +245,18 @@ export type BulkCreateAction_VariableBody_ = {
action_on_existence?: BulkActionOnExistence;
};
+export type BulkDeleteAction_BulkTaskInstanceBody_ = {
+ /**
+ * The action to be performed on the entities.
+ */
+ action: "delete";
+ /**
+ * A list of entity id/key to be deleted.
+ */
+ entities: Array<string>;
+ action_on_non_existence?: BulkActionNotOnExistence;
+};
+
export type BulkDeleteAction_ConnectionBody_ = {
/**
* The action to be performed on the entities.
@@ -283,6 +315,32 @@ export type BulkResponse = {
delete?: BulkActionResponse | null;
};
+/**
+ * Request body for bulk update, and delete task instances.
+ */
+export type BulkTaskInstanceBody = {
+ new_state?: TaskInstanceState | null;
+ note?: string | null;
+ include_upstream?: boolean;
+ include_downstream?: boolean;
+ include_future?: boolean;
+ include_past?: boolean;
+ task_id: string;
+ map_index?: number | null;
+};
+
+export type BulkUpdateAction_BulkTaskInstanceBody_ = {
+ /**
+ * The action to be performed on the entities.
+ */
+ action: "update";
+ /**
+ * A list of entities to be updated.
+ */
+ entities: Array<BulkTaskInstanceBody>;
+ action_on_non_existence?: BulkActionNotOnExistence;
+};
+
export type BulkUpdateAction_ConnectionBody_ = {
/**
* The action to be performed on the entities.
@@ -2406,6 +2464,14 @@ export type GetTaskInstancesData = {
export type GetTaskInstancesResponse = TaskInstanceCollectionResponse;
+export type BulkTaskInstancesData = {
+ dagId: string;
+ dagRunId: string;
+ requestBody: BulkBody_BulkTaskInstanceBody_;
+};
+
+export type BulkTaskInstancesResponse = BulkResponse;
+
export type GetTaskInstancesBatchData = {
dagId: "~";
dagRunId: "~";
@@ -4584,6 +4650,27 @@ export type $OpenApiTs = {
422: HTTPValidationError;
};
};
+ patch: {
+ req: BulkTaskInstancesData;
+ res: {
+ /**
+ * Successful Response
+ */
+ 200: BulkResponse;
+ /**
+ * Unauthorized
+ */
+ 401: HTTPExceptionResponse;
+ /**
+ * Forbidden
+ */
+ 403: HTTPExceptionResponse;
+ /**
+ * Validation Error
+ */
+ 422: HTTPValidationError;
+ };
+ };
};
"/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/list": {
post: {
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
index bfe8d049dcf..1e2a4b64601 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py
@@ -4307,3 +4307,360 @@ class TestDeleteTaskInstance(TestTaskInstanceEndpoint):
remaining_tis = base_query.filter(TaskInstance.map_index !=
-1).all()
if expected_remaining is not None:
assert set(ti.map_index for ti in remaining_tis) ==
expected_remaining
+
+
+class TestBulkTaskInstances(TestTaskInstanceEndpoint):
+ DAG_ID = "example_python_operator"
+ TASK_ID = "print_the_context"
+ RUN_ID = "TEST_DAG_RUN_ID"
+ ENDPOINT_URL = f"/dags/{DAG_ID}/dagRuns/{RUN_ID}/taskInstances"
+
+ @pytest.mark.parametrize(
+ "default_ti, actions, expected_results",
+ [
+ pytest.param(
+ [{"task_id": TASK_ID, "state": State.SUCCESS}],
+ {
+ "actions": [
+ {
+ "action": "delete",
+ "entities": [
+ TASK_ID,
+ ],
+ }
+ ]
+ },
+ {
+ "delete": {
+ "success": [TASK_ID],
+ "errors": [],
+ }
+ },
+ id="delete-success",
+ ),
+ pytest.param(
+ [{"task_id": TASK_ID, "state": State.SUCCESS}],
+ {
+ "actions": [
+ {
+ "action": "delete",
+ "entities": [
+ "non_existent_task",
+ ],
+ "action_on_non_existence": "skip",
+ }
+ ]
+ },
+ {
+ "delete": {
+ "success": [],
+ "errors": [],
+ }
+ },
+ id="delete-skip",
+ ),
+ pytest.param(
+ [{"task_id": TASK_ID, "state": State.SUCCESS}],
+ {
+ "actions": [
+ {
+ "action": "delete",
+ "entities": [
+ "non_existent_task",
+ ],
+ }
+ ]
+ },
+ {
+ "delete": {
+ "success": [],
+ "errors": [
+ {
+ "error": "The task instances with these
task_ids: ['non_existent_task'] were not found",
+ "status_code": 404,
+ }
+ ],
+ }
+ },
+ id="delete-failure",
+ ),
+ pytest.param(
+ [{"task_id": TASK_ID, "state": State.RUNNING}],
+ {
+ "actions": [
+ {
+ "action": "update",
+ "entities": [
+ {
+ "task_id": TASK_ID,
+ "new_state": "failed",
+ "note": "test",
+ "include_upstream": True,
+ "include_downstream": True,
+ "include_future": True,
+ "include_past": True,
+ },
+ ],
+ }
+ ]
+ },
+ {
+ "update": {
+ "success": [TASK_ID],
+ "errors": [],
+ }
+ },
+ id="update-success",
+ ),
+ pytest.param(
+ [{"task_id": TASK_ID, "state": State.RUNNING}],
+ {
+ "actions": [
+ {
+ "action": "update",
+ "entities": [
+ {
+ "task_id": "non_existent_task",
+ "new_state": "failed",
+ "note": "test",
+ "include_upstream": True,
+ "include_downstream": True,
+ "include_future": True,
+ "include_past": True,
+ },
+ ],
+ "action_on_non_existence": "skip",
+ }
+ ]
+ },
+ {
+ "update": {
+ "success": [],
+ "errors": [],
+ }
+ },
+ id="update-skip",
+ ),
+ pytest.param(
+ [{"task_id": TASK_ID, "state": State.RUNNING, "map_index":
100}],
+ {
+ "actions": [
+ {
+ "action": "update",
+ "entities": [
+ {
+ "task_id": TASK_ID,
+ "new_state": "failed",
+ "note": "test",
+ "include_upstream": True,
+ "include_downstream": True,
+ "include_future": True,
+ "include_past": True,
+ "map_index": 100,
+ },
+ ],
+ }
+ ]
+ },
+ {
+ "update": {
+ "success": [TASK_ID],
+ "errors": [],
+ }
+ },
+ id="update-success-mapped-task",
+ ),
+ pytest.param(
+ [{"task_id": TASK_ID, "state": State.RUNNING}],
+ {
+ "actions": [
+ {
+ "action": "update",
+ "entities": [
+ {
+ "task_id": "non_existent_task",
+ "new_state": "failed",
+ "note": "test",
+ "include_upstream": True,
+ "include_downstream": True,
+ "include_future": True,
+ "include_past": True,
+ },
+ ],
+ }
+ ]
+ },
+ {
+ "update": {
+ "success": [],
+ "errors": [
+ {
+ "error": "The Task Instance with dag_id:
`example_python_operator`, run_id: `TEST_DAG_RUN_ID`, task_id:
`non_existent_task` and map_index: `None` was not found",
+ "status_code": 404,
+ }
+ ],
+ }
+ },
+ id="update-failure",
+ ),
+ pytest.param(
+ [{"task_id": TASK_ID, "state": State.RUNNING}],
+ {
+ "actions": [
+ {
+ "action": "update",
+ "entities": [
+ {
+ "task_id": TASK_ID,
+ "new_state": "failed",
+ "note": "test",
+ "include_upstream": True,
+ "include_downstream": True,
+ "include_future": True,
+ "include_past": True,
+ "map_index": -100,
+ },
+ ],
+ }
+ ]
+ },
+ {
+ "update": {
+ "success": [],
+ "errors": [
+ {
+ "error": "The Task Instance with dag_id:
`example_python_operator`, run_id: `TEST_DAG_RUN_ID`, task_id:
`print_the_context` and map_index: `-100` was not found",
+ "status_code": 404,
+ }
+ ],
+ }
+ },
+ id="update-failure-mapped-task",
+ ),
+ pytest.param(
+ [{"task_id": TASK_ID, "state": State.RUNNING}],
+ {
+ "actions": [
+ {
+ "action": "update",
+ "entities": [
+ {
+ "task_id": TASK_ID,
+ "new_state": "failed",
+ "note": "test",
+ "include_upstream": True,
+ "include_downstream": True,
+ "include_future": True,
+ "include_past": True,
+ "map_index": -100,
+ },
+ ],
+ "action_on_non_existence": "skip",
+ }
+ ]
+ },
+ {
+ "update": {
+ "success": [],
+ "errors": [],
+ }
+ },
+ id="update-failure-mapped-task-with-skip",
+ ),
+ pytest.param(
+ [{"task_id": TASK_ID, "state": State.SUCCESS}],
+ {
+ "actions": [
+ {
+ "action": "update",
+ "entities": [
+ {
+ "task_id": "non_existent_task",
+ "new_state": "failed",
+ "note": "test",
+ "include_upstream": True,
+ "include_downstream": True,
+ "include_future": True,
+ "include_past": True,
+ },
+ ],
+ },
+ {
+ "action": "update",
+ "entities": [
+ {
+ "task_id": TASK_ID,
+ "new_state": "failed",
+ "note": "test",
+ "include_upstream": True,
+ "include_downstream": True,
+ "include_future": True,
+ "include_past": True,
+ },
+ ],
+ },
+ {"action": "delete", "entities": [TASK_ID]},
+ {"action": "delete", "entities":
["non_existent_task"]},
+ ],
+ },
+ {
+ "update": {
+ "success": [TASK_ID],
+ "errors": [
+ {
+ "error": "The Task Instance with dag_id:
`example_python_operator`, run_id: `TEST_DAG_RUN_ID`, task_id:
`non_existent_task` and map_index: `None` was not found",
+ "status_code": 404,
+ }
+ ],
+ },
+ "delete": {
+ "success": [TASK_ID],
+ "errors": [
+ {
+ "error": "The task instances with these
task_ids: ['non_existent_task'] were not found",
+ "status_code": 404,
+ }
+ ],
+ },
+ },
+ id="update-delete-success",
+ ),
+ pytest.param(
+ [{"task_id": TASK_ID, "state": State.SUCCESS}],
+ {
+ "actions": [{"action": "create", "entities": []}],
+ },
+ {
+ "create": {
+ "success": [],
+ "errors": [
+ {"error": "Task instances bulk create is not
supported", "status_code": 405}
+ ],
+ }
+ },
+ id="create-failure",
+ ),
+ ],
+ )
+ def test_bulk_task_instances(self, test_client, session, default_ti,
actions, expected_results):
+ self.create_task_instances(session, task_instances=default_ti)
+ response = test_client.patch(
+ self.ENDPOINT_URL,
+ json=actions,
+ )
+ assert response.status_code == 200
+ response_data = response.json()
+ for task_id, value in expected_results.items():
+ assert response_data[task_id] == value
+
+ def test_should_respond_401(self, unauthenticated_test_client):
+ response = unauthenticated_test_client.patch(self.ENDPOINT_URL,
json={})
+ assert response.status_code == 401
+
+ def test_should_respond_403(self, unauthorized_test_client):
+ response = unauthorized_test_client.patch(self.ENDPOINT_URL, json={})
+ assert response.status_code == 403
+
+ def test_should_respond_422(self, test_client):
+ response = test_client.patch(self.ENDPOINT_URL, json={})
+ assert response.status_code == 422
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 4dbcd4463f9..63056402f6a 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -98,6 +98,19 @@ class BulkActionResponse(BaseModel):
] = []
+class BulkDeleteActionBulkTaskInstanceBody(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ action: Annotated[
+ Literal["delete"], Field(description="The action to be performed on
the entities.", title="Action")
+ ]
+ entities: Annotated[
+ list[str], Field(description="A list of entity id/key to be deleted.",
title="Entities")
+ ]
+ action_on_non_existence: BulkActionNotOnExistence | None = "fail"
+
+
class BulkDeleteActionConnectionBody(BaseModel):
model_config = ConfigDict(
extra="forbid",
@@ -160,6 +173,10 @@ class BulkResponse(BaseModel):
] = None
+class Note(RootModel[str]):
+ root: Annotated[str, Field(max_length=1000, title="Note")]
+
+
class TaskIds(RootModel[list]):
root: Annotated[list, Field(max_length=2, min_length=2)]
@@ -293,10 +310,6 @@ class DAGRunClearBody(BaseModel):
only_failed: Annotated[bool | None, Field(title="Only Failed")] = False
-class Note(RootModel[str]):
- root: Annotated[str, Field(max_length=1000, title="Note")]
-
-
class DAGRunPatchStates(str, Enum):
"""
Enum for DAG Run states when updating a DAG Run.
@@ -1096,6 +1109,37 @@ class BulkCreateActionVariableBody(BaseModel):
action_on_existence: BulkActionOnExistence | None = "fail"
+class BulkTaskInstanceBody(BaseModel):
+ """
+ Request body for bulk update, and delete task instances.
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ new_state: TaskInstanceState | None = None
+ note: Annotated[Note | None, Field(title="Note")] = None
+ include_upstream: Annotated[bool | None, Field(title="Include Upstream")]
= False
+ include_downstream: Annotated[bool | None, Field(title="Include
Downstream")] = False
+ include_future: Annotated[bool | None, Field(title="Include Future")] =
False
+ include_past: Annotated[bool | None, Field(title="Include Past")] = False
+ task_id: Annotated[str, Field(title="Task Id")]
+ map_index: Annotated[int | None, Field(title="Map Index")] = None
+
+
+class BulkUpdateActionBulkTaskInstanceBody(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ action: Annotated[
+ Literal["update"], Field(description="The action to be performed on
the entities.", title="Action")
+ ]
+ entities: Annotated[
+ list[BulkTaskInstanceBody], Field(description="A list of entities to
be updated.", title="Entities")
+ ]
+ action_on_non_existence: BulkActionNotOnExistence | None = "fail"
+
+
class BulkUpdateActionConnectionBody(BaseModel):
model_config = ConfigDict(
extra="forbid",
@@ -1641,6 +1685,19 @@ class BulkBodyVariableBody(BaseModel):
]
+class BulkCreateActionBulkTaskInstanceBody(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ action: Annotated[
+ Literal["create"], Field(description="The action to be performed on
the entities.", title="Action")
+ ]
+ entities: Annotated[
+ list[BulkTaskInstanceBody], Field(description="A list of entities to
be created.", title="Entities")
+ ]
+ action_on_existence: BulkActionOnExistence | None = "fail"
+
+
class DAGCollectionResponse(BaseModel):
"""
DAG Collection serializer for responses.
@@ -1702,3 +1759,17 @@ class TaskInstanceHistoryCollectionResponse(BaseModel):
task_instances: Annotated[list[TaskInstanceHistoryResponse],
Field(title="Task Instances")]
total_entries: Annotated[int, Field(title="Total Entries")]
+
+
+class BulkBodyBulkTaskInstanceBody(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ actions: Annotated[
+ list[
+ BulkCreateActionBulkTaskInstanceBody
+ | BulkUpdateActionBulkTaskInstanceBody
+ | BulkDeleteActionBulkTaskInstanceBody
+ ],
+ Field(title="Actions"),
+ ]