perf(thumbs): pool NC client, smaller grid thumbs, eager owner load

Five stacked optimisations for the thumbnail hot path so the timeline
grid lands in fewer round trips and fewer bytes.

1. PhotoThumbnail: switch from 'medium' (640px) to 'small' (240px) for
   grid cells. 240px oversamples 150-200px logical cells on 2x retina
   and drops payload 5-8x. Lightbox and preview filmstrip keep 'large'
   and 'medium' respectively.

2. nextcloud_dav: pool the httpx client. A module-level AsyncClient
   with HTTP/2 + keepalive (max_connections=64, keepalive_expiry=120s)
   replaces the per-request constructor that paid a fresh TCP+TLS
   handshake on every preview fetch. Auth is per-user so it stays at
   the call site via auth=BasicAuth(...). Lifespan-managed: init in
   main.py's lifespan startup, aclose on shutdown. requirements.txt
   gains the http2 extra to pull in h2 (not currently installed).
   Same change applies to fetch_memories_info_async since it hits the
   same host.

3. PhotoThumbnail img: add decoding="async" so JPEG/WebP decode moves
   off the main thread, plus fetchPriority="low" so grid backfill
   doesn't fight UI fetches.

4. Eager-load Photo.user via joinedload from the thumb handler.
   _get_photo_with_share_fallback gains an options parameter so other
   callers stay zero-overhead; only the thumb handler asks for the
   owner join. Eliminates the second SELECT users per request.

5. Disk-fallback path picks up Cache-Control: private, max-age=86400
   in both the FileResponse and X-Accel branches so re-renders match
   the NC primary path's caching behaviour.

Net: a warm grid page should drop from ~200-400 ms median per thumb to
well under 100 ms; payload drops ~5-8x; backend sustains higher
concurrency with fewer sockets to Nextcloud and one fewer Postgres
round-trip per request.
This commit is contained in:
Claudio
2026-05-12 00:30:43 +02:00
parent 68bbe6f024
commit 347f58b4f3
6 changed files with 112 additions and 21 deletions

View File

@@ -148,6 +148,47 @@ def _async_client(auth: tuple[str, str]) -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=_TIMEOUT, auth=httpx.BasicAuth(*auth), follow_redirects=False)
# Pooled async client for the read-heavy NC endpoints (preview proxy,
# Memories info). Auth is per-user, so it's passed at call time via
# `auth=BasicAuth(...)`; the pool itself is auth-less. Keepalive +
# HTTP/2 cuts the TCP+TLS handshake from every thumbnail request and
# multiplexes the dozens of concurrent grid fetches over one socket.
_PREVIEW_LIMITS = httpx.Limits(
max_connections=64, max_keepalive_connections=32, keepalive_expiry=120.0,
)
_preview_client: httpx.AsyncClient | None = None
async def init_preview_client() -> None:
"""Called from the FastAPI lifespan startup hook."""
global _preview_client
if _preview_client is None:
_preview_client = httpx.AsyncClient(
timeout=_TIMEOUT,
limits=_PREVIEW_LIMITS,
http2=True,
follow_redirects=False,
)
async def close_preview_client() -> None:
"""Called from the FastAPI lifespan shutdown hook."""
global _preview_client
if _preview_client is not None:
await _preview_client.aclose()
_preview_client = None
def _shared_preview_client() -> httpx.AsyncClient:
"""Return the pooled client. Falls back to a one-shot AsyncClient if
init wasn't called (tests, scripts) — caller must aclose it."""
if _preview_client is not None:
return _preview_client
return httpx.AsyncClient(
timeout=_TIMEOUT, http2=True, follow_redirects=False,
)
def _raise_for_dav(resp: httpx.Response, action: str) -> None:
"""Translate Nextcloud WebDAV errors into FastAPI HTTPExceptions
the frontend can show. We surface Nextcloud's body verbatim when
@@ -395,7 +436,7 @@ async def fetch_memories_info_async(
url = (
f"{_base_url()}/index.php/apps/memories/api/image/info/{int(fileid)}"
)
client = _async_client((nc_user, app_pw))
client = _shared_preview_client()
try:
resp = await client.get(
url,
@@ -403,9 +444,13 @@ async def fetch_memories_info_async(
"OCS-APIRequest": "true",
"Accept": "application/json",
},
auth=httpx.BasicAuth(nc_user, app_pw),
)
finally:
await client.aclose()
# Only close if we got a one-shot fallback client; the pooled
# one is owned by the lifespan hook.
if client is not _preview_client:
await client.aclose()
if resp.status_code == 404:
return None
if not resp.is_success:
@@ -444,11 +489,16 @@ async def get_preview_async(
"a": "true",
"forceIcon": "false",
}
client = _async_client((nc_user, app_pw))
client = _shared_preview_client()
try:
return await client.get(url, params=params)
return await client.get(
url, params=params, auth=httpx.BasicAuth(nc_user, app_pw),
)
finally:
await client.aclose()
# Only close if we got a one-shot fallback client; the pooled
# one is owned by the lifespan hook.
if client is not _preview_client:
await client.aclose()
def whoami_dir_exists(nc_username: str) -> bool: