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,
     },
   );
 

Reply via email to