Lands the async-friendly Alembic env (NullPool, reads IX_POSTGRES_URL), the hand-written 001 migration matching the spec's table layout exactly (CHECK on status, partial index on pending rows, UNIQUE on (client_id, request_id)), the SQLAlchemy 2.0 ORM mapping, and a lazy engine/session factory. The factory reads the URL through ix.config when available; Task 3.2 makes that the only path. Smoke-tested: alembic upgrade head + downgrade base against a live postgres:16 produce the expected table shape and tear down cleanly. Unit tests assert the migration source contains every required column/index so the migration can't drift from spec at import time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
76 lines
2.4 KiB
Python
76 lines
2.4 KiB
Python
"""Lazy async engine + session-factory singletons.
|
|
|
|
The factories read ``IX_POSTGRES_URL`` from the environment on first call. In
|
|
Task 3.2 this switches to ``get_config()``; for now we go through ``os.environ``
|
|
directly so the store module doesn't depend on config that doesn't exist yet.
|
|
|
|
Both factories are idempotent on success — repeat calls return the same
|
|
engine / sessionmaker. ``reset_engine`` nukes the cache and should only be
|
|
used in tests (where we teardown-recreate the DB between sessions).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
from sqlalchemy.ext.asyncio import (
|
|
AsyncEngine,
|
|
AsyncSession,
|
|
async_sessionmaker,
|
|
create_async_engine,
|
|
)
|
|
|
|
_engine: AsyncEngine | None = None
|
|
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
|
|
|
|
|
def _resolve_url() -> str:
|
|
"""Grab the Postgres URL from the environment.
|
|
|
|
Task 3.2 refactors this to go through ``ix.config.get_config()``; this
|
|
version keeps the store module usable during the bootstrap window where
|
|
``ix.config`` doesn't exist yet. Behaviour after refactor is identical —
|
|
both paths ultimately read ``IX_POSTGRES_URL``.
|
|
"""
|
|
|
|
try:
|
|
from ix.config import get_config
|
|
except ImportError:
|
|
url = os.environ.get("IX_POSTGRES_URL")
|
|
if not url:
|
|
raise RuntimeError(
|
|
"IX_POSTGRES_URL is not set and ix.config is unavailable"
|
|
) from None
|
|
return url
|
|
return get_config().postgres_url
|
|
|
|
|
|
def get_engine() -> AsyncEngine:
|
|
"""Return the process-wide async engine; create on first call."""
|
|
|
|
global _engine
|
|
if _engine is None:
|
|
_engine = create_async_engine(_resolve_url(), pool_pre_ping=True)
|
|
return _engine
|
|
|
|
|
|
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
|
"""Return the process-wide session factory; create on first call.
|
|
|
|
``expire_on_commit=False`` so ORM instances stay usable after ``commit()``
|
|
— we frequently commit inside a repo method and then ``model_validate``
|
|
the row outside the session.
|
|
"""
|
|
|
|
global _session_factory
|
|
if _session_factory is None:
|
|
_session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
|
|
return _session_factory
|
|
|
|
|
|
def reset_engine() -> None:
|
|
"""Drop the cached engine + session factory. Test-only."""
|
|
|
|
global _engine, _session_factory
|
|
_engine = None
|
|
_session_factory = None
|