Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@
from __future__ import annotations

from datetime import datetime
from typing import Annotated
from typing import Annotated, cast

from pydantic import (
AliasPath,
BeforeValidator,
Field,
field_validator,
)

from airflow._shared.secrets_masker import redact
from airflow.api_fastapi.core_api.base import BaseModel
from airflow.api_fastapi.core_api.datamodels.dag_versions import DagVersionResponse
from airflow.utils.state import TaskInstanceState
Expand Down Expand Up @@ -62,6 +64,20 @@ class TaskInstanceHistoryResponse(BaseModel):
executor: str | None
executor_config: Annotated[str, BeforeValidator(str)]
dag_version: DagVersionResponse | None
state_reason: str | None = Field(
default=None,
validation_alias="retry_reason",
description=(
"The reason the task instance reached its current state, as recorded by a retry policy. May describe a previous attempt: it is cleared only when the task next starts running, so a task waiting to be retried or re-run can still carry the reason its last attempt ended."
),
)

@field_validator("state_reason", mode="after")
@classmethod
def redact_state_reason(cls, v: str | None) -> str | None:
if v is None:
return None
return cast("str", redact(v))


class TaskInstanceHistoryCollectionResponse(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from collections.abc import Iterable
from datetime import datetime
from typing import Annotated, Any
from typing import Annotated, Any, cast
from uuid import UUID

from pydantic import (
Expand All @@ -34,6 +34,7 @@
model_validator,
)

from airflow._shared.secrets_masker import redact
from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
from airflow.api_fastapi.core_api.datamodels.dag_versions import DagVersionResponse
from airflow.api_fastapi.core_api.datamodels.job import JobResponse
Expand Down Expand Up @@ -89,6 +90,23 @@ class TaskInstanceResponse(BaseModel):
queued_by_job: JobResponse | None = Field(alias="triggerer_job")
dag_version: DagVersionResponse | None
team_name: str | None = None
state_reason: str | None = Field(
default=None,
validation_alias="retry_reason",
description=(
"The reason the task instance reached its current state, as recorded by a retry policy. May describe a previous attempt: it is cleared only when the task next starts running, so a task waiting to be retried or re-run can still carry the reason its last attempt ended."
),
)

@field_validator("state_reason", mode="after")
@classmethod
def redact_state_reason(cls, v: str | None) -> str | None:
# A retry policy composes this from the exception text, and a policy may opt out of the
# worker-side redaction, so the same string that would be masked in a task log can reach
# here unmasked.
if v is None:
return None
return cast("str", redact(v))


class TaskInstanceCollectionResponse(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4837,6 +4837,15 @@ components:
- type: string
- type: 'null'
title: Team Name
state_reason:
anyOf:
- type: string
- type: 'null'
title: State Reason
description: 'The reason the task instance reached its current state, as
recorded by a retry policy. May describe a previous attempt: it is cleared
only when the task next starts running, so a task waiting to be retried
or re-run can still carry the reason its last attempt ended.'
type: object
required:
- id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16322,6 +16322,15 @@ components:
anyOf:
- $ref: '#/components/schemas/DagVersionResponse'
- type: 'null'
state_reason:
anyOf:
- type: string
- type: 'null'
title: State Reason
description: 'The reason the task instance reached its current state, as
recorded by a retry policy. May describe a previous attempt: it is cleared
only when the task next starts running, so a task waiting to be retried
or re-run can still carry the reason its last attempt ended.'
type: object
required:
- task_id
Expand Down Expand Up @@ -16505,6 +16514,15 @@ components:
- type: string
- type: 'null'
title: Team Name
state_reason:
anyOf:
- type: string
- type: 'null'
title: State Reason
description: 'The reason the task instance reached its current state, as
recorded by a retry policy. May describe a previous attempt: it is cleared
only when the task next starts running, so a task waiting to be retried
or re-run can still carry the reason its last attempt ended.'
type: object
required:
- id
Expand Down
24 changes: 24 additions & 0 deletions airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7516,6 +7516,18 @@ export const $TaskInstanceHistoryResponse = {
type: 'null'
}
]
},
state_reason: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'State Reason',
description: 'The reason the task instance reached its current state, as recorded by a retry policy. May describe a previous attempt: it is cleared only when the task next starts running, so a task waiting to be retried or re-run can still carry the reason its last attempt ended.'
}
},
type: 'object',
Expand Down Expand Up @@ -7816,6 +7828,18 @@ export const $TaskInstanceResponse = {
}
],
title: 'Team Name'
},
state_reason: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'State Reason',
description: 'The reason the task instance reached its current state, as recorded by a retry policy. May describe a previous attempt: it is cleared only when the task next starts running, so a task waiting to be retried or re-run can still carry the reason its last attempt ended.'
}
},
type: 'object',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,10 @@ export type TaskInstanceHistoryResponse = {
executor: string | null;
executor_config: string;
dag_version: DagVersionResponse | null;
/**
* The reason the task instance reached its current state, as recorded by a retry policy. May describe a previous attempt: it is cleared only when the task next starts running, so a task waiting to be retried or re-run can still carry the reason its last attempt ended.
*/
state_reason?: string | null;
};

