from collections.abc import Iterator from sqlalchemy import create_engine, text from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from app.config import settings class Base(DeclarativeBase): pass # Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on # checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch # (see routes/plex/), so this headroom is only for the many brief sessions, not long ones. The one # long hold — a playlist sync's advisory claim, for a whole push — opens its own unpooled connection # (sync/playlists.py `_claim_engine`) instead of drawing a third one from here. engine = create_engine( settings.database_url, pool_pre_ping=True, pool_size=20, max_overflow=30, pool_timeout=30, future=True, # A database error renders its bound parameters into its message — a title, an uploader, a # file path, a URL — and that message is logged and stored as a job's error text. The SQL # stays; the values are data, and the ids an operator needs are on the line already. hide_parameters=True, ) SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) def get_db() -> Iterator[Session]: db = SessionLocal() try: yield db finally: db.close() def release_read_connection(db: Session) -> None: """End a request's READ transaction so its pooled connection goes back BEFORE a long await. SQLAlchemy 2.0 autobegins on the first SELECT, so a request that has merely authenticated is already holding one of the connections above — `idle in transaction` to Postgres, and nothing times it out. A route that then awaits something slow (a request body still arriving, an external fetch) pins it for the whole wait, which is precisely the invariant the pool is sized on in the comment above. Measured: the next checkout after this call reuses the very backend that was released, and a 1.8 s upload goes from one sustained `idle in transaction` to none. Every route that reads its own body from a dependency needs this, and `test_body_limit.py` holds that — the three that do are the ones with a raised (or self-imposed) body ceiling, and they got here one at a time, which is how the upload route ended up the only one fixed. The caller must have NOTHING PENDING, and that is checked rather than described. The session is shared with every dependency that ran before, so a rollback here would silently discard a write one of them staged — `service.enqueue` already learned that lesson the expensive way and uses a SAVEPOINT for it. CLAUDE.md: a rule stated in a comment must also be enforced in code. TWO checks, because the obvious one is blind to the shapes this codebase actually writes in. `db.new`/`deleted`/`dirty` hold only UNFLUSHED unit-of-work: the moment a helper calls `db.flush()` — CLAUDE.md's canonical split is "the handler commits; helpers flush" — the object is persistent with no unflushed history and all three sets are empty again, and Core DML (`db.execute(update(...))`) never enters them at all. Measured: after `add`+`flush` the sets are empty, the old check passed, and the rollback lost the row. So the transaction is asked directly. Postgres assigns a transaction id on the first WRITE and never for a read, so `pg_current_xact_id_if_assigned()` is NULL for exactly the state this function is safe in — and it sees a raw `text("UPDATE …")` too, which no session-level bookkeeping would. """ if db.new or db.deleted or db.dirty: raise RuntimeError( "release_read_connection() with pending work: something ahead of this dependency " "staged a write, which ending the transaction would discard. Commit it first, or do " "it after the await." ) if ( db.in_transaction() and db.execute(text("SELECT pg_current_xact_id_if_assigned()")).scalar() is not None ): raise RuntimeError( "release_read_connection() on a transaction that has already WRITTEN: something ahead " "of this dependency flushed a row or ran an UPDATE/INSERT/DELETE, which ending the " "transaction would discard. Commit it first, or do it after the await." ) db.rollback()