fix(analyze-dedup): migration pre-dedup, force-collision upgrade, gate test (0.87.11)

Four 0.87.6 follow-ups from the adversarial review:

1. **Migration 0031 pre-dedup.** Demote all-but-lowest-id duplicate
   `analyze_page` rows in `(pending|running)` to `dead` before
   creating the unique index, with a curator-recoverable last_error
   marker. Without this, `sqlx::migrate!` would refuse to boot on
   any dirty production DB.

2. **`enqueue_for_page(force=true)` collision.** The partial unique
   index used to silently swallow force requests when a
   `force=false` job was already pending. Repo function now upgrades
   the pending row's `force` flag in place (or falls through to
   re-INSERT if the sibling drained mid-call), and reports an
   `EnqueueForPageOutcome` for accurate auditing.

3. **`record_duration` gate test.** New test seeds a `done` row with
   known duration, force-re-analyzes with failing dispatcher +
   max_attempts=3 (non-terminal), asserts duration_ms wasn't
   overwritten.

4. **`bookmark.rs` PK comment correction.** Use `b.id DESC` instead
   of the wrong `manga_id`; PK is actually `id`, and migration 0004
   allows both chapter-level and manga-level bookmarks on the same
   manga.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-23 07:22:20 +02:00
parent 93bb156fba
commit 34d6d570eb
10 changed files with 425 additions and 15 deletions

View File

@@ -415,15 +415,27 @@ async fn analyze_page(
return Err(AppError::NotFound);
}
repo::page_analysis::enqueue_for_page(&state.db, page_id, true).await?;
// Surface the actual outcome so the admin (and audit log) sees
// whether the click actually moved anything. Before 0.87.11 the
// partial unique index could silently swallow a force request
// when a `force=false` job was already pending — the worker would
// then pick up the non-force row, hit skip-if-done, ack done, and
// the admin saw "queued for re-analysis" with no re-analysis.
let outcome =
repo::page_analysis::enqueue_for_page(&state.db, page_id, true).await?;
let outcome_label = match outcome {
repo::page_analysis::EnqueueForPageOutcome::Inserted => "inserted",
repo::page_analysis::EnqueueForPageOutcome::UpgradedToForce => "upgraded_pending_to_force",
repo::page_analysis::EnqueueForPageOutcome::AlreadyEnqueued => "already_force_enqueued",
};
repo::admin_audit::insert(
&state.db,
admin.0.id,
"analysis_force_page",
"page",
Some(page_id),
json!({ "force": true }),
json!({ "force": true, "outcome": outcome_label }),
)
.await?;