This is an automated email from the ASF dual-hosted git repository.
shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 45bad9f54dd Use async DB session for Execution API task-instance
heartbeat (#67800)
45bad9f54dd is described below
commit 45bad9f54ddb186ba75f7062a76c0770cc125f29
Author: Dev-iL <[email protected]>
AuthorDate: Sat Sep 19 23:29:47 2026 +0300
Use async DB session for Execution API task-instance heartbeat (#67800)
---
.../src/airflow/api_fastapi/common/db/common.py | 2 +-
.../execution_api/routes/task_instances.py | 32 ++++++----
.../src/airflow/config_templates/config.yml | 7 ++-
airflow-core/src/airflow/settings.py | 34 +++++++++--
.../versions/head/test_task_instances.py | 71 +++++++++++++++++++---
airflow-core/tests/unit/core/test_settings.py | 57 ++++++++++++++++-
6 files changed, 174 insertions(+), 29 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/common/db/common.py
b/airflow-core/src/airflow/api_fastapi/common/db/common.py
index 8abcaf59fa8..5807cabcec3 100644
--- a/airflow-core/src/airflow/api_fastapi/common/db/common.py
+++ b/airflow-core/src/airflow/api_fastapi/common/db/common.py
@@ -94,7 +94,7 @@ async def _get_async_session() ->
AsyncGenerator[AsyncSession, None]:
yield session
-AsyncSessionDep = Annotated[AsyncSession, Depends(_get_async_session)]
+AsyncSessionDep = Annotated[AsyncSession, Depends(_get_async_session,
scope="function")]
@overload
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
index a49d8fe30e5..14910a4d866 100644
---
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
+++
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
@@ -33,7 +33,7 @@ from opentelemetry import trace
from opentelemetry.trace import StatusCode
from opentelemetry.trace.propagation.tracecontext import
TraceContextTextMapPropagator
from pydantic import JsonValue, ValidationError
-from sqlalchemy import and_, func, or_, tuple_, update
+from sqlalchemy import and_, exists, func, or_, tuple_, update
from sqlalchemy.engine import CursorResult
from sqlalchemy.exc import DataError, NoResultFound, SQLAlchemyError
from sqlalchemy.orm import contains_eager, joinedload
@@ -44,7 +44,7 @@ from airflow._shared.observability.traces import override_ids
from airflow._shared.state import TaskScope
from airflow._shared.timezones import timezone
from airflow.api_fastapi.common.dagbag import DagBagDep,
get_latest_version_of_dag
-from airflow.api_fastapi.common.db.common import SessionDep
+from airflow.api_fastapi.common.db.common import AsyncSessionDep, SessionDep
from airflow.api_fastapi.common.db.dags import eager_load_teams
from airflow.api_fastapi.common.types import UtcDateTime
from airflow.api_fastapi.compat import HTTP_422_UNPROCESSABLE_CONTENT
@@ -891,11 +891,9 @@ def ti_skip_downstream(
log.info("Downstream tasks skipped", tasks_skipped=getattr(result,
"rowcount", 0))
-def _raise_ti_not_in_live_table(task_instance_id: UUID, session: SessionDep)
-> NoReturn:
+def _raise_ti_not_in_live_table(task_instance_id: UUID, *,
archived_in_history: bool) -> NoReturn:
"""Raise 410 Gone if the missing TI id was archived to history, else 404
Not Found."""
- if session.scalar(
- select(func.count(TIH.task_instance_id)).where(TIH.task_instance_id ==
task_instance_id)
- ):
+ if archived_in_history:
log.error("TaskInstance not in live table but archived in history",
ti_id=str(task_instance_id))
raise HTTPException(
status_code=status.HTTP_410_GONE,
@@ -932,10 +930,10 @@ def _raise_ti_not_in_live_table(task_instance_id: UUID,
session: SessionDep) ->
]
),
)
-def ti_heartbeat(
+async def ti_heartbeat(
task_instance_id: UUID,
ti_payload: TIHeartbeatInfo,
- session: SessionDep,
+ session: AsyncSessionDep,
):
"""Update the heartbeat of a TaskInstance to mark it as alive & still
running."""
bind_contextvars(ti_id=str(task_instance_id))
@@ -945,7 +943,7 @@ def ti_heartbeat(
# so we can update last_heartbeat_at directly without first taking a row
lock.
fast_path_result = cast(
"CursorResult[Any]",
- session.execute(
+ await session.execute(
update(TI)
.where(
TI.id == task_instance_id,
@@ -966,7 +964,7 @@ def ti_heartbeat(
old = select(TI.state, TI.hostname, TI.pid).where(TI.id ==
task_instance_id).with_for_update()
try:
- (previous_state, hostname, pid) = session.execute(old).one()
+ (previous_state, hostname, pid) = (await session.execute(old)).one()
log.debug(
"Retrieved current task state", state=previous_state,
current_hostname=hostname, current_pid=pid
)
@@ -974,7 +972,10 @@ def ti_heartbeat(
# Check if the TI exists in the Task Instance History table.
# If it does, it was likely cleared while running, so return 410 Gone
# instead of 404 Not Found to give the client a more specific signal.
- _raise_ti_not_in_live_table(task_instance_id, session)
+ archived_in_history = bool(
+ await session.scalar(select(exists().where(TIH.task_instance_id ==
task_instance_id)))
+ )
+ _raise_ti_not_in_live_table(task_instance_id,
archived_in_history=archived_in_history)
if hostname != ti_payload.hostname or pid != ti_payload.pid:
log.warning(
@@ -1006,7 +1007,9 @@ def ti_heartbeat(
)
# Update the last heartbeat time!
- session.execute(update(TI).where(TI.id ==
task_instance_id).values(last_heartbeat_at=timezone.utcnow()))
+ await session.execute(
+ update(TI).where(TI.id ==
task_instance_id).values(last_heartbeat_at=timezone.utcnow())
+ )
log.debug("Heartbeat updated", state=previous_state)
@@ -1044,7 +1047,10 @@ def ti_put_rtif(
task_instance = session.scalar(select(TI).where(TI.id == task_instance_id))
if not task_instance:
# On retry/clear, the server regenerates the TI id. Return 410 for the
stale id.
- _raise_ti_not_in_live_table(task_instance_id, session)
+ archived_in_history = bool(
+ session.scalar(select(exists().where(TIH.task_instance_id ==
task_instance_id)))
+ )
+ _raise_ti_not_in_live_table(task_instance_id,
archived_in_history=archived_in_history)
task_instance.update_rtif(put_rtif_payload, session=session)
log.debug("RenderedTaskInstanceFields updated successfully")
diff --git a/airflow-core/src/airflow/config_templates/config.yml
b/airflow-core/src/airflow/config_templates/config.yml
index b6774cc8657..3cf451d55fc 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -736,9 +736,12 @@ database:
sql_alchemy_connect_args_async:
description: |
Import path for connect args in SQLAlchemy. Defaults to an empty dict.
- This is similar to ``sql_alchemy_connect_args``, but only for async
connections.
+ This is similar to ``sql_alchemy_connect_args``, but only for async
connections and must be a dict.
- This configuration is only applied to async engines, such as asyncpg.
+ This is applied to the async engine only. The default async Postgres
driver (psycopg3) needs
+ no extra args. If you opt in to the ``asyncpg`` driver behind
transaction-mode pgbouncer, set
+ ``statement_cache_size`` and ``prepared_statement_cache_size`` to
``0`` here to avoid "prepared
+ statement does not exist" errors. See the async driver section of the
Postgres setup guide.
version_added: 3.1.0
type: string
example: 'airflow_local_settings.connect_args_async'
diff --git a/airflow-core/src/airflow/settings.py
b/airflow-core/src/airflow/settings.py
index ec2724abb5b..08d4fbbfe72 100644
--- a/airflow-core/src/airflow/settings.py
+++ b/airflow-core/src/airflow/settings.py
@@ -240,8 +240,28 @@ def load_policy_plugins(pm: pluggy.PluginManager):
pm.load_setuptools_entrypoints("airflow.policy")
+def _translate_asyncpg_sslmode(async_uri: str) -> str:
+ """
+ Rename the libpq ``sslmode`` query param to asyncpg's ``ssl`` equivalent.
+
+ asyncpg has no ``sslmode`` connect arg -- SQLAlchemy's asyncpg dialect
passes URL
+ query params straight to ``asyncpg.connect()``, which spells the option
``ssl`` and
+ accepts the same libpq mode strings
(``disable``/``prefer``/``require``/``verify-ca``/
+ ``verify-full``). A sync Postgres URI commonly carries ``sslmode`` (the
official Helm
+ chart sets it unconditionally), so the derived async URI must translate
it; otherwise
+ every async DB call raises ``TypeError: connect() got an unexpected
keyword argument
+ 'sslmode'``.
+ """
+ url = make_url(async_uri)
+ if "sslmode" not in url.query:
+ return async_uri
+ query = dict(url.query)
+ query["ssl"] = query.pop("sslmode")
+ return url.set(query=query).render_as_string(hide_password=False)
+
+
def _get_async_conn_uri_from_sync(sync_uri):
- # Mapping of backend to async driver:
+ # Derive the async URI scheme from the sync one by swapping in the async
driver:
AIO_LIBS_MAPPING = {
"sqlite": "aiosqlite",
"postgresql": "psycopg_async" if _USE_PSYCOPG3 else "asyncpg",
@@ -251,9 +271,15 @@ def _get_async_conn_uri_from_sync(sync_uri):
scheme, rest = sync_uri.split(":", maxsplit=1)
scheme = scheme.split("+", maxsplit=1)[0]
aiolib = AIO_LIBS_MAPPING.get(scheme)
- if aiolib:
- return f"{scheme}+{aiolib}:{rest}"
- return sync_uri
+ if not aiolib:
+ return sync_uri
+ async_uri = f"{scheme}+{aiolib}:{rest}"
+ if aiolib == "asyncpg":
+ # asyncpg-only: it is not libpq-based and has no ``sslmode`` connect
arg. A
+ # libpq-based async driver (e.g. psycopg3) understands ``sslmode``
natively and
+ # would in turn reject ``ssl``, so this translation must stay gated on
asyncpg.
+ async_uri = _translate_asyncpg_sslmode(async_uri)
+ return async_uri
def configure_vars():
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
index ccaecce822d..80278c28d07 100644
---
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
+++
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
@@ -36,6 +36,7 @@ from opentelemetry.trace.propagation.tracecontext import
TraceContextTextMapProp
from pydantic import ValidationError
from sqlalchemy import select, update
from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from airflow._shared.observability.traces import OverrideableRandomIdGenerator
@@ -2862,6 +2863,19 @@ class TestTIHealthEndpoint:
def teardown_method(self):
clear_db_runs()
+ # ti_heartbeat runs on the async engine. The async engine binds its pool to
+ # the event loop that created it (once per process), but the test harness
+ # builds a fresh FastAPI app and event loop per test, so a pooled
connection
+ # from a prior test's closed loop gets reused and fails ("attached to a
+ # different loop"). Re-configuring the async session before each test
rebuilds
+ # the engine on the current loop. Same workaround as TestWaitDagRun in
+ # tests/unit/api_fastapi/core_api/routes/public/test_dag_run.py.
+ @pytest.fixture(autouse=True)
+ def reconfigure_async_db_engine(self):
+ from airflow.settings import _configure_async_session
+
+ _configure_async_session()
+
@pytest.mark.parametrize(
("hostname", "pid", "expected_status_code", "expected_detail"),
[
@@ -3120,10 +3134,10 @@ class TestTIHealthEndpoint:
new_time = time_now.add(minutes=10)
time_machine.move_to(new_time, tick=False)
- original_execute = Session.execute
+ original_execute = AsyncSession.execute
fast_path_intercepted = False
- def execute_with_fast_path_miss(session_obj, statement, *args,
**kwargs):
+ async def execute_with_fast_path_miss(session_obj, statement, *args,
**kwargs):
nonlocal fast_path_intercepted
if (
not fast_path_intercepted
@@ -3132,9 +3146,9 @@ class TestTIHealthEndpoint:
):
fast_path_intercepted = True
return mock.MagicMock(rowcount=0)
- return original_execute(session_obj, statement, *args, **kwargs)
+ return await original_execute(session_obj, statement, *args,
**kwargs)
- monkeypatch.setattr(Session, "execute", execute_with_fast_path_miss)
+ monkeypatch.setattr(AsyncSession, "execute",
execute_with_fast_path_miss)
response = client.put(
f"/execution/task-instances/{ti.id}/heartbeat",
@@ -3166,10 +3180,10 @@ class TestTIHealthEndpoint:
new_time = time_now.add(minutes=10)
time_machine.move_to(new_time, tick=False)
- original_execute = Session.execute
+ original_execute = AsyncSession.execute
fast_path_intercepted = False
- def execute_with_unknown_fast_path_rowcount(session_obj, statement,
*args, **kwargs):
+ async def execute_with_unknown_fast_path_rowcount(session_obj,
statement, *args, **kwargs):
nonlocal fast_path_intercepted
if (
not fast_path_intercepted
@@ -3178,9 +3192,9 @@ class TestTIHealthEndpoint:
):
fast_path_intercepted = True
return mock.MagicMock(rowcount=-1)
- return original_execute(session_obj, statement, *args, **kwargs)
+ return await original_execute(session_obj, statement, *args,
**kwargs)
- monkeypatch.setattr(Session, "execute",
execute_with_unknown_fast_path_rowcount)
+ monkeypatch.setattr(AsyncSession, "execute",
execute_with_unknown_fast_path_rowcount)
response = client.put(
f"/execution/task-instances/{ti.id}/heartbeat",
@@ -3192,6 +3206,47 @@ class TestTIHealthEndpoint:
session.refresh(ti)
assert ti.last_heartbeat_at == new_time
+ def test_ti_heartbeat_commit_failure_surfaces_error(
+ self, client, session, create_task_instance, monkeypatch
+ ):
+ """A commit failure must reach the worker as an error, never a silent
204.
+
+ ``AsyncSessionDep`` is function-scoped, so the yield-dependency commit
runs
+ *before* the response is sent -- mirroring the sync ``SessionDep``.
Were it
+ request-scoped (the FastAPI default for ``yield`` dependencies), the
204 would
+ be sent before the commit, so a commit failure (e.g. an asyncpg /
+ transaction-mode PgBouncer drop) would roll back *after* the worker
already
+ saw success. Regression guard for the parity goal of this route
conversion.
+ """
+ ti = create_task_instance(
+ task_id="test_ti_heartbeat_commit_failure",
+ state=State.RUNNING,
+ hostname="random-hostname",
+ pid=1789,
+ session=session,
+ )
+ session.commit()
+
+ async def failing_commit(self):
+ raise SQLAlchemyError("simulated commit failure (connection
dropped)")
+
+ monkeypatch.setattr(AsyncSession, "commit", failing_commit)
+ # The default TestClient re-raises server exceptions, and it does so
for *both*
+ # dependency scopes; the worker-visible status (500 vs a silent 204)
is what
+ # tells them apart, so observe the response the worker would actually
receive.
+ monkeypatch.setattr(client._transport, "raise_server_exceptions",
False)
+
+ response = client.put(
+ f"/execution/task-instances/{ti.id}/heartbeat",
+ json={"hostname": "random-hostname", "pid": 1789},
+ )
+
+ # Function scope -> commit fails before the response -> 500. Request
scope -> 204.
+ assert response.status_code == 500
+ # The transaction rolled back, so the heartbeat was not persisted.
+ session.refresh(ti)
+ assert ti.last_heartbeat_at is None
+
class TestTIPutRTIF:
def setup_method(self):
diff --git a/airflow-core/tests/unit/core/test_settings.py
b/airflow-core/tests/unit/core/test_settings.py
index c703ee05ab9..be9e34f611a 100644
--- a/airflow-core/tests/unit/core/test_settings.py
+++ b/airflow-core/tests/unit/core/test_settings.py
@@ -25,7 +25,7 @@ from unittest import mock
from unittest.mock import MagicMock, call, patch
import pytest
-from sqlalchemy.engine import Engine
+from sqlalchemy.engine import Engine, make_url
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.pool import NullPool
@@ -400,6 +400,61 @@ class TestMetadataEngineHooks:
assert engine is not None
[email protected](
+ ("sync_uri", "expected_drivername", "expected_query"),
+ [
+ # libpq's ``sslmode`` is not an asyncpg connect arg -- it must be
translated to
+ # asyncpg's ``ssl`` (which accepts the same mode strings). Without
this the
+ # derived async URI raises "connect() got an unexpected keyword
argument 'sslmode'"
+ # on every async DB call. The official Helm chart sets sslmode=disable
by default.
+ (
+ "postgresql://airflow:pw@postgres:5432/airflow?sslmode=disable",
+ "postgresql+asyncpg",
+ {"ssl": "disable"},
+ ),
+ (
+ "postgresql://airflow:pw@postgres/airflow?sslmode=verify-full",
+ "postgresql+asyncpg",
+ {"ssl": "verify-full"},
+ ),
+ # An explicit sync driver in the scheme is dropped before swapping to
asyncpg.
+ (
+ "postgresql+psycopg2://u:p@h/db?sslmode=require",
+ "postgresql+asyncpg",
+ {"ssl": "require"},
+ ),
+ # No sslmode -> the query is left untouched.
+ ("postgresql://u:p@h/db", "postgresql+asyncpg", {}),
+ ],
+)
+def test_get_async_conn_uri_translates_pg_sslmode(sync_uri,
expected_drivername, expected_query):
+ from airflow import settings
+ from airflow.settings import _get_async_conn_uri_from_sync
+
+ with patch.object(settings, "_USE_PSYCOPG3", False):
+ url = make_url(_get_async_conn_uri_from_sync(sync_uri))
+ assert url.drivername == expected_drivername
+ assert "sslmode" not in url.query
+ assert dict(url.query) == expected_query
+
+
[email protected](
+ "sync_uri",
+ [
+ # Non-postgres backends use a different driver and SSL params; the
sslmode
+ # translation must not touch their query strings.
+ "mysql://u:p@h/db?charset=utf8mb4",
+ "sqlite:////tmp/airflow.db",
+ ],
+)
+def test_get_async_conn_uri_leaves_non_postgres_query_untouched(sync_uri):
+ from airflow.settings import _get_async_conn_uri_from_sync
+
+ src_query = dict(make_url(sync_uri).query)
+ out_query = dict(make_url(_get_async_conn_uri_from_sync(sync_uri)).query)
+ assert out_query == src_query
+
+
_local_db_path_error = pytest.raises(AirflowConfigException, match=r"Cannot
use relative path:")