Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
83fda8b
test: add failing listener-registration tests for Litestar async config
hasansezertasan Apr 10, 2026
c536111
test: add failing listener-registration tests for Litestar sync config
hasansezertasan Apr 10, 2026
b27febe
test: add failing listener-registration tests for Starlette config
hasansezertasan Apr 10, 2026
4a0122d
test: add failing listener-registration tests for Sanic config
hasansezertasan Apr 10, 2026
76f56f0
test: add failing listener-registration tests for Flask config
hasansezertasan Apr 10, 2026
d2d46b4
fix(litestar): delegate create_session_maker to super for listener wi…
hasansezertasan Apr 10, 2026
a81b484
fix(litestar): delegate sync create_session_maker to super for listen…
hasansezertasan Apr 10, 2026
b08c820
fix(starlette): delegate async create_session_maker to super for list…
hasansezertasan Apr 10, 2026
3ffc3eb
fix(starlette): delegate sync create_session_maker to super for liste…
hasansezertasan Apr 10, 2026
f33ea90
fix(sanic): delegate async create_session_maker to super for listener…
hasansezertasan Apr 10, 2026
07b034d
fix(sanic): delegate sync create_session_maker to super for listener …
hasansezertasan Apr 10, 2026
103b6f3
fix(flask): delegate sync create_session_maker to super for listener …
hasansezertasan Apr 10, 2026
1259ceb
fix(flask): delegate async create_session_maker to super for listener…
hasansezertasan Apr 10, 2026
7207763
test: pre-populate engine_instance to isolate listener-count assertions
hasansezertasan Apr 10, 2026
7568fbd
fix(starlette): restore engine_instance pre-step in create_session_maker
hasansezertasan Apr 10, 2026
5880d37
fix(sanic): restore engine_instance pre-step in create_session_maker
hasansezertasan Apr 10, 2026
bd22f74
fix(flask): restore engine_instance pre-step in create_session_maker
hasansezertasan Apr 10, 2026
efe8ef8
test: strengthen Starlette/Sanic/Flask async listener assertions
hasansezertasan Apr 10, 2026
a393376
test: extract shared listener-contract helpers for extension tests
hasansezertasan Apr 10, 2026
71d2651
test: pin FastAPI listener-registration contract (#709)
hasansezertasan Apr 10, 2026
46badd7
Update tests/unit/test_extensions/_listener_contract.py
hasansezertasan Apr 27, 2026
751a86a
Update advanced_alchemy/extensions/flask/config.py
hasansezertasan Apr 27, 2026
65a2d17
Update advanced_alchemy/extensions/litestar/plugins/init/config/async…
hasansezertasan Apr 27, 2026
f169d58
fix(ci): stabilize uv exclude-newer timestamp and drop blank line
hasansezertasan Apr 27, 2026
2f3ab18
fix(routing): close routing session-maker engines on extension teardown
hasansezertasan Apr 28, 2026
0dd2999
fix(starlette): wrap dispose_session_maker for run_in_threadpool typing
hasansezertasan Apr 28, 2026
993fa4d
Merge branch 'main' into fix/extension-create-session-maker-listeners
hasansezertasan May 3, 2026
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
25 changes: 13 additions & 12 deletions advanced_alchemy/extensions/flask/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig as _SQLAlchemyAsyncConfig
from advanced_alchemy.config.sync import SQLAlchemySyncConfig as _SQLAlchemySyncConfig
from advanced_alchemy.exceptions import ImproperConfigurationError
from advanced_alchemy.routing.maker import adispose_session_maker, dispose_session_maker
from advanced_alchemy.service import schema_dump

if TYPE_CHECKING:
Expand Down Expand Up @@ -84,19 +85,18 @@ class SQLAlchemySyncConfig(_SQLAlchemySyncConfig):
def create_session_maker(self) -> "Callable[[], Session]":
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration runs.
See issue #709.

Returns:
Callable[[], Session]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()

def init_app(self, app: "Flask", portal: "Optional[Portal]" = None) -> None:
"""Initialize the Flask application with this configuration.
Expand Down Expand Up @@ -142,6 +142,7 @@ def close_engines(self, portal: "Portal") -> None:
"""
if self.engine_instance is not None:
self.engine_instance.dispose()
dispose_session_maker(self.session_maker)

def create_all_metadata(self) -> None: # pragma: no cover
"""Create all metadata tables in the database."""
Expand Down Expand Up @@ -173,19 +174,17 @@ class SQLAlchemyAsyncConfig(_SQLAlchemyAsyncConfig):
def create_session_maker(self) -> "Callable[[], AsyncSession]":
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration runs.

Returns:
Callable[[], AsyncSession]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()

def init_app(self, app: "Flask", portal: "Optional[Portal]" = None) -> None:
"""Initialize the Flask application with this configuration.
Expand Down Expand Up @@ -246,6 +245,8 @@ def close_engines(self, portal: "Portal") -> None:
"""
if self.engine_instance is not None:
_ = portal.call(self.engine_instance.dispose)
if self.session_maker is not None:
_ = portal.call(adispose_session_maker, self.session_maker)

async def create_all_metadata(self) -> None: # pragma: no cover
"""Create all metadata tables in the database."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
)
from advanced_alchemy.extensions.litestar.plugins.init.config.engine import EngineConfig
from advanced_alchemy.routing.context import reset_routing_context
from advanced_alchemy.routing.maker import adispose_session_maker

logger = logging.getLogger("advanced_alchemy.extensions.litestar")

Expand Down Expand Up @@ -201,16 +202,15 @@ def __post_init__(self) -> None:
def create_session_maker(self) -> "Callable[[], AsyncSession]":
"""Get a session maker. If none exists yet, create one.

Delegates to the base-class implementation so that listener
registration (file-object, timestamp, cache) runs.

Returns:
Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if session_kws.get("bind") is None:
session_kws["bind"] = self.get_engine()
return self.session_maker_class(**session_kws) # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType]
return super().create_session_maker()

@asynccontextmanager
async def lifespan(
Expand All @@ -228,6 +228,7 @@ async def lifespan(
engine = deps[self.engine_dependency_key]
if hasattr(engine, "dispose"):
await cast("AsyncEngine", engine).dispose()
await adispose_session_maker(self.session_maker)

def provide_engine(self, state: "State") -> "AsyncEngine":
"""Create an engine instance.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
)
from advanced_alchemy.extensions.litestar.plugins.init.config.engine import EngineConfig
from advanced_alchemy.routing.context import reset_routing_context
from advanced_alchemy.routing.maker import dispose_session_maker

logger = logging.getLogger("advanced_alchemy.extensions.litestar")

Expand Down Expand Up @@ -202,16 +203,15 @@ def __post_init__(self) -> None:
def create_session_maker(self) -> "Callable[[], Session]":
"""Get a session maker. If none exists yet, create one.

Delegates to the base-class implementation so that listener
registration (file-object, timestamp, cache) runs. See issue #709.

Returns:
Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if session_kws.get("bind") is None:
session_kws["bind"] = self.get_engine()
return self.session_maker_class(**session_kws)
return super().create_session_maker()

@asynccontextmanager
async def lifespan(
Expand All @@ -229,6 +229,7 @@ async def lifespan(
engine = deps[self.engine_dependency_key]
if hasattr(engine, "dispose"):
cast("Engine", engine).dispose()
dispose_session_maker(self.session_maker)

def provide_engine(self, state: "State") -> "Engine":
"""Create an engine instance.
Expand Down
29 changes: 16 additions & 13 deletions advanced_alchemy/extensions/sanic/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from advanced_alchemy.config import EngineConfig as _EngineConfig
from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig as _SQLAlchemyAsyncConfig
from advanced_alchemy.config.sync import SQLAlchemySyncConfig as _SQLAlchemySyncConfig
from advanced_alchemy.routing.maker import adispose_session_maker, dispose_session_maker
from advanced_alchemy.service import schema_dump

logger = logging.getLogger("advanced_alchemy.extensions.sanic")
Expand Down Expand Up @@ -187,6 +188,7 @@ async def on_startup(_: Any) -> None: # pyright: ignore[reportUnusedFunction]
async def on_shutdown(_: Any) -> None: # pyright: ignore[reportUnusedFunction]
if self.engine_instance is not None:
await self.engine_instance.dispose()
await adispose_session_maker(self.session_maker)
if hasattr(self.app.ctx, self.engine_key): # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess]
delattr(self.app.ctx, self.engine_key) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess]
if hasattr(self.app.ctx, self.session_maker_key): # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess]
Expand All @@ -213,19 +215,18 @@ async def on_startup(self) -> None:
def create_session_maker(self) -> Callable[[], "AsyncSession"]:
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration
(file-object, timestamp, cache) runs. See issue #709.

Returns:
Callable[[], Session]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()

async def session_handler(
self, session: "AsyncSession", request: "Request", response: "HTTPResponse"
Expand Down Expand Up @@ -298,6 +299,7 @@ async def close_engine(self) -> None: # pragma: no cover
"""Close the engine."""
if self.engine_instance is not None:
await self.engine_instance.dispose()
await adispose_session_maker(self.session_maker)

async def on_shutdown(self) -> None: # pragma: no cover
"""Handles the shutdown event by disposing of the SQLAlchemy engine.
Expand Down Expand Up @@ -426,19 +428,18 @@ async def on_startup(self) -> None:
def create_session_maker(self) -> Callable[[], "Session"]:
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration runs.
See issue #709.

Returns:
Callable[[], Session]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()

async def session_handler(
self, session: "Session", request: "Request", response: "HTTPResponse"
Expand Down Expand Up @@ -508,9 +509,11 @@ def get_session_from_request(self, request: Request) -> "Session":

async def close_engine(self) -> None: # pragma: no cover
"""Close the engine."""
loop = asyncio.get_event_loop()
if self.engine_instance is not None:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.engine_instance.dispose)
if self.session_maker is not None:
await loop.run_in_executor(None, dispose_session_maker, self.session_maker)

async def on_shutdown(self) -> None: # pragma: no cover
"""Handles the shutdown event by disposing of the SQLAlchemy engine.
Expand Down
25 changes: 13 additions & 12 deletions advanced_alchemy/extensions/starlette/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig as _SQLAlchemyAsyncConfig
from advanced_alchemy.config.sync import SQLAlchemySyncConfig as _SQLAlchemySyncConfig
from advanced_alchemy.routing.context import reset_routing_context
from advanced_alchemy.routing.maker import adispose_session_maker, dispose_session_maker
from advanced_alchemy.service import schema_dump

if TYPE_CHECKING:
Expand Down Expand Up @@ -245,19 +246,18 @@ async def on_startup(self) -> None:
def create_session_maker(self) -> Callable[[], "AsyncSession"]:
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration
(file-object, timestamp, cache) runs. See issue #709.

Returns:
Callable[[], Session]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()
Comment thread
hasansezertasan marked this conversation as resolved.

async def session_handler(
self, session: "AsyncSession", request: "Request", response: "Response"
Expand Down Expand Up @@ -334,6 +334,7 @@ async def close_engine(self) -> None: # pragma: no cover
"""Close the engine."""
if self.engine_instance is not None:
await self.engine_instance.dispose()
await adispose_session_maker(self.session_maker)

async def on_shutdown(self) -> None: # pragma: no cover
"""Handles the shutdown event by disposing of the SQLAlchemy engine.
Expand Down Expand Up @@ -408,19 +409,18 @@ async def on_startup(self) -> None:
def create_session_maker(self) -> Callable[[], "Session"]:
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration runs.
See issue #709.

Returns:
Callable[[], Session]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()

async def session_handler(
self, session: "Session", request: "Request", response: "Response"
Expand Down Expand Up @@ -497,6 +497,7 @@ async def close_engine(self) -> None: # pragma: no cover
"""Close the engines."""
if self.engine_instance is not None:
await run_in_threadpool(self.engine_instance.dispose)
await run_in_threadpool(lambda: dispose_session_maker(self.session_maker))

async def on_shutdown(self) -> None: # pragma: no cover
"""Handles the shutdown event by disposing of the SQLAlchemy engine.
Expand Down
9 changes: 8 additions & 1 deletion advanced_alchemy/routing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@
stick_to_primary_var,
use_bind_group,
)
from advanced_alchemy.routing.maker import RoutingAsyncSessionMaker, RoutingSyncSessionMaker
from advanced_alchemy.routing.maker import (
RoutingAsyncSessionMaker,
RoutingSyncSessionMaker,
adispose_session_maker,
dispose_session_maker,
)
from advanced_alchemy.routing.selectors import RandomSelector, ReplicaSelector, RoundRobinSelector
from advanced_alchemy.routing.session import RoutingAsyncSession, RoutingSyncSession

Expand All @@ -53,6 +58,8 @@
"RoutingAsyncSessionMaker",
"RoutingSyncSession",
"RoutingSyncSessionMaker",
"adispose_session_maker",
"dispose_session_maker",
"force_primary_var",
"primary_context",
"replica_context",
Expand Down
30 changes: 30 additions & 0 deletions advanced_alchemy/routing/maker.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
__all__ = (
"RoutingAsyncSessionMaker",
"RoutingSyncSessionMaker",
"adispose_session_maker",
"dispose_session_maker",
)


Expand Down Expand Up @@ -351,3 +353,31 @@ async def close_all(self) -> None:
for engine_list in self._engines.values():
for engine in engine_list:
await engine.dispose()


def dispose_session_maker(session_maker: Any) -> None:
"""Dispose all engines owned by a sync routing session maker.

No-op for non-routing session makers; the framework is responsible for
disposing ``engine_instance`` separately. Use during application shutdown
to release connections held by routing pools (primary + replicas).

Args:
session_maker: A session maker instance (any type).
"""
if isinstance(session_maker, RoutingSyncSessionMaker):
session_maker.close_all()


async def adispose_session_maker(session_maker: Any) -> None:
"""Dispose all engines owned by an async routing session maker.

No-op for non-routing session makers; the framework is responsible for
disposing ``engine_instance`` separately. Use during application shutdown
to release connections held by routing pools (primary + replicas).

Args:
session_maker: A session maker instance (any type).
"""
if isinstance(session_maker, RoutingAsyncSessionMaker):
await session_maker.close_all()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ search = 'version = "{current_version}"'
# NOTE: mysql-connector-python 9.6.0 (released 2026-01-21) removed Python 3.12+ wheels.
# This is an Oracle packaging bug. Pinning to versions before that date until fixed.
# Check https://pypi.org/project/mysql-connector-python/#files for updates.
exclude-newer-package = { mysql-connector-python = "2026-01-20" }
exclude-newer-package = { mysql-connector-python = "2026-01-20T00:00:00Z" }

[build-system]
build-backend = "hatchling.build"
Expand Down
Loading
Loading