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 83b140fbcc6 Apply Gantt date filters to mapped tasks and task groups
(#75725)
83b140fbcc6 is described below
commit 83b140fbcc60030b8b161270ba284e6986302248
Author: Deepak Jain (DJ) <[email protected]>
AuthorDate: Wed Nov 11 13:53:12 2026 -0800
Apply Gantt date filters to mapped tasks and task groups (#75725)
Old tries can stretch aggregate bars across a month after clearing a run.
Date filters must also constrain those aggregates so task durations remain
readable.
Related: #63715.
Signed-off-by: Deepak Jain <[email protected]>
---
.../src/airflow/api_fastapi/common/db/dag_runs.py | 9 +
.../api_fastapi/core_api/openapi/_private_ui.yaml | 72 ++++++
.../api_fastapi/core_api/routes/ui/gantt.py | 14 +-
.../airflow/api_fastapi/core_api/routes/ui/grid.py | 81 ++++++-
.../src/airflow/ui/openapi-gen/queries/common.ts | 12 +-
.../ui/openapi-gen/queries/ensureQueryData.ts | 20 +-
.../src/airflow/ui/openapi-gen/queries/prefetch.ts | 20 +-
.../src/airflow/ui/openapi-gen/queries/queries.ts | 20 +-
.../src/airflow/ui/openapi-gen/queries/suspense.ts | 20 +-
.../ui/openapi-gen/requests/services.gen.ts | 18 +-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 8 +
.../ui/src/layouts/Details/Gantt/Gantt.test.tsx | 19 +-
.../airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 4 +
.../ui/src/queries/useGridTISummaries.test.tsx | 72 ++++++
.../airflow/ui/src/queries/useGridTISummaries.ts | 25 ++-
.../api_fastapi/core_api/routes/ui/test_grid.py | 245 +++++++++++++++++++++
16 files changed, 624 insertions(+), 35 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/common/db/dag_runs.py
b/airflow-core/src/airflow/api_fastapi/common/db/dag_runs.py
index ed559e30225..71168ebc3e6 100644
--- a/airflow-core/src/airflow/api_fastapi/common/db/dag_runs.py
+++ b/airflow-core/src/airflow/api_fastapi/common/db/dag_runs.py
@@ -126,3 +126,12 @@ def attach_dag_versions_to_runs(dag_runs:
Sequence[DagRun], *, session: Session)
for dr in runs_needing_versions:
dr._prefetched_dag_version_ids = versions_per_run.get((dr.dag_id,
dr.run_id), {})
+
+
+def is_filtered_existing_run(dag_id: str, run_id: str, *, has_filter: bool,
session: Session) -> bool:
+ """Return whether an active filter can produce an empty result for this
run."""
+ return (
+ has_filter
+ and session.scalar(select(DagRun.run_id).where(DagRun.dag_id ==
dag_id, DagRun.run_id == run_id))
+ is not None
+ )
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index bee698ff30d..a58ea3fd517 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -2447,6 +2447,78 @@ paths:
type: string
- type: 'null'
title: Run Ids
+ - name: start_date_gte
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Start Date Gte
+ - name: start_date_gt
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Start Date Gt
+ - name: start_date_lte
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Start Date Lte
+ - name: start_date_lt
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Start Date Lt
+ - name: end_date_gte
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: End Date Gte
+ - name: end_date_gt
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: End Date Gt
+ - name: end_date_lte
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: End Date Lte
+ - name: end_date_lt
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: End Date Lt
responses:
'200':
description: "NDJSON stream \u2014 one ``GridTISummaries`` JSON
object per\
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/gantt.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/gantt.py
index 29f2e01b4f6..e7fc7c50edc 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/gantt.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/gantt.py
@@ -25,13 +25,13 @@ from sqlalchemy import or_, select
from airflow.api_fastapi.auth.managers.models.resource_details import
DagAccessEntity
from airflow.api_fastapi.common.dagbag import DagBagDep
from airflow.api_fastapi.common.db.common import SessionDep
+from airflow.api_fastapi.common.db.dag_runs import is_filtered_existing_run
from airflow.api_fastapi.common.parameters import RangeFilter,
datetime_range_filter_factory
from airflow.api_fastapi.common.router import AirflowRouter
from airflow.api_fastapi.core_api.datamodels.task_instances import
LoopIterationResponse
from airflow.api_fastapi.core_api.datamodels.ui.gantt import GanttResponse,
GanttTaskInstance
from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
from airflow.api_fastapi.core_api.security import requires_access_dag
-from airflow.models.dagrun import DagRun
from airflow.models.task_coordinates import TaskCoordinateResolver,
public_map_index_expression
from airflow.models.taskinstance import TaskInstance
from airflow.utils.state import TaskInstanceState
@@ -111,12 +111,12 @@ def get_gantt_data(
results = session.execute(query).fetchall()
if not results:
- filtered_existing_run = (
- start_date_range.is_active() or end_date_range.is_active()
- ) and session.scalar(
- select(DagRun.run_id).where(DagRun.dag_id == dag_id, DagRun.run_id
== run_id)
- ) is not None
- if not filtered_existing_run:
+ if not is_filtered_existing_run(
+ dag_id,
+ run_id,
+ has_filter=start_date_range.is_active() or
end_date_range.is_active(),
+ session=session,
+ ):
raise HTTPException(
status.HTTP_404_NOT_FOUND,
f"No task instances for dag_id={dag_id} run_id={run_id}",
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
index f6d571eea70..78e65efeda0 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
@@ -25,13 +25,13 @@ from uuid import UUID
import structlog
from fastapi import Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
-from sqlalchemy import exists, select
+from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session, joinedload, load_only
from airflow.api_fastapi.auth.managers.models.resource_details import
DagAccessEntity
from airflow.api_fastapi.common.dagbag import DagBagDep
from airflow.api_fastapi.common.db.common import SessionDep, paginated_select
-from airflow.api_fastapi.common.db.dag_runs import attach_dag_versions_to_runs
+from airflow.api_fastapi.common.db.dag_runs import
attach_dag_versions_to_runs, is_filtered_existing_run
from airflow.api_fastapi.common.parameters import (
QueryDagRunRunTypesFilter,
QueryDagRunStateFilter,
@@ -84,6 +84,7 @@ from airflow.models.taskinstance import TaskInstance,
TaskInstanceNote
from airflow.serialization.definitions.taskgroup import SerializedLoopTaskGroup
from airflow.utils.helpers import chunks
from airflow.utils.session import create_session
+from airflow.utils.state import TaskInstanceState
if TYPE_CHECKING:
from airflow.models.dagbag import DBDagBag
@@ -424,14 +425,18 @@ def _build_ti_summaries(
first = next(rows, None)
if first is None:
return None
- dag_version_id = first.dag_version_id or session.scalar(
- select(TaskInstance.dag_version_id)
- .where(
- TaskInstance.dag_id == dag_id,
- TaskInstance.run_id == run_id,
- TaskInstance.dag_version_id.is_not(None),
+ dag_version_id = (
+ getattr(first, "structure_version_id", None)
+ or first.dag_version_id
+ or session.scalar(
+ select(TaskInstance.dag_version_id)
+ .where(
+ TaskInstance.dag_id == dag_id,
+ TaskInstance.run_id == run_id,
+ TaskInstance.dag_version_id.is_not(None),
+ )
+ .limit(1)
)
- .limit(1)
)
serdag = _get_serdag(dag_bag, dag_id, dag_version_id, session)
if TYPE_CHECKING:
@@ -560,6 +565,10 @@ def _build_ti_summaries(
def get_grid_ti_summaries_stream(
dag_id: str,
dag_bag: DagBagDep,
+ start_date_range: Annotated[
+ RangeFilter, Depends(datetime_range_filter_factory("start_date",
TaskInstance))
+ ],
+ end_date_range: Annotated[RangeFilter,
Depends(datetime_range_filter_factory("end_date", TaskInstance))],
run_ids: Annotated[list[str] | None, Query()] = None,
) -> StreamingResponse:
"""
@@ -573,6 +582,7 @@ def get_grid_ti_summaries_stream(
(keyed by ``dag_version_id``), which avoids repeated deserialization across
runs of the same version *and* across requests.
"""
+ has_date_filter = start_date_range.is_active() or
end_date_range.is_active()
def _generate() -> Generator[str, None, None]:
# Each iteration opens and closes its own DB session so the connection
is
@@ -589,7 +599,7 @@ def get_grid_ti_summaries_stream(
for run_id in run_ids or []:
with create_session(scoped=False) as session:
- tis = session.execute(
+ query = (
select(
TaskInstance.task_id,
TaskInstance.region_id,
@@ -601,6 +611,18 @@ def get_grid_ti_summaries_stream(
TaskInstance.retry_at,
DagVersion.version_number,
has_note_subq,
+ select(TaskInstance.dag_version_id)
+ .join(DagVersion, TaskInstance.dag_version_id ==
DagVersion.id)
+ .where(
+ TaskInstance.dag_id == dag_id,
+ TaskInstance.run_id == run_id,
+ TaskInstance.working_set.is_(True),
+ )
+ .order_by(DagVersion.version_number.desc())
+ .limit(1)
+ .correlate(None)
+ .scalar_subquery()
+ .label("structure_version_id"),
)
.outerjoin(DagVersion, TaskInstance.dag_version_id ==
DagVersion.id)
.where(TaskInstance.dag_id == dag_id)
@@ -608,6 +630,37 @@ def get_grid_ti_summaries_stream(
.order_by(TaskInstance.task_id)
.execution_options(yield_per=1000)
)
+ if has_date_filter:
+ query = query.where(
+ or_(
+ TaskInstance.state !=
TaskInstanceState.UP_FOR_RETRY, TaskInstance.state.is_(None)
+ )
+ ).execution_options(include_all_attempts=True)
+ query = start_date_range.to_orm(query)
+ query = end_date_range.to_orm(query)
+ if has_date_filter:
+ coordinate = (TaskInstance.task_id,
TaskInstance.region_id, TaskInstance.region_index)
+ attempts = (
+ query.with_only_columns(
+ *(c for c in query.selected_columns if c.key not
in {"start_date", "end_date"}),
+ func.min(TaskInstance.start_date)
+ .over(partition_by=coordinate)
+ .label("start_date"),
+
func.max(TaskInstance.end_date).over(partition_by=coordinate).label("end_date"),
+ func.row_number()
+ .over(partition_by=coordinate,
order_by=TaskInstance.try_number.desc())
+ .label("attempt_rank"),
+ )
+ .order_by(None)
+ .subquery()
+ )
+ query = (
+ select(attempts)
+ .where(attempts.c.attempt_rank == 1)
+ .order_by(attempts.c.task_id)
+ .execution_options(include_all_attempts=True,
yield_per=1000)
+ )
+ tis = session.execute(query)
summary = _build_ti_summaries(
dag_id,
run_id,
@@ -615,8 +668,12 @@ def get_grid_ti_summaries_stream(
session,
dag_bag=dag_bag,
)
- if summary is None:
- continue
+ if summary is None:
+ if not is_filtered_existing_run(
+ dag_id, run_id, has_filter=has_date_filter,
session=session
+ ):
+ continue
+ summary = {"run_id": run_id, "dag_id": dag_id,
"task_instances": []}
yield GridTISummaries.model_validate(summary).model_dump_json() +
"\n"
return StreamingResponse(content=_generate(),
media_type="application/x-ndjson")
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 3a73c01dc6b..cf8e82b897b 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -1260,10 +1260,18 @@ export const UseGridServiceGetGridRunsKeyFn = ({ dagId,
limit, offset, orderBy,
export type GridServiceGetGridTiSummariesStreamDefaultResponse =
Awaited<ReturnType<typeof GridService.getGridTiSummariesStream>>;
export type GridServiceGetGridTiSummariesStreamQueryResult<TData =
GridServiceGetGridTiSummariesStreamDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
export const useGridServiceGetGridTiSummariesStreamKey =
"GridServiceGetGridTiSummariesStream";
-export const UseGridServiceGetGridTiSummariesStreamKeyFn = ({ dagId, runIds }:
{
+export const UseGridServiceGetGridTiSummariesStreamKeyFn = ({ dagId,
endDateGt, endDateGte, endDateLt, endDateLte, runIds, startDateGt,
startDateGte, startDateLt, startDateLte }: {
dagId: string;
+ endDateGt?: string;
+ endDateGte?: string;
+ endDateLt?: string;
+ endDateLte?: string;
runIds?: string[];
-}, queryKey?: Array<unknown>) => [useGridServiceGetGridTiSummariesStreamKey,
...(queryKey ?? [{ dagId, runIds }])];
+ startDateGt?: string;
+ startDateGte?: string;
+ startDateLt?: string;
+ startDateLte?: string;
+}, queryKey?: Array<unknown>) => [useGridServiceGetGridTiSummariesStreamKey,
...(queryKey ?? [{ dagId, endDateGt, endDateGte, endDateLt, endDateLte, runIds,
startDateGt, startDateGte, startDateLt, startDateLte }])];
export type GridServiceGetLoopSummaryDefaultResponse =
Awaited<ReturnType<typeof GridService.getLoopSummary>>;
export type GridServiceGetLoopSummaryQueryResult<TData =
GridServiceGetLoopSummaryDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
export const useGridServiceGetLoopSummaryKey = "GridServiceGetLoopSummary";
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index d61132785ae..5c8ecaa124d 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -2504,13 +2504,29 @@ export const ensureUseGridServiceGetGridRunsData =
(queryClient: QueryClient, {
* @param data The data for the request.
* @param data.dagId
* @param data.runIds
+* @param data.startDateGte
+* @param data.startDateGt
+* @param data.startDateLte
+* @param data.startDateLt
+* @param data.endDateGte
+* @param data.endDateGt
+* @param data.endDateLte
+* @param data.endDateLt
* @returns string NDJSON stream — one ``GridTISummaries`` JSON object per
line, one per Dag run
* @throws ApiError
*/
-export const ensureUseGridServiceGetGridTiSummariesStreamData = (queryClient:
QueryClient, { dagId, runIds }: {
+export const ensureUseGridServiceGetGridTiSummariesStreamData = (queryClient:
QueryClient, { dagId, endDateGt, endDateGte, endDateLt, endDateLte, runIds,
startDateGt, startDateGte, startDateLt, startDateLte }: {
dagId: string;
+ endDateGt?: string;
+ endDateGte?: string;
+ endDateLt?: string;
+ endDateLte?: string;
runIds?: string[];
-}) => queryClient.ensureQueryData({ queryKey:
Common.UseGridServiceGetGridTiSummariesStreamKeyFn({ dagId, runIds }), queryFn:
() => GridService.getGridTiSummariesStream({ dagId, runIds }) });
+ startDateGt?: string;
+ startDateGte?: string;
+ startDateLt?: string;
+ startDateLte?: string;
+}) => queryClient.ensureQueryData({ queryKey:
Common.UseGridServiceGetGridTiSummariesStreamKeyFn({ dagId, endDateGt,
endDateGte, endDateLt, endDateLte, runIds, startDateGt, startDateGte,
startDateLt, startDateLte }), queryFn: () =>
GridService.getGridTiSummariesStream({ dagId, endDateGt, endDateGte, endDateLt,
endDateLte, runIds, startDateGt, startDateGte, startDateLt, startDateLte }) });
/**
* Get Loop Summary
* Summarize one loop invocation from the run's live task instances.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index beb03c8697d..ce68fd80739 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -2504,13 +2504,29 @@ export const prefetchUseGridServiceGetGridRuns =
(queryClient: QueryClient, { da
* @param data The data for the request.
* @param data.dagId
* @param data.runIds
+* @param data.startDateGte
+* @param data.startDateGt
+* @param data.startDateLte
+* @param data.startDateLt
+* @param data.endDateGte
+* @param data.endDateGt
+* @param data.endDateLte
+* @param data.endDateLt
* @returns string NDJSON stream — one ``GridTISummaries`` JSON object per
line, one per Dag run
* @throws ApiError
*/
-export const prefetchUseGridServiceGetGridTiSummariesStream = (queryClient:
QueryClient, { dagId, runIds }: {
+export const prefetchUseGridServiceGetGridTiSummariesStream = (queryClient:
QueryClient, { dagId, endDateGt, endDateGte, endDateLt, endDateLte, runIds,
startDateGt, startDateGte, startDateLt, startDateLte }: {
dagId: string;
+ endDateGt?: string;
+ endDateGte?: string;
+ endDateLt?: string;
+ endDateLte?: string;
runIds?: string[];
-}) => queryClient.prefetchQuery({ queryKey:
Common.UseGridServiceGetGridTiSummariesStreamKeyFn({ dagId, runIds }), queryFn:
() => GridService.getGridTiSummariesStream({ dagId, runIds }) });
+ startDateGt?: string;
+ startDateGte?: string;
+ startDateLt?: string;
+ startDateLte?: string;
+}) => queryClient.prefetchQuery({ queryKey:
Common.UseGridServiceGetGridTiSummariesStreamKeyFn({ dagId, endDateGt,
endDateGte, endDateLt, endDateLte, runIds, startDateGt, startDateGte,
startDateLt, startDateLte }), queryFn: () =>
GridService.getGridTiSummariesStream({ dagId, endDateGt, endDateGte, endDateLt,
endDateLte, runIds, startDateGt, startDateGte, startDateLt, startDateLte }) });
/**
* Get Loop Summary
* Summarize one loop invocation from the run's live task instances.
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 39b7a669965..b654bded72b 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -2504,13 +2504,29 @@ export const useGridServiceGetGridRuns = <TData =
Common.GridServiceGetGridRunsD
* @param data The data for the request.
* @param data.dagId
* @param data.runIds
+* @param data.startDateGte
+* @param data.startDateGt
+* @param data.startDateLte
+* @param data.startDateLt
+* @param data.endDateGte
+* @param data.endDateGt
+* @param data.endDateLte
+* @param data.endDateLt
* @returns string NDJSON stream — one ``GridTISummaries`` JSON object per
line, one per Dag run
* @throws ApiError
*/
-export const useGridServiceGetGridTiSummariesStream = <TData =
Common.GridServiceGetGridTiSummariesStreamDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ dagId, runIds }: {
+export const useGridServiceGetGridTiSummariesStream = <TData =
Common.GridServiceGetGridTiSummariesStreamDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ dagId, endDateGt, endDateGte,
endDateLt, endDateLte, runIds, startDateGt, startDateGte, startDateLt,
startDateLte }: {
dagId: string;
+ endDateGt?: string;
+ endDateGte?: string;
+ endDateLt?: string;
+ endDateLte?: string;
runIds?: string[];
-}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseGridServiceGetGridTiSummariesStreamKeyFn({ dagId, runIds },
queryKey), queryFn: () => GridService.getGridTiSummariesStream({ dagId, runIds
}) as TData, ...options });
+ startDateGt?: string;
+ startDateGte?: string;
+ startDateLt?: string;
+ startDateLte?: string;
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseGridServiceGetGridTiSummariesStreamKeyFn({ dagId, endDateGt,
endDateGte, endDateLt, endDateLte, runIds, startDateGt, startDateGte,
startDateLt, startDateLte }, queryKey), queryFn: () =>
GridService.getGridTiSummariesStream({ dagId, endDateGt, endDateGte, endDateLt,
endDateLte, runIds, startDateGt, startDateGte, startDateLt, startDateLte }) as
T [...]
/**
* Get Loop Summary
* Summarize one loop invocation from the run's live task instances.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index 40cd839b2e0..c6e5712300a 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -2504,13 +2504,29 @@ export const useGridServiceGetGridRunsSuspense = <TData
= Common.GridServiceGetG
* @param data The data for the request.
* @param data.dagId
* @param data.runIds
+* @param data.startDateGte
+* @param data.startDateGt
+* @param data.startDateLte
+* @param data.startDateLt
+* @param data.endDateGte
+* @param data.endDateGt
+* @param data.endDateLte
+* @param data.endDateLt
* @returns string NDJSON stream — one ``GridTISummaries`` JSON object per
line, one per Dag run
* @throws ApiError
*/
-export const useGridServiceGetGridTiSummariesStreamSuspense = <TData =
Common.GridServiceGetGridTiSummariesStreamDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ dagId, runIds }: {
+export const useGridServiceGetGridTiSummariesStreamSuspense = <TData =
Common.GridServiceGetGridTiSummariesStreamDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ dagId, endDateGt, endDateGte,
endDateLt, endDateLte, runIds, startDateGt, startDateGte, startDateLt,
startDateLte }: {
dagId: string;
+ endDateGt?: string;
+ endDateGte?: string;
+ endDateLt?: string;
+ endDateLte?: string;
runIds?: string[];
-}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseGridServiceGetGridTiSummariesStreamKeyFn({ dagId, runIds },
queryKey), queryFn: () => GridService.getGridTiSummariesStream({ dagId, runIds
}) as TData, ...options });
+ startDateGt?: string;
+ startDateGte?: string;
+ startDateLt?: string;
+ startDateLte?: string;
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseGridServiceGetGridTiSummariesStreamKeyFn({ dagId, endDateGt,
endDateGte, endDateLt, endDateLte, runIds, startDateGt, startDateGte,
startDateLt, startDateLte }, queryKey), queryFn: () =>
GridService.getGridTiSummariesStream({ dagId, endDateGt, endDateGte, endDateLt,
endDateLte, runIds, startDateGt, startDateGte, startDateLt, startDateLte [...]
/**
* Get Loop Summary
* Summarize one loop invocation from the run's live task instances.
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 0136dead895..20ede748e99 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
@@ -5889,6 +5889,14 @@ export class GridService {
* @param data The data for the request.
* @param data.dagId
* @param data.runIds
+ * @param data.startDateGte
+ * @param data.startDateGt
+ * @param data.startDateLte
+ * @param data.startDateLt
+ * @param data.endDateGte
+ * @param data.endDateGt
+ * @param data.endDateLte
+ * @param data.endDateLt
* @returns string NDJSON stream — one ``GridTISummaries`` JSON object per
line, one per Dag run
* @throws ApiError
*/
@@ -5900,7 +5908,15 @@ export class GridService {
dag_id: data.dagId
},
query: {
- run_ids: data.runIds
+ run_ids: data.runIds,
+ start_date_gte: data.startDateGte,
+ start_date_gt: data.startDateGt,
+ start_date_lte: data.startDateLte,
+ start_date_lt: data.startDateLt,
+ end_date_gte: data.endDateGte,
+ end_date_gt: data.endDateGt,
+ end_date_lte: data.endDateLte,
+ end_date_lt: data.endDateLt
},
errors: {
400: 'Bad Request',
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 ffed3367ccb..31e1e3402f6 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
@@ -6017,7 +6017,15 @@ export type GetGridRunsResponse =
Array<GridRunsResponse>;
export type GetGridTiSummariesStreamData = {
dagId: string;
+ endDateGt?: string | null;
+ endDateGte?: string | null;
+ endDateLt?: string | null;
+ endDateLte?: string | null;
runIds?: Array<(string)> | null;
+ startDateGt?: string | null;
+ startDateGte?: string | null;
+ startDateLt?: string | null;
+ startDateLte?: string | null;
};
export type GetGridTiSummariesStreamResponse = string;
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.test.tsx
index cce6e00120f..404c080b7ca 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.test.tsx
@@ -30,7 +30,7 @@ import { BaseWrapper } from "src/utils/Wrapper";
import { Gantt } from "./Gantt";
-const { mockUseGanttServiceGetGanttData, mockUseGridRuns } = vi.hoisted(() =>
({
+const { mockUseGanttServiceGetGanttData, mockUseGridRuns,
mockUseGridTiSummariesStream } = vi.hoisted(() => ({
mockUseGanttServiceGetGanttData: vi.fn(() => ({ data: undefined, isLoading:
false })),
mockUseGridRuns: vi.fn(() => ({
data: [
@@ -44,6 +44,9 @@ const { mockUseGanttServiceGetGanttData, mockUseGridRuns } =
vi.hoisted(() => ({
],
isLoading: false,
})),
+ mockUseGridTiSummariesStream: vi.fn(() => ({
+ summariesByRunId: new Map([["run_1", { task_instances: [] }]]),
+ })),
}));
vi.mock("openapi/queries", async (importOriginal) => ({
@@ -60,9 +63,7 @@ vi.mock("src/queries/useGridStructure", () => ({
}));
vi.mock("src/queries/useGridTISummaries", () => ({
- useGridTiSummariesStream: () => ({
- summariesByRunId: new Map([["run_1", { task_instances: [] }]]),
- }),
+ useGridTiSummariesStream: mockUseGridTiSummariesStream,
}));
vi.mock("src/context/groups", () => ({
@@ -86,6 +87,7 @@ const createWrapper =
beforeEach(() => {
mockUseGanttServiceGetGanttData.mockClear();
mockUseGridRuns.mockClear();
+ mockUseGridTiSummariesStream.mockClear();
});
afterEach(cleanup);
@@ -129,6 +131,15 @@ describe("Gantt time range filter params", () => {
undefined,
expect.anything(),
);
+ expect(mockUseGridTiSummariesStream).toHaveBeenCalledWith({
+ dagId: "test_dag",
+ endDateGte: "2024-03-14T10:01:00Z",
+ endDateLte: "2024-03-14T10:10:00Z",
+ runIds: ["run_1"],
+ startDateGte: "2024-03-14T10:00:00Z",
+ startDateLte: "2024-03-14T10:05:00Z",
+ states: ["success"],
+ });
});
it("requests the full run when no date window is set", () => {
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
index 471ebe8738c..a384f815553 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
@@ -119,7 +119,11 @@ export const Gantt = ({
const { summariesByRunId } = useGridTiSummariesStream({
dagId,
+ endDateGte,
+ endDateLte,
runIds: runId && selectedRun ? [runId] : [],
+ startDateGte,
+ startDateLte,
states: selectedRun ? [selectedRun.state] : [],
});
const gridTiSummaries = summariesByRunId.get(runId);
diff --git
a/airflow-core/src/airflow/ui/src/queries/useGridTISummaries.test.tsx
b/airflow-core/src/airflow/ui/src/queries/useGridTISummaries.test.tsx
index be9bf46e3fb..569e1097774 100644
--- a/airflow-core/src/airflow/ui/src/queries/useGridTISummaries.test.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useGridTISummaries.test.tsx
@@ -83,6 +83,78 @@ describe("useGridTiSummariesStream", () => {
(useAutoRefresh as Mock).mockReturnValue(false);
});
+ it("refetches aggregate summaries when the Gantt date window changes", async
() => {
+ const queryClient = new QueryClient();
+ const wrapper = createWrapper(queryClient);
+ const summary = {
+ dag_id: "dag_1",
+ run_id: "run_1",
+ task_instances: [
+ {
+ child_states: { success: 1 },
+ max_end_date: "2024-12-31T00:01:00Z",
+ min_start_date: "2024-12-31T00:00:00Z",
+ state: "success",
+ task_display_name: "group",
+ task_id: "group",
+ },
+ ],
+ };
+
+ mockFetch
+
.mockResolvedValueOnce(createMockResponse([`${JSON.stringify(summary)}\n`]))
+ .mockResolvedValueOnce(createMockResponse([`${JSON.stringify({
...summary, task_instances: [] })}\n`]))
+
.mockResolvedValueOnce(createMockResponse([`${JSON.stringify(summary)}\n`]));
+
+ const { rerender, result } = renderHook<
+ ReturnType<typeof useGridTiSummariesStream>,
+ { endDateLte?: string; startDateGte?: string }
+ >(
+ ({ endDateLte, startDateGte }) =>
+ useGridTiSummariesStream({
+ dagId: "dag_1",
+ endDateLte,
+ runIds: ["run_1"],
+ startDateGte,
+ }),
+ {
+ initialProps: { endDateLte: "2024-12-31T23:59:00Z", startDateGte:
"2024-12-31T00:00:00Z" },
+ wrapper,
+ },
+ );
+
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ const firstUrl = new URL(mockFetch.mock.calls[0]?.[0] as string,
"http://localhost");
+
+
expect(firstUrl.searchParams.get("start_date_gte")).toBe("2024-12-31T00:00:00Z");
+
expect(firstUrl.searchParams.get("end_date_lte")).toBe("2024-12-31T23:59:00Z");
+ expect(result.current.summariesByRunId.get("run_1")).toEqual(summary);
+
+ rerender({ startDateGte: "2025-01-01T00:00:00Z" });
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ const secondUrl = new URL(mockFetch.mock.calls[1]?.[0] as string,
"http://localhost");
+
+
expect(secondUrl.searchParams.get("start_date_gte")).toBe("2025-01-01T00:00:00Z");
+
expect(result.current.summariesByRunId.get("run_1")?.task_instances).toEqual([]);
+
+ rerender({});
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ const thirdUrl = new URL(mockFetch.mock.calls[2]?.[0] as string,
"http://localhost");
+
+ expect(thirdUrl.searchParams.has("start_date_gte")).toBe(false);
+ expect(thirdUrl.searchParams.has("end_date_lte")).toBe(false);
+ expect(result.current.summariesByRunId.get("run_1")).toEqual(summary);
+ });
+
it("streams summaries correctly on mount", async () => {
const queryClient = new QueryClient({
defaultOptions: {
diff --git a/airflow-core/src/airflow/ui/src/queries/useGridTISummaries.ts
b/airflow-core/src/airflow/ui/src/queries/useGridTISummaries.ts
index 3fa06f7ec1e..fce4d4105e3 100644
--- a/airflow-core/src/airflow/ui/src/queries/useGridTISummaries.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useGridTISummaries.ts
@@ -48,11 +48,19 @@ const GRID_MUTATION_WATCHED_KEYS = new Set([
*/
export const useGridTiSummariesStream = ({
dagId,
+ endDateGte,
+ endDateLte,
runIds,
+ startDateGte,
+ startDateLte,
states,
}: {
dagId: string;
+ endDateGte?: string | undefined;
+ endDateLte?: string | undefined;
runIds: Array<string>;
+ startDateGte?: string | undefined;
+ startDateLte?: string | undefined;
states?: Array<TaskInstanceState | null | undefined>;
}) => {
const queryClient = useQueryClient();
@@ -64,6 +72,19 @@ export const useGridTiSummariesStream = ({
// Stable key so the effect only re-fires when the run list actually changes.
const runIdsKey = runIds.join(",");
+ const dateFilters = new URLSearchParams();
+
+ Object.entries({
+ end_date_gte: endDateGte,
+ end_date_lte: endDateLte,
+ start_date_gte: startDateGte,
+ start_date_lte: startDateLte,
+ }).forEach(([key, value]) => {
+ if (value !== undefined) {
+ dateFilters.set(key, value);
+ }
+ });
+ const dateFiltersKey = dateFilters.toString();
// Stream (or re-stream) whenever the run list or refresh tick changes.
useEffect(() => {
@@ -79,6 +100,8 @@ export const useGridTiSummariesStream = ({
// place as fresh lines arrive rather than flashing blank.
try {
const params = new URLSearchParams(runIds.map((id) => ["run_ids",
id]));
+
+ new URLSearchParams(dateFiltersKey).forEach((value, key) =>
params.set(key, value));
const response = await
fetch(`${OpenAPI.BASE}/ui/grid/ti_summaries/${dagId}?${params}`, {
signal: abortController.signal,
});
@@ -138,7 +161,7 @@ export const useGridTiSummariesStream = ({
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- runIdsKey
(stable join) intentionally replaces runIds array to avoid spurious re-streams
- }, [dagId, runIdsKey, refreshTick]);
+ }, [dagId, dateFiltersKey, runIdsKey, refreshTick]);
// Trigger a re-stream periodically while active runs are in flight.
useEffect(() => {
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
index cfb8e122cec..3add339c9ff 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
@@ -1295,11 +1295,216 @@ class TestGetGridDataEndpoint:
assert by_task_id["mapped_task_group.subtask"]["retry_at"] is None
assert by_task_id["mapped_task_group"]["retry_at"] is None
+ @pytest.mark.parametrize(
+ ("filters", "expected_start"),
+ [
+ ({"start_date_gte": "2024-12-30T00:00:00Z"},
"2024-12-31T00:00:00Z"),
+ ({"end_date_lte": "2024-12-02T00:00:00Z"}, "2024-12-01T00:00:00Z"),
+ ({"start_date_gte": "2025-01-01T00:00:00Z"}, None),
+ ],
+ )
+ def test_grid_ti_summaries_filters_mapped_tasks_and_groups(
+ self, session, test_client, filters, expected_start
+ ):
+ tis = session.scalars(
+ select(TaskInstance).where(TaskInstance.dag_id == DAG_ID,
TaskInstance.run_id == "run_1")
+ ).all()
+ for ti in tis:
+ ti.start_date = timezone.datetime(2024, 12, 1)
+ ti.end_date = ti.start_date + timedelta(minutes=1)
+ if ti.task_id == f"{TASK_GROUP_ID}.{MAPPED_TASK_ID}" and
ti.map_index == 1:
+ ti.start_date = timezone.datetime(2024, 12, 31)
+ ti.end_date = ti.start_date + timedelta(minutes=1)
+ session.commit()
+
+ response = test_client.get(f"/grid/ti_summaries/{DAG_ID}",
params={"run_ids": ["run_1"], **filters})
+ assert response.status_code == 200
+ [summary] = self._parse_ndjson(response)
+ if expected_start is None:
+ assert summary["task_instances"] == []
+ else:
+ by_task_id = {ti["task_id"]: ti for ti in
summary["task_instances"]}
+ for task_id in (TASK_GROUP_ID,
f"{TASK_GROUP_ID}.{MAPPED_TASK_ID}"):
+ assert by_task_id[task_id]["min_start_date"] == expected_start
+ assert by_task_id[task_id]["max_end_date"] == (
+ timezone.parse(expected_start) + timedelta(minutes=1)
+ ).isoformat().replace("+00:00", "Z")
+
@staticmethod
def _parse_ndjson(response) -> list[dict]:
"""Parse NDJSON streaming response into a list of dicts."""
return [json.loads(line) for line in response.text.splitlines() if
line.strip()]
+ @pytest.mark.parametrize(
+ ("archived_state", "retry_state", "filters", "expected_state",
"expected_end"),
+ [
+ (
+ "success",
+ "success",
+ {"end_date_lte": "2024-12-02T00:00:00Z"},
+ "success",
+ "2024-12-01T00:01:00Z",
+ ),
+ (
+ "success",
+ "up_for_retry",
+ {"end_date_lte": "2024-12-02T00:00:00Z"},
+ "success",
+ "2024-12-01T00:01:00Z",
+ ),
+ (
+ "failed",
+ "success",
+ {"start_date_gte": "2024-12-01T00:00:00Z"},
+ "success",
+ "2024-12-31T00:01:00Z",
+ ),
+ ("failed", "success", {"end_date_lte": "2024-12-02T00:00:00Z"},
"failed", "2024-12-01T00:01:00Z"),
+ ],
+ )
+ def test_grid_ti_summaries_filters_archived_group_attempts(
+ self,
+ dag_maker,
+ session,
+ test_client,
+ archived_state,
+ retry_state,
+ filters,
+ expected_state,
+ expected_end,
+ ):
+ with dag_maker("filtered_group_history", serialized=True):
+ with TaskGroup(group_id="group"):
+ EmptyOperator(task_id="work")
+ run = dag_maker.create_dagrun()
+ first = run.task_instances[0]
+ first.try_number = 1
+ first.state = archived_state
+ first.start_date = timezone.datetime(2024, 12, 1)
+ first.end_date = first.start_date + timedelta(minutes=1)
+ first.archive(reason="cleared", session=session)
+ retry = TaskInstance(
+ task=dag_maker.serialized_dag.get_task("group.work"),
+ run_id=run.run_id,
+ dag_version_id=first.dag_version_id,
+ region_id=first.region_id,
+ region_index=first.region_index,
+ )
+ retry.try_number = 2
+ retry.state = retry_state
+ retry.start_date = (
+ first.start_date
+ if retry_state == TaskInstanceState.UP_FOR_RETRY
+ else timezone.datetime(2024, 12, 31)
+ )
+ retry.end_date = retry.start_date + timedelta(minutes=1)
+ session.add(retry)
+ session.commit()
+
+ response = test_client.get(
+ f"/grid/ti_summaries/{run.dag_id}",
+ params={"run_ids": [run.run_id], **filters},
+ )
+ assert response.status_code == 200
+ [summary] = self._parse_ndjson(response)
+ group = next(ti for ti in summary["task_instances"] if ti["task_id"]
== "group")
+ assert group["min_start_date"] == "2024-12-01T00:00:00Z"
+ assert group["max_end_date"] == expected_end
+ assert group["state"] == expected_state
+ assert group["child_states"] == {expected_state: 1}
+
+ @pytest.mark.parametrize("current_state", [TaskInstanceState.SUCCESS,
TaskInstanceState.RUNNING])
+ def test_filtered_mapped_retries_count_each_coordinate_once(self, session,
test_client, current_state):
+ task_id = f"{TASK_GROUP_ID}.{MAPPED_TASK_ID}"
+ tis = session.scalars(
+ select(TaskInstance).where(
+ TaskInstance.dag_id == DAG_ID,
+ TaskInstance.run_id == "run_1",
+ TaskInstance.task_id == task_id,
+ )
+ ).all()
+ assert {ti.region_index for ti in tis} == {0, 1, 2, 3}
+ for ti in tis:
+ ti.state = TaskInstanceState.FAILED
+ ti.try_number = 1
+ ti.start_date = timezone.datetime(2024, 12, 1)
+ ti.end_date = ti.start_date + timedelta(minutes=1)
+ current = ti.prepare_db_for_next_try(session=session)
+ current.state = current_state
+ current.start_date = timezone.datetime(2024, 12, 31)
+ current.end_date = (
+ current.start_date + timedelta(minutes=1)
+ if current_state == TaskInstanceState.SUCCESS
+ else None
+ )
+ session.commit()
+
+ response = test_client.get(
+ f"/grid/ti_summaries/{DAG_ID}",
+ params={"run_ids": ["run_1"], "start_date_gte":
"2024-12-01T00:00:00Z"},
+ )
+ assert response.status_code == 200
+ [summary] = self._parse_ndjson(response)
+ mapped = next(ti for ti in summary["task_instances"] if ti["task_id"]
== task_id)
+ assert mapped["child_states"] == {current_state: 4}
+ assert mapped["state"] == current_state
+ assert mapped["min_start_date"] == "2024-12-01T00:00:00Z"
+ expected_day = 31 if current_state == TaskInstanceState.SUCCESS else 1
+ assert mapped["max_end_date"] == f"2024-12-{expected_day:02}T00:01:00Z"
+
+ def test_filtered_archived_attempt_does_not_choose_an_old_group_structure(
+ self, dag_maker, session, test_client
+ ):
+ dag_id = "filtered_versioned_group"
+ with dag_maker(dag_id, serialized=True):
+ EmptyOperator(task_id="a")
+ run = dag_maker.create_dagrun(run_id="version_1")
+ archived = run.task_instances[0]
+ archived.try_number = 1
+ archived.state = TaskInstanceState.SUCCESS
+ archived.start_date = timezone.datetime(2024, 12, 1)
+ archived.end_date = archived.start_date + timedelta(minutes=1)
+ archived.archive(reason="cleared", session=session)
+
+ with dag_maker(dag_id, serialized=True):
+ EmptyOperator(task_id="a")
+ with TaskGroup(group_id="group"):
+ EmptyOperator(task_id="work")
+ dag_maker.create_dagrun(run_id="version_2",
logical_date=timezone.datetime(2024, 12, 31))
+ version_id = session.scalar(
+ select(DagVersion.id)
+ .where(DagVersion.dag_id == dag_id)
+ .order_by(DagVersion.version_number.desc())
+ .limit(1)
+ )
+ for task_id, day in [("a", 31), ("group.work", 1)]:
+ ti = TaskInstance(
+ task=dag_maker.serialized_dag.get_task(task_id),
+ run_id=run.run_id,
+ dag_version_id=archived.dag_version_id if task_id == "a" else
version_id,
+ region_id=archived.region_id,
+ region_index=archived.region_index,
+ )
+ ti.state = TaskInstanceState.SUCCESS
+ ti.start_date = timezone.datetime(2024, 12, day)
+ ti.end_date = ti.start_date + timedelta(minutes=1)
+ session.add(ti)
+ session.commit()
+
+ for filters in ({}, {"end_date_lte": "2024-12-02T00:00:00Z"}):
+ response = test_client.get(
+ f"/grid/ti_summaries/{dag_id}",
+ params={"run_ids": [run.run_id], **filters},
+ )
+ assert response.status_code == 200
+ [summary] = self._parse_ndjson(response)
+ group = next(ti for ti in summary["task_instances"] if
ti["task_id"] == "group")
+ assert group["min_start_date"] == "2024-12-01T00:00:00Z"
+ assert group["max_end_date"] == "2024-12-01T00:01:00Z"
+ child = next(ti for ti in summary["task_instances"] if
ti["task_id"] == "group.work")
+ assert child["state"] == TaskInstanceState.SUCCESS
+ assert group["child_states"] == {"success": 1}
+
def test_grid_ti_summaries_stream_returns_all_runs(self, session,
test_client):
"""Streaming endpoint returns one NDJSON line per requested run_id."""
session.commit()
@@ -1533,6 +1738,46 @@ class TestGetLoopSummaryEndpoint:
"loop_exit_criteria_doc": None,
}
+ def test_filtered_loop_retries_preserve_iterations(self,
executed_fixed_loop, session, test_client):
+ run = executed_fixed_loop
+ tis = session.scalars(
+ select(TaskInstance).where(
+ TaskInstance.dag_id == run.dag_id,
+ TaskInstance.run_id == run.run_id,
+ TaskInstance.task_id == "body.work",
+ )
+ ).all()
+ assert {ti.region_index for ti in tis} == {1, 2, 3}
+ for ti in tis:
+ ti.state = TaskInstanceState.FAILED
+ ti.start_date = timezone.datetime(2024, 12, 1)
+ ti.end_date = ti.start_date + timedelta(minutes=1)
+ current = ti.prepare_db_for_next_try(session=session)
+ current.state = TaskInstanceState.SUCCESS
+ current.start_date = timezone.datetime(2024, 12, 31)
+ current.end_date = current.start_date + timedelta(minutes=1)
+ session.commit()
+
+ response = test_client.get(
+ f"/grid/ti_summaries/{run.dag_id}",
+ params={
+ "run_ids": [run.run_id],
+ "start_date_gte": "2024-12-01T00:00:00Z",
+ "end_date_lte": "2025-01-01T00:00:00Z",
+ },
+ )
+ assert response.status_code == 200
+ [summary] = TestGetGridDataEndpoint._parse_ndjson(response)
+ work = next(ti for ti in summary["task_instances"] if ti["task_id"] ==
"body.work")
+ assert work["state"] == TaskInstanceState.SUCCESS
+ body = next(ti for ti in summary["task_instances"] if ti["task_id"] ==
"body")
+ # Each iteration contains both the work task and the loop gate.
+ assert body["child_states"] == {"success": 6}
+ assert body["loop_iterations_count"] == 3
+ assert work["latest_region_index"] == 3
+ assert work["min_start_date"] == "2024-12-01T00:00:00Z"
+ assert work["max_end_date"] == "2024-12-31T00:01:00Z"
+
def test_ti_summaries_stream_rows_after_the_first(self,
executed_fixed_loop, session):
run = executed_fixed_loop
rows = session.execute(