perf(admin): fix O(mangas x jobs) overview query that pinned Postgres (0.85.1)
All checks were successful
deploy / test-backend (push) Successful in 26m28s
deploy / test-frontend (push) Successful in 10m18s
deploy / build-and-push (push) Successful in 11m21s
deploy / deploy (push) Successful in 13s

The admin overview dashboard polls /admin/overview every 30s. Its
`manga_stats` aggregate evaluated MANGA_SYNC_STATE_CASE over the WHOLE
library (14.5k mangas) with no LIMIT, and that CASE runs a correlated
EXISTS over crawler_jobs. With no index on the `sync_chapter_list`
manga_id path, Postgres seqscanned all in-flight jobs *per manga* —
O(mangas x jobs). The disabled-analysis backlog (9.5k pending
`analyze_page` rows that can never match the sync-kind filter) inflated
every scan, so each call took 6-7 min. The 30s poll stacked ~10 of them
concurrently → load 11, Postgres at ~100%.

EXPLAIN ANALYZE on the live DB: the rewrite drops the query from 6-7 min
to ~1.5s (per-manga crawler_jobs seqscan → single index-backed pass).

Fixes:
- Rewrite `manga_stats` to collect the in-flight manga-id set in ONE pass
  over the sync jobs (index-backed), then classify via a hash semi-join —
  O(mangas + jobs), immune to the analyze_page backlog size.
- Add partial index crawler_jobs_sync_chapter_list_manga_idx (migration
  0029) covering the sync_chapter_list manga_id path; mirrors the existing
  sync_manga index (0020). Also speeds the per-row Mangas tab listing.
- statement_timeout backstop (5s) on the aggregate so a pathological plan
  can never pin a backend for minutes again.
- Single-flight + 10s TTL cache on the /admin/overview handler so
  concurrent pollers coalesce instead of stacking.
- Slow the dashboard poll 30s -> 60s (server-cached now anyway).

The in_progress rule in the rewrite is kept in lockstep with the first
arm of MANGA_SYNC_STATE_CASE (documented in both places).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:05:40 +02:00
parent cde4aca98b
commit 35664bccc7
7 changed files with 127 additions and 20 deletions

View File

@@ -0,0 +1,24 @@
-- Index the `sync_chapter_list` in-flight path used by the admin sync-state
-- derivation (overview `manga_stats` + the Mangas tab listing).
--
-- A manga is "in_progress" if a pending/running job targets it. For the
-- `sync_manga` kind that join is already covered by
-- crawler_jobs_sync_manga_key_idx (0020). The OTHER kind, `sync_chapter_list`,
-- carries the target `manga_id` directly in its payload and had NO index — so
-- the EXISTS fell back to a full seqscan of crawler_jobs. Per manga that is
-- cheap; across the whole library (overview scans every manga) it is O(mangas
-- x jobs), and the disabled-analysis backlog (thousands of pending
-- `analyze_page` rows that can never match this filter) inflates every scan.
--
-- Partial on the same `state IN ('pending','running') AND kind = ...`
-- predicate as the sibling indexes so it stays tiny (only in-flight list
-- jobs) and Postgres can probe it instead of scanning. Mirrors 0020.
--
-- Not CONCURRENTLY: sqlx::migrate! wraps each migration in a transaction;
-- CREATE INDEX CONCURRENTLY can't run inside one. The table is small at our
-- scale so a brief build lock on deploy is safe. IF NOT EXISTS keeps it
-- idempotent with any operator who pre-created it on the live DB.
CREATE INDEX IF NOT EXISTS crawler_jobs_sync_chapter_list_manga_idx
ON crawler_jobs ((payload->>'manga_id'))
WHERE state IN ('pending', 'running')
AND payload->>'kind' = 'sync_chapter_list';