infoxtractor/src/ix/store/engine.py
Dirk Riemann 1c60c30084
All checks were successful
tests / test (push) Successful in 1m15s
tests / test (pull_request) Successful in 1m2s
feat(store): Alembic scaffolding + initial ix_jobs migration (spec §4)
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>
2026-04-18 11:37:21 +02:00

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