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

@@ -86,6 +86,14 @@ pub async fn enqueue_pages(
}
ReenqueueScope::Chapter(_) => "AND p.chapter_id = $2",
};
// The `NOT EXISTS(... pending|running ...)` pre-check used to live in
// this query, but it races with concurrent admin clicks (two requests
// both see "no in-flight job" and both INSERT). The partial unique
// index `crawler_jobs_analyze_page_dedup_idx` (migration 0031) covers
// (payload->>'page_id') WHERE state IN ('pending', 'running') AND
// kind = 'analyze_page', so `ON CONFLICT DO NOTHING` is now the
// atomic primitive — duplicates can't land and concurrent enqueue
// calls are both observably correct.
let sql = format!(
r#"
INSERT INTO crawler_jobs (payload)
@@ -95,11 +103,7 @@ pub async fn enqueue_pages(
SELECT 1 FROM page_analysis pa
WHERE pa.page_id = p.id AND pa.status = 'done'))
{scope_clause}
AND NOT EXISTS (
SELECT 1 FROM crawler_jobs j
WHERE j.payload->>'kind' = 'analyze_page'
AND j.payload->>'page_id' = p.id::text
AND j.state IN ('pending', 'running'))
ON CONFLICT DO NOTHING
"#
);
let query = sqlx::query(&sql).bind(only_unanalyzed);