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:
@@ -824,3 +824,153 @@ async fn reap_done_zero_is_a_no_op(pool: PgPool) {
|
||||
assert_eq!(deleted, 0);
|
||||
assert_eq!(job_count(&pool).await, 1);
|
||||
}
|
||||
|
||||
/// Migration 0031 ships a pre-dedup step (demote all-but-lowest-id
|
||||
/// duplicates to `dead`) so it can apply cleanly on dirty production
|
||||
/// databases where the prior buggy `enqueue_pages` left duplicate
|
||||
/// `analyze_page` rows in `pending`/`running`. This test simulates the
|
||||
/// dirty state by dropping the unique index, inserting duplicates, then
|
||||
/// running the migration's pre-dedup SQL — the same SQL the migration
|
||||
/// file runs — and confirming the duplicates land in `dead` so the
|
||||
/// subsequent index creation would succeed.
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn migration_0031_prededup_demotes_duplicate_analyze_page_rows(pool: PgPool) {
|
||||
// 1) Simulate pre-0031 dirty state: drop the dedup index so a second
|
||||
// pending insert for the same page_id is accepted.
|
||||
sqlx::query("DROP INDEX IF EXISTS crawler_jobs_analyze_page_dedup_idx")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let page_a = Uuid::new_v4();
|
||||
let page_b = Uuid::new_v4();
|
||||
let make_row = |page_id: Uuid| {
|
||||
serde_json::json!({
|
||||
"kind": "analyze_page",
|
||||
"page_id": page_id,
|
||||
"force": false
|
||||
})
|
||||
};
|
||||
// Two pending duplicates for page A, plus one running + one pending
|
||||
// for page B (the migration handles all in-flight states).
|
||||
let a1: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO crawler_jobs (payload, state) VALUES ($1, 'pending') RETURNING id",
|
||||
)
|
||||
.bind(make_row(page_a))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let a2: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO crawler_jobs (payload, state) VALUES ($1, 'pending') RETURNING id",
|
||||
)
|
||||
.bind(make_row(page_a))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let b1: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO crawler_jobs (payload, state) VALUES ($1, 'running') RETURNING id",
|
||||
)
|
||||
.bind(make_row(page_b))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let b2: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO crawler_jobs (payload, state) VALUES ($1, 'pending') RETURNING id",
|
||||
)
|
||||
.bind(make_row(page_b))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 2) Run the migration's pre-dedup statement verbatim.
|
||||
sqlx::query(
|
||||
"UPDATE crawler_jobs \
|
||||
SET state = 'dead', \
|
||||
last_error = COALESCE(last_error, '') || \
|
||||
CASE WHEN last_error IS NULL OR last_error = '' THEN '' \
|
||||
ELSE ' | ' END || \
|
||||
'pre-0031 duplicate analyze_page; superseded by earlier sibling', \
|
||||
updated_at = now() \
|
||||
WHERE id IN ( \
|
||||
SELECT id FROM ( \
|
||||
SELECT id, \
|
||||
row_number() OVER ( \
|
||||
PARTITION BY payload->>'page_id' \
|
||||
ORDER BY id \
|
||||
) AS rn \
|
||||
FROM crawler_jobs \
|
||||
WHERE payload->>'kind' = 'analyze_page' \
|
||||
AND state IN ('pending', 'running') \
|
||||
) ranked \
|
||||
WHERE rn > 1 \
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 3) The earliest-id row per page_id stays in-flight; later
|
||||
// duplicates moved to `dead` with the curator-recoverable
|
||||
// last_error marker. Compute the expected winner per page by
|
||||
// Postgres' UUID ordering (sql min, not Rust Ord — they differ).
|
||||
let winner_a: Uuid =
|
||||
sqlx::query_scalar("SELECT LEAST($1::uuid, $2::uuid)")
|
||||
.bind(a1)
|
||||
.bind(a2)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let loser_a = if winner_a == a1 { a2 } else { a1 };
|
||||
let winner_b: Uuid =
|
||||
sqlx::query_scalar("SELECT LEAST($1::uuid, $2::uuid)")
|
||||
.bind(b1)
|
||||
.bind(b2)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let loser_b = if winner_b == b1 { b2 } else { b1 };
|
||||
|
||||
let state_a_winner: String =
|
||||
sqlx::query_scalar("SELECT state FROM crawler_jobs WHERE id = $1")
|
||||
.bind(winner_a)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let state_a_loser: String =
|
||||
sqlx::query_scalar("SELECT state FROM crawler_jobs WHERE id = $1")
|
||||
.bind(loser_a)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
state_a_winner == "pending" || state_a_winner == "running",
|
||||
"winner kept in-flight"
|
||||
);
|
||||
assert_eq!(state_a_loser, "dead", "loser demoted to dead");
|
||||
|
||||
let state_b_winner: String =
|
||||
sqlx::query_scalar("SELECT state FROM crawler_jobs WHERE id = $1")
|
||||
.bind(winner_b)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let state_b_loser: String =
|
||||
sqlx::query_scalar("SELECT state FROM crawler_jobs WHERE id = $1")
|
||||
.bind(loser_b)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(state_b_winner == "pending" || state_b_winner == "running");
|
||||
assert_eq!(state_b_loser, "dead");
|
||||
|
||||
// 4) After dedup, the index is creatable. Migration would now succeed.
|
||||
sqlx::query(
|
||||
"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'",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("post-dedup index creation must succeed");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user