merge: fix Plex image-proxy DB-pool exhaustion — v0.28.1

This commit is contained in:
npeter83 2026-07-06 07:14:29 +02:00
commit 6df43445df
4 changed files with 55 additions and 19 deletions

View file

@ -1 +1 @@
0.28.0 0.28.1

View file

@ -10,7 +10,17 @@ class Base(DeclarativeBase):
pass pass
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True) # 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.py), 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,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)

View file

@ -12,15 +12,15 @@ from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import and_, func, or_, text from sqlalchemy import and_, func, or_, text
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app import sysconfig from app import sysconfig
from app.auth import current_user from app.auth import current_user, resolved_user_id
from app.config import settings from app.config import settings
from app.db import get_db from app.db import SessionLocal, get_db
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.plex import paths as plex_paths from app.plex import paths as plex_paths
from app.plex import stream as plex_stream from app.plex import stream as plex_stream
@ -562,28 +562,44 @@ def _img_response(data: bytes, ct: str) -> Response:
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}) return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
def _require_session(request: Request) -> int:
"""Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT
a DB user-load, so these endpoints never hold a pool connection for the auth. Poster images are
low-sensitivity; a valid session is enough."""
uid, _ = resolved_user_id(request)
if not uid:
raise HTTPException(status_code=401, detail="Not authenticated")
return uid
@router.get("/image/{rating_key}") @router.get("/image/{rating_key}")
def image( def image(rating_key: str, request: Request, variant: str = "thumb") -> Response:
rating_key: str,
variant: str = "thumb",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> Response:
"""Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication). A """Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication). A
thin on-disk cache serves repeat views without re-hitting Plex.""" thin on-disk cache serves repeat views without re-hitting Plex.
This endpoint is called in BURSTS (a collections page fires 100+ concurrent poster requests),
so it must NOT hold a DB connection during the slow Plex fetch doing so exhausted the pool
(QueuePool timeout, broken images on prod). Warm path (cache hit) touches NO DB; cold path opens a
brief session only to resolve the image key + read the Plex config, then RELEASES it before the
fetch. Auth is the signed-session check (no DB user-load)."""
_require_session(request)
v = "art" if variant == "art" else "thumb" v = "art" if variant == "art" else "thumb"
cache_key = f"item_{rating_key}_{v}" cache_key = f"item_{rating_key}_{v}"
hit = _img_cache_read(cache_key) hit = _img_cache_read(cache_key)
if hit: if hit:
return _img_response(*hit) return _img_response(*hit)
# Cold miss: short-lived session for the key + Plex client config, released before the slow fetch.
try:
with SessionLocal() as db:
key = _image_key(db, str(rating_key), v) key = _image_key(db, str(rating_key), v)
if not key: if not key:
raise HTTPException(status_code=404, detail="No image") raise HTTPException(status_code=404, detail="No image")
try: plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
with PlexClient(db) as plex:
data, ct = plex.image_bytes(key)
except PlexNotConfigured as e: except PlexNotConfigured as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
try:
with plex:
data, ct = plex.image_bytes(key)
except PlexError as e: except PlexError as e:
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}") raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
_img_cache_write(cache_key, data, ct) _img_cache_write(cache_key, data, ct)
@ -665,8 +681,10 @@ def _rich_meta(meta: dict) -> dict:
@router.get("/person-image") @router.get("/person-image")
def person_image(u: str, _: User = Depends(current_user)) -> Response: def person_image(u: str, request: Request) -> Response:
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).""" """Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
_require_session(request)
if not u.startswith(_PERSON_IMG_HOST): if not u.startswith(_PERSON_IMG_HOST):
raise HTTPException(status_code=400, detail="Unsupported image host") raise HTTPException(status_code=400, detail="Unsupported image host")
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest() cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()

View file

@ -14,6 +14,14 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.28.1",
date: "2026-07-06",
summary: "Fix: slow / broken Plex poster images on collection-heavy pages.",
features: [
"Plex images: fixed the poster/art proxy exhausting the database connection pool when a page fired many poster requests at once (the culprit behind slow, partially-loaded collection grids) — image requests no longer hold a database connection while fetching from Plex, and repeat views are served straight from the disk cache.",
],
},
{ {
version: "0.28.0", version: "0.28.0",
date: "2026-07-06", date: "2026-07-06",