You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by ra...@apache.org on 2026/04/15 06:01:01 UTC
(airflow) branch main updated: Fix dispose_orm() not disposing async engine on shutdown (#65274)
This is an automated email from the ASF dual-hosted git repository.
rahulvats 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 69a88bfecc6 Fix dispose_orm() not disposing async engine on shutdown (#65274)
69a88bfecc6 is described below
commit 69a88bfecc63ff336070529c8443c61315e2bdef
Author: Kaxil Naik <ka...@apache.org>
AuthorDate: Wed Apr 15 07:00:52 2026 +0100
Fix dispose_orm() not disposing async engine on shutdown (#65274)
When _configure_async_session() was extracted from configure_orm() in
PR #51920, the corresponding cleanup in dispose_orm() was not updated.
This left async_engine connections abandoned on process exit, gunicorn
worker restarts, and atexit -- gradually exhausting PostgreSQL's
max_connections.
Dispose async_engine.sync_engine (the synchronous path, matching
the existing clean_in_fork pattern) and clear both async_engine and
AsyncSession references.
---
airflow-core/src/airflow/settings.py | 13 ++++-
airflow-core/tests/unit/core/test_settings.py | 71 +++++++++++++++++++++++++++
2 files changed, 82 insertions(+), 2 deletions(-)
diff --git a/airflow-core/src/airflow/settings.py b/airflow-core/src/airflow/settings.py
index a00339603be..bf47f86a2c2 100644
--- a/airflow-core/src/airflow/settings.py
+++ b/airflow-core/src/airflow/settings.py
@@ -567,10 +567,14 @@ def prepare_engine_args(disable_connection_pool=False, pool_class=None):
def dispose_orm(do_log: bool = True):
"""Properly close pooled database connections."""
- global Session, engine, NonScopedSession
+ global Session, engine, NonScopedSession, async_engine, AsyncSession
_globals = globals()
- if _globals.get("engine") is None and _globals.get("Session") is None:
+ if (
+ _globals.get("engine") is None
+ and _globals.get("Session") is None
+ and _globals.get("async_engine") is None
+ ):
return
if do_log:
@@ -588,6 +592,11 @@ def dispose_orm(do_log: bool = True):
engine.dispose()
engine = None
+ if "async_engine" in _globals and async_engine is not None:
+ async_engine.sync_engine.dispose()
+ async_engine = None
+ AsyncSession = None
+
def reconfigure_orm(disable_connection_pool=False, pool_class=None):
"""Properly close database connections and re-configure ORM."""
diff --git a/airflow-core/tests/unit/core/test_settings.py b/airflow-core/tests/unit/core/test_settings.py
index a865094a574..22cb00b36bb 100644
--- a/airflow-core/tests/unit/core/test_settings.py
+++ b/airflow-core/tests/unit/core/test_settings.py
@@ -25,7 +25,10 @@ from unittest import mock
from unittest.mock import MagicMock, call, patch
import pytest
+from sqlalchemy.engine import Engine
+from sqlalchemy.ext.asyncio import AsyncEngine
+from airflow import settings
from airflow.exceptions import AirflowClusterPolicyViolation, AirflowConfigException
SETTINGS_FILE_CUSTOM_ENGINE = """
@@ -370,3 +373,71 @@ def test_sqlite_relative_path(value, expectation):
):
with expectation:
settings.configure_orm()
+
+
+class TestDisposeOrm:
+ """Tests for dispose_orm() async engine disposal."""
+
+ def setup_method(self):
+ self._orig = {
+ "engine": settings.engine,
+ "Session": settings.Session,
+ "NonScopedSession": settings.NonScopedSession,
+ "async_engine": settings.async_engine,
+ "AsyncSession": settings.AsyncSession,
+ }
+
+ def teardown_method(self):
+ for attr, val in self._orig.items():
+ setattr(settings, attr, val)
+
+ def test_disposes_async_engine(self):
+ """dispose_orm() must dispose the async engine and clear AsyncSession."""
+ mock_sync_engine = MagicMock(spec=Engine)
+ mock_async_engine = MagicMock(spec=AsyncEngine)
+ mock_async_engine.sync_engine = mock_sync_engine
+
+ settings.engine = None
+ settings.Session = None
+ settings.NonScopedSession = None
+ settings.async_engine = mock_async_engine
+ settings.AsyncSession = MagicMock()
+
+ settings.dispose_orm(do_log=False)
+
+ mock_sync_engine.dispose.assert_called_once()
+ assert settings.async_engine is None
+ assert settings.AsyncSession is None
+
+ def test_disposes_both_sync_and_async(self):
+ """dispose_orm() disposes both engines when both are set."""
+ mock_engine = MagicMock(spec=Engine)
+ mock_sync_engine = MagicMock(spec=Engine)
+ mock_async_engine = MagicMock(spec=AsyncEngine)
+ mock_async_engine.sync_engine = mock_sync_engine
+
+ settings.engine = mock_engine
+ settings.Session = MagicMock()
+ settings.NonScopedSession = MagicMock()
+ settings.async_engine = mock_async_engine
+ settings.AsyncSession = MagicMock()
+
+ with patch("sqlalchemy.orm.session.close_all_sessions"):
+ settings.dispose_orm(do_log=False)
+
+ mock_engine.dispose.assert_called_once()
+ mock_sync_engine.dispose.assert_called_once()
+ assert settings.engine is None
+ assert settings.async_engine is None
+ assert settings.AsyncSession is None
+
+ def test_early_return_when_all_none(self):
+ """dispose_orm() returns early without side effects when nothing is configured."""
+ settings.engine = None
+ settings.Session = None
+ settings.async_engine = None
+
+ with patch("sqlalchemy.orm.session.close_all_sessions") as mock_close:
+ settings.dispose_orm(do_log=False)
+
+ mock_close.assert_not_called()