"""Per-invocation async session factory for Celery task modules. Async engine connections are bound to the event loop. Each Celery task runs its async body under a fresh ``asyncio.run()`` loop, so it needs its own engine created (and disposed) within that loop — a process-wide async engine would reuse loop-bound connections across tasks and raise "attached to a different loop". So unlike the process-wide sync engine in ``_sync_engine.py``, this returns a fresh engine per call; the caller disposes it (``await engine.dispose()``) when its loop ends. """ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from ..config import get_config def async_session_factory(): """Return ``(sessionmaker, engine)`` bound to a fresh async engine.""" cfg = get_config() # NullPool: this engine lives for ONE task (created + disposed per # asyncio.run loop), so intra-task connection pooling buys nothing and # actively bit us — download_source releases its phase-1 connection # before a multi-minute gallery-dl subprocess, and a *pooled* idle # connection would be reaped by the server and handed back dead to # phase 3 (asyncpg ConnectionDoesNotExistError, Anduo #40014). NullPool # opens a fresh real connection on each checkout, so phase 3 always # reconnects clean; pre_ping is then redundant. engine = create_async_engine(cfg.database_url, future=True, poolclass=NullPool) return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine