3 Commits

Author SHA1 Message Date
Claudio
1408ec3fa3 perf(db): partial index for the photos list query
The default photos list (GET /api/v1/photos?per_page=N&sort=taken_at&order=desc)
filters NOT is_trashed AND NOT is_hidden and sorts by
(taken_at DESC NULLS LAST, id DESC). EXPLAIN on the 21k-row table
shows a seq-scan + top-N heapsort (~20ms standalone, multiplied under
concurrent fan-out on page load). The existing single-column
ix_photos_taken_at can't be used because the leading WHERE clause is
two booleans.

Partial index over the sort key, restricted to the visible subset.
Lets the planner index-scan in reverse and stop at LIMIT N.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:28:28 +02:00
Claudio
b1c3ee68dd perf(ui): smaller initial page, slower idle polling
Photos grid was fetching per_page=500 on the very first request, which
serialized hundreds of thumbnail requests behind a single sort+payload.
Split into PER_PAGE_INITIAL=100 (one viewport, fast paint) and
PER_PAGE_BACKGROUND=500 (subsequent prefetch pages, fewer round-trips).

Idle polling for scan-status and worker-status was set to 10s / 15s
respectively. With nothing queued the typical session was firing 4–6
status requests every minute through the single uvicorn event loop on
top of everything else. Bumped both to 30s. While actively scanning /
processing the 2s / 3s cadence is unchanged — that's where the user
actually wants live updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:28:16 +02:00
Claudio
4bb2c959a8 fix(cleanup): distinguish renamed source root from unmounted drive
prune_missing_photos previously skipped every photo whose source root
path didn't resolve, on the assumption that a missing path meant the
underlying drive was unmounted (and silently deleting under those
conditions would be data loss). That conflated 'drive unmounted'
with 'user renamed the folder in their file manager'.

A library with 4,154 orphaned photo rows from a since-renamed Nextcloud
folder hit exactly this case: the /nextcloud-users mount was fine, but
the source root path 'Taco and Muli - 2024 onward' no longer existed
because the user had renamed it to 'Photo Archive 2004-2024'. Every
photo under it was reported as skipped_unmounted forever.

Classify source root state as present/renamed/unmounted by checking
whether the immediate parent is readable. 'renamed' is now treated as
prunable; 'unmounted' still skips. Warning messages differ so the user
knows which fix to apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:24:28 +02:00
5 changed files with 115 additions and 25 deletions

View File

