fix(correctness): pagination tiebreakers, dedup race, duration gating (0.87.6)

Three medium correctness fixes:

1. **Pagination ORDER BY missing tiebreakers** — four admin list queries
   sorted by a timestamp or `chapters.number` with no stable secondary
   sort. Add `id` tiebreakers across `repo::admin_view`,
   `repo::admin_audit`, `repo::bookmark`.

2. **`enqueue_pages` NOT EXISTS read-then-insert race** — concurrent
   admin clicks could land duplicate analyze_page jobs. Migration 0031
   adds a partial unique index mirroring 0014; query relies on
   `ON CONFLICT DO NOTHING`.

3. **`record_duration` overwrote a prior done row's duration on a
   non-terminal failed retry of a force-re-analyze.** Gate the call
   on "did this attempt actually write a row?".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-22 21:34:39 +02:00
parent 80f4819fad
commit b9dd75684e
9 changed files with 64 additions and 16 deletions

View File

@@ -0,0 +1,17 @@
-- Dedup analyze_page jobs in flight (mirrors 0014 for sync_chapter_content).
--
-- Without this, `repo::page_analysis::enqueue_pages`' `NOT EXISTS(... pending
-- | running ...)` read-then-insert race let concurrent admin POSTs land
-- duplicate jobs for the same page — visible as duplicate "now analyzing"
-- SSE events and inflated `pending` counters in the admin dashboard, even
-- though the worker's `skip-if-done` net (analysis/daemon.rs:210-217)
-- prevented the actual duplicate vision call.
--
-- The partial unique index lets enqueue_pages drop the NOT EXISTS subquery
-- and rely on `ON CONFLICT DO NOTHING` instead. At most one (pending|running)
-- job per page_id can exist; the slot frees the moment the job transitions
-- to done/failed/dead, so a force re-analyze still re-enqueues cleanly.
CREATE UNIQUE INDEX crawler_jobs_analyze_page_dedup_idx
ON crawler_jobs ((payload->>'page_id'))
WHERE state IN ('pending', 'running')
AND payload->>'kind' = 'analyze_page';