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

@@ -9,7 +9,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select, and_, or_, func, tuple_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import joinedload, selectinload
import asyncio
import base64
import json
@@ -597,25 +597,33 @@ async def remove_photo_tag(
async def _get_photo_with_share_fallback(
photo_id: str, user: User, db: AsyncSession,
*, options=None,
) -> Photo:
"""Fetch a photo the user owns, or one they can access via a share.
The fast path (owned photo) does a single indexed query. The share
fallback only runs when the first query returns nothing — this
happens only for shared photos, not during normal browsing.
`options` is a list of SQLAlchemy loader options applied to both
queries. Pass `[joinedload(Photo.user)]` from the thumb handler so
the photo lookup also yields the owner row (NC creds) in one round
trip instead of issuing a second SELECT users.
"""
# Fast path — owned photo (single indexed query, no extra joins).
result = await db.execute(
select(Photo).where(Photo.id == photo_id, Photo.user_id == user.id)
)
stmt = select(Photo).where(Photo.id == photo_id, Photo.user_id == user.id)
if options:
stmt = stmt.options(*options)
result = await db.execute(stmt)
photo = result.scalar_one_or_none()
if photo:
return photo
# Slow path — check share access (only for shared photos).
if await can_access_photo_via_share(photo_id, user, db):
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
stmt = select(Photo).where(Photo.id == photo_id)
if options:
stmt = stmt.options(*options)
result = await db.execute(stmt)
photo = result.scalar_one_or_none()
if photo:
return photo
@@ -653,13 +661,15 @@ async def get_thumbnail(
if size not in _NC_PREVIEW_PX:
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
photo = await _get_photo_with_share_fallback(photo_id, current_user, db)
# joinedload(Photo.user) folds the owner row into the same query so
# we don't issue a follow-up SELECT users on every thumbnail.
photo = await _get_photo_with_share_fallback(
photo_id, current_user, db, options=[joinedload(Photo.user)],
)
# ── Primary: proxy Nextcloud's preview endpoint ────────────────────
if photo.nextcloud_fileid and photo.user_id:
owner = (
await db.execute(select(User).where(User.id == photo.user_id))
).scalar_one_or_none()
owner = photo.user
if owner is not None:
try:
upstream = await get_preview_async(
@@ -766,6 +776,10 @@ async def get_thumbnail(
)
response.headers["X-Mule-Thumb-Source"] = "disk"
# Same one-day cache as the NC primary path so re-renders / back-
# button navigation hit the browser cache instead of round-tripping
# the backend just to re-serve an unchanged WebP.
response.headers["Cache-Control"] = "private, max-age=86400"
# Check if we're behind Nginx
if os.environ.get('USE_X_ACCEL_REDIRECT'):
# Use Nginx X-Accel-Redirect for better performance
@@ -774,7 +788,11 @@ async def get_thumbnail(
return Response()
else:
# Direct file serving for development
return FileResponse(thumb_path, media_type='image/webp')
return FileResponse(
thumb_path,
media_type='image/webp',
headers={"Cache-Control": "private, max-age=86400"},
)
_INLINE_MEDIA_TYPES = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',