Deep-review round 2, on round 1's own fix commit. It found MORE than round 1 did (28 raw, against 13), and most of it was mine -- a new piece of UI attracts findings a backend-only diff never does. The pattern in almost all of them is the same: a principle applied to the site that prompted it and not to its siblings. * `display_text` reached three of the FIVE writers of a user-supplied name. `POST /api/downloads` and `POST /api/downloads/edit` still handed Postgres an unbounded string, so a 300-character name pasted into the Add-download dialog or the editor was a bare 500 (main.py re-raises every DataError but the NUL case). Four of the seven reviewers found it independently. The bound now lives in `models.py`, the three columns are DECLARED with it, and the guard for the next writer is parsed rather than remembered: every `display_name` / `display_uploader` assignment under `app/routes/` must go through the helper. That guard immediately found two more -- the Google sign-in callback writes the profile name into the same 255-wide column -- so it is six, not five. * The connection release reached one of the THREE routes that read their own body. The screenshot route (10 MiB, from a phone) and the client log sink both still held a pooled connection idle-in-transaction for the length of the upload, which is the invariant db.py sizes the pool on. All three now call `db.release_read_connection`, whose precondition -- nothing pending on the shared session -- is CHECKED rather than described, because a whole-session rollback discarding a caller's staged write is a mistake this repo has already made once. `test_body_limit.py` holds the rule for the next such route. * The AST drift registry keyed its sites by (module, limit expression), so a second route copied from the first collapsed onto the same entry and went uncovered -- the exact hole the registry exists to close. Keyed per call site now, and it carries the release requirement too. * The dialog's own refusal was announced once and never again: two over-cap picks leave an identical node with identical text, and an untouched live region says nothing. It is keyed per pick now, the drop zone reads as invalid and points at the sentence, and the reason is reachable from the control rather than only from a one-shot announcement. * Two tests rebuilt the blind spot they were written to remove: the dialog's cap test sat nowhere near the boundary (a `>` loosened to `>=` survived it, which is the mutation round 1 fixed on the server side), and the header-precedence test only covered an exact-case duplicate while a differently-cased one would have been COMBINED onto the wire rather than replaced. Both fixed, and the merge now dedupes case-insensitively because header names are. Also: the upload dialog's unstubbed usage query was firing a real request from jsdom in a neighbouring suite and leaking a delayed "connection lost" toast across test boundaries; `GET /api/downloads/usage` had no test at all though the whole client-side check hangs off it; and the comment claiming the edge 413 is readable now says what was measured -- readable to 200 MiB over loopback, a bare `onerror` through a proxy -- so the pre-send check is named as the mechanism and that branch as the backstop it is. Every test added or changed here was mutation-checked. One mutation SURVIVED and is recorded rather than papered over: the `tooBig` term inside `submit()` cannot be reached, because `ui/Button` swallows the activation itself, so it is marked as belt and braces instead of being described as the guard.
65 lines
2.8 KiB
Python
65 lines
2.8 KiB
Python
from collections.abc import Iterator
|
|
|
|
from sqlalchemy import create_engine
|
|
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.
|
|
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.
|
|
"""
|
|
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."
|
|
)
|
|
db.rollback()
|