fix(plex): image proxy no longer exhausts the DB connection pool

A collections page fires 100+ concurrent poster requests. Both image proxies
(/image and /person-image) authenticated via current_user + Depends(get_db),
holding a pooled DB connection for the whole request — including the slow Plex/
CDN fetch. Under the burst, the 15-connection pool was exhausted → QueuePool
checkout timeouts → 502s → slow, partially-loaded grids on prod.

Fix: authenticate these two high-fan-out endpoints with a signed-session check
(no DB user-load), serve disk-cache hits with zero DB access, and on a cold miss
open a short session only to resolve the image key + Plex config, releasing it
BEFORE the fetch (image_bytes uses no DB). Also raise the pool (20 + 30 overflow)
as headroom above the sync-endpoint threadpool.
This commit is contained in:
npeter83 2026-07-06 07:14:28 +02:00
parent 4b360f8f36
commit 6dece18ae8
4 changed files with 55 additions and 19 deletions

View file

@ -12,15 +12,15 @@ from pathlib import Path
from urllib.parse import quote
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 sqlalchemy import and_, func, or_, text
from sqlalchemy.orm import Session, aliased
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.db import get_db
from app.db import SessionLocal, get_db
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.plex import paths as plex_paths
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"})
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}")
def image(
rating_key: str,
variant: str = "thumb",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> Response:
def image(rating_key: str, request: Request, variant: str = "thumb") -> Response:
"""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"
cache_key = f"item_{rating_key}_{v}"
hit = _img_cache_read(cache_key)
if hit:
return _img_response(*hit)
key = _image_key(db, str(rating_key), v)
if not key:
raise HTTPException(status_code=404, detail="No image")
# Cold miss: short-lived session for the key + Plex client config, released before the slow fetch.
try:
with PlexClient(db) as plex:
data, ct = plex.image_bytes(key)
with SessionLocal() as db:
key = _image_key(db, str(rating_key), v)
if not key:
raise HTTPException(status_code=404, detail="No image")
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
except PlexNotConfigured as e:
raise HTTPException(status_code=400, detail=str(e))
try:
with plex:
data, ct = plex.image_bytes(key)
except PlexError as e:
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
_img_cache_write(cache_key, data, ct)
@ -665,8 +681,10 @@ def _rich_meta(meta: dict) -> dict:
@router.get("/person-image")
def person_image(u: str, _: User = Depends(current_user)) -> Response:
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed)."""
def person_image(u: str, request: Request) -> Response:
"""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):
raise HTTPException(status_code=400, detail="Unsupported image host")
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()