/**
Expand Down Expand Up @@ -2038,6 +2042,10 @@ export type TaskInstanceResponse = {
triggerer_job: JobResponse | null;
dag_version: DagVersionResponse | null;
team_name?: string | null;
/**
* The reason the task instance reached its current state, as recorded by a retry policy. May describe a previous attempt: it is cleared only when the task next starts running, so a task waiting to be retried or re-run can still carry the reason its last attempt ended.
*/
state_reason?: string | null;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,11 @@
"queuedWhen": "Queued At",
"renderedMapIndex": "Rendered Map Index",
"scheduledWhen": "Scheduled At",
"stateReason": "Reason for state",
"stateReasonSummary": {
"failed": "Stopped on try {{tryNumber}} of {{totalTries}}",
"upForRetry": "Retrying after try {{tryNumber}} of {{totalTries}}"
},
"trigger": "Trigger",
"triggerer": {
"assigned": "Assigned triggerer",
Expand Down
137 changes: 137 additions & 0 deletions airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*!
* 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";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import type { TaskInstanceHistoryResponse, TaskInstanceResponse } from "openapi/requests/types.gen";

import i18n from "src/i18n/config";
import { Wrapper } from "src/utils/Wrapper";

import commonLocale from "../../../public/i18n/locales/en/common.json";
import { Details } from "./Details";

// Sibling panels each fetch their own data and are unrelated to the row under test.
vi.mock("./BlockingDeps", () => ({ BlockingDeps: () => undefined }));
vi.mock("./ExtraLinks", () => ({ ExtraLinks: () => undefined }));
vi.mock("./TriggererInfo", () => ({ TriggererInfo: () => undefined }));
vi.mock("src/components/DagVersionDetails", () => ({ DagVersionDetails: () => undefined }));
vi.mock("src/components/TaskTrySelect", () => ({ TaskTrySelect: () => undefined }));
vi.mock("src/components/TeamName", () => ({ TeamName: () => undefined }));
vi.mock("src/hooks/useShowTeam", () => ({ useShowTeam: () => false }));

const mockTaskInstance = vi.fn<() => TaskInstanceResponse | undefined>();
const mockTryInstance = vi.fn<() => TaskInstanceHistoryResponse | undefined>();

vi.mock("openapi/queries", async () => {
const actual = await vi.importActual("openapi/queries");

return {
...actual,
useTaskInstanceServiceGetMappedTaskInstance: () => ({ data: mockTaskInstance() }),
useTaskInstanceServiceGetTaskInstanceTryDetails: () => ({ data: mockTryInstance() }),
};
});

vi.mock("src/utils", async () => {
const actual = await vi.importActual("src/utils");

return { ...actual, useAutoRefresh: () => false };
});

const buildTaskInstance = (overrides: Partial<TaskInstanceResponse>): TaskInstanceResponse =>
({
dag_id: "test_dag",
dag_run_id: "run_1",
dag_version: null,
duration: null,
end_date: null,
id: "ti-id",
map_index: -1,
max_tries: 2,
note: null,
operator_name: "PythonOperator",
rendered_map_index: null,
start_date: null,
state: "failed",
state_reason: null,
task_display_name: "test_task",
task_id: "test_task",
trigger: null,
triggerer_job: null,
try_number: 3,
...overrides,
}) as unknown as TaskInstanceResponse;

const renderDetails = (
taskInstance: TaskInstanceResponse,
tryInstance: Partial<TaskInstanceHistoryResponse> = {},
) => {
mockTaskInstance.mockReturnValue(taskInstance);
mockTryInstance.mockReturnValue({
...taskInstance,
...tryInstance,
});

return render(<Details />, { wrapper: Wrapper });
};

describe("Details state reason row", () => {
// Without the bundle i18n.t() echoes the key, so the label assertions below would pass blindly.
beforeEach(() => {
i18n.addResourceBundle("en", "common", commonLocale, true, true);
});

it("does not render the banner when there is no reason", () => {
renderDetails(buildTaskInstance({ state_reason: null }));

expect(screen.queryByText(i18n.t("common:taskInstance.stateReason"))).not.toBeInTheDocument();
});

// Cleared only once the task next reaches RUNNING, so these states still carry a stale reason.
it.each(["queued", "running", "success", null] as const)(
"renders neither the banner nor the row for a %s task that still carries a reason",
(state) => {
renderDetails(buildTaskInstance({ state, state_reason: "auth error, do not retry" }));

expect(screen.queryByText(i18n.t("common:taskInstance.stateReason"))).not.toBeInTheDocument();
expect(screen.queryByText("auth error, do not retry")).not.toBeInTheDocument();
},
);

it("keeps an earlier failed try's reason while the task is running again", () => {
renderDetails(buildTaskInstance({ state: "running", state_reason: null }), {
state: "failed",
state_reason: "try 1: auth error",
});

expect(screen.getByText("try 1: auth error")).toBeInTheDocument();
});

it("shows the selected try's reason in the table", () => {
renderDetails(buildTaskInstance({ state_reason: "latest try: rate limit" }), {
state_reason: "older try: auth error",
});

expect(screen.getByText("older try: auth error")).toBeInTheDocument();
expect(screen.queryByText("latest try: rate limit")).not.toBeInTheDocument();
expect(screen.getByText(i18n.t("common:taskInstance.stateReason"))).toBeInTheDocument();
});
});
16 changes: 16 additions & 0 deletions airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { isStatePending, useAutoRefresh, useDurationFormat } from "src/utils";
import { BlockingDeps } from "./BlockingDeps";
import { ExtraLinks } from "./ExtraLinks";
import { TriggererInfo } from "./TriggererInfo";
import { stateReasonDisplay } from "./stateReason";

export const Details = () => {
const { t: translate } = useTranslation();
Expand Down Expand Up @@ -115,6 +116,15 @@ export const Details = () => {
return translate("common:none", { defaultValue: "None" });
};

// Keyed off the selected try's own state, so an earlier failed try keeps its reason while the
// current one is running again.
const tryStateReason =
tryInstance?.state_reason !== null &&
tryInstance?.state_reason !== undefined &&
stateReasonDisplay(tryInstance.state) !== undefined
? tryInstance.state_reason
: undefined;

// omit kwargs from trigger
const triggerWithoutKwargs = taskInstance?.trigger
? (({ kwargs, ...rest }) => rest)(taskInstance.trigger)
Expand Down Expand Up @@ -162,6 +172,12 @@ export const Details = () => {
</Flex>
</Table.Cell>
</Table.Row>
{tryStateReason === undefined ? undefined : (
<Table.Row>
<Table.Cell>{translate("taskInstance.stateReason")}</Table.Cell>
<Table.Cell>{tryStateReason}</Table.Cell>
</Table.Row>
)}
<Table.Row>
<Table.Cell>{translate("taskId")}</Table.Cell>
<Table.Cell>
Expand Down
Loading
Loading