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:
@@ -392,6 +392,78 @@ async fn force_analyze_page_enqueues_with_force_flag(pool: PgPool) {
|
||||
assert_eq!(payload["force"].as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn force_analyze_upgrades_pending_non_force_to_force(pool: PgPool) {
|
||||
// The cover-up case the partial unique index used to silently
|
||||
// create: when a `force=false` job is already pending and admin
|
||||
// clicks force, the INSERT used to dedup-skip silently. The worker
|
||||
// then picked up the non-force row, hit skip-if-done on a `done`
|
||||
// page, and admin saw "queued" with no re-analysis. 0.87.11
|
||||
// upgrades the pending row in-place instead.
|
||||
let h = common::harness_with_analysis(pool.clone());
|
||||
let cookie = seed_admin(&pool, &h.app).await;
|
||||
let ids = seed_pages(&pool, 1).await;
|
||||
let page_id = ids[0];
|
||||
|
||||
// 1) Pre-existing non-force pending row (simulates the crawler's
|
||||
// post-upload enqueue path).
|
||||
mangalord::repo::page_analysis::enqueue_for_page(&pool, page_id, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let payload_before: serde_json::Value = sqlx::query_scalar(
|
||||
"SELECT payload FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(payload_before["force"].as_bool(), Some(false));
|
||||
|
||||
// 2) Admin force-click.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/admin/pages/{page_id}/analyze"),
|
||||
json!({}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 3) STILL one row (no dedup duplicate), but its `force` flag
|
||||
// flipped to true — the admin intent landed where the worker
|
||||
// can see it.
|
||||
let rows: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 1, "upgrade in place; no duplicate row");
|
||||
|
||||
let payload_after: serde_json::Value = sqlx::query_scalar(
|
||||
"SELECT payload FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(payload_after["force"].as_bool(), Some(true));
|
||||
|
||||
// 4) Audit row carries the outcome so a moderator can tell
|
||||
// upgrade-paths from fresh-inserts.
|
||||
let audit_payload: serde_json::Value = sqlx::query_scalar(
|
||||
"SELECT payload FROM admin_audit WHERE action = 'analysis_force_page'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
audit_payload["outcome"].as_str(),
|
||||
Some("upgraded_pending_to_force")
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn force_analyze_unknown_page_is_404(pool: PgPool) {
|
||||
let h = common::harness_with_analysis(pool.clone());
|
||||
|
||||
Reference in New Issue
Block a user