This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 985c5a8be32 [v3-3-test] UI: Keep task try history consistent when
switching tasks (#70711) (#71126)
985c5a8be32 is described below
commit 985c5a8be32ac6f5f838670b79af80d8e8066e31
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Aug 5 13:39:56 2026 +0530
[v3-3-test] UI: Keep task try history consistent when switching tasks
(#70711) (#71126)
Task try history responses can include duplicate or non-attempt records,
which caused the UI to display mixed or invalid attempts after task navigation.
(cherry picked from commit 75d57f429e15ac41597fd045dc9187427fb93623)
Co-authored-by: Shivam Rastogi <[email protected]>
Co-authored-by: Shivam <[email protected]>
Co-authored-by: Rahul Vats <[email protected]>
---
.../ui/src/components/TaskTrySelect.test.tsx | 206 +++++++++++++++++----
.../airflow/ui/src/components/TaskTrySelect.tsx | 12 +-
2 files changed, 180 insertions(+), 38 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/components/TaskTrySelect.test.tsx
b/airflow-core/src/airflow/ui/src/components/TaskTrySelect.test.tsx
index 9a1e39766b1..142dc5fc36c 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskTrySelect.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskTrySelect.test.tsx
@@ -18,8 +18,8 @@
*/
import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { render, screen } from "@testing-library/react";
-import type { PropsWithChildren } from "react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { PropsWithChildren, ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -33,6 +33,12 @@ import {
import { TaskTrySelect } from "./TaskTrySelect";
+vi.mock("src/components/StateBadge", () => ({
+ StateBadge: ({ children, state }: { readonly children?: ReactNode; readonly
state?: string | null }) => (
+ <span data-state={state}>{children}</span>
+ ),
+}));
+
vi.mock("src/utils", async () => {
const actual = await vi.importActual("src/utils");
@@ -47,34 +53,59 @@ const DAG_RUN_ID = "test_run";
const TASK_A = "task_a";
const TASK_B = "task_b";
-const buildTaskInstance = (taskId: string, tryNumber: number):
TaskInstanceResponse =>
+const buildTaskInstance = (
+ taskId: string,
+ tryNumber: number,
+ {
+ mapIndex = -1,
+ state = "success",
+ }: {
+ readonly mapIndex?: number;
+ readonly state?: TaskInstanceResponse["state"];
+ } = {},
+): TaskInstanceResponse =>
({
dag_id: DAG_ID,
dag_run_id: DAG_RUN_ID,
- id: `${taskId}-id`,
- map_index: -1,
- state: "success",
+ id: `${taskId}-${mapIndex}`,
+ map_index: mapIndex,
+ state,
task_display_name: taskId,
task_id: taskId,
try_number: tryNumber,
}) as TaskInstanceResponse;
-const buildTaskTry = (tryNumber: number): TaskInstanceHistoryResponse =>
- ({
- dag_id: DAG_ID,
- dag_run_id: DAG_RUN_ID,
- map_index: -1,
- state: "success",
- task_display_name: TASK_A,
- task_id: TASK_A,
- try_number: tryNumber,
- }) as TaskInstanceHistoryResponse;
+const buildTaskTry = (
+ taskId: string,
+ tryNumber: number,
+ {
+ mapIndex = -1,
+ state = "success",
+ }: {
+ readonly mapIndex?: number;
+ readonly state?: TaskInstanceHistoryResponse["state"];
+ } = {},
+): TaskInstanceHistoryResponse => ({
+ ...buildTaskInstance(taskId, tryNumber, { mapIndex, state }),
+});
-const buildTaskTries = (tryNumbers: Array<number>):
TaskInstanceHistoryCollectionResponse => ({
- task_instances: tryNumbers.map(buildTaskTry),
- total_entries: tryNumbers.length,
+const buildTaskTries = (
+ taskInstances: Array<TaskInstanceHistoryResponse>,
+): TaskInstanceHistoryCollectionResponse => ({
+ task_instances: taskInstances,
+ total_entries: taskInstances.length,
});
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ staleTime: 5 * 60 * 1000,
+ },
+ },
+ });
+
const createWrapper =
(queryClient: QueryClient) =>
({ children }: PropsWithChildren) => (
@@ -85,18 +116,31 @@ const createWrapper =
</ChakraProvider>
);
+const expectTries = async (tries: Array<number>) => {
+ await waitFor(() => {
+ expect(
+ screen
+ .getAllByTestId(/^log-attempt-select-button-/u)
+ .map((button) => button.getAttribute("data-testid")),
+ ).toEqual(tries.map((tryNumber) =>
`log-attempt-select-button-${tryNumber}`));
+ });
+ expect(screen.queryByTestId("log-attempt-select-button-0")).toBeNull();
+};
+
+const expectTryState = (tryNumber: number, state: string) => {
+ expect(
+ screen
+ .getByTestId(`log-attempt-select-button-${tryNumber}`)
+ .querySelector("[data-state]")
+ ?.getAttribute("data-state"),
+ ).toBe(state);
+};
+
afterEach(() => vi.restoreAllMocks());
describe("TaskTrySelect", () => {
it("refetches cached tries immediately when switching tasks", async () => {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- staleTime: 5 * 60 * 1000,
- },
- },
- });
+ const queryClient = createQueryClient();
const params = {
dagId: DAG_ID,
dagRunId: DAG_RUN_ID,
@@ -106,9 +150,11 @@ describe("TaskTrySelect", () => {
queryClient.setQueryData(
UseTaskInstanceServiceGetMappedTaskInstanceTriesKeyFn(params),
- buildTaskTries([1, 2]),
+ buildTaskTries([1, 2].map((tryNumber) => buildTaskTry(TASK_A,
tryNumber))),
+ );
+ vi.spyOn(TaskInstanceService,
"getMappedTaskInstanceTries").mockResolvedValue(
+ buildTaskTries([1, 2, 3].map((tryNumber) => buildTaskTry(TASK_A,
tryNumber))),
);
- vi.spyOn(TaskInstanceService,
"getMappedTaskInstanceTries").mockResolvedValue(buildTaskTries([1, 2, 3]));
const { rerender } = render(
<TaskTrySelect selectedTryNumber={1}
taskInstance={buildTaskInstance(TASK_B, 1)} />,
@@ -117,12 +163,102 @@ describe("TaskTrySelect", () => {
rerender(<TaskTrySelect selectedTryNumber={3}
taskInstance={buildTaskInstance(TASK_A, 3)} />);
- expect(await
screen.findByTestId("log-attempt-select-button-3")).toBeTruthy();
- expect(
- screen
- .getAllByTestId(/^log-attempt-select-button-/u)
- .map((button) => button.getAttribute("data-testid")),
- ).toEqual(["log-attempt-select-button-1", "log-attempt-select-button-2",
"log-attempt-select-button-3"]);
+ await expectTries([1, 2, 3]);
expect(TaskInstanceService.getMappedTaskInstanceTries).toHaveBeenCalledWith(params);
});
+
+ it("keeps positive tries unique while switching between task instances",
async () => {
+ const queryClient = createQueryClient();
+ const histories = {
+ start: buildTaskTries([1, 2, 2, 3, 4].map((tryNumber) =>
buildTaskTry("start", tryNumber))),
+ task_1: buildTaskTries([
+ buildTaskTry("task_1", 0, { state: "skipped" }),
+ buildTaskTry("task_1", 1),
+ buildTaskTry("task_1", 2),
+ ]),
+ task_2: buildTaskTries([buildTaskTry("task_2", 1),
buildTaskTry("task_2", 2)]),
+ };
+
+ const getTries = vi
+ .spyOn(TaskInstanceService, "getMappedTaskInstanceTries")
+ .mockResolvedValue(histories.start);
+
+ const { rerender } = render(<TaskTrySelect
taskInstance={buildTaskInstance("start", 4)} />, {
+ wrapper: createWrapper(queryClient),
+ });
+
+ await expectTries([1, 2, 3, 4]);
+
+ getTries.mockResolvedValue(histories.task_1);
+ rerender(<TaskTrySelect taskInstance={buildTaskInstance("task_1", 2)} />);
+ await expectTries([1, 2]);
+
+ getTries.mockResolvedValue(histories.task_2);
+ rerender(<TaskTrySelect taskInstance={buildTaskInstance("task_2", 2)} />);
+ await expectTries([1, 2]);
+
+ getTries.mockResolvedValue(histories.task_1);
+ rerender(<TaskTrySelect taskInstance={buildTaskInstance("task_1", 2)} />);
+ await expectTries([1, 2]);
+
+ getTries.mockResolvedValue(histories.start);
+ rerender(<TaskTrySelect taskInstance={buildTaskInstance("start", 4)} />);
+ await expectTries([1, 2, 3, 4]);
+ });
+
+ it("uses a real current try but not retry or null placeholders", async () =>
{
+ const queryClient = createQueryClient();
+ const params = {
+ dagId: DAG_ID,
+ dagRunId: DAG_RUN_ID,
+ mapIndex: 1,
+ taskId: "mapped_task",
+ };
+ const history = buildTaskTries([
+ buildTaskTry("mapped_task", 1, { mapIndex: 1 }),
+ buildTaskTry("mapped_task", 2, { mapIndex: 1, state: "failed" }),
+ ]);
+ const onSelectTryNumber = vi.fn();
+
+
queryClient.setQueryData(UseTaskInstanceServiceGetMappedTaskInstanceTriesKeyFn(params),
history);
+ vi.spyOn(TaskInstanceService,
"getMappedTaskInstanceTries").mockResolvedValue(history);
+
+ const { rerender } = render(
+ <TaskTrySelect
+ onSelectTryNumber={onSelectTryNumber}
+ selectedTryNumber={2}
+ taskInstance={buildTaskInstance("mapped_task", 2, { mapIndex: 1 })}
+ />,
+ { wrapper: createWrapper(queryClient) },
+ );
+
+ await expectTries([1, 2]);
+ expectTryState(2, "success");
+
+ rerender(
+ <TaskTrySelect
+ onSelectTryNumber={onSelectTryNumber}
+ selectedTryNumber={2}
+ taskInstance={buildTaskInstance("mapped_task", 2, { mapIndex: 1,
state: "up_for_retry" })}
+ />,
+ );
+ expectTryState(2, "failed");
+
+ rerender(
+ <TaskTrySelect
+ onSelectTryNumber={onSelectTryNumber}
+ selectedTryNumber={2}
+ taskInstance={buildTaskInstance("mapped_task", 2, { mapIndex: 1,
state: null })}
+ />,
+ );
+ expectTryState(2, "failed");
+
+ fireEvent.click(screen.getByTestId("log-attempt-select-button-1"));
+ expect(onSelectTryNumber).toHaveBeenCalledOnce();
+ expect(onSelectTryNumber).toHaveBeenCalledWith(1);
+
expect(TaskInstanceService.getMappedTaskInstanceTries).toHaveBeenCalledWith(params);
+
+ rerender(<TaskTrySelect taskInstance={buildTaskInstance("mapped_task", 0,
{ mapIndex: 1 })} />);
+ expect(screen.queryByTestId(/^log-attempt-select-button-/u)).toBeNull();
+ });
});
diff --git a/airflow-core/src/airflow/ui/src/components/TaskTrySelect.tsx
b/airflow-core/src/airflow/ui/src/components/TaskTrySelect.tsx
index 8cbf595d0c2..75132ffa013 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskTrySelect.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskTrySelect.tsx
@@ -71,11 +71,17 @@ export const TaskTrySelect = ({ onSelectTryNumber,
selectedTryNumber, taskInstan
const logAttemptDropdownLimit = 10;
const showDropdown = finalTryNumber > logAttemptDropdownLimit;
- // For some reason tries aren't sorted by try_number
- const sortedTries = [...(tiHistory?.task_instances ?? [])].sort(
- (tryA, tryB) => tryA.try_number - tryB.try_number,
+ const triesByNumber = new Map(
+ (tiHistory?.task_instances ?? []).filter((ti) => ti.try_number >
0).map((ti) => [ti.try_number, ti]),
);
+ if (finalTryNumber > 0 && state !== "up_for_retry" && state !== null) {
+ // The current task instance is authoritative when it is also present in
history.
+ triesByNumber.set(finalTryNumber, taskInstance);
+ }
+
+ const sortedTries = [...triesByNumber.values()].sort((tryA, tryB) =>
tryA.try_number - tryB.try_number);
+
const tryOptions = createListCollection({
items: sortedTries.map((ti) => ({
task_instance: ti,