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?;

View File

@@ -63,11 +63,13 @@ pub async fn list_for_user(
INNER JOIN mangas m ON m.id = b.manga_id
LEFT JOIN chapters c ON c.id = b.chapter_id
WHERE b.user_id = $1
-- `manga_id` tiebreaker: the (user_id, manga_id) PK guarantees no
-- two bookmarks share both, so this is a deterministic ordering.
-- Without it, multiple bookmarks added in the same `created_at`
-- tick (timestamptz resolution: 1µs) can skip/repeat across pages.
ORDER BY b.created_at DESC, b.manga_id DESC
-- `id` tiebreaker for deterministic pagination. The bookmarks PK
-- is `id` (see migration 0001); the (user_id, manga_id) pair
-- alone is not unique — migration 0004 lets a user keep both a
-- chapter-level bookmark and a manga-level bookmark on the same
-- manga, so two rows can tie on (created_at, manga_id). A 0.87.6
-- earlier revision mistakenly used `manga_id` here.
ORDER BY b.created_at DESC, b.id DESC
LIMIT $2 OFFSET $3
"#,
)

View File

@@ -44,12 +44,79 @@ pub struct PageSearchQuery {
/// aborting the whole page's analysis on one bad model output.
const MAX_TAG_CHARS: usize = 64;
/// Outcome of [`enqueue_for_page`] — surfaces whether the admin's intent
/// actually landed as a worker-visible change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnqueueForPageOutcome {
/// A fresh `analyze_page` job row was inserted.
Inserted,
/// A pending `force=false` row was upgraded in-place to `force=true`,
/// because the partial unique index `crawler_jobs_analyze_page_dedup_idx`
/// blocks a second pending insert per page. Returned only for force
/// requests.
UpgradedToForce,
/// A matching `(pending|running)` job already encodes the request;
/// nothing changed. For force requests this means the existing row
/// is already `force=true`.
AlreadyEnqueued,
}
/// Enqueue an `analyze_page` job for `page_id`. `force` re-analyzes a page
/// that is already `done`. Enqueue is idempotent at the job level only in
/// that duplicate pending jobs are harmless — processing is idempotent.
pub async fn enqueue_for_page(pool: &PgPool, page_id: Uuid, force: bool) -> AppResult<()> {
jobs::enqueue(pool, &JobPayload::AnalyzePage { page_id, force }).await?;
Ok(())
/// that is already `done`.
///
/// **Force-vs-dedup correctness.** Migration 0031's partial unique index
/// is keyed on `page_id` (not on `(page_id, force)`), so a plain
/// `jobs::enqueue` of a `force=true` payload when a `force=false` job is
/// already pending silently goes to `Skipped` — the worker would then
/// pick up the non-force row, hit the skip-if-done net, and ack done
/// without re-analyzing. The admin's force request was lost.
///
/// This function fixes that by: (1) trying the insert; (2) on Skipped
/// with `force=true`, running an UPDATE that flips the existing pending
/// row's `force` flag to `true`; (3) reporting which path ran so the
/// caller can audit accurately.
pub async fn enqueue_for_page(
pool: &PgPool,
page_id: Uuid,
force: bool,
) -> AppResult<EnqueueForPageOutcome> {
use crate::crawler::jobs::EnqueueResult;
match jobs::enqueue(pool, &JobPayload::AnalyzePage { page_id, force }).await? {
EnqueueResult::Inserted(_) => Ok(EnqueueForPageOutcome::Inserted),
EnqueueResult::Skipped if !force => Ok(EnqueueForPageOutcome::AlreadyEnqueued),
EnqueueResult::Skipped => {
// Force-specific upgrade. The WHERE clause matches the same
// partial index predicate (`pending|running` + `analyze_page`
// + this page_id) and adds `force=false` so we never overwrite
// a row that's already what the admin wants. RETURNING tells
// us whether the upgrade actually moved anything.
let upgraded: Option<Uuid> = sqlx::query_scalar(
"UPDATE crawler_jobs \
SET payload = jsonb_set(payload, '{force}', 'true'::jsonb), \
updated_at = now() \
WHERE state IN ('pending', 'running') \
AND payload->>'kind' = 'analyze_page' \
AND payload->>'page_id' = $1 \
AND (payload->>'force')::boolean = false \
RETURNING id",
)
.bind(page_id.to_string())
.fetch_optional(pool)
.await?;
Ok(match upgraded {
Some(_) => EnqueueForPageOutcome::UpgradedToForce,
// Race: between the skipped INSERT and the UPDATE the
// sibling row could have been completed (job state moves
// out of pending/running). At that point a re-INSERT
// would succeed. Retry once to close the race without
// unbounded loops.
None => match jobs::enqueue(pool, &JobPayload::AnalyzePage { page_id, force }).await? {
EnqueueResult::Inserted(_) => EnqueueForPageOutcome::Inserted,
EnqueueResult::Skipped => EnqueueForPageOutcome::AlreadyEnqueued,
},
})
}
}
}
/// How wide a bulk re-enqueue reaches.