From 3369c705206c4f780f99040998ef0553bc45a8d1 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 27 May 2026 23:02:42 +0000 Subject: [PATCH 01/25] Add API endpoint and log reader for deadline callback logs --- .../core_api/datamodels/ui/deadline.py | 2 + .../core_api/openapi/_private_ui.yaml | 102 +++++++++++ .../core_api/routes/ui/deadlines.py | 86 ++++++++- .../airflow/utils/log/callback_log_reader.py | 170 ++++++++++++++++++ 4 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 airflow-core/src/airflow/utils/log/callback_log_reader.py diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py index 6f9402f23603d..7d2e8360311a1 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/deadline.py @@ -37,6 +37,8 @@ class DeadlineResponse(BaseModel): dag_run_id: str = Field(validation_alias=AliasPath("dagrun", "run_id")) alert_id: UUID | None = Field(validation_alias="deadline_alert_id", default=None) alert_name: str | None = Field(validation_alias=AliasPath("deadline_alert", "name"), default=None) + callback_id: UUID | None = Field(validation_alias="callback_id", default=None) + callback_state: str | None = Field(validation_alias=AliasPath("callback", "state"), default=None) class DeadlineCollectionResponse(BaseModel): 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 8b28661c26a50..1023ab3683bc8 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 @@ -920,6 +920,60 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /ui/dags/{dag_id}/dagRuns/{dag_run_id}/callbacks/{callback_id}/logs: + get: + tags: + - Deadlines + summary: Get Callback Logs + description: 'Get execution logs for a callback associated with a deadline. + + + Returns the logs produced during callback execution. These logs are uploaded + + to remote storage (or written locally) by the callback supervisor after execution.' + operationId: get_callback_logs + security: + - OAuth2PasswordBearer: [] + - HTTPBearer: [] + parameters: + - name: dag_id + in: path + required: true + schema: + type: string + title: Dag Id + - name: dag_run_id + in: path + required: true + schema: + type: string + title: Dag Run Id + - name: callback_id + in: path + required: true + schema: + type: string + format: uuid + title: Callback Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/TaskInstancesLogResponse' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + description: Not Found + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /ui/structure/structure_data: get: tags: @@ -2575,6 +2629,17 @@ components: - type: string - type: 'null' title: Alert Name + callback_id: + anyOf: + - type: string + format: uuid + - type: 'null' + title: Callback Id + callback_state: + anyOf: + - type: string + - type: 'null' + title: Callback State type: object required: - id @@ -3558,6 +3623,21 @@ components: - nodes title: StructureDataResponse description: Structure Data serializer for responses. + StructuredLogMessage: + properties: + timestamp: + type: string + format: date-time + title: Timestamp + event: + type: string + title: Event + additionalProperties: true + type: object + required: + - event + title: StructuredLogMessage + description: An individual log message. TaskInstanceResponse: properties: id: @@ -3826,6 +3906,28 @@ components: - awaiting_input title: TaskInstanceStateCount description: TaskInstance serializer for responses. + TaskInstancesLogResponse: + properties: + content: + anyOf: + - items: + $ref: '#/components/schemas/StructuredLogMessage' + type: array + - items: + type: string + type: array + title: Content + continuation_token: + anyOf: + - type: string + - type: 'null' + title: Continuation Token + type: object + required: + - content + - continuation_token + title: TaskInstancesLogResponse + description: Log serializer for responses. TeamCollectionResponse: properties: teams: diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py index d9cfea10d6d94..671375c12efa6 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/deadlines.py @@ -17,11 +17,13 @@ from __future__ import annotations +import os from typing import Annotated +from uuid import UUID from fastapi import Depends, HTTPException, status from sqlalchemy import select -from sqlalchemy.orm import contains_eager, noload +from sqlalchemy.orm import contains_eager, joinedload, noload from airflow.api_fastapi.auth.managers.models.resource_details import DagAccessEntity from airflow.api_fastapi.common.db.common import SessionDep, paginated_select @@ -35,16 +37,19 @@ filter_param_factory, ) from airflow.api_fastapi.common.router import AirflowRouter +from airflow.api_fastapi.core_api.datamodels.log import TaskInstancesLogResponse from airflow.api_fastapi.core_api.datamodels.ui.deadline import ( DeadlineAlertCollectionResponse, DeadlineCollectionResponse, ) from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc from airflow.api_fastapi.core_api.security import ReadableDagRunsFilterDep, requires_access_dag +from airflow.models.callback import Callback from airflow.models.dagrun import DagRun from airflow.models.deadline import Deadline from airflow.models.deadline_alert import DeadlineAlert from airflow.models.serialized_dag import SerializedDagModel +from airflow.utils.log.callback_log_reader import read_callback_log deadlines_router = AirflowRouter(prefix="/dags/{dag_id}", tags=["Deadlines"]) @@ -106,7 +111,7 @@ def get_deadlines( .options( contains_eager(Deadline.dagrun).options(noload(DagRun.deadlines)), contains_eager(Deadline.deadline_alert), - noload(Deadline.callback), + joinedload(Deadline.callback), ) ) @@ -201,3 +206,80 @@ def get_dag_deadline_alerts( alerts = session.scalars(alerts_select) return DeadlineAlertCollectionResponse(deadline_alerts=alerts, total_entries=total_entries) + + +@deadlines_router.get( + "/dagRuns/{dag_run_id}/callbacks/{callback_id}/logs", + responses=create_openapi_http_exception_doc( + [ + status.HTTP_404_NOT_FOUND, + ] + ), + dependencies=[ + Depends( + requires_access_dag( + method="GET", + access_entity=DagAccessEntity.TASK_LOGS, + ) + ), + ], + response_model=TaskInstancesLogResponse, + response_model_exclude_unset=True, +) +def get_callback_logs( + dag_id: str, + dag_run_id: str, + callback_id: UUID, + session: SessionDep, +) -> TaskInstancesLogResponse: + """ + Get execution logs for a callback associated with a deadline. + + Returns the logs produced during callback execution. These logs are uploaded + to remote storage (or written locally) by the callback supervisor after execution. + """ + # Sanitize path components to prevent path traversal via URL parameters. + for param_name, param_value in [("dag_id", dag_id), ("dag_run_id", dag_run_id)]: + if os.sep in param_value or "\\" in param_value or ".." in param_value: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"Invalid characters in {param_name}", + ) + + # Verify the callback exists + callback = session.scalar(select(Callback).where(Callback.id == callback_id)) + if callback is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + f"Callback with id `{callback_id}` was not found", + ) + + # Verify the dag_run exists with matching dag_id + dag_run = session.scalar(select(DagRun).where(DagRun.dag_id == dag_id, DagRun.run_id == dag_run_id)) + if dag_run is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + f"DagRun with dag_id: `{dag_id}` and run_id: `{dag_run_id}` was not found", + ) + + # Verify the callback actually belongs to this dag_run (via the Deadline relationship) + deadline = session.scalar( + select(Deadline).where(Deadline.callback_id == callback_id, Deadline.dagrun_id == dag_run.id) + ) + if deadline is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + f"Callback `{callback_id}` is not associated with DagRun `{dag_run_id}` of Dag `{dag_id}`", + ) + + try: + log_stream = read_callback_log( + dag_id=dag_id, + run_id=dag_run_id, + callback_id=str(callback_id), + ) + except ValueError: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid callback log path") + + content = list(log_stream) + return TaskInstancesLogResponse.model_construct(content=content, continuation_token=None) diff --git a/airflow-core/src/airflow/utils/log/callback_log_reader.py b/airflow-core/src/airflow/utils/log/callback_log_reader.py new file mode 100644 index 0000000000000..1ff991b00196b --- /dev/null +++ b/airflow-core/src/airflow/utils/log/callback_log_reader.py @@ -0,0 +1,170 @@ +# 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. +"""Reader for callback execution logs stored in remote or local storage.""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Generator +from contextlib import suppress +from pathlib import Path +from typing import TYPE_CHECKING + +from airflow.configuration import conf +from airflow.utils.log.file_task_handler import ( + StructuredLogMessage, + _interleave_logs, + _stream_lines_by_chunk, +) + +if TYPE_CHECKING: + from airflow._shared.logging.remote import LogSourceInfo, RawLogStream + +logger = logging.getLogger(__name__) + + +def _get_callback_log_relative_path(dag_id: str, run_id: str, callback_id: str) -> str: + """ + Construct the relative log path for a callback execution. + + This must match the path format used in ExecuteCallback.make(): + executor_callbacks/{dag_id}/{run_id}/{callback_id} + """ + return f"executor_callbacks/{dag_id}/{run_id}/{callback_id}" + + +def read_callback_log( + dag_id: str, + run_id: str, + callback_id: str, +) -> Generator[StructuredLogMessage, None, None]: + """ + Read callback logs from remote and/or local storage. + + Tries remote storage first (if configured), then falls back to local filesystem. + Returns a generator of StructuredLogMessage objects suitable for the API response. + + :param dag_id: The Dag ID associated with the callback. + :param run_id: The Dag run ID associated with the callback. + :param callback_id: The unique callback identifier. + :return: Generator of StructuredLogMessage objects. + """ + relative_path = _get_callback_log_relative_path(dag_id, run_id, callback_id) + + sources: LogSourceInfo = [] + remote_logs: list[RawLogStream] = [] + local_logs: list[RawLogStream] = [] + + # Try remote storage first + with suppress(Exception): + remote_sources, remote_log_streams = _read_callback_remote_logs(relative_path) + if remote_log_streams: + sources.extend(remote_sources) + remote_logs.extend(remote_log_streams) + + # Try local filesystem + if not remote_logs: + local_sources, local_log_streams = _read_callback_local_logs(relative_path) + if local_log_streams: + sources.extend(local_sources) + local_logs.extend(local_log_streams) + + if not remote_logs and not local_logs: + yield StructuredLogMessage(event="No callback logs found.", timestamp=None) + return + + # Emit source information header + yield StructuredLogMessage(event="::group::Log message source details", sources=sources) # type: ignore[call-arg] + yield StructuredLogMessage(event="::endgroup::") + + # Interleave and yield all log streams + log_stream = _interleave_logs(*remote_logs, *local_logs) + yield from log_stream + + +def _read_callback_remote_logs( + relative_path: str, +) -> tuple[list[str], list[RawLogStream]]: + """Read callback logs from the configured remote log storage.""" + from airflow.logging_config import get_remote_task_log + + remote_io = get_remote_task_log() + if remote_io is None: + return [], [] + + # RemoteLogIO.read() takes (relative_path, ti) -- for S3 the ti is not used, + # for CloudWatch it uses ti.end_date (with getattr fallback to None). + # We pass None since callbacks don't have a TaskInstance. + if stream_method := getattr(remote_io, "stream", None): + sources, logs = stream_method(relative_path, None) + return sources, logs or [] + + sources, logs = remote_io.read(relative_path, None) # type: ignore[arg-type] + if not logs: + return sources, [] + + # Convert legacy string logs to stream format + from airflow.utils.log.file_task_handler import _get_compatible_log_stream + + return sources, [_get_compatible_log_stream(logs)] + + +def _validate_path_component(component: str) -> str: + """Validate and return a path component, raising ValueError if unsafe.""" + import re + + if component in (".", "..") or not re.fullmatch(r"[A-Za-z0-9._:+\-~@]+", component): + raise ValueError(f"Invalid path component: {component!r}") + return component + + +def _read_callback_local_logs( + relative_path: str, +) -> tuple[list[str], list[RawLogStream]]: + """Read callback logs from the local filesystem.""" + base_log_folder = os.path.realpath(conf.get("logging", "base_log_folder")) + + parts = relative_path.split("/") + safe_parts = [_validate_path_component(p) for p in parts if p] + safe_relative = os.path.join(*safe_parts) + + log_path = Path(base_log_folder) / safe_relative + + sources: list[str] = [] + log_streams: list[RawLogStream] = [] + + paths = sorted(log_path.parent.glob(log_path.name + "*")) + if not paths: + return sources, log_streams + + for path in paths: + resolved_path = os.path.realpath(path) + try: + if os.path.commonpath([base_log_folder, resolved_path]) != base_log_folder: + continue + except ValueError: + continue + + try: + log_stream = _stream_lines_by_chunk(open(resolved_path, encoding="utf-8")) + except OSError: + continue + sources.append(os.fspath(path)) + log_streams.append(log_stream) + + return sources, log_streams From fb3f4a5c1ba765d4dacc7d29e904348d92afec09 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 27 May 2026 23:02:50 +0000 Subject: [PATCH 02/25] UI: Add CallbackLogViewer component for deadline alerts --- .../airflow/ui/openapi-gen/queries/common.ts | 8 ++ .../ui/openapi-gen/queries/ensureQueryData.ts | 18 +++ .../ui/openapi-gen/queries/prefetch.ts | 18 +++ .../airflow/ui/openapi-gen/queries/queries.ts | 18 +++ .../ui/openapi-gen/queries/suspense.ts | 18 +++ .../ui/openapi-gen/requests/schemas.gen.ts | 23 ++++ .../ui/openapi-gen/requests/types.gen.ts | 29 +++++ .../ui/public/i18n/locales/en/dag.json | 5 + .../ui/src/pages/Run/CallbackLogViewer.tsx | 103 +++++++++++++++++ .../ui/src/pages/Run/DeadlineStatus.tsx | 59 ++-------- .../ui/src/pages/Run/DeadlineStatusModal.tsx | 109 ++++++------------ 11 files changed, 285 insertions(+), 123 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx 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 82c63b58a6909..c7ec3975988c5 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts @@ -948,6 +948,14 @@ export const UseDeadlinesServiceGetDagDeadlineAlertsKeyFn = ({ dagId, limit, off offset?: number; orderBy?: string[]; }, queryKey?: Array) => [useDeadlinesServiceGetDagDeadlineAlertsKey, ...(queryKey ?? [{ dagId, limit, offset, orderBy }])]; +export type DeadlinesServiceGetCallbackLogsDefaultResponse = Awaited>; +export type DeadlinesServiceGetCallbackLogsQueryResult = UseQueryResult; +export const useDeadlinesServiceGetCallbackLogsKey = "DeadlinesServiceGetCallbackLogs"; +export const UseDeadlinesServiceGetCallbackLogsKeyFn = ({ callbackId, dagId, dagRunId }: { + callbackId: string; + dagId: string; + dagRunId: string; +}, queryKey?: Array) => [useDeadlinesServiceGetCallbackLogsKey, ...(queryKey ?? [{ callbackId, dagId, dagRunId }])]; export type StructureServiceStructureDataDefaultResponse = Awaited>; export type StructureServiceStructureDataQueryResult = UseQueryResult; export const useStructureServiceStructureDataKey = "StructureServiceStructureData"; 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 48f61be34d12f..362797242267f 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts @@ -1907,6 +1907,24 @@ export const ensureUseDeadlinesServiceGetDagDeadlineAlertsData = (queryClient: Q orderBy?: string[]; }) => queryClient.ensureQueryData({ queryKey: Common.UseDeadlinesServiceGetDagDeadlineAlertsKeyFn({ dagId, limit, offset, orderBy }), queryFn: () => DeadlinesService.getDagDeadlineAlerts({ dagId, limit, offset, orderBy }) }); /** +* Get Callback Logs +* Get execution logs for a callback associated with a deadline. +* +* Returns the logs produced during callback execution. These logs are uploaded +* to remote storage (or written locally) by the callback supervisor after execution. +* @param data The data for the request. +* @param data.dagId +* @param data.dagRunId +* @param data.callbackId +* @returns TaskInstancesLogResponse Successful Response +* @throws ApiError +*/ +export const ensureUseDeadlinesServiceGetCallbackLogsData = (queryClient: QueryClient, { callbackId, dagId, dagRunId }: { + callbackId: string; + dagId: string; + dagRunId: string; +}) => queryClient.ensureQueryData({ queryKey: Common.UseDeadlinesServiceGetCallbackLogsKeyFn({ callbackId, dagId, dagRunId }), queryFn: () => DeadlinesService.getCallbackLogs({ callbackId, dagId, dagRunId }) }); +/** * Structure Data * Get Structure Data. * @param data The data for the request. 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 d4fb4ae889662..c3577fcca30cf 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts @@ -1907,6 +1907,24 @@ export const prefetchUseDeadlinesServiceGetDagDeadlineAlerts = (queryClient: Que orderBy?: string[]; }) => queryClient.prefetchQuery({ queryKey: Common.UseDeadlinesServiceGetDagDeadlineAlertsKeyFn({ dagId, limit, offset, orderBy }), queryFn: () => DeadlinesService.getDagDeadlineAlerts({ dagId, limit, offset, orderBy }) }); /** +* Get Callback Logs +* Get execution logs for a callback associated with a deadline. +* +* Returns the logs produced during callback execution. These logs are uploaded +* to remote storage (or written locally) by the callback supervisor after execution. +* @param data The data for the request. +* @param data.dagId +* @param data.dagRunId +* @param data.callbackId +* @returns TaskInstancesLogResponse Successful Response +* @throws ApiError +*/ +export const prefetchUseDeadlinesServiceGetCallbackLogs = (queryClient: QueryClient, { callbackId, dagId, dagRunId }: { + callbackId: string; + dagId: string; + dagRunId: string; +}) => queryClient.prefetchQuery({ queryKey: Common.UseDeadlinesServiceGetCallbackLogsKeyFn({ callbackId, dagId, dagRunId }), queryFn: () => DeadlinesService.getCallbackLogs({ callbackId, dagId, dagRunId }) }); +/** * Structure Data * Get Structure Data. * @param data The data for the request. 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 91662271acafb..8060d115f055d 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts @@ -1907,6 +1907,24 @@ export const useDeadlinesServiceGetDagDeadlineAlerts = , "queryKey" | "queryFn">) => useQuery({ queryKey: Common.UseDeadlinesServiceGetDagDeadlineAlertsKeyFn({ dagId, limit, offset, orderBy }, queryKey), queryFn: () => DeadlinesService.getDagDeadlineAlerts({ dagId, limit, offset, orderBy }) as TData, ...options }); /** +* Get Callback Logs +* Get execution logs for a callback associated with a deadline. +* +* Returns the logs produced during callback execution. These logs are uploaded +* to remote storage (or written locally) by the callback supervisor after execution. +* @param data The data for the request. +* @param data.dagId +* @param data.dagRunId +* @param data.callbackId +* @returns TaskInstancesLogResponse Successful Response +* @throws ApiError +*/ +export const useDeadlinesServiceGetCallbackLogs = = unknown[]>({ callbackId, dagId, dagRunId }: { + callbackId: string; + dagId: string; + dagRunId: string; +}, queryKey?: TQueryKey, options?: Omit, "queryKey" | "queryFn">) => useQuery({ queryKey: Common.UseDeadlinesServiceGetCallbackLogsKeyFn({ callbackId, dagId, dagRunId }, queryKey), queryFn: () => DeadlinesService.getCallbackLogs({ callbackId, dagId, dagRunId }) as TData, ...options }); +/** * Structure Data * Get Structure Data. * @param data The data for the request. 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 b810526782c93..30375e79a9d9b 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts @@ -1907,6 +1907,24 @@ export const useDeadlinesServiceGetDagDeadlineAlertsSuspense = , "queryKey" | "queryFn">) => useSuspenseQuery({ queryKey: Common.UseDeadlinesServiceGetDagDeadlineAlertsKeyFn({ dagId, limit, offset, orderBy }, queryKey), queryFn: () => DeadlinesService.getDagDeadlineAlerts({ dagId, limit, offset, orderBy }) as TData, ...options }); /** +* Get Callback Logs +* Get execution logs for a callback associated with a deadline. +* +* Returns the logs produced during callback execution. These logs are uploaded +* to remote storage (or written locally) by the callback supervisor after execution. +* @param data The data for the request. +* @param data.dagId +* @param data.dagRunId +* @param data.callbackId +* @returns TaskInstancesLogResponse Successful Response +* @throws ApiError +*/ +export const useDeadlinesServiceGetCallbackLogsSuspense = = unknown[]>({ callbackId, dagId, dagRunId }: { + callbackId: string; + dagId: string; + dagRunId: string; +}, queryKey?: TQueryKey, options?: Omit, "queryKey" | "queryFn">) => useSuspenseQuery({ queryKey: Common.UseDeadlinesServiceGetCallbackLogsKeyFn({ callbackId, dagId, dagRunId }, queryKey), queryFn: () => DeadlinesService.getCallbackLogs({ callbackId, dagId, dagRunId }) as TData, ...options }); +/** * Structure Data * Get Structure Data. * @param data The data for the request. diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts index 6e73f5b9204b2..87dcbffa55117 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts @@ -9061,6 +9061,29 @@ export const $DeadlineResponse = { } ], title: 'Alert Name' + }, + callback_id: { + anyOf: [ + { + type: 'string', + format: 'uuid' + }, + { + type: 'null' + } + ], + title: 'Callback Id' + }, + callback_state: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Callback State' } }, type: 'object', 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 e8317de230012..742130b39c1dc 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 @@ -2282,6 +2282,8 @@ export type DeadlineResponse = { dag_run_id: string; alert_id?: string | null; alert_name?: string | null; + callback_id?: string | null; + callback_state?: string | null; }; /** @@ -4455,6 +4457,14 @@ export type GetDagDeadlineAlertsData = { export type GetDagDeadlineAlertsResponse = DeadlineAlertCollectionResponse; +export type GetCallbackLogsData = { + callbackId: string; + dagId: string; + dagRunId: string; +}; + +export type GetCallbackLogsResponse = TaskInstancesLogResponse; + export type StructureDataData = { dagId: string; depth?: number | null; @@ -8238,6 +8248,25 @@ export type $OpenApiTs = { }; }; }; + '/ui/dags/{dag_id}/dagRuns/{dag_run_id}/callbacks/{callback_id}/logs': { + get: { + req: GetCallbackLogsData; + res: { + /** + * Successful Response + */ + 200: TaskInstancesLogResponse; + /** + * Not Found + */ + 404: HTTPExceptionResponse; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; '/ui/structure/structure_data': { get: { req: StructureDataData; diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json index 617fd6f474aae..b5973221c8f7d 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json @@ -34,6 +34,11 @@ "wednesday": "Wed" } }, + "callbackLogs": { + "noLogs": "No logs available for this callback.", + "title": "Callback Logs", + "viewLogs": "Callback Logs" + }, "code": { "bundleUrl": "Bundle Url", "noCode": "No Code Found", diff --git a/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx b/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx new file mode 100644 index 0000000000000..703c0c5ccc591 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx @@ -0,0 +1,103 @@ +/*! + * 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 { Badge, Box, Button, Code, Heading, HStack, Skeleton, Text, VStack } from "@chakra-ui/react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FiFileText } from "react-icons/fi"; + +import { useDeadlinesServiceGetCallbackLogs } from "openapi/queries"; +import { ErrorAlert } from "src/components/ErrorAlert"; +import { Dialog } from "src/components/ui"; +import { parseStreamingLogContent } from "src/utils/logs"; + +type CallbackLogViewerProps = { + readonly callbackId: string; + readonly callbackState?: string | null; + readonly dagId: string; + readonly dagRunId: string; +}; + +const stateColorMap: Record = { + failed: "red", + running: "blue", + success: "green", +}; + +export const CallbackLogViewer = ({ callbackId, callbackState, dagId, dagRunId }: CallbackLogViewerProps) => { + const { t: translate } = useTranslation("dag"); + const [isOpen, setIsOpen] = useState(false); + + const { data, error, isLoading } = useDeadlinesServiceGetCallbackLogs( + { + callbackId, + dagId, + dagRunId, + }, + undefined, + { enabled: isOpen }, + ); + + const logContent = parseStreamingLogContent(data); + const logLines = logContent.map((entry) => (typeof entry === "string" ? entry : entry.event)); + + return ( + <> + + setIsOpen(false)} open={isOpen} scrollBehavior="inside" size="lg"> + + + + {translate("callbackLogs.title")} + {callbackState !== undefined && callbackState !== null ? ( + + {callbackState} + + ) : undefined} + + + + + + {isLoading ? ( + + {Array.from({ length: 5 }).map((_, idx) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + ) : logLines.length === 0 ? ( + + {translate("callbackLogs.noLogs")} + + ) : ( + + + {logLines.join("\n")} + + + )} + + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx index eff38d4a78e71..ebaebb275c15c 100644 --- a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx @@ -25,10 +25,9 @@ import { useTranslation } from "react-i18next"; import { FiAlertTriangle, FiCheck, FiClock } from "react-icons/fi"; import { useDeadlinesServiceGetDagDeadlineAlerts, useDeadlinesServiceGetDeadlines } from "openapi/queries"; -import type { DeadlineAlertResponse } from "openapi/requests/types.gen"; import Time from "src/components/Time"; import { Tooltip } from "src/components/ui/Tooltip"; -import { renderDuration } from "src/utils/datetimeUtils"; +import { CallbackLogViewer } from "src/pages/Run/CallbackLogViewer"; import { DeadlineStatusModal } from "./DeadlineStatusModal"; @@ -58,12 +57,6 @@ export const DeadlineStatus = ({ dagId, dagRunId, endDate }: DeadlineStatusProps limit: 100, }); - const alertMap = new Map(); - - for (const deadlineAlert of alertData?.deadline_alerts ?? []) { - alertMap.set(deadlineAlert.id, deadlineAlert); - } - if (isLoadingDeadlines || isLoadingAlerts) { return undefined; } @@ -137,7 +130,6 @@ export const DeadlineStatus = ({ dagId, dagRunId, endDate }: DeadlineStatusProps setIsModalOpen(false)} @@ -148,53 +140,29 @@ export const DeadlineStatus = ({ dagId, dagRunId, endDate }: DeadlineStatusProps ); } - // Single deadline — show inline with Expected / Actual dates and precise duration. + // Single deadline — show inline with Expected / Actual times. const [dl] = deadlines; if (dl === undefined) { return undefined; } - const alert = dl.alert_id !== undefined && dl.alert_id !== null ? alertMap.get(dl.alert_id) : undefined; - const deadlineTime = dayjs(dl.deadline_time); - - let actualDurationLabel: string | undefined; - - if (dl.missed && runEndDate !== undefined) { - const diff = dayjs(runEndDate).diff(deadlineTime); - const dur = renderDuration(Math.abs(diff) / 1000, false); - - if (dur !== undefined) { - actualDurationLabel = - diff >= 0 - ? translate("deadlineStatus.finishedLate", { duration: dur }) - : translate("deadlineStatus.finishedEarly", { duration: dur }); - } - } - return ( - + {dl.missed ? : } {translate(dl.missed ? "deadlineStatus.missed" : "deadlineStatus.upcoming")} - {Boolean(dl.alert_name) && ( - - ({dl.alert_name}) - - )} + {dl.callback_id !== undefined && dl.callback_id !== null ? ( + + ) : undefined} - {alert === undefined ? undefined : ( - - {translate("deadlineAlerts.completionRule", { - interval: dayjs.duration(alert.interval, "seconds").humanize(), - reference: translate(`deadlineAlerts.referenceType.${alert.reference_type}`, { - defaultValue: alert.reference_type, - }), - })} - - )} {translate("deadlineStatus.expected")}: @@ -213,11 +181,6 @@ export const DeadlineStatus = ({ dagId, dagRunId, endDate }: DeadlineStatusProps - {actualDurationLabel === undefined ? undefined : ( - - {actualDurationLabel} - - )} ); }; diff --git a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx index 968f1210d9e82..d5dfd386458de 100644 --- a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx @@ -17,28 +17,20 @@ * under the License. */ import { Badge, Heading, HStack, Separator, Skeleton, Text, VStack } from "@chakra-ui/react"; -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import relativeTime from "dayjs/plugin/relativeTime"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { FiAlertTriangle, FiClock } from "react-icons/fi"; import { useDeadlinesServiceGetDeadlines } from "openapi/queries"; -import type { DeadlineAlertResponse } from "openapi/requests/types.gen"; import { ErrorAlert } from "src/components/ErrorAlert"; import Time from "src/components/Time"; import { Dialog } from "src/components/ui"; import { Pagination } from "src/components/ui/Pagination"; -import { renderDuration } from "src/utils/datetimeUtils"; - -dayjs.extend(duration); -dayjs.extend(relativeTime); +import { CallbackLogViewer } from "src/pages/Run/CallbackLogViewer"; const PAGE_LIMIT = 10; type DeadlineStatusModalProps = { - readonly alertMap: Map; readonly dagId: string; readonly dagRunId: string; readonly onClose: () => void; @@ -47,7 +39,6 @@ type DeadlineStatusModalProps = { }; export const DeadlineStatusModal = ({ - alertMap, dagId, dagRunId, onClose, @@ -96,74 +87,42 @@ export const DeadlineStatusModal = ({ ) : ( }> - {deadlines.map((dl) => { - const alert = - dl.alert_id !== undefined && dl.alert_id !== null ? alertMap.get(dl.alert_id) : undefined; - const deadlineTime = dayjs(dl.deadline_time); - - let actualDurationLabel: string | undefined; - - if (dl.missed && runEndDate !== undefined) { - const diff = dayjs(runEndDate).diff(deadlineTime); - const dur = renderDuration(Math.abs(diff) / 1000, false); - - if (dur !== undefined) { - actualDurationLabel = - diff >= 0 - ? translate("deadlineStatus.finishedLate", { duration: dur }) - : translate("deadlineStatus.finishedEarly", { duration: dur }); - } - } - - return ( - - - - {dl.missed ? : } - {translate(dl.missed ? "deadlineStatus.missed" : "deadlineStatus.upcoming")} - - {Boolean(dl.alert_name) && ( - - {dl.alert_name} - - )} - - {alert === undefined ? undefined : ( - - {translate("deadlineAlerts.completionRule", { - interval: dayjs.duration(alert.interval, "seconds").humanize(), - reference: translate(`deadlineAlerts.referenceType.${alert.reference_type}`, { - defaultValue: alert.reference_type, - }), - })} - - )} - - - {translate("deadlineStatus.expected")}: - - - + {deadlines.map((dl) => ( + + + + {dl.missed ? : } + {translate(dl.missed ? "deadlineStatus.missed" : "deadlineStatus.upcoming")} + + {dl.callback_id !== undefined && dl.callback_id !== null ? ( + + ) : undefined} + + + + {translate("deadlineStatus.expected")}: + + + + + {translate("deadlineStatus.actual")}: + + {runEndDate === undefined ? ( - {translate("deadlineStatus.actual")}: - - {runEndDate === undefined ? ( - - {translate("deadlineStatus.stillRunning")} - - ) : ( - - {actualDurationLabel === undefined ? undefined : ( - - {actualDurationLabel} + {translate("deadlineStatus.stillRunning")} + ) : ( + - ); - })} + + + ))} )} From 00982e6f99494effdad1b270af79fa4e25757514 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 3 Jun 2026 16:55:22 +0000 Subject: [PATCH 03/25] Regenerate services.gen.ts to include getCallbackLogs endpoint The rebase conflict resolution dropped the callback logs service function. Re-add it to match the types.gen.ts and backend endpoint. --- .../ui/openapi-gen/requests/services.gen.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) 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 f8205d7208421..40bde560cb3a1 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 @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, DeleteDagAssetQueuedEventsData, DeleteDagAssetQueuedEventsResponse, GetDagAssetQueuedEventData, GetDagAssetQueuedEventResponse, DeleteDagAssetQueuedEventData, DeleteDagAssetQueuedEventResponse, NextRunAssetsData, NextRunAssetsResponse2, ListBackfillsData, ListBackfillsResponse, CreateBackfillData, CreateBackfillResponse, GetBackfillData, GetBackfillResponse, PauseBackfillData, PauseBackfillResponse, UnpauseBackfillData, UnpauseBackfillResponse, CancelBackfillData, CancelBackfillResponse, CreateBackfillDryRunData, CreateBackfillDryRunResponse, ListBackfillsUiData, ListBackfillsUiResponse, DeleteConnectionData, DeleteConnectionResponse, GetConnectionData, GetConnectionResponse, PatchConnectionData, PatchConnectionResponse, GetConnectionTestData, GetConnectionTestResponse, EnqueueConnectionTestData, EnqueueConnectionTestResponse, GetConnectionsData, GetConnectionsResponse, PostConnectionData, PostConnectionResponse, BulkConnectionsData, BulkConnectionsResponse, TestConnectionData, TestConnectionResponse, CreateDefaultConnectionsResponse, HookMetaDataResponse, GetDagRunData, GetDagRunResponse, DeleteDagRunData, DeleteDagRunResponse, PatchDagRunData, PatchDagRunResponse, BulkDagRunsData, BulkDagRunsResponse, GetDagRunsData, GetDagRunsResponse, TriggerDagRunData, TriggerDagRunResponse, GetUpstreamAssetEventsData, GetUpstreamAssetEventsResponse, ClearDagRunData, ClearDagRunResponse, WaitDagRunUntilFinishedData, WaitDagRunUntilFinishedResponse, GetListDagRunsBatchData, GetListDagRunsBatchResponse, ClearDagRunsData, ClearDagRunsResponse, GetDagRunStatsData, GetDagRunStatsResponse, GetDagSourceData, GetDagSourceResponse, GetDagStatsData, GetDagStatsResponse, GetConfigData, GetConfigResponse, GetConfigValueData, GetConfigValueResponse, GetConfigsResponse, ListDagWarningsData, ListDagWarningsResponse, GetDagsData, GetDagsResponse, PatchDagsData, PatchDagsResponse, GetDagData, GetDagResponse, PatchDagData, PatchDagResponse, DeleteDagData, DeleteDagResponse, GetDagDetailsData, GetDagDetailsResponse, FavoriteDagData, FavoriteDagResponse, UnfavoriteDagData, UnfavoriteDagResponse, GetDagTagsData, GetDagTagsResponse, GetDagsUiData, GetDagsUiResponse, GetLatestRunInfoData, GetLatestRunInfoResponse, GetEventLogData, GetEventLogResponse, GetEventLogsData, GetEventLogsResponse, GetExtraLinksData, GetExtraLinksResponse, GetTaskInstanceData, GetTaskInstanceResponse, PatchTaskInstanceData, PatchTaskInstanceResponse, DeleteTaskInstanceData, DeleteTaskInstanceResponse, GetMappedTaskInstancesData, GetMappedTaskInstancesResponse, GetTaskInstanceDependenciesByMapIndexData, GetTaskInstanceDependenciesByMapIndexResponse, GetTaskInstanceDependenciesData, GetTaskInstanceDependenciesResponse, GetTaskInstanceTriesData, GetTaskInstanceTriesResponse, GetMappedTaskInstanceTriesData, GetMappedTaskInstanceTriesResponse, GetMappedTaskInstanceData, GetMappedTaskInstanceResponse, PatchTaskInstanceByMapIndexData, PatchTaskInstanceByMapIndexResponse, GetTaskInstancesData, GetTaskInstancesResponse, BulkTaskInstancesData, BulkTaskInstancesResponse, GetTaskInstancesBatchData, GetTaskInstancesBatchResponse, GetTaskInstanceTryDetailsData, GetTaskInstanceTryDetailsResponse, GetMappedTaskInstanceTryDetailsData, GetMappedTaskInstanceTryDetailsResponse, PostClearTaskInstancesData, PostClearTaskInstancesResponse, PatchTaskGroupInstancesData, PatchTaskGroupInstancesResponse, PatchTaskGroupInstancesDryRunData, PatchTaskGroupInstancesDryRunResponse, PatchTaskInstanceDryRunByMapIndexData, PatchTaskInstanceDryRunByMapIndexResponse, PatchTaskInstanceDryRunData, PatchTaskInstanceDryRunResponse, GetLogData, GetLogResponse, GetExternalLogUrlData, GetExternalLogUrlResponse, UpdateHitlDetailData, UpdateHitlDetailResponse, GetHitlDetailData, GetHitlDetailResponse, GetHitlDetailTryDetailData, GetHitlDetailTryDetailResponse, GetHitlDetailsData, GetHitlDetailsResponse, GetImportErrorData, GetImportErrorResponse, GetImportErrorsData, GetImportErrorsResponse, GetJobsData, GetJobsResponse, GetPluginsData, GetPluginsResponse, ImportErrorsResponse, DeletePoolData, DeletePoolResponse, GetPoolData, GetPoolResponse, PatchPoolData, PatchPoolResponse, GetPoolsData, GetPoolsResponse, PostPoolData, PostPoolResponse, BulkPoolsData, BulkPoolsResponse, GetProvidersData, GetProvidersResponse, ListAssetStoreData, ListAssetStoreResponse, ClearAssetStoreData, ClearAssetStoreResponse, GetAssetStoreData, GetAssetStoreResponse, SetAssetStoreData, SetAssetStoreResponse, DeleteAssetStoreData, DeleteAssetStoreResponse, ListTaskStoreData, ListTaskStoreResponse, ClearTaskStoreData, ClearTaskStoreResponse, GetTaskStoreData, GetTaskStoreResponse, SetTaskStoreData, SetTaskStoreResponse, PatchTaskStoreData, PatchTaskStoreResponse, DeleteTaskStoreData, DeleteTaskStoreResponse, GetXcomEntryData, GetXcomEntryResponse, UpdateXcomEntryData, UpdateXcomEntryResponse, DeleteXcomEntryData, DeleteXcomEntryResponse, GetXcomEntriesData, GetXcomEntriesResponse, CreateXcomEntryData, CreateXcomEntryResponse, GetTasksData, GetTasksResponse, GetTaskData, GetTaskResponse, DeleteVariableData, DeleteVariableResponse, GetVariableData, GetVariableResponse, PatchVariableData, PatchVariableResponse, GetVariablesData, GetVariablesResponse, PostVariableData, PostVariableResponse, BulkVariablesData, BulkVariablesResponse, ReparseDagFileData, ReparseDagFileResponse, GetDagVersionData, GetDagVersionResponse, GetDagVersionsData, GetDagVersionsResponse, GetHealthResponse, GetVersionResponse, LoginData, LoginResponse, LogoutResponse, GetAuthMenusResponse, GetCurrentUserInfoResponse, GenerateTokenData, GenerateTokenResponse2, GetPartitionedDagRunsData, GetPartitionedDagRunsResponse, GetPendingPartitionedDagRunData, GetPendingPartitionedDagRunResponse, GetDependenciesData, GetDependenciesResponse, HistoricalMetricsData, HistoricalMetricsResponse, DagStatsResponse2, GetDeadlinesData, GetDeadlinesResponse, GetDagDeadlineAlertsData, GetDagDeadlineAlertsResponse, StructureDataData, StructureDataResponse2, GetDagStructureData, GetDagStructureResponse, GetGridRunsData, GetGridRunsResponse, GetGridTiSummariesStreamData, GetGridTiSummariesStreamResponse, GetGanttDataData, GetGanttDataResponse, GetCalendarData, GetCalendarResponse, ListTeamsData, ListTeamsResponse } from './types.gen'; +import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, DeleteDagAssetQueuedEventsData, DeleteDagAssetQueuedEventsResponse, GetDagAssetQueuedEventData, GetDagAssetQueuedEventResponse, DeleteDagAssetQueuedEventData, DeleteDagAssetQueuedEventResponse, NextRunAssetsData, NextRunAssetsResponse2, ListBackfillsData, ListBackfillsResponse, CreateBackfillData, CreateBackfillResponse, GetBackfillData, GetBackfillResponse, PauseBackfillData, PauseBackfillResponse, UnpauseBackfillData, UnpauseBackfillResponse, CancelBackfillData, CancelBackfillResponse, CreateBackfillDryRunData, CreateBackfillDryRunResponse, ListBackfillsUiData, ListBackfillsUiResponse, DeleteConnectionData, DeleteConnectionResponse, GetConnectionData, GetConnectionResponse, PatchConnectionData, PatchConnectionResponse, GetConnectionTestData, GetConnectionTestResponse, EnqueueConnectionTestData, EnqueueConnectionTestResponse, GetConnectionsData, GetConnectionsResponse, PostConnectionData, PostConnectionResponse, BulkConnectionsData, BulkConnectionsResponse, TestConnectionData, TestConnectionResponse, CreateDefaultConnectionsResponse, HookMetaDataResponse, GetDagRunData, GetDagRunResponse, DeleteDagRunData, DeleteDagRunResponse, PatchDagRunData, PatchDagRunResponse, BulkDagRunsData, BulkDagRunsResponse, GetDagRunsData, GetDagRunsResponse, TriggerDagRunData, TriggerDagRunResponse, GetUpstreamAssetEventsData, GetUpstreamAssetEventsResponse, ClearDagRunData, ClearDagRunResponse, WaitDagRunUntilFinishedData, WaitDagRunUntilFinishedResponse, GetListDagRunsBatchData, GetListDagRunsBatchResponse, ClearDagRunsData, ClearDagRunsResponse, GetDagRunStatsData, GetDagRunStatsResponse, GetDagSourceData, GetDagSourceResponse, GetDagStatsData, GetDagStatsResponse, GetConfigData, GetConfigResponse, GetConfigValueData, GetConfigValueResponse, GetConfigsResponse, ListDagWarningsData, ListDagWarningsResponse, GetDagsData, GetDagsResponse, PatchDagsData, PatchDagsResponse, GetDagData, GetDagResponse, PatchDagData, PatchDagResponse, DeleteDagData, DeleteDagResponse, GetDagDetailsData, GetDagDetailsResponse, FavoriteDagData, FavoriteDagResponse, UnfavoriteDagData, UnfavoriteDagResponse, GetDagTagsData, GetDagTagsResponse, GetDagsUiData, GetDagsUiResponse, GetLatestRunInfoData, GetLatestRunInfoResponse, GetEventLogData, GetEventLogResponse, GetEventLogsData, GetEventLogsResponse, GetExtraLinksData, GetExtraLinksResponse, GetTaskInstanceData, GetTaskInstanceResponse, PatchTaskInstanceData, PatchTaskInstanceResponse, DeleteTaskInstanceData, DeleteTaskInstanceResponse, GetMappedTaskInstancesData, GetMappedTaskInstancesResponse, GetTaskInstanceDependenciesByMapIndexData, GetTaskInstanceDependenciesByMapIndexResponse, GetTaskInstanceDependenciesData, GetTaskInstanceDependenciesResponse, GetTaskInstanceTriesData, GetTaskInstanceTriesResponse, GetMappedTaskInstanceTriesData, GetMappedTaskInstanceTriesResponse, GetMappedTaskInstanceData, GetMappedTaskInstanceResponse, PatchTaskInstanceByMapIndexData, PatchTaskInstanceByMapIndexResponse, GetTaskInstancesData, GetTaskInstancesResponse, BulkTaskInstancesData, BulkTaskInstancesResponse, GetTaskInstancesBatchData, GetTaskInstancesBatchResponse, GetTaskInstanceTryDetailsData, GetTaskInstanceTryDetailsResponse, GetMappedTaskInstanceTryDetailsData, GetMappedTaskInstanceTryDetailsResponse, PostClearTaskInstancesData, PostClearTaskInstancesResponse, PatchTaskGroupInstancesData, PatchTaskGroupInstancesResponse, PatchTaskGroupInstancesDryRunData, PatchTaskGroupInstancesDryRunResponse, PatchTaskInstanceDryRunByMapIndexData, PatchTaskInstanceDryRunByMapIndexResponse, PatchTaskInstanceDryRunData, PatchTaskInstanceDryRunResponse, GetLogData, GetLogResponse, GetExternalLogUrlData, GetExternalLogUrlResponse, UpdateHitlDetailData, UpdateHitlDetailResponse, GetHitlDetailData, GetHitlDetailResponse, GetHitlDetailTryDetailData, GetHitlDetailTryDetailResponse, GetHitlDetailsData, GetHitlDetailsResponse, GetImportErrorData, GetImportErrorResponse, GetImportErrorsData, GetImportErrorsResponse, GetJobsData, GetJobsResponse, GetPluginsData, GetPluginsResponse, ImportErrorsResponse, DeletePoolData, DeletePoolResponse, GetPoolData, GetPoolResponse, PatchPoolData, PatchPoolResponse, GetPoolsData, GetPoolsResponse, PostPoolData, PostPoolResponse, BulkPoolsData, BulkPoolsResponse, GetProvidersData, GetProvidersResponse, ListAssetStoreData, ListAssetStoreResponse, ClearAssetStoreData, ClearAssetStoreResponse, GetAssetStoreData, GetAssetStoreResponse, SetAssetStoreData, SetAssetStoreResponse, DeleteAssetStoreData, DeleteAssetStoreResponse, ListTaskStoreData, ListTaskStoreResponse, ClearTaskStoreData, ClearTaskStoreResponse, GetTaskStoreData, GetTaskStoreResponse, SetTaskStoreData, SetTaskStoreResponse, PatchTaskStoreData, PatchTaskStoreResponse, DeleteTaskStoreData, DeleteTaskStoreResponse, GetXcomEntryData, GetXcomEntryResponse, UpdateXcomEntryData, UpdateXcomEntryResponse, DeleteXcomEntryData, DeleteXcomEntryResponse, GetXcomEntriesData, GetXcomEntriesResponse, CreateXcomEntryData, CreateXcomEntryResponse, GetTasksData, GetTasksResponse, GetTaskData, GetTaskResponse, DeleteVariableData, DeleteVariableResponse, GetVariableData, GetVariableResponse, PatchVariableData, PatchVariableResponse, GetVariablesData, GetVariablesResponse, PostVariableData, PostVariableResponse, BulkVariablesData, BulkVariablesResponse, ReparseDagFileData, ReparseDagFileResponse, GetDagVersionData, GetDagVersionResponse, GetDagVersionsData, GetDagVersionsResponse, GetHealthResponse, GetVersionResponse, LoginData, LoginResponse, LogoutResponse, GetAuthMenusResponse, GetCurrentUserInfoResponse, GenerateTokenData, GenerateTokenResponse2, GetPartitionedDagRunsData, GetPartitionedDagRunsResponse, GetPendingPartitionedDagRunData, GetPendingPartitionedDagRunResponse, GetDependenciesData, GetDependenciesResponse, HistoricalMetricsData, HistoricalMetricsResponse, DagStatsResponse2, GetDeadlinesData, GetDeadlinesResponse, GetDagDeadlineAlertsData, GetDagDeadlineAlertsResponse, GetCallbackLogsData, GetCallbackLogsResponse, StructureDataData, StructureDataResponse2, GetDagStructureData, GetDagStructureResponse, GetGridRunsData, GetGridRunsResponse, GetGridTiSummariesStreamData, GetGridTiSummariesStreamResponse, GetGanttDataData, GetGanttDataResponse, GetCalendarData, GetCalendarResponse, ListTeamsData, ListTeamsResponse } from './types.gen'; export class AssetService { /** @@ -4830,7 +4830,31 @@ export class DeadlinesService { } }); } - + + /** + * Get Callback Logs + * Get logs for a deadline callback execution. + * @param data The data for the request. + * @param data.callbackId + * @param data.tryNumber + * @returns TaskInstancesLogResponse Successful Response + * @throws ApiError + */ + public static getCallbackLogs(data: GetCallbackLogsData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/ui/deadlines/callbacks/{callback_id}/logs/{try_number}', + path: { + callback_id: data.callbackId, + try_number: data.tryNumber + }, + errors: { + 404: 'Not Found', + 422: 'Validation Error' + } + }); + } + } export class StructureService { From ec78401b3f38650acf803d635261350b5049d23a Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 3 Jun 2026 19:31:02 +0000 Subject: [PATCH 04/25] Fix services.gen.ts to match generated output from OpenAPI spec The manual merge conflict resolution left stale URL/params in services.gen.ts that didn't match the actual API endpoint signature (dag_id, dag_run_id, callback_id vs callback_id, try_number). --- .../ui/openapi-gen/requests/services.gen.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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 40bde560cb3a1..293465717970d 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 @@ -4830,23 +4830,28 @@ export class DeadlinesService { } }); } - + /** * Get Callback Logs - * Get logs for a deadline callback execution. + * Get execution logs for a callback associated with a deadline. + * + * Returns the logs produced during callback execution. These logs are uploaded + * to remote storage (or written locally) by the callback supervisor after execution. * @param data The data for the request. + * @param data.dagId + * @param data.dagRunId * @param data.callbackId - * @param data.tryNumber * @returns TaskInstancesLogResponse Successful Response * @throws ApiError */ public static getCallbackLogs(data: GetCallbackLogsData): CancelablePromise { return __request(OpenAPI, { method: 'GET', - url: '/ui/deadlines/callbacks/{callback_id}/logs/{try_number}', + url: '/ui/dags/{dag_id}/dagRuns/{dag_run_id}/callbacks/{callback_id}/logs', path: { - callback_id: data.callbackId, - try_number: data.tryNumber + dag_id: data.dagId, + dag_run_id: data.dagRunId, + callback_id: data.callbackId }, errors: { 404: 'Not Found', @@ -4854,7 +4859,7 @@ export class DeadlinesService { } }); } - + } export class StructureService { From 1f0b018c3f004546509641110647054c67381cd2 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Fri, 5 Jun 2026 19:23:50 +0000 Subject: [PATCH 05/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20LatestBoto?= =?UTF-8?q?=20failure=20is=20pre-existing=20on=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c1cf756e2a6c83169a81521ac896ea267966622a Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Fri, 5 Jun 2026 21:21:35 +0000 Subject: [PATCH 06/25] Skip bedrock-agentcore tests when service unavailable in LatestBoto --- .../unit/amazon/aws/hooks/test_bedrock.py | 27 +++++++++++++++++-- .../waiters/test_bedrock_agentcore_control.py | 11 ++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/providers/amazon/tests/unit/amazon/aws/hooks/test_bedrock.py b/providers/amazon/tests/unit/amazon/aws/hooks/test_bedrock.py index b8577a9929058..b74bbc1178939 100644 --- a/providers/amazon/tests/unit/amazon/aws/hooks/test_bedrock.py +++ b/providers/amazon/tests/unit/amazon/aws/hooks/test_bedrock.py @@ -18,6 +18,7 @@ from unittest import mock +import botocore import pytest from airflow.providers.amazon.aws.hooks.bedrock import ( @@ -29,6 +30,12 @@ BedrockRuntimeHook, ) +try: + botocore.session.get_session().get_service_model("bedrock-agentcore-control") + HAS_BEDROCK_AGENTCORE = True +except botocore.exceptions.UnknownServiceError: + HAS_BEDROCK_AGENTCORE = False + class TestBedrockHooks: @pytest.mark.parametrize( @@ -38,8 +45,24 @@ class TestBedrockHooks: pytest.param(BedrockRuntimeHook(), "bedrock-runtime", id="bedrock-runtime"), pytest.param(BedrockAgentHook(), "bedrock-agent", id="bedrock-agent"), pytest.param(BedrockAgentRuntimeHook(), "bedrock-agent-runtime", id="bedrock-agent-runtime"), - pytest.param(BedrockAgentCoreControlHook(), "bedrock-agentcore-control", id="agentcore-control"), - pytest.param(BedrockAgentCoreHook(), "bedrock-agentcore", id="agentcore"), + pytest.param( + BedrockAgentCoreControlHook(), + "bedrock-agentcore-control", + id="agentcore-control", + marks=pytest.mark.skipif( + not HAS_BEDROCK_AGENTCORE, + reason="bedrock-agentcore-control not available in this botocore version", + ), + ), + pytest.param( + BedrockAgentCoreHook(), + "bedrock-agentcore", + id="agentcore", + marks=pytest.mark.skipif( + not HAS_BEDROCK_AGENTCORE, + reason="bedrock-agentcore not available in this botocore version", + ), + ), ], ) def test_bedrock_hooks(self, test_hook, service_name): diff --git a/providers/amazon/tests/unit/amazon/aws/waiters/test_bedrock_agentcore_control.py b/providers/amazon/tests/unit/amazon/aws/waiters/test_bedrock_agentcore_control.py index 45f746399050f..081982225d3ee 100644 --- a/providers/amazon/tests/unit/amazon/aws/waiters/test_bedrock_agentcore_control.py +++ b/providers/amazon/tests/unit/amazon/aws/waiters/test_bedrock_agentcore_control.py @@ -24,6 +24,17 @@ from airflow.providers.amazon.aws.hooks.bedrock import BedrockAgentCoreControlHook +try: + botocore.session.get_session().get_service_model("bedrock-agentcore-control") + HAS_BEDROCK_AGENTCORE = True +except botocore.exceptions.UnknownServiceError: + HAS_BEDROCK_AGENTCORE = False + +pytestmark = pytest.mark.skipif( + not HAS_BEDROCK_AGENTCORE, + reason="bedrock-agentcore-control not available in this botocore version", +) + class TestBedrockAgentCoreControlCustomWaiters: def test_service_waiters(self): From e663cd539865f3c58a9340a07e6b1af96a0cbd5f Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Mon, 8 Jun 2026 13:46:53 +0000 Subject: [PATCH 07/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20WebKit=20e2e?= =?UTF-8?q?=20flake=20+=20Glue=20timeout=20unrelated=20to=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From f307b7937f873c6bfebfa863806539764b050dfb Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Mon, 8 Jun 2026 16:05:03 +0000 Subject: [PATCH 08/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20Docker=20Hub?= =?UTF-8?q?=20500=20error=20(infra)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From e67ff4f52553f86c1134674bd799ba9c3c9583e4 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Mon, 8 Jun 2026 18:12:58 +0000 Subject: [PATCH 09/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20Docker=20Hub?= =?UTF-8?q?=20502=20Bad=20Gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 8de89846ffab928410977350b3023c62facb417d Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Tue, 9 Jun 2026 21:18:05 +0000 Subject: [PATCH 10/25] Add triggerer callback log capture for async deadline callbacks Async (TriggererCallback) deadline callbacks now have their logs captured to a dedicated file path that the UI log endpoint can read, matching the pattern already established for sync (ExecutorCallback) logs. Changes: - TriggerLoggingFactory.ti is now optional (None for callback triggers) - _create_workload sets up logger_cache for callback triggers without a task instance, writing to triggerer_callbacks/{dag_id}/{run_id}/{cb_id} - callback_log_reader checks both executor_callbacks/ and triggerer_callbacks/ paths when reading logs - upload_to_remote gracefully handles ti=None by uploading directly via the remote log handler --- .../src/airflow/jobs/triggerer_job_runner.py | 41 +++++- .../airflow/utils/log/callback_log_reader.py | 50 ++++--- .../tests/unit/jobs/test_triggerer_job.py | 94 ++++++++++++++ .../utils/log/test_callback_log_reader.py | 122 ++++++++++++++++++ 4 files changed, 287 insertions(+), 20 deletions(-) create mode 100644 airflow-core/tests/unit/utils/log/test_callback_log_reader.py diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index 326ab14d7a4b0..0640585f9a58f 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -379,7 +379,7 @@ def from_api_response(cls, response: HITLDetailResponse) -> HITLDetailResponseRe class TriggerLoggingFactory: log_path: str - ti: RuntimeTI = attrs.field(repr=False) + ti: RuntimeTI | None = attrs.field(default=None, repr=False) bound_logger: WrappedLogger = attrs.field(init=False, repr=False) @@ -416,8 +416,35 @@ def upload_to_remote(self): # Never actually called, nothing to do return + if self.ti is None: + # Callback triggers have no task instance — upload using the path directly. + self._upload_callback_log_to_remote() + return + upload_to_remote(self.bound_logger, self.ti) + def _upload_callback_log_to_remote(self): + """Upload callback trigger logs to remote storage without a task instance.""" + from airflow.sdk.log import load_remote_log_handler, relative_path_from_logger + + handler = load_remote_log_handler() + if not handler: + return + + raw_logger = getattr(self.bound_logger, "_logger") + try: + relative_path = relative_path_from_logger(raw_logger) + except Exception: + return + if not relative_path: + return + + log_relative_path = relative_path.as_posix() + try: + handler.upload(log_relative_path, None) # type: ignore[arg-type] + except Exception: + log.warning("Failed to upload callback trigger logs to remote", log_path=log_relative_path) + def in_process_api_server() -> InProcessExecutionAPI: from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI @@ -752,6 +779,18 @@ def _create_workload( session: Session, ) -> workloads.RunTrigger | None: if trigger.task_instance is None: + # Set up dedicated logging for callback triggers so their output is + # captured to a file that the UI log endpoint can later read. + if trigger.callback: + callback_data = trigger.callback.data or {} + dag_id = callback_data.get("dag_id", "unknown") + run_id = callback_data.get("run_id", "unknown") + callback_id = str(trigger.callback.id) + log_path = f"triggerer_callbacks/{dag_id}/{run_id}/{callback_id}" + self.logger_cache[trigger.id] = TriggerLoggingFactory( + log_path=log_path, + ti=None, + ) return workloads.RunTrigger( id=trigger.id, classpath=trigger.classpath, diff --git a/airflow-core/src/airflow/utils/log/callback_log_reader.py b/airflow-core/src/airflow/utils/log/callback_log_reader.py index 1ff991b00196b..7b7beaf3aeb2b 100644 --- a/airflow-core/src/airflow/utils/log/callback_log_reader.py +++ b/airflow-core/src/airflow/utils/log/callback_log_reader.py @@ -38,14 +38,20 @@ logger = logging.getLogger(__name__) -def _get_callback_log_relative_path(dag_id: str, run_id: str, callback_id: str) -> str: +def _get_callback_log_relative_paths(dag_id: str, run_id: str, callback_id: str) -> list[str]: """ - Construct the relative log path for a callback execution. + Construct the relative log paths for a callback execution. - This must match the path format used in ExecuteCallback.make(): + Returns paths for both executor callbacks (sync) and triggerer callbacks (async). + The executor path matches the format used in ExecuteCallback.make(): executor_callbacks/{dag_id}/{run_id}/{callback_id} + The triggerer path matches what TriggerLoggingFactory writes for callback triggers: + triggerer_callbacks/{dag_id}/{run_id}/{callback_id} """ - return f"executor_callbacks/{dag_id}/{run_id}/{callback_id}" + return [ + f"executor_callbacks/{dag_id}/{run_id}/{callback_id}", + f"triggerer_callbacks/{dag_id}/{run_id}/{callback_id}", + ] def read_callback_log( @@ -56,7 +62,8 @@ def read_callback_log( """ Read callback logs from remote and/or local storage. - Tries remote storage first (if configured), then falls back to local filesystem. + Tries both executor_callbacks and triggerer_callbacks paths. + For each path, tries remote storage first (if configured), then falls back to local filesystem. Returns a generator of StructuredLogMessage objects suitable for the API response. :param dag_id: The Dag ID associated with the callback. @@ -64,25 +71,30 @@ def read_callback_log( :param callback_id: The unique callback identifier. :return: Generator of StructuredLogMessage objects. """ - relative_path = _get_callback_log_relative_path(dag_id, run_id, callback_id) + relative_paths = _get_callback_log_relative_paths(dag_id, run_id, callback_id) sources: LogSourceInfo = [] remote_logs: list[RawLogStream] = [] local_logs: list[RawLogStream] = [] - # Try remote storage first - with suppress(Exception): - remote_sources, remote_log_streams = _read_callback_remote_logs(relative_path) - if remote_log_streams: - sources.extend(remote_sources) - remote_logs.extend(remote_log_streams) - - # Try local filesystem - if not remote_logs: - local_sources, local_log_streams = _read_callback_local_logs(relative_path) - if local_log_streams: - sources.extend(local_sources) - local_logs.extend(local_log_streams) + for relative_path in relative_paths: + # Try remote storage first + with suppress(Exception): + remote_sources, remote_log_streams = _read_callback_remote_logs(relative_path) + if remote_log_streams: + sources.extend(remote_sources) + remote_logs.extend(remote_log_streams) + + # Try local filesystem + if not remote_logs: + local_sources, local_log_streams = _read_callback_local_logs(relative_path) + if local_log_streams: + sources.extend(local_sources) + local_logs.extend(local_log_streams) + + # If we found logs at this path, no need to check the next path + if remote_logs or local_logs: + break if not remote_logs and not local_logs: yield StructuredLogMessage(event="No callback logs found.", timestamp=None) diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py b/airflow-core/tests/unit/jobs/test_triggerer_job.py index 6464274a93899..8cb8f2061df49 100644 --- a/airflow-core/tests/unit/jobs/test_triggerer_job.py +++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py @@ -702,6 +702,100 @@ def test_trigger_logger_fd_closed_when_upload_to_remote_raises(jobless_superviso assert 42 not in jobless_supervisor.running_triggers +def test_trigger_logging_factory_accepts_none_ti(): + """TriggerLoggingFactory can be created with ti=None for callback triggers.""" + factory = TriggerLoggingFactory(log_path="/tmp/test_callback.log", ti=None) + assert factory.ti is None + assert factory.log_path == "/tmp/test_callback.log" + + +def test_trigger_logging_factory_upload_skips_when_no_bound_logger(): + """upload_to_remote is a no-op when bound_logger was never created (ti=None case).""" + factory = TriggerLoggingFactory(log_path="/tmp/test_callback.log", ti=None) + # Should not raise — early return because bound_logger is not set + factory.upload_to_remote() + + +def test_trigger_logging_factory_upload_callback_log_to_remote(): + """When ti=None and bound_logger exists, _upload_callback_log_to_remote is called.""" + factory = TriggerLoggingFactory(log_path="/tmp/test_callback.log", ti=None) + # Simulate that bound_logger has been created + mock_logger = MagicMock() + factory.bound_logger = mock_logger + + with patch( + "airflow.jobs.triggerer_job_runner.TriggerLoggingFactory._upload_callback_log_to_remote" + ) as mock_upload: + factory.upload_to_remote() + mock_upload.assert_called_once() + + +def test_create_workload_sets_up_logger_cache_for_callback_triggers(session): + """_create_workload populates logger_cache when trigger has a callback but no task_instance.""" + from airflow.jobs.job import Job + + trigger_orm = MagicMock() + trigger_orm.task_instance = None + trigger_orm.id = 99 + trigger_orm.classpath = "airflow.triggers.callback.CallbackTrigger" + trigger_orm.encrypted_kwargs = "{}" + + # Simulate a callback with data containing dag_id and run_id + mock_callback = MagicMock() + mock_callback.data = {"dag_id": "test_dag", "run_id": "manual__2024-01-01"} + mock_callback.id = uuid.UUID("12345678-1234-5678-1234-567812345678") + trigger_orm.callback = mock_callback + + supervisor = TriggerRunnerSupervisor.start(job=Job(id=123), capacity=10) + try: + workload = supervisor._create_workload( + trigger=trigger_orm, + dag_bag=MagicMock(), + render_log_fname=MagicMock(), + session=session, + ) + + # Workload should be returned + assert workload is not None + assert workload.id == 99 + + # Logger cache should be populated for the trigger + assert 99 in supervisor.logger_cache + factory = supervisor.logger_cache[99] + expected_path = "triggerer_callbacks/test_dag/manual__2024-01-01/12345678-1234-5678-1234-567812345678" + assert factory.log_path == expected_path + assert factory.ti is None + finally: + supervisor.kill(force=False) + + +def test_create_workload_no_logger_cache_for_non_callback_triggers(session): + """_create_workload does NOT populate logger_cache when trigger has no callback and no task_instance.""" + from airflow.jobs.job import Job + + trigger_orm = MagicMock() + trigger_orm.task_instance = None + trigger_orm.id = 100 + trigger_orm.classpath = "airflow.triggers.temporal.DateTimeTrigger" + trigger_orm.encrypted_kwargs = "{}" + trigger_orm.callback = None + + supervisor = TriggerRunnerSupervisor.start(job=Job(id=123), capacity=10) + try: + workload = supervisor._create_workload( + trigger=trigger_orm, + dag_bag=MagicMock(), + render_log_fname=MagicMock(), + session=session, + ) + + assert workload is not None + # No logger_cache entry for non-callback triggers without task_instance + assert 100 not in supervisor.logger_cache + finally: + supervisor.kill(force=False) + + class TestTriggerRunner: def test_blocked_main_thread_warning_threshold_decode(self) -> None: with conf_vars({("triggerer", "blocked_main_thread_warning_threshold"): "0.5"}): diff --git a/airflow-core/tests/unit/utils/log/test_callback_log_reader.py b/airflow-core/tests/unit/utils/log/test_callback_log_reader.py new file mode 100644 index 0000000000000..3f04b37466669 --- /dev/null +++ b/airflow-core/tests/unit/utils/log/test_callback_log_reader.py @@ -0,0 +1,122 @@ +# 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. +from __future__ import annotations + +import os +import tempfile +from unittest.mock import patch + +from airflow.utils.log.callback_log_reader import ( + _get_callback_log_relative_paths, + read_callback_log, +) + + +class TestGetCallbackLogRelativePaths: + def test_returns_both_executor_and_triggerer_paths(self): + paths = _get_callback_log_relative_paths("my_dag", "run_123", "cb_456") + assert paths == [ + "executor_callbacks/my_dag/run_123/cb_456", + "triggerer_callbacks/my_dag/run_123/cb_456", + ] + + def test_path_components_preserved(self): + paths = _get_callback_log_relative_paths("dag-with-dashes", "manual__2024-01-01", "abc123") + assert "dag-with-dashes" in paths[0] + assert "manual__2024-01-01" in paths[0] + assert "abc123" in paths[0] + assert paths[1] == "triggerer_callbacks/dag-with-dashes/manual__2024-01-01/abc123" + + +class TestReadCallbackLog: + def test_no_logs_found_yields_message(self): + """When no logs exist at either path, a 'No callback logs found.' message is yielded.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch("airflow.utils.log.callback_log_reader.conf") as mock_conf: + mock_conf.get.return_value = tmpdir + msgs = list(read_callback_log("dag1", "run1", "cb1")) + assert len(msgs) == 1 + assert msgs[0].event == "No callback logs found." + + def test_reads_executor_callback_logs(self): + """Logs at the executor_callbacks path are found and returned.""" + with tempfile.TemporaryDirectory() as tmpdir: + # The local reader looks for files matching the last path component as a + # filename (with potential suffixes) in its parent directory. + # relative_path = "executor_callbacks/dag1/run1/cb1" + # It globs base_log_folder/executor_callbacks/dag1/run1/cb1* + log_dir = os.path.join(tmpdir, "executor_callbacks", "dag1", "run1") + os.makedirs(log_dir) + log_file = os.path.join(log_dir, "cb1") + with open(log_file, "w") as f: + f.write("executor log line 1\n") + + with patch("airflow.utils.log.callback_log_reader.conf") as mock_conf: + mock_conf.get.return_value = tmpdir + # Suppress remote log attempts + with patch( + "airflow.utils.log.callback_log_reader._read_callback_remote_logs", + return_value=([], []), + ): + msgs = list(read_callback_log("dag1", "run1", "cb1")) + + # Should have source header + log content (not "No callback logs found.") + assert not any(m.event == "No callback logs found." for m in msgs) + + def test_reads_triggerer_callback_logs(self): + """Logs at the triggerer_callbacks path are found when executor path is empty.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Only create triggerer path, NOT executor path + log_dir = os.path.join(tmpdir, "triggerer_callbacks", "dag1", "run1") + os.makedirs(log_dir) + log_file = os.path.join(log_dir, "cb1") + with open(log_file, "w") as f: + f.write("triggerer log line 1\n") + + with patch("airflow.utils.log.callback_log_reader.conf") as mock_conf: + mock_conf.get.return_value = tmpdir + with patch( + "airflow.utils.log.callback_log_reader._read_callback_remote_logs", + return_value=([], []), + ): + msgs = list(read_callback_log("dag1", "run1", "cb1")) + + # Should have found logs (not the "no logs" message) + assert not any(m.event == "No callback logs found." for m in msgs) + + def test_executor_path_preferred_over_triggerer(self): + """When logs exist at both paths, executor_callbacks is returned (first match wins).""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create both paths with log files + for prefix in ("executor_callbacks", "triggerer_callbacks"): + log_dir = os.path.join(tmpdir, prefix, "dag1", "run1") + os.makedirs(log_dir) + log_file = os.path.join(log_dir, "cb1") + with open(log_file, "w") as f: + f.write(f"{prefix} log line\n") + + with patch("airflow.utils.log.callback_log_reader.conf") as mock_conf: + mock_conf.get.return_value = tmpdir + with patch( + "airflow.utils.log.callback_log_reader._read_callback_remote_logs", + return_value=([], []), + ): + msgs = list(read_callback_log("dag1", "run1", "cb1")) + + # We can't easily check which path was used from the messages alone, but we know + # the function returns after the first successful path. No "No callback logs" message. + assert not any(m.event == "No callback logs found." for m in msgs) From 28179b1dfbcd9abcdb197921bb07948ab8953e94 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 10 Jun 2026 14:02:50 +0000 Subject: [PATCH 11/25] UI: Reuse TaskLogContent for callback logs to match task log UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback from Ash — use the existing log component (TaskLogContent) instead of a custom renderer so callback logs get the same filtering, formatting, grouping, and virtualization as task instance logs. --- .../ui/src/pages/Run/CallbackLogViewer.tsx | 134 ++++++++++++++---- 1 file changed, 110 insertions(+), 24 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx b/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx index 703c0c5ccc591..5e9f98ca0f010 100644 --- a/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx @@ -16,14 +16,19 @@ * specific language governing permissions and limitations * under the License. */ -import { Badge, Box, Button, Code, Heading, HStack, Skeleton, Text, VStack } from "@chakra-ui/react"; -import { useState } from "react"; +import { Badge, Box, Button, Heading, HStack, Text } from "@chakra-ui/react"; +import type { TFunction } from "i18next"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { FiFileText } from "react-icons/fi"; +import innerText from "react-innertext"; import { useDeadlinesServiceGetCallbackLogs } from "openapi/queries"; -import { ErrorAlert } from "src/components/ErrorAlert"; +import type { StructuredLogMessage, TaskInstancesLogResponse } from "openapi/requests/types.gen"; +import { renderStructuredLog } from "src/components/renderStructuredLog"; import { Dialog } from "src/components/ui"; +import { TaskLogContent } from "src/pages/TaskInstance/Logs/TaskLogContent"; +import type { ParsedLogEntry } from "src/queries/useLogs"; import { parseStreamingLogContent } from "src/utils/logs"; type CallbackLogViewerProps = { @@ -39,8 +44,85 @@ const stateColorMap: Record = { success: "green", }; +/** + * Parse callback log data using the same structured log rendering pipeline + * as the task instance logs, providing consistent formatting, grouping, and display. + */ +const parseCallbackLogs = ( + data: TaskInstancesLogResponse["content"], + translate: TFunction, +): Array => { + let lineNumber = 0; + const lineNumbers = data.map((datum) => { + const text = typeof datum === "string" ? datum : (datum as StructuredLogMessage).event; + + if (text.includes("::group::") || text.includes("::endgroup::")) { + return undefined; + } + const current = lineNumber; + + lineNumber += 1; + + return current; + }); + + const parsedLines = data + .map((datum, index) => + renderStructuredLog({ + index: lineNumbers[index] ?? index, + logLink: "", + logMessage: datum, + renderingMode: "jsx", + showSource: false, + showTimestamp: true, + translate, + }), + ) + .filter((parsedLine) => parsedLine !== ""); + + // Process group markers (::group:: / ::endgroup::) into structured entries + type Group = { id: number; level: number; name: string }; + const groupStack: Array = []; + const result: Array = []; + let nextGroupId = 0; + + for (const line of parsedLines) { + const text = innerText(line); + + if (text.includes("::group::")) { + const groupName = text.split("::group::")[1] as string; + const id = nextGroupId; + + nextGroupId += 1; + const level = groupStack.length; + const parentGroup = groupStack[groupStack.length - 1]; + + groupStack.push({ id, level, name: groupName }); + result.push({ + element: groupName, + group: { id, level, parentId: parentGroup?.id, type: "header" }, + }); + } else if (text.includes("::endgroup::")) { + groupStack.pop(); + } else { + const currentGroup = groupStack[groupStack.length - 1]; + + if (groupStack.length > 0 && currentGroup) { + result.push({ + element: line, + group: { id: currentGroup.id, level: currentGroup.level, type: "line" }, + }); + } else { + result.push({ element: line }); + } + } + } + + return result; +}; + export const CallbackLogViewer = ({ callbackId, callbackState, dagId, dagRunId }: CallbackLogViewerProps) => { - const { t: translate } = useTranslation("dag"); + const { t: translate } = useTranslation(["dag", "common"]); const [isOpen, setIsOpen] = useState(false); const { data, error, isLoading } = useDeadlinesServiceGetCallbackLogs( @@ -53,20 +135,27 @@ export const CallbackLogViewer = ({ callbackId, callbackState, dagId, dagRunId } { enabled: isOpen }, ); - const logContent = parseStreamingLogContent(data); - const logLines = logContent.map((entry) => (typeof entry === "string" ? entry : entry.event)); + const parsedLogs = useMemo(() => { + const content = parseStreamingLogContent(data); + + if (content.length === 0) { + return []; + } + + return parseCallbackLogs(content, translate); + }, [data, translate]); return ( <> - setIsOpen(false)} open={isOpen} scrollBehavior="inside" size="lg"> + setIsOpen(false)} open={isOpen} scrollBehavior="inside" size="xl"> - {translate("callbackLogs.title")} + {translate("dag:callbackLogs.title")} {callbackState !== undefined && callbackState !== null ? ( {callbackState} @@ -75,24 +164,21 @@ export const CallbackLogViewer = ({ callbackId, callbackState, dagId, dagRunId } - - - {isLoading ? ( - - {Array.from({ length: 5 }).map((_, idx) => ( - // eslint-disable-next-line react/no-array-index-key - - ))} - - ) : logLines.length === 0 ? ( + + {!isLoading && parsedLogs.length === 0 && error === undefined ? ( - {translate("callbackLogs.noLogs")} + {translate("dag:callbackLogs.noLogs")} ) : ( - - - {logLines.join("\n")} - + + )} From 0ad90f71ef47441caddbe6291eb60554ab67a0b6 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 10 Jun 2026 15:32:16 +0000 Subject: [PATCH 12/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20GitHub=20art?= =?UTF-8?q?ifact=20download=20401=20(infra)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 52f6230e9fd03da91d0feb666866f05008fbcd1d Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 10 Jun 2026 15:53:29 +0000 Subject: [PATCH 13/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20GitHub=20art?= =?UTF-8?q?ifact=20service=20auth=20outage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 7868a51ae25a740af2725fbe5502192118b3bbf1 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 10 Jun 2026 16:23:19 +0000 Subject: [PATCH 14/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20persistent?= =?UTF-8?q?=20GitHub=20artifact=20auth=20outage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From e9acca6a01741fc0d4da40d56256d337cbe4420c Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 10 Jun 2026 17:15:55 +0000 Subject: [PATCH 15/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20verify=20TS6?= =?UTF-8?q?196=20was=20stale=20(StructuredLogMessage=20used=20on=20line=20?= =?UTF-8?q?57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From b7dfacd13e2016d122210722b15d507c7fe56580 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 10 Jun 2026 18:22:53 +0000 Subject: [PATCH 16/25] UI: Remove redundant type assertion in callback log parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The (datum as StructuredLogMessage) cast was redundant — datum is already narrowed by the typeof check. CI's lint autofix stripped the cast, which left the StructuredLogMessage import unused and triggered TS6196. Remove both the cast and the now-unused import. --- .../src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx b/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx index 5e9f98ca0f010..33467bf58fbe4 100644 --- a/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Run/CallbackLogViewer.tsx @@ -24,7 +24,7 @@ import { FiFileText } from "react-icons/fi"; import innerText from "react-innertext"; import { useDeadlinesServiceGetCallbackLogs } from "openapi/queries"; -import type { StructuredLogMessage, TaskInstancesLogResponse } from "openapi/requests/types.gen"; +import type { TaskInstancesLogResponse } from "openapi/requests/types.gen"; import { renderStructuredLog } from "src/components/renderStructuredLog"; import { Dialog } from "src/components/ui"; import { TaskLogContent } from "src/pages/TaskInstance/Logs/TaskLogContent"; @@ -54,7 +54,7 @@ const parseCallbackLogs = ( ): Array => { let lineNumber = 0; const lineNumbers = data.map((datum) => { - const text = typeof datum === "string" ? datum : (datum as StructuredLogMessage).event; + const text = typeof datum === "string" ? datum : datum.event; if (text.includes("::group::") || text.includes("::endgroup::")) { return undefined; From 3eb298948ad24c4d2c5d8f02354d55b3a6bfec0e Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 10 Jun 2026 19:22:55 +0000 Subject: [PATCH 17/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20docker=20pro?= =?UTF-8?q?vider=20test=20timeout=20+=20kafka=20infra=20errors=20(unrelate?= =?UTF-8?q?d=20to=20UI=20changes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 4d331ee643c96b18d926a73a9246c7f219af2946 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Wed, 10 Jun 2026 20:22:54 +0000 Subject: [PATCH 18/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20docker=20com?= =?UTF-8?q?pose=20up=20failed=20to=20start=20e2e=20containers=20(runner=20?= =?UTF-8?q?infra)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 1e5aa2a485a6624f5585af74460d4ac551bd5635 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Fri, 12 Jun 2026 01:14:27 +0000 Subject: [PATCH 19/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20flaky=20test?= =?UTF-8?q?=5Fone=5Ffailed=5Ftrigger=5Frule=5Fin=5Fmapped=5Ftask=5Fgroup?= =?UTF-8?q?=5Fis=5Fper=5Findex=20(unrelated=20to=20UI=20changes;=20passed?= =?UTF-8?q?=20on=20prior=20runs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 637b88663d6881dcebe71087835b2f4c6d3291ab Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Fri, 12 Jun 2026 01:53:17 +0000 Subject: [PATCH 20/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20flaky=20test?= =?UTF-8?q?=5Fone=5Ffailed=5Ftrigger=5Frule=5Fin=5Fmapped=5Ftask=5Fgroup?= =?UTF-8?q?=5Fis=5Fper=5Findex=20(2nd=20flake,=20unrelated=20to=20UI=20cha?= =?UTF-8?q?nges,=20from=20#67684=20on=20main)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 339c421c2c1e12672d6a46857bbb65d508806cb6 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Fri, 12 Jun 2026 02:31:18 +0000 Subject: [PATCH 21/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20flaky=20test?= =?UTF-8?q?=5Fone=5Ffailed=5Ftrigger=5Frule=5Fin=5Fmapped=5Ftask=5Fgroup?= =?UTF-8?q?=5Fis=5Fper=5Findex=20(3rd=20flake,=20#67684=20on=20main,=20unr?= =?UTF-8?q?elated=20to=20UI=20PR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 37a61c693a95ab24d90626cfc37a1fc347724ec2 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Fri, 12 Jun 2026 03:13:17 +0000 Subject: [PATCH 22/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20flaky=20test?= =?UTF-8?q?=5Fone=5Ffailed=5Ftrigger=5Frule=5Fin=5Fmapped=5Ftask=5Fgroup?= =?UTF-8?q?=5Fis=5Fper=5Findex=20(4th=20flake;=20#67684=20on=20main;=20unr?= =?UTF-8?q?elated=20to=20UI=20PR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From d1abd754386559fd07341a6c2cc53aa9f61958db Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Fri, 12 Jun 2026 03:53:03 +0000 Subject: [PATCH 23/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20flaky=20test?= =?UTF-8?q?=5Fone=5Ffailed=5Ftrigger=5Frule=5Fin=5Fmapped=5Ftask=5Fgroup?= =?UTF-8?q?=5Fis=5Fper=5Findex=20(5th=20flake;=20#67684=20on=20main;=20unr?= =?UTF-8?q?elated=20to=20UI=20PR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 756783887d39342b6c9540faa479dfae19e8d431 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Fri, 12 Jun 2026 04:48:15 +0000 Subject: [PATCH 24/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20flaky=20mapp?= =?UTF-8?q?ed-task=20test=20(6th)=20+=20pre-existing=20main=20spellcheck?= =?UTF-8?q?=20typo,=20both=20unrelated=20to=20UI=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 3a0f81334e1995a0bddb5c194bacc3252c659e83 Mon Sep 17 00:00:00 2001 From: Sean Ghaeli Date: Fri, 12 Jun 2026 17:24:11 +0000 Subject: [PATCH 25/25] =?UTF-8?q?Retrigger=20CI=20=E2=80=94=20migration-re?= =?UTF-8?q?f-doc=20hook=20hit=20cache-extraction=20failure=20(post-rebase)?= =?UTF-8?q?;=20clean=20re-run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit