fix: cache sessionmaker per engine to avoid recreation overhead

use async_sessionmaker (SQLAlchemy 2.0) and cache it alongside the engine
instead of recreating it on every db_session() call. while the overhead
is minimal, this follows the intended pattern and reduces object churn.

changes:
- switch from sessionmaker to async_sessionmaker (proper async API)
- add SESSION_MAKERS cache dict alongside ENGINES
- add get_session_maker() function for cached retrieval

Related to #708

Changed files
+34 -10
backend
src
backend
utilities
+34 -10
backend/src/backend/utilities/database.py
··· 6 6 from contextlib import asynccontextmanager 7 7 from typing import Any 8 8 9 - from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine 10 - from sqlalchemy.orm import sessionmaker 9 + from sqlalchemy.ext.asyncio import ( 10 + AsyncEngine, 11 + AsyncSession, 12 + async_sessionmaker, 13 + create_async_engine, 14 + ) 11 15 12 16 from backend.config import settings 13 17 14 - # per-event-loop engine cache 18 + # per-event-loop engine and sessionmaker cache. 19 + # sessionmaker is cached alongside engine since it's bound to a specific engine. 15 20 ENGINES: dict[tuple[Any, ...], AsyncEngine] = {} 21 + SESSION_MAKERS: dict[tuple[Any, ...], async_sessionmaker[AsyncSession]] = {} 16 22 17 23 18 24 def get_engine() -> AsyncEngine: ··· 73 79 return ENGINES[cache_key] 74 80 75 81 82 + def get_session_maker() -> async_sessionmaker[AsyncSession]: 83 + """retrieve a cached async sessionmaker. 84 + 85 + the sessionmaker is cached per event loop alongside the engine. 86 + this avoids recreating the sessionmaker on every db_session() call. 87 + 88 + returns: 89 + async_sessionmaker bound to the current event loop's engine 90 + """ 91 + loop = get_running_loop() 92 + cache_key = (loop, settings.database.url) 93 + 94 + if cache_key not in SESSION_MAKERS: 95 + engine = get_engine() 96 + SESSION_MAKERS[cache_key] = async_sessionmaker( 97 + bind=engine, 98 + class_=AsyncSession, 99 + expire_on_commit=False, 100 + ) 101 + 102 + return SESSION_MAKERS[cache_key] 103 + 104 + 76 105 @asynccontextmanager 77 106 async def db_session() -> AsyncGenerator[AsyncSession, None]: 78 107 """get async database session.""" 79 - engine = get_engine() 80 - async_session_maker = sessionmaker( # type: ignore 81 - bind=engine, # type: ignore 82 - class_=AsyncSession, 83 - expire_on_commit=False, 84 - ) 85 - async with async_session_maker() as session: 108 + session_maker = get_session_maker() 109 + async with session_maker() as session: 86 110 yield session 87 111 88 112