This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 fe8d8702f22 Paginate partitioned Dag run modal requests (#68412)
fe8d8702f22 is described below
commit fe8d8702f223a33f39a9a49d8520047fbead32d6
Author: Steve Ahn <[email protected]>
AuthorDate: Mon Jun 29 08:29:06 2026 -0700
Paginate partitioned Dag run modal requests (#68412)
* Paginate partitioned Dag run modal requests
* Add stable secondary sort to partitioned Dag run pagination
* Fix asset_expressions typing in partitioned Dag run list response
Build the response via model_validate (matching the rest of the route) so
the
strictly-typed asset_expressions field passes mypy after the main merge; the
direct constructor tripped the static check while keeping identical
behavior.
---
.../api_fastapi/core_api/openapi/_private_ui.yaml | 16 ++
.../core_api/routes/ui/partitioned_dag_runs.py | 21 ++-
.../src/airflow/ui/openapi-gen/queries/common.ts | 6 +-
.../ui/openapi-gen/queries/ensureQueryData.ts | 8 +-
.../src/airflow/ui/openapi-gen/queries/prefetch.ts | 8 +-
.../src/airflow/ui/openapi-gen/queries/queries.ts | 8 +-
.../src/airflow/ui/openapi-gen/queries/suspense.ts | 8 +-
.../ui/openapi-gen/requests/services.gen.ts | 4 +
.../airflow/ui/openapi-gen/requests/types.gen.ts | 2 +
.../pages/DagsList/PartitionScheduleModal.test.tsx | 199 +++++++++++++++++++++
.../src/pages/DagsList/PartitionScheduleModal.tsx | 29 ++-
.../routes/ui/test_partitioned_dag_runs.py | 63 ++++++-
12 files changed, 346 insertions(+), 26 deletions(-)
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 5a62197dcfc..618b158c0f4 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
@@ -152,6 +152,22 @@ paths:
- OAuth2PasswordBearer: []
- HTTPBearer: []
parameters:
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 50
+ title: Limit
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ title: Offset
- name: dag_id
in: query
required: false
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
index 29b393070e3..ad319ced8ee 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
+++
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
@@ -22,8 +22,10 @@ import structlog
from fastapi import Depends, HTTPException, status
from sqlalchemy import and_, select
-from airflow.api_fastapi.common.db.common import SessionDep,
apply_filters_to_select
+from airflow.api_fastapi.common.db.common import SessionDep,
apply_filters_to_select, paginated_select
from airflow.api_fastapi.common.parameters import (
+ QueryLimit,
+ QueryOffset,
QueryPartitionedDagRunDagIdFilter,
QueryPartitionedDagRunHasCreatedDagRunIdFilter,
)
@@ -244,6 +246,8 @@ def _build_response(row, required_count: int,
received_count: int) -> Partitione
)
def get_partitioned_dag_runs(
session: SessionDep,
+ limit: QueryLimit,
+ offset: QueryOffset,
readable_dags_filter: ReadableDagsFilterDep,
dag_id: QueryPartitionedDagRunDagIdFilter,
has_created_dag_run_id: QueryPartitionedDagRunHasCreatedDagRunIdFilter,
@@ -268,14 +272,21 @@ def get_partitioned_dag_runs(
readable_dag_ids = readable_dags_filter.value
if readable_dag_ids is not None:
query =
query.where(AssetPartitionDagRun.target_dag_id.in_(readable_dag_ids))
- query = query.order_by(AssetPartitionDagRun.created_at.desc())
+ query = query.order_by(AssetPartitionDagRun.created_at.desc(),
AssetPartitionDagRun.id.desc())
+
+ query, total_entries = paginated_select(
+ statement=query,
+ offset=offset,
+ limit=limit,
+ session=session,
+ )
if not (rows := session.execute(query).all()):
- if dag_id.value is not None:
+ if dag_id.value is not None and total_entries == 0:
dag_exists =
session.scalar(select(DagModel.dag_id).where(DagModel.dag_id == dag_id.value))
if dag_exists is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"Dag with id
{dag_id.value} was not found")
- return PartitionedDagRunCollectionResponse(partitioned_dag_runs=[],
total=0)
+ return PartitionedDagRunCollectionResponse(partitioned_dag_runs=[],
total=total_entries)
# Batch-fetch DagModels (for cached partition_mapper_info), required
assets,
# and APDR log entries in three single queries instead of N per-Dag
queries.
@@ -336,7 +347,7 @@ def get_partitioned_dag_runs(
model_data: dict[str, Any] = {
"partitioned_dag_runs": results,
- "total": len(results),
+ "total": total_entries,
"asset_expressions": asset_expressions,
}
return PartitionedDagRunCollectionResponse.model_validate(model_data)
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 b72cc90eb30..69372a087a3 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -891,10 +891,12 @@ export const UseAuthLinksServiceGetCurrentUserInfoKeyFn =
(queryKey?: Array<unkn
export type PartitionedDagRunServiceGetPartitionedDagRunsDefaultResponse =
Awaited<ReturnType<typeof PartitionedDagRunService.getPartitionedDagRuns>>;
export type PartitionedDagRunServiceGetPartitionedDagRunsQueryResult<TData =
PartitionedDagRunServiceGetPartitionedDagRunsDefaultResponse, TError = unknown>
= UseQueryResult<TData, TError>;
export const usePartitionedDagRunServiceGetPartitionedDagRunsKey =
"PartitionedDagRunServiceGetPartitionedDagRuns";
-export const UsePartitionedDagRunServiceGetPartitionedDagRunsKeyFn = ({ dagId,
hasCreatedDagRunId }: {
+export const UsePartitionedDagRunServiceGetPartitionedDagRunsKeyFn = ({ dagId,
hasCreatedDagRunId, limit, offset }: {
dagId?: string;
hasCreatedDagRunId?: boolean;
-} = {}, queryKey?: Array<unknown>) =>
[usePartitionedDagRunServiceGetPartitionedDagRunsKey, ...(queryKey ?? [{ dagId,
hasCreatedDagRunId }])];
+ limit?: number;
+ offset?: number;
+} = {}, queryKey?: Array<unknown>) =>
[usePartitionedDagRunServiceGetPartitionedDagRunsKey, ...(queryKey ?? [{ dagId,
hasCreatedDagRunId, limit, offset }])];
export type PartitionedDagRunServiceGetPendingPartitionedDagRunDefaultResponse
= Awaited<ReturnType<typeof
PartitionedDagRunService.getPendingPartitionedDagRun>>;
export type
PartitionedDagRunServiceGetPendingPartitionedDagRunQueryResult<TData =
PartitionedDagRunServiceGetPendingPartitionedDagRunDefaultResponse, TError =
unknown> = UseQueryResult<TData, TError>;
export const usePartitionedDagRunServiceGetPendingPartitionedDagRunKey =
"PartitionedDagRunServiceGetPendingPartitionedDagRun";
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 74a833b2ccf..ff0174b77e9 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -1794,15 +1794,19 @@ export const
ensureUseAuthLinksServiceGetCurrentUserInfoData = (queryClient: Que
* Get Partitioned Dag Runs
* Return PartitionedDagRuns. Filter by dag_id and/or has_created_dag_run_id.
* @param data The data for the request.
+* @param data.limit
+* @param data.offset
* @param data.dagId
* @param data.hasCreatedDagRunId
* @returns PartitionedDagRunCollectionResponse Successful Response
* @throws ApiError
*/
-export const ensureUsePartitionedDagRunServiceGetPartitionedDagRunsData =
(queryClient: QueryClient, { dagId, hasCreatedDagRunId }: {
+export const ensureUsePartitionedDagRunServiceGetPartitionedDagRunsData =
(queryClient: QueryClient, { dagId, hasCreatedDagRunId, limit, offset }: {
dagId?: string;
hasCreatedDagRunId?: boolean;
-} = {}) => queryClient.ensureQueryData({ queryKey:
Common.UsePartitionedDagRunServiceGetPartitionedDagRunsKeyFn({ dagId,
hasCreatedDagRunId }), queryFn: () =>
PartitionedDagRunService.getPartitionedDagRuns({ dagId, hasCreatedDagRunId })
});
+ limit?: number;
+ offset?: number;
+} = {}) => queryClient.ensureQueryData({ queryKey:
Common.UsePartitionedDagRunServiceGetPartitionedDagRunsKeyFn({ dagId,
hasCreatedDagRunId, limit, offset }), queryFn: () =>
PartitionedDagRunService.getPartitionedDagRuns({ dagId, hasCreatedDagRunId,
limit, offset }) });
/**
* Get Pending Partitioned Dag Run
* Return full details for pending PartitionedDagRun.
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 9db58a9676c..1f8db278d9d 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -1794,15 +1794,19 @@ export const
prefetchUseAuthLinksServiceGetCurrentUserInfo = (queryClient: Query
* Get Partitioned Dag Runs
* Return PartitionedDagRuns. Filter by dag_id and/or has_created_dag_run_id.
* @param data The data for the request.
+* @param data.limit
+* @param data.offset
* @param data.dagId
* @param data.hasCreatedDagRunId
* @returns PartitionedDagRunCollectionResponse Successful Response
* @throws ApiError
*/
-export const prefetchUsePartitionedDagRunServiceGetPartitionedDagRuns =
(queryClient: QueryClient, { dagId, hasCreatedDagRunId }: {
+export const prefetchUsePartitionedDagRunServiceGetPartitionedDagRuns =
(queryClient: QueryClient, { dagId, hasCreatedDagRunId, limit, offset }: {
dagId?: string;
hasCreatedDagRunId?: boolean;
-} = {}) => queryClient.prefetchQuery({ queryKey:
Common.UsePartitionedDagRunServiceGetPartitionedDagRunsKeyFn({ dagId,
hasCreatedDagRunId }), queryFn: () =>
PartitionedDagRunService.getPartitionedDagRuns({ dagId, hasCreatedDagRunId })
});
+ limit?: number;
+ offset?: number;
+} = {}) => queryClient.prefetchQuery({ queryKey:
Common.UsePartitionedDagRunServiceGetPartitionedDagRunsKeyFn({ dagId,
hasCreatedDagRunId, limit, offset }), queryFn: () =>
PartitionedDagRunService.getPartitionedDagRuns({ dagId, hasCreatedDagRunId,
limit, offset }) });
/**
* Get Pending Partitioned Dag Run
* Return full details for pending PartitionedDagRun.
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 c10c9635706..c5ec74825be 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -1794,15 +1794,19 @@ export const useAuthLinksServiceGetCurrentUserInfo =
<TData = Common.AuthLinksSe
* Get Partitioned Dag Runs
* Return PartitionedDagRuns. Filter by dag_id and/or has_created_dag_run_id.
* @param data The data for the request.
+* @param data.limit
+* @param data.offset
* @param data.dagId
* @param data.hasCreatedDagRunId
* @returns PartitionedDagRunCollectionResponse Successful Response
* @throws ApiError
*/
-export const usePartitionedDagRunServiceGetPartitionedDagRuns = <TData =
Common.PartitionedDagRunServiceGetPartitionedDagRunsDefaultResponse, TError =
unknown, TQueryKey extends Array<unknown> = unknown[]>({ dagId,
hasCreatedDagRunId }: {
+export const usePartitionedDagRunServiceGetPartitionedDagRuns = <TData =
Common.PartitionedDagRunServiceGetPartitionedDagRunsDefaultResponse, TError =
unknown, TQueryKey extends Array<unknown> = unknown[]>({ dagId,
hasCreatedDagRunId, limit, offset }: {
dagId?: string;
hasCreatedDagRunId?: boolean;
-} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UsePartitionedDagRunServiceGetPartitionedDagRunsKeyFn({ dagId,
hasCreatedDagRunId }, queryKey), queryFn: () =>
PartitionedDagRunService.getPartitionedDagRuns({ dagId, hasCreatedDagRunId })
as TData, ...options });
+ limit?: number;
+ offset?: number;
+} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UsePartitionedDagRunServiceGetPartitionedDagRunsKeyFn({ dagId,
hasCreatedDagRunId, limit, offset }, queryKey), queryFn: () =>
PartitionedDagRunService.getPartitionedDagRuns({ dagId, hasCreatedDagRunId,
limit, offset }) as TData, ...options });
/**
* Get Pending Partitioned Dag Run
* Return full details for pending PartitionedDagRun.
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 8c4f6707176..90f75e1cfb3 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -1794,15 +1794,19 @@ export const
useAuthLinksServiceGetCurrentUserInfoSuspense = <TData = Common.Aut
* Get Partitioned Dag Runs
* Return PartitionedDagRuns. Filter by dag_id and/or has_created_dag_run_id.
* @param data The data for the request.
+* @param data.limit
+* @param data.offset
* @param data.dagId
* @param data.hasCreatedDagRunId
* @returns PartitionedDagRunCollectionResponse Successful Response
* @throws ApiError
*/
-export const usePartitionedDagRunServiceGetPartitionedDagRunsSuspense = <TData
= Common.PartitionedDagRunServiceGetPartitionedDagRunsDefaultResponse, TError =
unknown, TQueryKey extends Array<unknown> = unknown[]>({ dagId,
hasCreatedDagRunId }: {
+export const usePartitionedDagRunServiceGetPartitionedDagRunsSuspense = <TData
= Common.PartitionedDagRunServiceGetPartitionedDagRunsDefaultResponse, TError =
unknown, TQueryKey extends Array<unknown> = unknown[]>({ dagId,
hasCreatedDagRunId, limit, offset }: {
dagId?: string;
hasCreatedDagRunId?: boolean;
-} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UsePartitionedDagRunServiceGetPartitionedDagRunsKeyFn({ dagId,
hasCreatedDagRunId }, queryKey), queryFn: () =>
PartitionedDagRunService.getPartitionedDagRuns({ dagId, hasCreatedDagRunId })
as TData, ...options });
+ limit?: number;
+ offset?: number;
+} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UsePartitionedDagRunServiceGetPartitionedDagRunsKeyFn({ dagId,
hasCreatedDagRunId, limit, offset }, queryKey), queryFn: () =>
PartitionedDagRunService.getPartitionedDagRuns({ dagId, hasCreatedDagRunId,
limit, offset }) as TData, ...options });
/**
* Get Pending Partitioned Dag Run
* Return full details for pending PartitionedDagRun.
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 e51b6cf770c..ebb30ffa68c 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
@@ -4663,6 +4663,8 @@ export class PartitionedDagRunService {
* Get Partitioned Dag Runs
* Return PartitionedDagRuns. Filter by dag_id and/or
has_created_dag_run_id.
* @param data The data for the request.
+ * @param data.limit
+ * @param data.offset
* @param data.dagId
* @param data.hasCreatedDagRunId
* @returns PartitionedDagRunCollectionResponse Successful Response
@@ -4673,6 +4675,8 @@ export class PartitionedDagRunService {
method: 'GET',
url: '/ui/partitioned_dag_runs',
query: {
+ limit: data.limit,
+ offset: data.offset,
dag_id: data.dagId,
has_created_dag_run_id: data.hasCreatedDagRunId
},
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 83e95991808..c070c9d74d7 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
@@ -4509,6 +4509,8 @@ export type GenerateTokenResponse2 =
GenerateTokenResponse;
export type GetPartitionedDagRunsData = {
dagId?: string | null;
hasCreatedDagRunId?: boolean | null;
+ limit?: number;
+ offset?: number;
};
export type GetPartitionedDagRunsResponse =
PartitionedDagRunCollectionResponse;
diff --git
a/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.test.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.test.tsx
new file mode 100644
index 00000000000..929d0917ab6
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.test.tsx
@@ -0,0 +1,199 @@
+/*!
+ * 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.
+ */
+import "@testing-library/jest-dom/vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type * as OpenapiQueries from "openapi/queries";
+import type { PartitionedDagRunResponse } from "openapi/requests/types.gen";
+import type { TableState } from "src/components/DataTable/types";
+import type * as Ui from "src/components/ui";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { PartitionScheduleModal } from "./PartitionScheduleModal";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ // eslint-disable-next-line id-length
+ t: (key: string, options?: { count?: number }) =>
+ options?.count === undefined ? key : `${key}:${options.count}`,
+ }),
+}));
+
+vi.mock("src/queries/useConfig", () => ({
+ useConfig: () => 2,
+}));
+
+vi.mock("src/components/ui", async (importOriginal) => {
+ const actual = await importOriginal<typeof Ui>();
+ const DialogPart = ({ children }: { readonly children?: ReactNode }) =>
<div>{children}</div>;
+
+ return {
+ ...actual,
+ Dialog: {
+ ...actual.Dialog,
+ Body: DialogPart,
+ CloseTrigger: () => undefined,
+ Content: DialogPart,
+ Header: DialogPart,
+ Root: ({
+ children,
+ onOpenChange,
+ open,
+ }: {
+ readonly children?: ReactNode;
+ readonly onOpenChange?: () => void;
+ readonly open?: boolean;
+ }) =>
+ open ? (
+ <div>
+ <button onClick={onOpenChange} type="button">
+ close dialog
+ </button>
+ {children}
+ </div>
+ ) : undefined,
+ },
+ };
+});
+
+vi.mock("src/components/DataTable", () => ({
+ DataTable: ({
+ data,
+ initialState,
+ onStateChange,
+ total,
+ }: {
+ readonly data: ReadonlyArray<PartitionedDagRunResponse>;
+ readonly initialState?: TableState;
+ readonly onStateChange?: (state: TableState) => void;
+ readonly total?: number;
+ }) => (
+ <div>
+ <div data-testid="partitioned-dag-run-count">{data.length}</div>
+ <div data-testid="partitioned-dag-run-total">{total}</div>
+ <button
+ onClick={() => {
+ if (initialState !== undefined) {
+ onStateChange?.({
+ ...initialState,
+ pagination: {
+ ...initialState.pagination,
+ pageIndex: 1,
+ },
+ });
+ }
+ }}
+ type="button"
+ >
+ next page
+ </button>
+ </div>
+ ),
+}));
+
+vi.mock("openapi/queries", async (importOriginal) => {
+ const actual = await importOriginal<typeof OpenapiQueries>();
+
+ return {
+ ...actual,
+ usePartitionedDagRunServiceGetPartitionedDagRuns: vi.fn(),
+ };
+});
+
+const { usePartitionedDagRunServiceGetPartitionedDagRuns } = await
import("openapi/queries");
+
+const partitionedDagRun = {
+ dag_id: "example_dag",
+ id: 1,
+ partition_key: "2024-06-01",
+ total_received: 0,
+ total_required: 1,
+} satisfies PartitionedDagRunResponse;
+
+const getPartitionedDagRunsResponse = (partitionedDagRuns:
Array<PartitionedDagRunResponse>) =>
+ ({
+ data: {
+ asset_expressions: null,
+ partitioned_dag_runs: partitionedDagRuns,
+ total: 3,
+ },
+ error: null,
+ isFetching: false,
+ isLoading: false,
+ }) as ReturnType<typeof usePartitionedDagRunServiceGetPartitionedDagRuns>;
+
+describe("PartitionScheduleModal", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
vi.mocked(usePartitionedDagRunServiceGetPartitionedDagRuns).mockReturnValue(
+ getPartitionedDagRunsResponse([partitionedDagRun]),
+ );
+ });
+
+ it("requests partitioned Dag runs for the current table page", async () => {
+ const onClose = vi.fn();
+
+ render(<PartitionScheduleModal dagId="example_dag" onClose={onClose} open
/>, { wrapper: Wrapper });
+
+
expect(usePartitionedDagRunServiceGetPartitionedDagRuns).toHaveBeenLastCalledWith(
+ {
+ dagId: "example_dag",
+ hasCreatedDagRunId: false,
+ limit: 2,
+ offset: 0,
+ },
+ undefined,
+ { enabled: true },
+ );
+
expect(screen.getByTestId("partitioned-dag-run-total")).toHaveTextContent("3");
+
+ fireEvent.click(screen.getByRole("button", { name: "next page" }));
+
+ await waitFor(() =>
+
expect(usePartitionedDagRunServiceGetPartitionedDagRuns).toHaveBeenLastCalledWith(
+ {
+ dagId: "example_dag",
+ hasCreatedDagRunId: false,
+ limit: 2,
+ offset: 2,
+ },
+ undefined,
+ { enabled: true },
+ ),
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "close dialog" }));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ await waitFor(() =>
+
expect(usePartitionedDagRunServiceGetPartitionedDagRuns).toHaveBeenLastCalledWith(
+ {
+ dagId: "example_dag",
+ hasCreatedDagRunId: false,
+ limit: 2,
+ offset: 0,
+ },
+ undefined,
+ { enabled: true },
+ ),
+ );
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx
index ba0ef1a0a13..8a4901ceecb 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx
@@ -18,6 +18,7 @@
*/
import { Heading, HStack, Text } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
+import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FiDatabase } from "react-icons/fi";
@@ -25,9 +26,11 @@ import { usePartitionedDagRunServiceGetPartitionedDagRuns }
from "openapi/querie
import type { PartitionedDagRunResponse } from "openapi/requests/types.gen";
import { AssetProgressCell } from "src/components/AssetProgressCell";
import { DataTable } from "src/components/DataTable";
+import type { TableState } from "src/components/DataTable/types";
import { ErrorAlert } from "src/components/ErrorAlert";
import Time from "src/components/Time";
import { Dialog } from "src/components/ui";
+import { useConfig } from "src/queries/useConfig";
type PartitionScheduleModalProps = {
readonly dagId: string;
@@ -71,9 +74,24 @@ const getColumns = (
export const PartitionScheduleModal = ({ dagId, onClose, open }:
PartitionScheduleModalProps) => {
const { t: translate } = useTranslation("common");
+ const pageSize = (useConfig("fallback_page_limit") as number | undefined) ??
100;
+ const [pageIndex, setPageIndex] = useState(0);
+ const tableState = {
+ pagination: {
+ pageIndex,
+ pageSize,
+ },
+ sorting: [],
+ } satisfies TableState;
+ const { pagination } = tableState;
const { data, error, isFetching, isLoading } =
usePartitionedDagRunServiceGetPartitionedDagRuns(
- { dagId, hasCreatedDagRunId: false },
+ {
+ dagId,
+ hasCreatedDagRunId: false,
+ limit: pagination.pageSize,
+ offset: pagination.pageIndex * pagination.pageSize,
+ },
undefined,
{ enabled: open },
);
@@ -82,8 +100,13 @@ export const PartitionScheduleModal = ({ dagId, onClose,
open }: PartitionSchedu
const total = data?.total ?? 0;
const columns = getColumns(translate, dagId);
+ const handleOpenChange = () => {
+ setPageIndex(0);
+ onClose();
+ };
+
return (
- <Dialog.Root lazyMount onOpenChange={onClose} open={open}
scrollBehavior="inside" unmountOnExit>
+ <Dialog.Root lazyMount onOpenChange={handleOpenChange} open={open}
scrollBehavior="inside" unmountOnExit>
<Dialog.Content backdrop>
<Dialog.Header>
<HStack>
@@ -97,9 +120,11 @@ export const PartitionScheduleModal = ({ dagId, onClose,
open }: PartitionSchedu
<DataTable
columns={columns}
data={partitionedDagRuns}
+ initialState={tableState}
isFetching={isFetching}
isLoading={isLoading}
modelName="partitionedDagRun"
+ onStateChange={(state) => setPageIndex(state.pagination.pageIndex)}
showRowCountHeading={false}
total={total}
/>
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
index 3bea3b87d01..93cab212edc 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_partitioned_dag_runs.py
@@ -68,7 +68,7 @@ class TestGetPartitionedDagRuns:
dag_maker.create_dagrun()
dag_maker.sync_dagbag_to_db()
- with assert_queries_count(3):
+ with assert_queries_count(4):
resp =
test_client.get("/partitioned_dag_runs?dag_id=normal&has_created_dag_run_id=false")
assert resp.status_code == 200
assert resp.json() == {"partitioned_dag_runs": [], "total": 0,
"asset_expressions": None}
@@ -155,16 +155,17 @@ class TestGetPartitionedDagRuns:
)
session.commit()
- # Five batch queries, each independent of len(rows) / num_assets:
- # 1. outer SELECT of AssetPartitionDagRun rows
- # 2. SELECT DagModel WHERE dag_id IN (unique_dag_ids)
- # 3. _fetch_active_assets_per_dag — joined SELECT on AssetModel +
DagScheduleAssetReference
- # 4. SELECT PartitionedAssetKeyLog WHERE asset_partition_dag_run_id IN
(apdr_ids)
- # 5. SELECT timetable for asset_expressions (cached per response)
+ # Six batch queries, each independent of len(rows) / num_assets:
+ # 1. SELECT COUNT of matching AssetPartitionDagRun rows
+ # 2. outer SELECT of paginated AssetPartitionDagRun rows
+ # 3. SELECT DagModel WHERE dag_id IN (unique_dag_ids)
+ # 4. _fetch_active_assets_per_dag — joined SELECT on AssetModel +
DagScheduleAssetReference
+ # 5. SELECT PartitionedAssetKeyLog WHERE asset_partition_dag_run_id IN
(apdr_ids)
+ # 6. SELECT timetable for asset_expressions (cached per response)
# The count does not scale with num_assets or len(rows), so the bump
- # from main's baseline of 3 (3 → 4 → 5 across this branch) is constant
+ # from main's baseline of 3 (3 → 4 → 5 → 6 across this branch) is
constant
# per request, not per row.
- with assert_queries_count(5):
+ with assert_queries_count(6):
resp = test_client.get(
f"/partitioned_dag_runs?dag_id=list_dag"
f"&has_created_dag_run_id={str(has_created_dag_run_id).lower()}"
@@ -178,6 +179,50 @@ class TestGetPartitionedDagRuns:
assert pdr_resp["total_received"] == received_count
assert pdr_resp["total_required"] == num_assets
+ def test_should_paginate_partitioned_dag_runs(self, test_client,
dag_maker, session):
+ with dag_maker(
+ dag_id="paginated_dag",
+
schedule=PartitionedAssetTimetable(assets=Asset(uri="s3://bucket/page",
name="page")),
+ serialized=True,
+ ):
+ EmptyOperator(task_id="t")
+ dag_maker.sync_dagbag_to_db()
+
+ start = pendulum.datetime(2024, 6, 1, tz="UTC")
+ session.add_all(
+ [
+ AssetPartitionDagRun(
+ target_dag_id="paginated_dag",
+ partition_key=f"key-{idx}",
+ created_at=start.add(minutes=idx),
+ )
+ for idx in range(3)
+ ]
+ )
+ session.commit()
+
+ first_page = test_client.get(
+
"/partitioned_dag_runs?dag_id=paginated_dag&has_created_dag_run_id=false&limit=2"
+ )
+ assert first_page.status_code == 200
+ first_body = first_page.json()
+ assert first_body["total"] == 3
+ assert [row["partition_key"] for row in
first_body["partitioned_dag_runs"]] == ["key-2", "key-1"]
+
+ second_page = test_client.get(
+
"/partitioned_dag_runs?dag_id=paginated_dag&has_created_dag_run_id=false&limit=2&offset=2"
+ )
+ assert second_page.status_code == 200
+ second_body = second_page.json()
+ assert second_body["total"] == 3
+ assert [row["partition_key"] for row in
second_body["partitioned_dag_runs"]] == ["key-0"]
+
+ empty_page = test_client.get(
+
"/partitioned_dag_runs?dag_id=paginated_dag&has_created_dag_run_id=false&limit=2&offset=20"
+ )
+ assert empty_page.status_code == 200
+ assert empty_page.json() == {"partitioned_dag_runs": [], "total": 3,
"asset_expressions": None}
+
@pytest.mark.parametrize(
("num_target_assets", "num_other_assets", "received_count"),
[(1, 1, 1), (1, 2, 0), (2, 1, 1)],