From 1408ec3fa377e0831f3e3277be44e3e3dc048fca Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 10 May 2026 21:28:28 +0200 Subject: [PATCH] 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) --- .../versions/0017_photos_list_index.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 backend/alembic/versions/0017_photos_list_index.py diff --git a/backend/alembic/versions/0017_photos_list_index.py b/backend/alembic/versions/0017_photos_list_index.py new file mode 100644 index 0000000..cfb7b65 --- /dev/null +++ b/backend/alembic/versions/0017_photos_list_index.py @@ -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")