This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 9b6d80b5539 Fail task instances whose stored next_kwargs cannot be
processed (#70685) (#71183)
9b6d80b5539 is described below
commit 9b6d80b55393401086aa88edea52ebd6cce9eade
Author: Rahul Vats <[email protected]>
AuthorDate: Wed Aug 5 20:44:49 2026 +0530
Fail task instances whose stored next_kwargs cannot be processed (#70685)
(#71183)
* Fail task instances whose stored next_kwargs cannot be processed
handle_event_submit decoded a task instance's stored next_kwargs and assumed
the result was a dict. Neither assumption held: the decode caught only four
exception types, so anything the BaseSerialization fallback raised escaped,
and the isinstance check sat under TYPE_CHECKING, so it never ran at
runtime.
Both escape as exceptions from a function whose callers walk every waiting
task instance in one pass — the scheduler's timeout sweep and two API routes
— so a single unusable payload aborted the whole batch.
Decode through a helper that checks its result, and guard decode, event
insertion and re-encode together. A task instance whose payload cannot be
processed is re-queued to fail through the existing __fail__ path, so its
normal retry and callback handling still runs, instead of being left parked
for the next sweep to trip over again.
Generated-by: Claude Opus 5 (1M context) following the guidelines at
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
* Report why a task instance could not be resumed, and to whom
The Dag author sees only the task log, so the traceback has to travel in
next_kwargs the way submit_failure already sends it; the process log where
this
runs is often not theirs to read.
Decode and re-encode also fail for different reasons: blaming the stored
kwargs
for a payload the trigger just yielded points the author at database state
that
was never at fault. The sweep's summary counted an unresumable task as
resolved.
* Update airflow-core/src/airflow/models/trigger.py
* Fix ruff D205/D213 on _fail_unresumable_task_instance docstring
One-line summary on the second line + blank line before the description, so
the docstring
satisfies both D205 (blank between summary and description) and D213
(summary on second line).
Static checks were failing on ruff for this.
---------
(cherry picked from commit 2b7a0be359cd7bd9da9054aeac6762b14e286762)
Co-authored-by: Jarek Potiuk <[email protected]>
Co-authored-by: Amogh Desai <[email protected]>
---
.../src/airflow/jobs/scheduler_job_runner.py | 21 +++-
airflow-core/src/airflow/models/trigger.py | 112 ++++++++++++++++++---
airflow-core/tests/unit/jobs/test_scheduler_job.py | 52 ++++++++++
airflow-core/tests/unit/models/test_trigger.py | 67 ++++++++++++
4 files changed, 233 insertions(+), 19 deletions(-)
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 9114ff979b9..fb738455019 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -3414,8 +3414,10 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
num_resolved = 0
num_failed = 0
+ num_unresumable = 0
for ti in timed_out_tis:
hitl_detail = ti.hitl_detail
+ resuming = True
if hitl_detail is not None and hitl_detail.responded_at is
not None:
# A response landed just before the deadline; resume
with it.
handle_event_submit(
@@ -3423,7 +3425,6 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
task_instance=ti,
session=session,
)
- num_resolved += 1
elif hitl_detail is not None and hitl_detail.defaults is
not None:
# Apply the configured defaults as the response, then
resume to success.
hitl_detail.chosen_options = list(hitl_detail.defaults)
@@ -3439,7 +3440,6 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
task_instance=ti,
session=session,
)
- num_resolved += 1
else:
# No defaults and no response: resume into
execute_complete with a timeout
# failure event so the operator raises
HITLTimeoutError (matching the old
@@ -3455,16 +3455,29 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
task_instance=ti,
session=session,
)
+ resuming = False
+
+ # ``handle_event_submit`` routes a task instance it could
not process to
+ # ``__fail__`` instead of resuming it. That is neither of
the outcomes the
+ # branches above intended, so it is counted on its own
rather than being
+ # reported as resolved.
+ if ti.next_method == TRIGGER_FAIL_REPR:
+ num_unresumable += 1
+ elif resuming:
+ num_resolved += 1
+ else:
num_failed += 1
# Flush within the retry block so both branches persist
consistently (the defaults
# branch already flushes via handle_event_submit; the fail
branch relies on this).
session.flush()
- if num_resolved or num_failed:
+ if num_resolved or num_failed or num_unresumable:
self.log.info(
- "AWAITING_INPUT timeout sweep: %i resolved
(response/defaults), %i failed",
+ "AWAITING_INPUT timeout sweep: %i resolved
(response/defaults), %i failed, "
+ "%i could not be resumed",
num_resolved,
num_failed,
+ num_unresumable,
)
# [START find_and_purge_task_instances_without_heartbeats]
diff --git a/airflow-core/src/airflow/models/trigger.py
b/airflow-core/src/airflow/models/trigger.py
index 9d8c195e9c9..c7039750b4e 100644
--- a/airflow-core/src/airflow/models/trigger.py
+++ b/airflow-core/src/airflow/models/trigger.py
@@ -500,6 +500,55 @@ class Trigger(Base):
return result
+def _decode_next_kwargs(next_kwargs_raw: Any) -> dict[str, Any]:
+ """
+ Decode the stored ``next_kwargs`` of a task instance into a plain dict.
+
+ Deserialize with serde first to provide a compat layer if there are mixed
serialized
+ (BaseSerialisation and serde) data, which can happen if a deferred task
resumes after upgrade.
+
+ The result is checked here rather than assumed, so callers never have to
trust the shape of
+ what comes back out of the stored payload.
+
+ :raise ValueError: The payload did not decode to a dict.
+ :raise Exception: Whatever the two decoders raise on a payload they cannot
read -- the stored
+ blob is arbitrary, so the set is open and callers have to treat it as
such.
+ """
+ from airflow.sdk.serde import deserialize
+
+ try:
+ next_kwargs = deserialize(next_kwargs_raw)
+ except (ImportError, KeyError, AttributeError, TypeError):
+ from airflow.serialization.serialized_objects import BaseSerialization
+
+ next_kwargs = BaseSerialization.deserialize(next_kwargs_raw)
+
+ if not isinstance(next_kwargs, dict):
+ raise ValueError(f"next_kwargs decoded to
{type(next_kwargs).__name__}, expected a dict")
+ return next_kwargs
+
+
+def _fail_unresumable_task_instance(
+ task_instance: TaskInstance, reason: str, exc: BaseException, *, session:
Session
+) -> None:
+ """
+ Route through ``__fail__`` so a worker fails the task normally instead of
stranding it.
+
+ Mirrors ``Trigger.submit_failure``: without this the task is left with no
event to resume it.
+ Traceback goes into ``next_kwargs`` as a list -- the only channel reaching
the task log --
+ since ``format_exception`` returns it that way and the runtime joins it.
+ """
+ task_instance.next_method = TRIGGER_FAIL_REPR
+ task_instance.next_kwargs = {
+ "error": reason,
+ "traceback": format_exception(type(exc), exc, exc.__traceback__),
+ }
+ task_instance.trigger_id = None
+ task_instance.state = TaskInstanceState.SCHEDULED
+ task_instance.scheduled_dttm = timezone.utcnow()
+ session.flush()
+
+
@singledispatch
def handle_event_submit(event: TriggerEvent, *, task_instance: TaskInstance,
session: Session) -> None:
"""
@@ -509,33 +558,66 @@ def handle_event_submit(event: TriggerEvent, *,
task_instance: TaskInstance, ses
as well as its state to scheduled. It also adds the event's payload
into the kwargs for the task.
+ A task instance whose stored kwargs cannot be decoded, or which the event
payload cannot be
+ encoded into, is failed rather than resumed. This runs in the triggerer,
the scheduler and the
+ API processes, each of which handles every waiting task instance in one
pass, so a single
+ unusable payload must not be able to abort the caller. The triggerer had
the worst of it: an
+ event whose submit raised was left unconfirmed and redelivered
indefinitely.
+
+ Failing the task instance is not free for every caller: a
Human-in-the-loop response whose
+ ``params_input`` serde cannot encode is now recorded and discarded rather
than rejected, which
+ the submitter cannot retry. That wants validating on the write side;
tracked at
+ https://github.com/apache/airflow/issues/71036
+
:param task_instance: The task instance to handle the submit event for.
:param session: The session to be used for the database callback sink.
"""
- from airflow.sdk.serde import deserialize, serialize
- from airflow.utils.state import TaskInstanceState
+ from airflow.sdk.serde import serialize
next_kwargs_raw = task_instance.next_kwargs or {}
- # deserialize first to provide a compat layer if there are mixed
serialized (BaseSerialisation and serde) data
- # which can happen if a deferred task resumes after upgrade
+ # Decoding and re-encoding fail for different reasons and are reported
separately: blaming the
+ # stored kwargs for a payload the trigger just yielded would point the
author at DB state that
+ # was never the problem.
try:
- next_kwargs = deserialize(next_kwargs_raw)
- except (ImportError, KeyError, AttributeError, TypeError):
- from airflow.serialization.serialized_objects import BaseSerialization
-
- next_kwargs = BaseSerialization.deserialize(next_kwargs_raw)
+ next_kwargs = _decode_next_kwargs(next_kwargs_raw)
+ except Exception as exc:
+ log.exception(
+ "Could not decode the stored next_kwargs of %s; failing it instead
of resuming it",
+ task_instance,
+ )
+ _fail_unresumable_task_instance(
+ task_instance,
+ "Could not resume the task: its stored next_kwargs could not be
decoded "
+ f"({type(exc).__name__}: {exc})",
+ exc,
+ session=session,
+ )
+ return
# Add event to the plain dict, then serialize everything together so nested
# non-primitive values get proper serde encoding.
- if TYPE_CHECKING:
- assert isinstance(next_kwargs, dict)
next_kwargs["event"] = event.payload
+ try:
+ # Re-serialize using serde. The Execution API version converter
+ # (ModifyDeferredTaskKwargsToJsonValue) handles converting this to
+ # BaseSerialization format when serving old workers.
+ serialized_next_kwargs = serialize(next_kwargs)
+ except Exception as exc:
+ log.exception(
+ "Could not serialize the event payload for %s; failing it instead
of resuming it",
+ task_instance,
+ )
+ _fail_unresumable_task_instance(
+ task_instance,
+ f"Could not resume the task: the event payload could not be
serialized "
+ f"({type(exc).__name__}: {exc})",
+ exc,
+ session=session,
+ )
+ return
- # Re-serialize using serde. The Execution API version converter
- # (ModifyDeferredTaskKwargsToJsonValue) handles converting this to
- # BaseSerialization format when serving old workers.
- task_instance.next_kwargs = serialize(next_kwargs)
+ task_instance.next_kwargs = serialized_next_kwargs
# Remove ourselves as its trigger
task_instance.trigger_id = None
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 439869d33c7..804befe0eba 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -7787,6 +7787,58 @@ class TestSchedulerJob:
assert ti.next_method == "execute_complete"
assert ti.next_kwargs["event"]["error_type"] == "timeout"
+ def test_awaiting_input_timeout_sweep_survives_unusable_next_kwargs(self,
dag_maker):
+ """The sweep finishes its batch even when one task's stored kwargs
cannot be read."""
+ session = settings.Session()
+ with dag_maker(
+ dag_id="test_awaiting_input_bad_kwargs",
+ start_date=DEFAULT_DATE,
+ schedule="@once",
+ session=session,
+ ):
+ EmptyOperator(task_id="dummy1")
+ dr_bad = dag_maker.create_dagrun()
+ dr_good = dag_maker.create_dagrun(
+ run_id="good", logical_date=DEFAULT_DATE +
datetime.timedelta(seconds=1)
+ )
+ ti_bad = dr_bad.get_task_instance("dummy1", session=session)
+ ti_good = dr_good.get_task_instance("dummy1", session=session)
+ for ti in (ti_bad, ti_good):
+ ti.state = State.AWAITING_INPUT
+ ti.trigger_timeout = timezone.utcnow() -
datetime.timedelta(seconds=60)
+ ti.next_method = "execute_complete"
+ ti.next_kwargs = {}
+ # Stored kwargs that no longer decode into a dict (here: a class name
that is not allowed
+ # for deserialization, which the legacy fallback cannot read either).
+ ti_bad.next_kwargs = {"__classname__": "not.allowed.Thing",
"__version__": 1, "__data__": {}}
+ session.add(
+ HITLDetail(
+ ti_id=ti_good.id,
+ options=["Approve", "Reject"],
+ subject="approve?",
+ defaults=["Approve"],
+ multiple=False,
+ params={},
+ )
+ )
+ session.flush()
+
+ self.job_runner = SchedulerJobRunner(job=Job())
+ mock_log = mock.MagicMock(spec=logging.Logger)
+ with mock.patch.object(SchedulerJobRunner, "log", mock_log):
+ self.job_runner.check_awaiting_input_timeouts(session=session)
+
+ session.refresh(ti_bad)
+ session.refresh(ti_good)
+ assert ti_bad.state == State.SCHEDULED
+ assert ti_bad.next_method == "__fail__"
+ assert "error" in ti_bad.next_kwargs
+ assert ti_good.state == State.SCHEDULED
+ assert ti_good.next_method == "execute_complete"
+ assert ti_good.next_kwargs["event"]["chosen_options"] == ["Approve"]
+ # The one it could not resume is reported as such rather than counted
as resolved.
+ assert mock_log.info.call_args.args[1:] == (1, 0, 1)
+
def test_retry_on_db_error_when_update_timeout_triggers(self, dag_maker,
testing_dag_bundle, session):
"""
Tests that it will retry on DB error like deadlock when updating
timeout triggers.
diff --git a/airflow-core/tests/unit/models/test_trigger.py
b/airflow-core/tests/unit/models/test_trigger.py
index 2785c48c1dd..797f0922189 100644
--- a/airflow-core/tests/unit/models/test_trigger.py
+++ b/airflow-core/tests/unit/models/test_trigger.py
@@ -35,6 +35,7 @@ from airflow.models import TaskInstance, Trigger
from airflow.models.asset import AssetEvent, AssetModel, AssetWatcherModel
from airflow.models.callback import Callback, TriggererCallback
from airflow.models.taskinstancehistory import TaskInstanceHistory
+from airflow.models.trigger import handle_event_submit
from airflow.models.xcom import XComModel
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.sdk.definitions.callback import AsyncCallback
@@ -257,6 +258,72 @@ def test_submit_event_no_n_plus_one_for_assets(_, session,
asset_count, expected
Trigger.submit_event(trigger_id, TriggerEvent("payload"),
session=session)
[email protected](
+ "stored_next_kwargs",
+ [
+ # Decoding blows up: serde rejects the class name, and the
BaseSerialization fallback then
+ # trips over the missing legacy keys.
+ pytest.param(
+ {"__classname__": "not.allowed.Thing", "__version__": 1,
"__data__": {}},
+ id="undecodable",
+ ),
+ # Decodes cleanly, but not into a dict: legacy encoding of a bare
datetime.
+ pytest.param({"__type": "datetime", "__var": 1735689600.0},
id="not-a-dict"),
+ ],
+)
+def test_handle_event_submit_fails_task_with_unusable_next_kwargs(
+ session, create_task_instance, stored_next_kwargs
+):
+ """
+ Tests that stored kwargs which cannot be turned into a dict fail the task
instance instead of
+ raising out of ``handle_event_submit``. Its callers walk every waiting
task instance in one
+ pass, so one unusable payload must not abort them.
+ """
+ task_instance = create_task_instance(
+ session=session, logical_date=timezone.utcnow(), state=State.DEFERRED
+ )
+ task_instance.next_method = "execute_complete"
+ task_instance.next_kwargs = stored_next_kwargs
+ session.flush()
+
+ handle_event_submit(TriggerEvent("payload"), task_instance=task_instance,
session=session)
+
+ session.refresh(task_instance)
+ assert task_instance.state == State.SCHEDULED
+ assert task_instance.next_method == "__fail__"
+ assert task_instance.trigger_id is None
+ assert "event" not in task_instance.next_kwargs
+ assert "stored next_kwargs could not be decoded" in
task_instance.next_kwargs["error"]
+ # The traceback reaches the task log only through next_kwargs, and the
runtime joins the list.
+ assert isinstance(task_instance.next_kwargs["traceback"], list)
+
+
+def
test_handle_event_submit_fails_task_when_the_event_payload_cannot_be_serialized(
+ session, create_task_instance
+):
+ """A payload serde cannot encode is reported as such, not as unreadable
stored kwargs.
+
+ Blaming the stored kwargs would point the Dag author at database state
that was never the
+ problem.
+ """
+ task_instance = create_task_instance(
+ session=session, logical_date=timezone.utcnow(), state=State.DEFERRED
+ )
+ task_instance.next_method = "execute_complete"
+ task_instance.next_kwargs = {}
+ session.flush()
+
+ # serde refuses any dict carrying its reserved keys, at any depth.
+ handle_event_submit(
+ TriggerEvent({"__classname__": "anything"}),
task_instance=task_instance, session=session
+ )
+
+ session.refresh(task_instance)
+ assert task_instance.state == State.SCHEDULED
+ assert task_instance.next_method == "__fail__"
+ assert "event payload could not be serialized" in
task_instance.next_kwargs["error"]
+
+
def test_submit_failure(session, create_task_instance):
"""
Tests that failures submitted to a trigger fail their dependent