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 46321fb722f UI: Reduce duplicate Grid polling (#75979)
46321fb722f is described below
commit 46321fb722fa702771fa3e20e96e2800ac942ceb
Author: safaehar <[email protected]>
AuthorDate: Fri Nov 6 17:41:08 2026 -0500
UI: Reduce duplicate Grid polling (#75979)
---
.../ui/src/layouts/Details/DetailsLayout.test.tsx | 108 +++++++++++++++++++++
.../ui/src/layouts/Details/DetailsLayout.tsx | 26 +++--
.../ui/src/layouts/Details/Gantt/Gantt.test.tsx | 41 +++++---
.../airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 6 +-
.../layouts/Details/Grid/useGridPagination.test.ts | 36 ++++++-
.../src/layouts/Details/Grid/useGridPagination.ts | 14 +--
.../airflow/ui/src/queries/useGridRuns.test.tsx | 48 +++++++++
.../src/airflow/ui/src/queries/useGridRuns.ts | 50 ++++++----
8 files changed, 279 insertions(+), 50 deletions(-)
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.test.tsx
new file mode 100644
index 00000000000..7eb316e147d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.test.tsx
@@ -0,0 +1,108 @@
+/*!
+ * 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 type { PropsWithChildren } from "react";
+
+import { render } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { expect, it, vi } from "vitest";
+
+import type * as OpenApiQueries from "openapi/queries";
+
+import type * as Utils from "src/utils";
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { DetailsLayout } from "./DetailsLayout";
+
+const { mockUseDagRunServiceGetDagRun, mockUseGridRuns } = vi.hoisted(() => ({
+ mockUseDagRunServiceGetDagRun: vi.fn(() => ({ data: undefined })),
+ mockUseGridRuns: vi.fn(() => ({
+ data: [{ run_id: "run_2" }, { run_id: "run_1" }, { run_id: "lookahead_run"
}],
+ })),
+}));
+
+vi.mock("@xyflow/react", () => ({
+ useReactFlow: () => ({ fitView: vi.fn(), getZoom: () => 1 }),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ i18n: { dir: () => "ltr" },
+ // eslint-disable-next-line id-length
+ t: (key: string) => key,
+ }),
+}));
+
+vi.mock("react-resizable-panels", () => ({
+ Group: ({ children }: PropsWithChildren) => children,
+ Panel: ({ children }: PropsWithChildren) => children,
+ Separator: ({ children }: PropsWithChildren) => children,
+ useDefaultLayout: () => ({ defaultLayout: undefined, onLayoutChanged:
vi.fn() }),
+ useGroupRef: () => ({ current: null }),
+}));
+
+vi.mock("usehooks-ts", () => ({
+ useLocalStorage: (_storageKey: string, initialValue: unknown) =>
[initialValue, vi.fn()],
+}));
+
+vi.mock("openapi/queries", async (importOriginal) => ({
+ ...(await importOriginal<typeof OpenApiQueries>()),
+ useDagRunServiceGetDagRun: mockUseDagRunServiceGetDagRun,
+ useDagRunServiceGetDagRuns: () => ({ data: undefined }),
+ useDagServiceGetDag: () => ({ data: undefined }),
+}));
+
+vi.mock("src/components/DagStatusChip", () => ({ DagStatusChip: () => null }));
+vi.mock("src/context/groups", () => ({ GroupsProvider: ({ children }:
PropsWithChildren) => children }));
+vi.mock("src/hooks/useDagRunsLimit", () => ({ useDagRunsLimit: () => ({ limit:
2, setLimit: vi.fn() }) }));
+vi.mock("src/queries/useGridRuns.ts", () => ({ useGridRuns: mockUseGridRuns
}));
+vi.mock("src/utils", async (importOriginal) => ({
+ ...(await importOriginal<typeof Utils>()),
+ useAutoRefresh: () => false,
+ useContainerWidth: () => 1000,
+}));
+vi.mock("./DagBreadcrumb", () => ({ DagBreadcrumb: () => null }));
+vi.mock("./Gantt/Gantt", () => ({ Gantt: () => null }));
+vi.mock("./Graph", () => ({ Graph: () => null }));
+vi.mock("./Grid", () => ({ Grid: () => null }));
+vi.mock("./Grid/useGridCrosshairHover", () => ({ useGridCrosshairHover:
vi.fn() }));
+vi.mock("./NavTabs", () => ({ NavTabs: () => null }));
+vi.mock("./PanelButtons", () => ({ PanelButtons: () => null }));
+vi.mock("./runLimitConfig", () => ({ getEffectiveLimit: (limit: number) =>
limit }));
+
+it("positions a selected run that is only present as the page look-ahead row",
() => {
+ render(
+ <BaseWrapper>
+ <MemoryRouter initialEntries={["/dags/test_dag/runs/lookahead_run"]}>
+ <Routes>
+ <Route element={<DetailsLayout tabs={[]} />}
path="/dags/:dagId/runs/:runId" />
+ </Routes>
+ </MemoryRouter>
+ </BaseWrapper>,
+ );
+
+ expect(mockUseGridRuns).toHaveBeenCalledWith(expect.objectContaining({
limit: 3, offset: 0 }), {
+ autoRefresh: false,
+ enabled: true,
+ });
+ expect(mockUseDagRunServiceGetDagRun).toHaveBeenCalledWith(
+ { dagId: "test_dag", dagRunId: "lookahead_run" },
+ undefined,
+ { enabled: true },
+ );
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
index 95b30d7599a..87ab370ed6d 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -168,15 +168,23 @@ export const DetailsLayout = ({ children, error,
isLoading, outletContext, tabs
const noDateFilters = runAfterGte === undefined && runAfterLte === undefined;
- // Initial grid page (no date ceiling) — loaded first so we can check whether
- // the selected run is already visible before fetching it individually.
- const { data: initialGridRuns } = useGridRuns({
- dagRunState: dagRunStateFilter,
- limit,
- runIdPattern: runIdPatternFilter,
- runType: runTypeFilter,
- triggeringUser: triggeringUserFilter,
- });
+ // Match the Grid's first-page query so React Query shares the request. This
observer does not
+ // keep polling after the Grid moves to another page; it only positions a
selected older run.
+ const { data: dataGridRuns } = useGridRuns(
+ {
+ dagRunState: dagRunStateFilter,
+ limit: limit + 1,
+ offset: 0,
+ runIdPattern: runIdPatternFilter,
+ runType: runTypeFilter,
+ triggeringUser: triggeringUserFilter,
+ },
+ {
+ autoRefresh: false,
+ enabled: Boolean(runId) && noDateFilters,
+ },
+ );
+ const initialGridRuns = dataGridRuns?.slice(0, limit);
// Whether the selected run is absent from the initial grid page and we don't
// yet have a ceiling set. Only true once initialGridRuns has loaded.
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 f952d63043f..cce6e00120f 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,17 +30,9 @@ import { BaseWrapper } from "src/utils/Wrapper";
import { Gantt } from "./Gantt";
-const { mockUseGanttServiceGetGanttData } = vi.hoisted(() => ({
+const { mockUseGanttServiceGetGanttData, mockUseGridRuns } = vi.hoisted(() =>
({
mockUseGanttServiceGetGanttData: vi.fn(() => ({ data: undefined, isLoading:
false })),
-}));
-
-vi.mock("openapi/queries", async (importOriginal) => ({
- ...(await importOriginal<typeof OpenapiQueries>()),
- useGanttServiceGetGanttData: mockUseGanttServiceGetGanttData,
-}));
-
-vi.mock("src/queries/useGridRuns", () => ({
- useGridRuns: () => ({
+ mockUseGridRuns: vi.fn(() => ({
data: [
{
end_date: "2024-03-14T10:10:00Z",
@@ -51,7 +43,16 @@ vi.mock("src/queries/useGridRuns", () => ({
},
],
isLoading: false,
- }),
+ })),
+}));
+
+vi.mock("openapi/queries", async (importOriginal) => ({
+ ...(await importOriginal<typeof OpenapiQueries>()),
+ useGanttServiceGetGanttData: mockUseGanttServiceGetGanttData,
+}));
+
+vi.mock("src/queries/useGridRuns", () => ({
+ useGridRuns: mockUseGridRuns,
}));
vi.mock("src/queries/useGridStructure", () => ({
@@ -84,11 +85,29 @@ const createWrapper =
beforeEach(() => {
mockUseGanttServiceGetGanttData.mockClear();
+ mockUseGridRuns.mockClear();
});
afterEach(cleanup);
describe("Gantt time range filter params", () => {
+ it("shares the Grid's run query in the combined view", () => {
+ render(<Gantt limit={10} offset={0} />, {
+ wrapper: createWrapper(["/dags/test_dag/runs/run_1"]),
+ });
+
+ expect(mockUseGridRuns).toHaveBeenCalledWith({
+ dagRunState: undefined,
+ limit: 11,
+ offset: 0,
+ runAfterGte: undefined,
+ runAfterLte: undefined,
+ runIdPattern: undefined,
+ runType: undefined,
+ triggeringUser: undefined,
+ });
+ });
+
it("passes the URL date window to the backend query", () => {
render(<Gantt limit={10} />, {
wrapper: createWrapper([
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 e755789e415..471ebe8738c 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
@@ -91,9 +91,10 @@ export const Gantt = ({
const endDateGte = searchParams.get(SearchParamsKeys.END_DATE_GTE) ??
undefined;
const endDateLte = searchParams.get(SearchParamsKeys.END_DATE_LTE) ??
undefined;
- const { data: gridRuns, isLoading: runsLoading } = useGridRuns({
+ // Match the Grid's query and discard its pagination lookahead row so the
combined view polls once.
+ const { data: dataGridRuns, isLoading: runsLoading } = useGridRuns({
dagRunState,
- limit,
+ limit: limit + 1,
offset,
runAfterGte,
runAfterLte,
@@ -101,6 +102,7 @@ export const Gantt = ({
runType,
triggeringUser,
});
+ const gridRuns = dataGridRuns?.slice(0, limit);
const { data: dagStructure, isLoading: structureLoading } =
useGridStructure({
dagRunState,
depth,
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.test.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.test.ts
index 33a40c8e366..be5afc11f27 100644
---
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.test.ts
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.test.ts
@@ -16,11 +16,23 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { describe, expect, it } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
import type { GridRunsResponse } from "openapi/requests";
-import { computePaginationState } from "./useGridPagination";
+import { computePaginationState, useGridPagination } from
"./useGridPagination";
+
+const { mockUseDagServiceGetLatestRunInfo } = vi.hoisted(() => ({
+ mockUseDagServiceGetLatestRunInfo: vi.fn(),
+}));
+
+vi.mock("react-router-dom", () => ({ useParams: () => ({ dagId: "my_dag" })
}));
+vi.mock("openapi/queries", () => ({
+ useDagServiceGetLatestRunInfo: mockUseDagServiceGetLatestRunInfo,
+}));
+
+afterEach(() => mockUseDagServiceGetLatestRunInfo.mockReset());
/** Minimal GridRunsResponse stub with a given run_id. */
const makeRun = (runId: string): GridRunsResponse => ({
@@ -159,3 +171,23 @@ describe("computePaginationState", () => {
});
});
});
+
+describe("useGridPagination", () => {
+ it("reuses the latest-run query when checking whether the newest run is
visible", () => {
+ mockUseDagServiceGetLatestRunInfo.mockReturnValue({ data: { run_id:
"run_3" } });
+
+ const { result } = renderHook(() =>
+ useGridPagination({
+ gridRuns: [makeRun("run_2"), makeRun("run_1")],
+ limit: 10,
+ offset: 0,
+ setOffset: vi.fn(),
+ }),
+ );
+
+ expect(mockUseDagServiceGetLatestRunInfo).toHaveBeenCalledWith({ dagId:
"my_dag" }, undefined, {
+ enabled: true,
+ });
+ expect(result.current.latestNotVisible).toBe(true);
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.ts
index 55ecc40a591..132a4878653 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.ts
@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
-import type { GridRunsResponse } from "openapi/requests";
+import { useParams } from "react-router-dom";
-import { useGridRuns } from "src/queries/useGridRuns.ts";
+import { useDagServiceGetLatestRunInfo } from "openapi/queries";
+import type { GridRunsResponse } from "openapi/requests";
type Params = {
gridRuns: Array<GridRunsResponse> | undefined;
@@ -65,10 +66,11 @@ export const computePaginationState = ({
* handlers that advance the offset in either direction.
*/
export const useGridPagination = ({ gridRuns, limit, offset, setOffset }:
Params): Result => {
- // Fetch only the latest run (no filters) so we can tell whether it is
- // already visible in the current grid window.
- const { data: latestRuns } = useGridRuns({ limit: 1 });
- const latestRunId = latestRuns?.[0]?.run_id;
+ const { dagId = "" } = useParams();
+ const { data: latestRun } = useDagServiceGetLatestRunInfo({ dagId },
undefined, {
+ enabled: Boolean(dagId),
+ });
+ const latestRunId = latestRun?.run_id;
return {
...computePaginationState({ gridRuns, latestRunId, limit, offset }),
diff --git a/airflow-core/src/airflow/ui/src/queries/useGridRuns.test.tsx
b/airflow-core/src/airflow/ui/src/queries/useGridRuns.test.tsx
new file mode 100644
index 00000000000..a9f8ea05b86
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useGridRuns.test.tsx
@@ -0,0 +1,48 @@
+/*!
+ * 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 { renderHook } from "@testing-library/react";
+import { expect, it, vi } from "vitest";
+
+import { useGridRuns } from "./useGridRuns";
+
+const { mockUseGridServiceGetGridRuns } = vi.hoisted(() => ({
+ mockUseGridServiceGetGridRuns: vi.fn((..._args: Array<unknown>) => ({ data:
undefined })),
+}));
+
+vi.mock("react-router-dom", () => ({ useParams: () => ({ dagId: "test_dag" })
}));
+vi.mock("openapi/queries", () => ({ useGridServiceGetGridRuns:
mockUseGridServiceGetGridRuns }));
+vi.mock("src/hooks/useAdvancedSearch", () => ({ useAdvancedSearchArg: () =>
({}) }));
+vi.mock("src/utils", () => ({
+ isStatePending: (state?: string) => state === "running",
+ useAutoRefresh: () => 3000,
+}));
+
+it("can observe a grid-runs query without starting another polling loop", ()
=> {
+ renderHook(() => useGridRuns({ limit: 11, offset: 0 }, { autoRefresh: false,
enabled: false }));
+
+ const options = mockUseGridServiceGetGridRuns.mock.calls[0]?.[2] as
+ | {
+ enabled: boolean;
+ refetchInterval: (query: { state: { data: Array<{ state: string }> }
}) => number | false;
+ }
+ | undefined;
+
+ expect(options?.enabled).toBe(false);
+ expect(options?.refetchInterval({ state: { data: [{ state: "running" }] }
})).toBe(false);
+});
diff --git a/airflow-core/src/airflow/ui/src/queries/useGridRuns.ts
b/airflow-core/src/airflow/ui/src/queries/useGridRuns.ts
index c343acb781d..32555fbdd12 100644
--- a/airflow-core/src/airflow/ui/src/queries/useGridRuns.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useGridRuns.ts
@@ -25,25 +25,34 @@ import { SearchParamsKeys } from
"src/constants/searchParams";
import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch";
import { isStatePending, useAutoRefresh } from "src/utils";
-export const useGridRuns = ({
- dagRunState,
- limit,
- offset,
- runAfterGte,
- runAfterLte,
- runIdPattern,
- runType,
- triggeringUser,
-}: {
- dagRunState?: DagRunState | undefined;
- limit: number;
- offset?: number;
- runAfterGte?: string;
- runAfterLte?: string;
- runIdPattern?: string | undefined;
- runType?: DagRunType | undefined;
- triggeringUser?: string | undefined;
-}) => {
+export const useGridRuns = (
+ {
+ dagRunState,
+ limit,
+ offset,
+ runAfterGte,
+ runAfterLte,
+ runIdPattern,
+ runType,
+ triggeringUser,
+ }: {
+ dagRunState?: DagRunState | undefined;
+ limit: number;
+ offset?: number;
+ runAfterGte?: string;
+ runAfterLte?: string;
+ runIdPattern?: string | undefined;
+ runType?: DagRunType | undefined;
+ triggeringUser?: string | undefined;
+ },
+ {
+ autoRefresh = true,
+ enabled = true,
+ }: {
+ autoRefresh?: boolean;
+ enabled?: boolean;
+ } = {},
+) => {
const { dagId = "" } = useParams();
const refetchInterval = useAutoRefresh({ dagId });
@@ -81,9 +90,10 @@ export const useGridRuns = ({
},
undefined,
{
+ enabled,
placeholderData: (prev) => prev,
refetchInterval: (query) =>
- query.state.data?.some((run) => isStatePending(run.state)) &&
refetchInterval,
+ autoRefresh && query.state.data?.some((run) =>
isStatePending(run.state)) && refetchInterval,
},
);