This is an automated email from the ASF dual-hosted git repository.
jedcunningham pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-2-test by this push:
new 46eca447a5b Fix triggerer crash when multiple triggers call sync SDK
methods concurrently (#66412) (#66446)
46eca447a5b is described below
commit 46eca447a5bd35c0014ec7ec68ce7d2a43b4baa9
Author: Jed Cunningham <[email protected]>
AuthorDate: Tue May 5 19:18:42 2026 -0600
Fix triggerer crash when multiple triggers call sync SDK methods
concurrently (#66412) (#66446)
TriggerCommsDecoder used an asyncio.Lock to serialise concurrent requests,
then read the response while holding the lock. When a trigger called a
sync SDK method wrapped with sync_to_async(), that method ran in a worker
thread and internally used async_to_sync() to make async calls, spinning
up a second event loop in that thread. The two event loops then raced to
read from the same asyncio.StreamReader, producing "Response read out of
order!" and crashing the TriggerRunner subprocess. PR #64882 attempted a
threading.Lock fix but introduced a new deadlock where the triggerer
heartbeat normally while processing zero triggers.
Replace the lock-based serial approach with response multiplexing:
* Each asend() registers an asyncio.Future keyed by frame.id in a
_pending dict. A single _reader_loop background task reads frames
one at a time and dispatches each response to the right waiter.
No lock, no ordering assumption.
* sync send() from worker threads schedules asend() on the main event
loop via run_coroutine_threadsafe() and blocks the calling thread.
This eliminates competing event loops entirely.
* sync send() from the event loop thread itself (e.g. a trigger calling
a sync SDK method directly from async def run() via greenback) would
deadlock with run_coroutine_threadsafe(...).result() because .result()
blocks the thread the loop runs on. Detected via thread-ID comparison;
greenback.await_() is used instead to teleport the coroutine back into
the running loop.
* asend() called from a foreign event loop (async_to_sync inside a
sync_to_async-wrapped trigger) detects the loop mismatch and bridges
back to the main loop via run_coroutine_threadsafe + wrap_future so
the foreign loop can still await the result. This is the root-cause
code path, covered directly by test_foreign_loop_path.
* init_comms() reads the initial StartTriggerer frame before starting
the reader loop so the loop never races with initialisation.
Add a subprocess event-loop watchdog: TriggerRunnerSupervisor stamps
_last_runner_comms on every message received from the subprocess. If the
subprocess goes silent for longer than the new [triggerer]
runner_health_check_threshold (default 30 s), heartbeat() skips the DB
update so the scheduler sees the triggerer as unhealthy and reassigns its
triggers. This detects a deadlocked or hung event loop that the
process-alive check alone cannot catch.
(cherry picked from commit 099fd2b03f3a4e642f75e748fbce2f3d99ffbba8)
Co-authored-by: Ash Berlin-Taylor <[email protected]>
---
airflow-core/newsfragments/66412.significant.rst | 26 +++
.../src/airflow/config_templates/config.yml | 11 +
.../src/airflow/jobs/triggerer_job_runner.py | 127 ++++++++---
airflow-core/tests/unit/jobs/test_triggerer_job.py | 231 ++++++++++++++++++++-
.../sdk/execution_time/secrets/execution_api.py | 40 ----
.../tests/task_sdk/execution_time/test_secrets.py | 101 ---------
6 files changed, 363 insertions(+), 173 deletions(-)
diff --git a/airflow-core/newsfragments/66412.significant.rst
b/airflow-core/newsfragments/66412.significant.rst
new file mode 100644
index 00000000000..22b06e7eeb0
--- /dev/null
+++ b/airflow-core/newsfragments/66412.significant.rst
@@ -0,0 +1,26 @@
+Fix triggerer race condition and deadlock that caused deferred tasks to stall
indefinitely
+
+Triggers that call synchronous SDK methods (e.g. ``get_task_states`` used by
+``safe_to_cancel`` in several Google provider operators) could crash the
triggerer's
+internal subprocess. The triggerer would then continue to heartbeat normally —
+appearing healthy to the scheduler — while silently processing zero triggers,
causing
+every deferred task to time out. This was first reported in
:github-issue:`64620`; a
+partial fix shipped in Airflow 3.2.1 (:github-pr:`64882`) but introduced a new
deadlock
+with the same visible symptom under load.
+
+Both issues are fixed by replacing the lock-based serialisation with response
+multiplexing: each request now carries a unique ID and the response is routed
back to
+the correct caller, so concurrent requests from trigger threads no longer
contend or
+deadlock regardless of how many triggers are running or what SDK methods they
call.
+
+**New: triggerer subprocess watchdog**
+
+Even with the race fixed, a trigger that blocks the event loop (e.g. by calling
+``time.sleep()`` or performing blocking I/O directly in ``async def run()``)
would
+previously leave the triggerer appearing healthy indefinitely.
+
+A new ``[triggerer] runner_health_check_threshold`` config option (default: 30
seconds)
+adds a watchdog: if the triggerer subprocess goes silent for longer than the
threshold,
+the parent process stops updating the heartbeat so the scheduler can detect
the hang and
+reassign triggers rather than waiting for them to individually time out. Set
the option
+to ``0`` to disable the watchdog.
diff --git a/airflow-core/src/airflow/config_templates/config.yml
b/airflow-core/src/airflow/config_templates/config.yml
index 445f89150db..46101ef2385 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -2650,6 +2650,17 @@ triggerer:
type: float
example: ~
default: "30"
+ runner_health_check_threshold:
+ description: |
+ If the TriggerRunner subprocess's async event loop sends no
communication to the parent
+ process for more than this many seconds, the parent stops updating the
triggerer's
+ heartbeat in the database. The triggerer then appears unhealthy to the
scheduler, which
+ will reassign its triggers. This detects a deadlocked or hung event
loop that the normal
+ process-alive check cannot catch. Set to 0 to disable the watchdog.
+ version_added: 3.2.2
+ type: float
+ example: ~
+ default: "30"
max_trigger_to_select_per_loop:
description: |
Maximum number of triggers to select per loop. Set this notably lower
than ``[triggerer] capacity``
diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
index a25632acac7..169455ac06f 100644
--- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
+++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
@@ -19,10 +19,12 @@ from __future__ import annotations
import asyncio
import functools
import logging
+import math
import os
import selectors
import signal
import sys
+import threading
import time
from collections import deque
from collections.abc import Generator, Iterable
@@ -34,6 +36,7 @@ from typing import TYPE_CHECKING, Annotated, Any, BinaryIO,
ClassVar, Literal, T
import anyio
import attrs
+import greenback
import structlog
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
@@ -389,10 +392,19 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
queues: set[str] | None = None
health_check_threshold = conf.getint("triggerer",
"triggerer_health_check_threshold")
+ runner_health_check_threshold = conf.getfloat("triggerer",
"runner_health_check_threshold")
runner: TriggerRunner | None = None
stop: bool = False
+ # Timestamp of the last message received from the TriggerRunner
subprocess. Updated on
+ # every message; if it goes silent for longer than
runner_health_check_threshold the
+ # subprocess's async event loop has likely deadlocked. Initialised to
+inf so the watchdog
+ # stays silent until the first message arrives — avoids false positives on
slow/cold-start
+ # hosts where startup can exceed the threshold.
+ _last_runner_comms: float = attrs.field(init=False, default=math.inf)
+ _runner_comms_silence_logged: bool = attrs.field(init=False, default=False)
+
decoder: ClassVar[TypeAdapter[ToTriggerSupervisor]] =
TypeAdapter(ToTriggerSupervisor)
# Maps trigger IDs that we think are running in the sub process
@@ -451,6 +463,7 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
resp: BaseModel | None = None
dump_opts = {}
+ self._last_runner_comms = time.monotonic()
if isinstance(msg, messages.TriggerStateChanges):
if msg.events:
@@ -599,6 +612,19 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
self.emit_metrics()
def heartbeat(self):
+ elapsed = time.monotonic() - self._last_runner_comms
+ if self.runner_health_check_threshold > 0 and elapsed >
self.runner_health_check_threshold:
+ if not self._runner_comms_silence_logged:
+ log.error(
+ "TriggerRunner subprocess event loop appears deadlocked:
no communication received "
+ "for %.1fs (threshold: %ds). Skipping heartbeat so the
triggerer appears unhealthy "
+ "to the scheduler and its triggers are reassigned.",
+ elapsed,
+ self.runner_health_check_threshold,
+ )
+ self._runner_comms_silence_logged = True
+ return
+ self._runner_comms_silence_logged = False
perform_heartbeat(self.job,
heartbeat_callback=self.heartbeat_callback, only_if_necessary=True)
def heartbeat_callback(self, session: Session | None = None) -> None:
@@ -818,46 +844,80 @@ class TriggerCommsDecoder(CommsDecoder[ToTriggerRunner,
ToTriggerSupervisor]):
factory=lambda: TypeAdapter(ToTriggerRunner), repr=False
)
- def _read_frame(self):
- from asgiref.sync import async_to_sync
-
- with self._thread_lock:
- return async_to_sync(self._aread_frame)()
-
- def send(self, msg: ToTriggerSupervisor) -> ToTriggerRunner | None:
- from asgiref.sync import async_to_sync
-
- with self._thread_lock:
- return async_to_sync(self.asend)(msg)
+ _pending: dict[int, asyncio.Future] = attrs.field(factory=dict, repr=False)
+ _loop: asyncio.AbstractEventLoop | None = attrs.field(default=None,
repr=False)
+ _loop_thread_id: int | None = attrs.field(default=None, repr=False)
+ _reader_task: asyncio.Task | None = attrs.field(default=None, repr=False)
async def _aread_frame(self):
try:
len_bytes = await self._async_reader.readexactly(4)
except ConnectionResetError:
asyncio.current_task().cancel("Supervisor closed")
+ raise
length = int.from_bytes(len_bytes, byteorder="big")
if length >= 2**32:
raise OverflowError(f"Refusing to receive messages larger than
4GiB {length=}")
-
buffer = await self._async_reader.readexactly(length)
return self.resp_decoder.decode(buffer)
- async def _aget_response(self, expect_id: int) -> ToTriggerRunner | None:
- frame = await self._aread_frame()
- if frame.id != expect_id:
- # Given the lock we take out in `asend`, this _shouldn't_ be
possible, but I'd rather fail with
- # this explicit error return the wrong type of message back to a
Trigger
- raise RuntimeError(f"Response read out of order! Got {frame.id=},
{expect_id=}")
- return self._from_frame(frame)
+ async def _reader_loop(self) -> None:
+ try:
+ while True:
+ frame = await self._aread_frame()
+ future = self._pending.pop(frame.id, None)
+ if future is not None and not future.done():
+ future.set_result(frame)
+ else:
+ self.log.warning("Got response for unknown request frame",
frame_id=frame.id)
+ finally:
+ for fut in self._pending.values():
+ if not fut.done():
+ fut.cancel("Reader loop exited")
+ self._pending.clear()
- async def asend(self, msg: ToTriggerSupervisor) -> ToTriggerRunner | None:
- frame = _RequestFrame(id=next(self.id_counter), body=msg.model_dump())
- bytes = frame.as_bytes()
+ async def start_reader(self) -> None:
+ self._loop = asyncio.get_running_loop()
+ self._loop_thread_id = threading.get_ident()
+ self._reader_task = asyncio.create_task(self._reader_loop(),
name="trigger-comms-reader")
- async with self._async_lock:
- self._async_writer.write(bytes)
+ def send(self, msg: ToTriggerSupervisor) -> ToTriggerRunner | None:
+ if self._loop is None:
+ raise RuntimeError("start_reader() must be called before send()")
+ if threading.get_ident() == self._loop_thread_id:
+ # Called from the event loop thread itself (e.g. a trigger calling
a sync SDK method
+ # directly from async def run()).
run_coroutine_threadsafe(...).result() would deadlock
+ # here because .result() blocks the thread the event loop runs on.
+ # greenback.await_() teleports the coroutine back into the running
loop instead.
+ if not greenback.has_portal():
+ raise RuntimeError(
+ "Sync SDK methods (e.g. get_connection(), get_variable())
cannot be called "
+ "from a trigger's async def run() when
AIRFLOW_DISABLE_GREENBACK_PORTAL is "
+ "set. Either remove that environment variable, or use the
async equivalent "
+ "(e.g. aget_connection(), aget_variable())."
+ )
+ return greenback.await_(self.asend(msg))
+ return asyncio.run_coroutine_threadsafe(self.asend(msg),
self._loop).result()
- return await self._aget_response(frame.id)
+ async def asend(self, msg: ToTriggerSupervisor) -> ToTriggerRunner | None:
+ if self._loop is None:
+ raise RuntimeError("start_reader() must be called before asend()")
+ current_loop = asyncio.get_running_loop()
+ if self._loop is not None and current_loop is not self._loop:
+ # Called from a foreign event loop (e.g. via async_to_sync).
Bridge to the main loop
+ # so _reader_loop can resolve the future, then await via
wrap_future which is
+ # non-blocking for the foreign loop.
+ cf = asyncio.run_coroutine_threadsafe(self.asend(msg), self._loop)
+ return await asyncio.wrap_future(cf)
+ frame = _RequestFrame(id=next(self.id_counter), body=msg.model_dump())
+ future: asyncio.Future = current_loop.create_future()
+ self._pending[frame.id] = future
+ try:
+ self._async_writer.write(frame.as_bytes())
+ return self._from_frame(await future)
+ except BaseException:
+ self._pending.pop(frame.id, None)
+ raise
class TriggerRunner:
@@ -937,6 +997,10 @@ class TriggerRunner:
if watchdog.done():
watchdog.result()
+ if self.comms_decoder._reader_task.done():
+ self.comms_decoder._reader_task.result()
+ raise RuntimeError("Supervisor connection lost")
+
# Run core logic
finished_ids = await self.cleanup_finished_triggers()
@@ -960,6 +1024,11 @@ class TriggerRunner:
await log.aexception("Trigger runner failed")
self.stop = True
raise
+ finally:
+ if (reader_task := self.comms_decoder._reader_task) is not None:
+ reader_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await reader_task
# Wait for supporting tasks to complete
await watchdog
@@ -982,11 +1051,13 @@ class TriggerRunner:
task_runner.SUPERVISOR_COMMS = self.comms_decoder
- msg = await self.comms_decoder._aget_response(expect_id=0)
-
+ frame = await self.comms_decoder._aread_frame()
+ msg = self.comms_decoder._from_frame(frame)
if not isinstance(msg, messages.StartTriggerer):
raise RuntimeError(f"Required first message to be a
messages.StartTriggerer, it was {msg}")
+ await self.comms_decoder.start_reader()
+
async def create_triggers(self):
"""Drain the to_create queue and create all new triggers that have
been requested in the DB."""
while self.to_create:
@@ -1205,8 +1276,6 @@ class TriggerRunner:
async def run_trigger(self, trigger_id: int, trigger: BaseTrigger,
timeout_after: datetime | None = None):
"""Run a trigger (they are async generators) and push their events
into our outbound event deque."""
if not os.environ.get("AIRFLOW_DISABLE_GREENBACK_PORTAL", "").lower()
== "true":
- import greenback
-
await greenback.ensure_portal()
ti = trigger.task_instance
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index bf5d6de6ec8..550e9916e56 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -22,19 +22,24 @@ import contextlib
import datetime
import itertools
import os
+import random
import selectors
+import threading
import time
import typing
import uuid
from collections.abc import AsyncIterator
-from socket import socket
+from socket import socket, socketpair
from typing import TYPE_CHECKING, Any
from unittest import mock
from unittest.mock import ANY, AsyncMock, MagicMock, patch
+import greenback
+import msgspec
import pendulum
import pytest
-from asgiref.sync import sync_to_async
+import pytest_asyncio
+from asgiref.sync import async_to_sync, sync_to_async
from opentelemetry import trace as otel_trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
@@ -67,7 +72,7 @@ from airflow.providers.standard.operators.python import
PythonOperator
from airflow.providers.standard.triggers.file import FileDeleteTrigger
from airflow.providers.standard.triggers.temporal import DateTimeTrigger,
TimeDeltaTrigger
from airflow.sdk import DAG, BaseHook, BaseOperator
-from airflow.sdk.execution_time.comms import ToSupervisor, ToTask
+from airflow.sdk.execution_time.comms import ToSupervisor, ToTask,
_RequestFrame, _ResponseFrame
from airflow.serialization.serialized_objects import LazyDeserializedDAG
from airflow.triggers.base import BaseTrigger, TriggerEvent
from airflow.triggers.testing import FailureTrigger, SuccessTrigger
@@ -229,6 +234,54 @@ def supervisor_builder(mocker, session):
return builder
+def test_heartbeat_watchdog(supervisor_builder, mocker):
+ """heartbeat() fires when the subprocess is active, skips when silent, and
only arms the
+ silence flag once (so the error is logged once, not on every subsequent
call)."""
+ supervisor = supervisor_builder()
+ perform_heartbeat_mock =
mocker.patch("airflow.jobs.triggerer_job_runner.perform_heartbeat")
+
+ # Within threshold — heartbeat fires
+ supervisor._last_runner_comms = time.monotonic() - 5.0
+ supervisor.heartbeat()
+ perform_heartbeat_mock.assert_called_once()
+
+ # Just inside threshold (29.9s < 30s default) — still fires
+ supervisor._last_runner_comms = time.monotonic() - 29.9
+ supervisor.heartbeat()
+ assert perform_heartbeat_mock.call_count == 2
+
+ # Beyond threshold — heartbeat skips and silence flag is set
+ supervisor._last_runner_comms = time.monotonic() - 9999.0
+ supervisor.heartbeat()
+ assert perform_heartbeat_mock.call_count == 2
+ assert supervisor._runner_comms_silence_logged is True
+
+ # Subsequent silent heartbeats don't re-arm the flag (error logged only
once)
+ supervisor.heartbeat()
+ supervisor.heartbeat()
+ assert perform_heartbeat_mock.call_count == 2
+ assert supervisor._runner_comms_silence_logged is True
+
+ # Once the subprocess speaks again the flag resets and heartbeat resumes
+ supervisor._last_runner_comms = time.monotonic()
+ supervisor.heartbeat()
+ assert perform_heartbeat_mock.call_count == 3
+ assert supervisor._runner_comms_silence_logged is False
+
+
+def
test_heartbeat_watchdog_disabled_when_threshold_is_zero(supervisor_builder,
mocker):
+ """Setting runner_health_check_threshold=0 disables the watchdog;
heartbeat always fires."""
+ supervisor = supervisor_builder()
+ mocker.patch.object(type(supervisor), "runner_health_check_threshold",
new=0)
+ perform_heartbeat_mock =
mocker.patch("airflow.jobs.triggerer_job_runner.perform_heartbeat")
+
+ supervisor._last_runner_comms = time.monotonic() - 9999.0
+
+ supervisor.heartbeat()
+
+ perform_heartbeat_mock.assert_called_once()
+
+
def test_trigger_lifecycle(spy_agency: SpyAgency, session, testing_dag_bundle):
"""
Checks that the triggerer will correctly see a new Trigger in the database
@@ -1455,3 +1508,175 @@ class TestMakeTriggerSpan:
assert attrs["airflow.trigger.name"] == "OnlyTrigger"
assert "airflow.dag_id" not in attrs
assert "airflow.task_id" not in attrs
+
+
+def _read_frame_sync(sock) -> _RequestFrame | None:
+ """Read a length-prefixed msgpack frame from a blocking socket."""
+ lb = b""
+ while len(lb) < 4:
+ chunk = sock.recv(4 - len(lb))
+ if not chunk:
+ return None
+ lb += chunk
+ n = int.from_bytes(lb, "big")
+ data = b""
+ while len(data) < n:
+ chunk = sock.recv(n - len(data))
+ if not chunk:
+ return None
+ data += chunk
+ return msgspec.msgpack.decode(data, type=_RequestFrame)
+
+
+@pytest_asyncio.fixture
+async def decoder_pair():
+ """Yield (decoder, server_sock). Caller owns closing."""
+ server_sock, client_sock = socketpair()
+ reader, writer = await asyncio.open_connection(sock=client_sock)
+ decoder = TriggerCommsDecoder(async_writer=writer, async_reader=reader,
socket=client_sock)
+ await decoder.start_reader()
+ yield decoder, server_sock
+ if decoder._reader_task:
+ if not decoder._reader_task.done():
+ decoder._reader_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError, Exception):
+ await decoder._reader_task
+ writer.close()
+ server_sock.close()
+
+
[email protected]
[email protected]_timeout(15)
+async def test_all_send_paths_concurrent(decoder_pair):
+ """
+ All four send() paths running concurrently with responses returned out of
order:
+
+ 1. asend() directly from async code — pure-async path
+ 2. send() via asyncio.to_thread() — mirrors
apache/airflow#63913:
+
sync_to_async(hook_class)() → get_connection()
+ →
SUPERVISOR_COMMS.send() from a thread pool thread
+ 3. send() from the event-loop thread — mirrors
apache/airflow#63760:
+ via greenback async_to_sync raised
RuntimeError in same thread
+ 4. async_to_sync(asend)() from a thread — trigger code that wraps
an async fn which
+ internally calls asend;
bridges via wrap_future
+
+ The concurrent mix with shuffled responses also covers
apache/airflow#65286: the
+ _thread_lock + async_to_sync approach stalled the triggerer under this
exact load pattern.
+ """
+ decoder, server_sock = decoder_pair
+ N = 5
+ N_TOTAL = N * 4
+
+ def supervisor():
+ frames = []
+ for _ in range(N_TOTAL):
+ f = _read_frame_sync(server_sock)
+ if f is None:
+ break
+ frames.append(f)
+ random.shuffle(frames)
+ for f in frames:
+ server_sock.sendall(
+ _ResponseFrame(
+ id=f.id,
+ body={"type": "TriggerStateSync", "to_create": [],
"to_cancel": []},
+ ).as_bytes()
+ )
+
+ sup = threading.Thread(target=supervisor, daemon=True)
+ sup.start()
+
+ async def async_send(idx):
+ return await decoder.asend(messages.TriggerStateChanges(events=None,
finished=[idx], failures=None))
+
+ async def from_thread_send(idx):
+ # In production this path is taken by asgiref's own thread pool
(sync_to_async),
+ # which is invisible to asyncio's default executor. We avoid
asyncio.to_thread()
+ # here because on Python < 3.12 loop.shutdown_default_executor() has
no timeout
+ # and hangs if any executor threads are still alive at loop teardown.
+ # TODO: simplify with asyncio.to_thread() when Python 3.12 is the
minimum.
+ loop = asyncio.get_running_loop()
+ fut: asyncio.Future[messages.TriggerStateSync] = loop.create_future()
+
+ def sync_send():
+ try:
+ result = decoder.send(
+ messages.TriggerStateChanges(events=None, finished=[N +
idx], failures=None)
+ )
+ loop.call_soon_threadsafe(fut.set_result, result)
+ except Exception as exc:
+ loop.call_soon_threadsafe(fut.set_exception, exc)
+
+ threading.Thread(target=sync_send, daemon=True).start()
+ return await fut
+
+ async def greenback_send(idx):
+ await greenback.ensure_portal()
+ return decoder.send(messages.TriggerStateChanges(events=None,
finished=[2 * N + idx], failures=None))
+
+ async def async_to_sync_send(idx):
+ # Same executor-avoidance reason as from_thread_send above.
+ # TODO: simplify with asyncio.to_thread() when Python 3.12 is the
minimum.
+ loop = asyncio.get_running_loop()
+ fut: asyncio.Future[messages.TriggerStateSync] = loop.create_future()
+
+ def thread_fn():
+ try:
+ result = async_to_sync(decoder.asend)(
+ messages.TriggerStateChanges(events=None, finished=[3 * N
+ idx], failures=None)
+ )
+ loop.call_soon_threadsafe(fut.set_result, result)
+ except Exception as exc:
+ loop.call_soon_threadsafe(fut.set_exception, exc)
+
+ threading.Thread(target=thread_fn, daemon=True).start()
+ return await fut
+
+ results = await asyncio.gather(
+ *[asyncio.create_task(async_send(i)) for i in range(N)],
+ *[asyncio.create_task(from_thread_send(i)) for i in range(N)],
+ *[asyncio.create_task(greenback_send(i)) for i in range(N)],
+ *[asyncio.create_task(async_to_sync_send(i)) for i in range(N)],
+ return_exceptions=True,
+ )
+
+ sup.join(timeout=5)
+
+ errors = [r for r in results if isinstance(r, Exception)]
+ assert not errors, f"errors: {errors}"
+ assert len(results) == N_TOTAL
+ assert all(isinstance(r, messages.TriggerStateSync) for r in results)
+
+
[email protected]
+async def test_connection_close_cancels_pending(decoder_pair):
+ """When the connection closes while asend() is awaiting, the future is
cancelled."""
+ decoder, server_sock = decoder_pair
+
+ task = asyncio.create_task(
+ decoder.asend(messages.TriggerStateChanges(events=None, finished=[1],
failures=None))
+ )
+ await asyncio.sleep(0)
+
+ server_sock.close()
+
+ with pytest.raises((asyncio.CancelledError, Exception)):
+ await asyncio.wait_for(task, timeout=5)
+
+
[email protected]
+async def test_unknown_frame_id_doesnt_crash_reader(decoder_pair):
+ """An orphan response frame (no matching pending future) is silently
dropped; reader stays alive."""
+ decoder, server_sock = decoder_pair
+
+ server_sock.sendall(
+ _ResponseFrame(
+ id=99999,
+ body={"type": "TriggerStateSync", "to_create": [], "to_cancel":
[]},
+ ).as_bytes()
+ )
+
+ await asyncio.sleep(0.05)
+
+ assert decoder._reader_task is not None
+ assert not decoder._reader_task.done(), "reader loop crashed unexpectedly"
diff --git a/task-sdk/src/airflow/sdk/execution_time/secrets/execution_api.py
b/task-sdk/src/airflow/sdk/execution_time/secrets/execution_api.py
index 6bda6440e07..b8b240b4988 100644
--- a/task-sdk/src/airflow/sdk/execution_time/secrets/execution_api.py
+++ b/task-sdk/src/airflow/sdk/execution_time/secrets/execution_api.py
@@ -65,26 +65,6 @@ class ExecutionAPISecretsBackend(BaseSecretsBackend):
# Convert ExecutionAPI response to SDK Connection
return _process_connection_result_conn(msg)
- except RuntimeError as e:
- # TriggerCommsDecoder.send() uses async_to_sync internally, which
raises RuntimeError
- # when called within an async event loop. In greenback portal
contexts (triggerer),
- # we catch this and use greenback to call the async version
instead.
- if str(e).startswith("You cannot use AsyncToSync in the same
thread as an async event loop"):
- import asyncio
-
- import greenback
-
- task = asyncio.current_task()
- if greenback.has_portal(task):
- import warnings
-
- warnings.warn(
- "You should not use sync calls here -- use `await
aget_connection` instead",
- stacklevel=2,
- )
- return greenback.await_(self.aget_connection(conn_id))
- # Fall through to the general exception handler for other
RuntimeErrors
- return None
except Exception:
# If SUPERVISOR_COMMS fails for any reason, return None
# to allow fallback to other backends
@@ -113,26 +93,6 @@ class ExecutionAPISecretsBackend(BaseSecretsBackend):
if isinstance(msg, VariableResult):
return msg.value # Already a string | None
return None
- except RuntimeError as e:
- # TriggerCommsDecoder.send() uses async_to_sync internally, which
raises RuntimeError
- # when called within an async event loop. In greenback portal
contexts (triggerer),
- # we catch this and use greenback to call the async version
instead.
- if str(e).startswith("You cannot use AsyncToSync in the same
thread as an async event loop"):
- import asyncio
-
- import greenback
-
- task = asyncio.current_task()
- if greenback.has_portal(task):
- import warnings
-
- warnings.warn(
- "You should not use sync calls here -- use `await
aget_variable` instead",
- stacklevel=2,
- )
- return greenback.await_(self.aget_variable(key))
- # Fall through to the general exception handler for other
RuntimeErrors
- return None
except Exception:
# If SUPERVISOR_COMMS fails for any reason, return None
# to allow fallback to other backends
diff --git a/task-sdk/tests/task_sdk/execution_time/test_secrets.py
b/task-sdk/tests/task_sdk/execution_time/test_secrets.py
index ccdabb3dd58..bda87a6a64a 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_secrets.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_secrets.py
@@ -19,7 +19,6 @@ from __future__ import annotations
import pytest
-from airflow.sdk.definitions.connection import Connection
from airflow.sdk.execution_time.secrets.execution_api import
ExecutionAPISecretsBackend
@@ -121,106 +120,6 @@ class TestExecutionAPISecretsBackend:
with pytest.raises(NotImplementedError, match="Use get_connection
instead"):
backend.get_conn_value("test_conn")
- def test_runtime_error_triggers_greenback_fallback(self, mocker,
mock_supervisor_comms):
- """
- Test that RuntimeError from async_to_sync triggers greenback fallback.
-
- This test verifies the fix for issue #57145: when
SUPERVISOR_COMMS.send()
- raises the specific RuntimeError about async_to_sync in an event loop,
- the backend catches it and uses greenback to call aget_connection().
- """
-
- # Expected connection to be returned
- expected_conn = Connection(
- conn_id="databricks_default",
- conn_type="databricks",
- host="example.databricks.com",
- )
-
- # Simulate the RuntimeError that triggers greenback fallback
- mock_supervisor_comms.send.side_effect = RuntimeError(
- "You cannot use AsyncToSync in the same thread as an async event
loop"
- )
-
- # Mock the greenback and asyncio modules that are imported inside the
exception handler
- mocker.patch("greenback.has_portal", return_value=True)
- mocker.patch("asyncio.current_task")
-
- # Mock greenback.await_ to actually await the coroutine it receives.
- # This prevents Python 3.13 RuntimeWarning about unawaited coroutines.
- import asyncio
-
- def greenback_await_side_effect(coro):
- loop = asyncio.new_event_loop()
- try:
- return loop.run_until_complete(coro)
- finally:
- loop.close()
-
- mock_greenback_await = mocker.patch("greenback.await_",
side_effect=greenback_await_side_effect)
-
- # Mock aget_connection to return the expected connection directly.
- # We need to mock this because the real aget_connection would try to
- # use SUPERVISOR_COMMS.asend which is not set up for this test.
- async def mock_aget_connection(self, conn_id):
- return expected_conn
-
- mocker.patch.object(ExecutionAPISecretsBackend, "aget_connection",
mock_aget_connection)
-
- backend = ExecutionAPISecretsBackend()
- conn = backend.get_connection("databricks_default")
-
- # Verify we got the expected connection
- assert conn is not None
- assert conn.conn_id == "databricks_default"
- # Verify the greenback fallback was called
- mock_greenback_await.assert_called_once()
- # Verify send was attempted first (and raised RuntimeError)
- mock_supervisor_comms.send.assert_called_once()
-
- def test_get_variable_runtime_error_triggers_greenback_fallback(self,
mocker, mock_supervisor_comms):
- """
- Test that RuntimeError from async_to_sync triggers greenback fallback
for variables.
-
- Same as the connection test but for get_variable — verifies the fix
for #61676:
- triggers calling Variable.get() fail because SUPERVISOR_COMMS.send()
raises
- RuntimeError in the async event loop, but the greenback fallback was
missing.
- """
- expected_value = "10"
-
- # Simulate the RuntimeError that triggers greenback fallback
- mock_supervisor_comms.send.side_effect = RuntimeError(
- "You cannot use AsyncToSync in the same thread as an async event
loop"
- )
-
- # Mock the greenback and asyncio modules
- mocker.patch("greenback.has_portal", return_value=True)
- mocker.patch("asyncio.current_task")
-
- import asyncio
-
- def greenback_await_side_effect(coro):
- loop = asyncio.new_event_loop()
- try:
- return loop.run_until_complete(coro)
- finally:
- loop.close()
-
- mock_greenback_await = mocker.patch("greenback.await_",
side_effect=greenback_await_side_effect)
-
- # Mock aget_variable to return the expected value
- async def mock_aget_variable(self, key):
- return expected_value
-
- mocker.patch.object(ExecutionAPISecretsBackend, "aget_variable",
mock_aget_variable)
-
- backend = ExecutionAPISecretsBackend()
- result = backend.get_variable("retries")
-
- assert result == expected_value
- mock_greenback_await.assert_called_once()
- mock_supervisor_comms.send.assert_called_once()
-
class TestContextDetection:
"""Test context detection in ensure_secrets_backend_loaded."""