Skip to content
Merged
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
98 changes: 83 additions & 15 deletions task-sdk/src/airflow/sdk/execution_time/task_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,12 @@
from airflow.sdk.definitions.param import process_params
from airflow.sdk.exceptions import (
AirflowException,
AirflowFailException,
AirflowInactiveAssetInInletOrOutletException,
AirflowRescheduleException,
AirflowRuntimeError,
AirflowSensorTimeout,
AirflowTaskTerminated,
AirflowTaskTimeout,
ErrorType,
TaskAwaitingInput,
Expand Down Expand Up @@ -238,6 +241,9 @@ class RuntimeTaskInstance(TaskInstance):
_terminal_state_send_failed: bool = False
"""True when the supervisor IPC send for a non-success terminal state raised; signals main() to sys.exit(1) after finalize() so the supervisor doesn't misclassify the run as SUCCESS via exit code 0."""

_failure_metrics_emitted: bool = False
"""True once the failure counters have been recorded, so a second pass through the failure path (one attempt raised part-way through) does not count the same failure twice."""

_ti_context_from_server: Annotated[TIRunContext | None, Field(repr=False)] = None
"""The Task Instance context from the API server, if any."""

Expand Down Expand Up @@ -1524,22 +1530,17 @@ def _await_input_task(
return msg, state


@Sentry.enrich_errors
@detail_span("run")
def run(
def _run_task_and_map_outcome(
ti: RuntimeTaskInstance,
context: Context,
log: Logger,
) -> tuple[TaskInstanceState, ToSupervisor | None, BaseException | None]:
"""Run the task in this process."""
"""Execute the task and map its outcome -- success or a handled exception -- to a message and state."""
import signal

from airflow.sdk.exceptions import (
AirflowFailException,
AirflowRescheduleException,
AirflowSensorTimeout,
AirflowSkipException,
AirflowTaskTerminated,
DagRunTriggerException,
DownstreamTasksSkipped,
TaskAwaitingInput,
Expand All @@ -1565,9 +1566,6 @@ def _on_term(signum, frame):
state: TaskInstanceState | None = None
error: BaseException | None = None

stats_tags = ti.stats_tags
stats.incr("ti.start", tags=stats_tags)

try:
# First, clear the xcom data sent from server
if ti._ti_context_from_server and (keys_to_delete := ti._ti_context_from_server.xcom_keys_to_clear):
Expand Down Expand Up @@ -1691,6 +1689,32 @@ def _on_term(signum, frame):
log.info("::group::Post Execute")
msg, state = _handle_current_task_failed(ti, e, log, context)
error = e

return state, msg, error


@Sentry.enrich_errors
@detail_span("run")
def run(
ti: RuntimeTaskInstance,
context: Context,
log: Logger,
) -> tuple[TaskInstanceState, ToSupervisor | None, BaseException | None]:
"""Run the task in this process."""
msg: ToSupervisor | None = None
state: TaskInstanceState | None = None
error: BaseException | None = None

stats_tags = ti.stats_tags
stats.incr("ti.start", tags=stats_tags)

try:
state, msg, error = _run_task_and_map_outcome(ti, context, log)
except Exception as e:
# Python does not offer an exception raised inside an ``except`` clause to that
# clause's siblings, so a handler that fails would otherwise escape entirely --
# skipping the retry decision and every callback in ``finalize()``.
msg, state, error = _handle_handler_failure(ti, e, log, context)
finally:
# `state` may still be unset if an exception handler above raised before
# binding it
Expand Down Expand Up @@ -1798,6 +1822,46 @@ def _evaluate_retry_policy(
return None


def _handle_handler_failure(
ti: RuntimeTaskInstance, exception: Exception, log: Logger, context: Context
) -> tuple[RetryTask | TaskState, TaskInstanceState, Exception]:
"""
Decide the outcome for an exception raised by one of the outcome handlers themselves.

Handlers reach this by talking to the API server -- a missing Dag makes
``_handle_trigger_dag_run`` raise ``AirflowRuntimeError`` -- or by serializing
user-supplied values, since ``_defer_task`` and ``_await_input_task`` run
``serde_serialize`` over kwargs and it raises ``TypeError`` for anything it has no
serializer for.

The handler chain's own classifications are preserved rather than routing everything
through the retry-count check, so an exception that means "do not retry" still means
that when it surfaces from a handler.
"""
log.exception("Task failed with exception")
if isinstance(exception, (AirflowFailException, AirflowSensorTimeout, AirflowTaskTerminated)):
return _terminal_failure(ti), TaskInstanceState.FAILED, exception
try:
msg, state = _handle_current_task_failed(ti, exception, log, context)
except Exception:
# The failure path itself failed. Re-entering it would just fail again and
# escape, losing the callbacks this whole function exists to preserve, so fail
# closed on a plain FAILED state instead.
log.exception("Could not determine terminal state, failing closed")
return _terminal_failure(ti), TaskInstanceState.FAILED, exception
return msg, state, exception


def _terminal_failure(ti: RuntimeTaskInstance) -> TaskState:
"""Build a plain FAILED terminal message, bypassing any retry decision."""
ti.end_date = datetime.now(tz=timezone.utc)
return TaskState(
state=TaskInstanceState.FAILED,
end_date=ti.end_date,
rendered_map_index=ti.rendered_map_index,
)


def _handle_current_task_failed(
ti: RuntimeTaskInstance,
exception: BaseException,
Expand Down Expand Up @@ -1849,12 +1913,16 @@ def _finalize_task_failure(
end_date = datetime.now(tz=timezone.utc)
ti.end_date = end_date

# Record operator and task instance failed metrics
operator = ti.task.__class__.__name__
stats_tags = ti.stats_tags
# Record operator and task instance failed metrics. One failure is one increment even
# if this runs twice, which happens when a first pass raised after counting -- see
# `_handle_handler_failure`.
if not ti._failure_metrics_emitted:
operator = ti.task.__class__.__name__
stats_tags = ti.stats_tags

stats.incr("operator_failures", tags={**stats_tags, "operator_name": operator})
stats.incr("ti_failures", tags=stats_tags)
stats.incr("operator_failures", tags={**stats_tags, "operator_name": operator})
stats.incr("ti_failures", tags=stats_tags)
ti._failure_metrics_emitted = True

if ti._ti_context_from_server and ti._ti_context_from_server.should_retry:
retry_kwargs: dict[str, Any] = {"end_date": end_date}
Expand Down
Loading