@@ -0,0 +1,40 @@
"""Partial index for the photos list query
Revision ID: 0017_photos_list_index
Revises: 0016_nextcloud_integration
Create Date: 2026-05-10
The default photos-list query (GET /api/v1/photos?per_page=N&sort=taken_at&order=desc)
filters `NOT is_trashed AND NOT is_hidden` and sorts by
`(taken_at DESC NULLS LAST, id DESC)`. EXPLAIN ANALYZE on a 21k-row
table showed a seq-scan + top-N heapsort (~20ms standalone, worse under
concurrency) — Postgres can't use the single-column ix_photos_taken_at
when the leading WHERE clause is two booleans.
A partial index on the sort key, scoped to the visible subset, lets the
planner index-scan in reverse and stop at LIMIT N. Two booleans select
~95% of rows, so the partial predicate is tighter than the full table
without losing common queries.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0017_photos_list_index"
down_revision: Union[str, None] = "0016_nextcloud_integration"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_photos_list_visible
ON photos (taken_at DESC NULLS LAST, id DESC)
WHERE NOT is_trashed AND NOT is_hidden
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_photos_list_visible")

View File

@@ -119,22 +119,64 @@ async def _recompute_folder_counts(session: AsyncSession) -> None:
f.photo_count = int(count_result.scalar() or 0) f.photo_count = int(count_result.scalar() or 0)
def _parent_is_accessible(path: str) -> bool:
"""True if the parent directory of `path` is readable. Used to
distinguish 'user renamed/deleted the source root folder' (parent
mount fine, leaf gone) from 'drive unmounted' (whole subtree
inaccessible). The former is safe to prune from; the latter is
not."""
parent = os.path.dirname(path.rstrip(os.sep))
if not parent:
return False
try:
os.listdir(parent)
return True
except OSError:
return False
def _sr_state(sr_path: str) -> str:
"""Classify a source root path as one of:
'present' — directory exists, business as usual
'renamed' — leaf missing but parent mount is accessible (user
renamed/deleted the folder in their file manager)
'unmounted'— parent itself inaccessible (drive not mounted)
"""
if os.path.isdir(sr_path):
return 'present'
if _parent_is_accessible(sr_path):
return 'renamed'
return 'unmounted'
async def _warn_stale_source_roots(session: AsyncSession) -> int: async def _warn_stale_source_roots(session: AsyncSession) -> int:
"""Log a warning for any active source root whose path no longer exists """Log a warning for any active source root whose path no longer exists
on disk. Doesn't delete — a missing path could be a temporarily on disk. Doesn't delete — a missing path could be a temporarily
unmounted drive, and silently dropping user data is worse than unmounted drive, and silently dropping user data is worse than
surfacing a noisy log line. surfacing a noisy log line. Logs different hints for renamed-vs-
unmounted so the user knows which knob to turn.
""" """
result = await session.execute(select(SourceRoot)) result = await session.execute(select(SourceRoot))
rows = result.scalars().all() rows = result.scalars().all()
stale = 0 stale = 0
for sr in rows: for sr in rows:
if not os.path.isdir(sr.path): state = _sr_state(sr.path)
stale += 1 if state == 'present':
continue
stale += 1
if state == 'renamed':
logger.warning( logger.warning(
f"Source root '{sr.name}' path is missing on disk: {sr.path} " f"Source root '{sr.name}' path is missing on disk: {sr.path} "
f"is the docker mount still in place? " f"parent mount is fine, looks like the folder was renamed "
f"(Edit docker-compose.yml or PHOTO_DIRS in .env to fix.)" f"or deleted. Photos under it can be cleared via "
f"POST /api/v1/library/maintenance/prune-missing."
)
else:
logger.warning(
f"Source root '{sr.name}' path is missing on disk: {sr.path} "
f"— parent directory is also inaccessible; is the docker "
f"mount still in place? (Edit docker-compose.yml or "
f"PHOTO_DIRS in .env to fix.)"
) )
return stale return stale
@@ -146,14 +188,20 @@ async def find_missing(
they still resolve on disk. Returns they still resolve on disk. Returns
(deletable_photo_ids, deletable_folder_ids, skipped_photo_ids). (deletable_photo_ids, deletable_folder_ids, skipped_photo_ids).
Skipped rows are photos/folders whose owning source_root path itself Skipped rows are photos/folders whose owning source_root is truly
doesn't resolve — that's almost always an unmounted drive, and inaccessible (parent mount missing) — that's almost always an
silently deleting those rows would be data loss. The caller can unmounted drive, and silently deleting those rows would be data
surface the skip count separately so the user knows the cleanup loss. Photos under a source root whose leaf is missing but whose
wasn't a no-op by accident. parent mount IS accessible (user renamed/deleted the folder) are
treated as deletable, since their files are genuinely gone from
the user's library.
""" """
sr_rows = (await session.execute(select(SourceRoot))).scalars().all() sr_rows = (await session.execute(select(SourceRoot))).scalars().all()
sr_mounted: dict[str, bool] = {sr.id: os.path.isdir(sr.path) for sr in sr_rows} # "Available" = source root path exists OR parent mount is accessible.
# Only truly-unmounted source roots skip pruning.
sr_mounted: dict[str, bool] = {
sr.id: _sr_state(sr.path) != 'unmounted' for sr in sr_rows
}
photos = (await session.execute( photos = (await session.execute(
select(Photo.id, Photo.filepath, Photo.folder_id) select(Photo.id, Photo.filepath, Photo.folder_id)

View File

@@ -28,20 +28,20 @@ export function ScanProgress() {
queryKey: ['scan-status'], queryKey: ['scan-status'],
queryFn: () => library.scanStatus(), queryFn: () => library.scanStatus(),
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.is_scanning ? 2000 : 10000, query.state.data?.is_scanning ? 2000 : 30000,
enabled: true, enabled: true,
}) })
const isScanning = scanStatus?.is_scanning ?? false const isScanning = scanStatus?.is_scanning ?? false
// Poll worker status to track vision queue activity. // Poll worker status to track vision queue activity.
// Fast polling (3s) while processing, slow (15s) otherwise. // Fast polling (3s) while processing, slow (30s) otherwise.
const { data: workerStatus } = useQuery<WorkerStatus>({ const { data: workerStatus } = useQuery<WorkerStatus>({
queryKey: ['worker-status-progress'], queryKey: ['worker-status-progress'],
queryFn: () => library.maintenance.workerStatus(), queryFn: () => library.maintenance.workerStatus(),
refetchInterval: (query) => { refetchInterval: (query) => {
const q = totalQueued(query.state.data) const q = totalQueued(query.state.data)
return q > 0 ? 3000 : 15000 return q > 0 ? 3000 : 30000
}, },
enabled: true, enabled: true,
}) })

View File

@@ -81,17 +81,19 @@ export function usePhotosQuery() {
// seeks directly to the next slice via an indexed range scan, // seeks directly to the next slice via an indexed range scan,
// O(1) regardless of depth (no OFFSET skipping). // O(1) regardless of depth (no OFFSET skipping).
// //
// MAX_PAGES is intentionally modest: 20 × 500 = 10 000 photos // First page is small (~one viewport) so the grid paints fast;
// covers almost every browsing session up-front without burning // background pages are larger so we still cover ~10k photos in
// through a 100k library on cold load. If a user scrolls past // a few round-trips without burning through a 100k library on
// that horizon we'll add an infinite-query trigger; for now the // cold load. If a user scrolls past that horizon we'll add an
// cap keeps cold-load memory / network pressure sane. // infinite-query trigger; for now the cap keeps cold-load
const PER_PAGE = 500 // memory / network pressure sane.
const PER_PAGE_INITIAL = 100
const PER_PAGE_BACKGROUND = 500
const MAX_PAGES = 20 const MAX_PAGES = 20
const INTER_PAGE_DELAY_MS = 50 const INTER_PAGE_DELAY_MS = 50
const first = await fetchCursorPage( const first = await fetchCursorPage(
{ per_page: PER_PAGE, ...filterParams }, { per_page: PER_PAGE_INITIAL, ...filterParams },
signal, signal,
) )
const firstBatch = first.photos || [] const firstBatch = first.photos || []
@@ -104,7 +106,7 @@ export function usePhotosQuery() {
if (signal?.aborted) return if (signal?.aborted) return
try { try {
const page = await fetchCursorPage( const page = await fetchCursorPage(
{ per_page: PER_PAGE, cursor: nextCursor, ...filterParams }, { per_page: PER_PAGE_BACKGROUND, cursor: nextCursor, ...filterParams },
signal, signal,
) )
if (signal?.aborted) return if (signal?.aborted) return
@@ -114,7 +116,7 @@ export function usePhotosQuery() {
['photos', filterParams], ['photos', filterParams],
(prev) => (prev ? [...prev, ...more] : more) (prev) => (prev ? [...prev, ...more] : more)
) )
if (!nextCursor || more.length < PER_PAGE) return if (!nextCursor || more.length < PER_PAGE_BACKGROUND) return
// Yield a beat between pages so the main thread stays // Yield a beat between pages so the main thread stays
// responsive (thumbnail decode, scroll handling) while // responsive (thumbnail decode, scroll handling) while
// we're back-filling in the background. // we're back-filling in the background.

View File

@@ -49,14 +49,14 @@ export function useScanActivity() {
queryKey: ['scan-status'], queryKey: ['scan-status'],
queryFn: () => library.scanStatus(), queryFn: () => library.scanStatus(),
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.is_scanning ? 2000 : 10000, query.state.data?.is_scanning ? 2000 : 30000,
}) })
const { data: workerStatus } = useQuery<WorkerStatus>({ const { data: workerStatus } = useQuery<WorkerStatus>({
queryKey: ['worker-status-progress'], queryKey: ['worker-status-progress'],
queryFn: () => library.maintenance.workerStatus(), queryFn: () => library.maintenance.workerStatus(),
refetchInterval: (query) => { refetchInterval: (query) => {
const data = query.state.data const data = query.state.data
return pendingTasks(data) + activeTasks(data) > 0 ? 3000 : 15000 return pendingTasks(data) + activeTasks(data) > 0 ? 3000 : 30000
}, },
}) })