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:
parent
4b360f8f36
commit
6dece18ae8
4 changed files with 55 additions and 19 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.28.0
|
||||
0.28.1
|
||||
|
|
|
|||
|
|
@ -10,7 +10,17 @@ class Base(DeclarativeBase):
|
|||
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# 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)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="No image")
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
data, ct = plex.image_bytes(key)
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ export interface 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",
|
||||
date: "2026-07-06",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue