| 399 |
399 |
|
"an email outage must not touch the purchase"
|
| 400 |
400 |
|
);
|
| 401 |
401 |
|
}
|
|
402 |
+ |
|
|
403 |
+ |
// The scan-job retry budget
|
|
404 |
+ |
|
|
405 |
+ |
/// Set up a trusted creator with an audio item, presign an upload, put the
|
|
406 |
+ |
/// bytes, and confirm it, leaving exactly one queued scan job. Returns the item
|
|
407 |
+ |
/// id and the staging key the job will try to download.
|
|
408 |
+ |
async fn queue_one_scan_job(h: &mut TestHarness) -> (String, String) {
|
|
409 |
+ |
let setup = h.create_creator_with_item("fpscan", "audio", 0).await;
|
|
410 |
+ |
h.trust_user(setup.user_id).await;
|
|
411 |
+ |
h.grant_tier(setup.user_id, "small_files").await;
|
|
412 |
+ |
|
|
413 |
+ |
let body = serde_json::json!({
|
|
414 |
+ |
"item_id": setup.item_id,
|
|
415 |
+ |
"file_type": "audio",
|
|
416 |
+ |
"file_name": "held.mp3",
|
|
417 |
+ |
"content_type": "audio/mpeg",
|
|
418 |
+ |
});
|
|
419 |
+ |
let resp = h
|
|
420 |
+ |
.client
|
|
421 |
+ |
.post_json("/api/upload/presign", &body.to_string())
|
|
422 |
+ |
.await;
|
|
423 |
+ |
assert_eq!(resp.status, 200, "presign failed: {}", resp.text);
|
|
424 |
+ |
let s3_key = resp.json::<Value>()["s3_key"]
|
|
425 |
+ |
.as_str()
|
|
426 |
+ |
.expect("presign returns s3_key")
|
|
427 |
+ |
.to_string();
|
|
428 |
+ |
|
|
429 |
+ |
let mut mp3 = b"ID3".to_vec();
|
|
430 |
+ |
mp3.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
|
431 |
+ |
mp3.extend_from_slice(&[0u8; 100]);
|
|
432 |
+ |
h.storage.as_ref().unwrap().put(&s3_key, mp3);
|
|
433 |
+ |
|
|
434 |
+ |
let body = serde_json::json!({
|
|
435 |
+ |
"item_id": setup.item_id,
|
|
436 |
+ |
"file_type": "audio",
|
|
437 |
+ |
"s3_key": s3_key,
|
|
438 |
+ |
});
|
|
439 |
+ |
let resp = h
|
|
440 |
+ |
.client
|
|
441 |
+ |
.post_json("/api/upload/confirm", &body.to_string())
|
|
442 |
+ |
.await;
|
|
443 |
+ |
assert_eq!(resp.status, 200, "confirm failed: {}", resp.text);
|
|
444 |
+ |
|
|
445 |
+ |
(setup.item_id, s3_key)
|
|
446 |
+ |
}
|
|
447 |
+ |
|
|
448 |
+ |
async fn job_row(h: &TestHarness, item_id: &str) -> (String, i32, Option<String>) {
|
|
449 |
+ |
sqlx::query_as("SELECT status, attempts, last_error FROM scan_jobs WHERE target_id = $1::uuid")
|
|
450 |
+ |
.bind(item_id)
|
|
451 |
+ |
.fetch_one(&h.db)
|
|
452 |
+ |
.await
|
|
453 |
+ |
.unwrap()
|
|
454 |
+ |
}
|
|
455 |
+ |
|
|
456 |
+ |
/// A scan whose download fails must record the failure and park the entity at
|
|
457 |
+ |
/// `held_for_review`. Leaving it at `scanning` is the production regression the
|
|
458 |
+ |
/// reset in `process_job` exists to prevent: the file is invisible to the buyer
|
|
459 |
+ |
/// and invisible to the admin queue, so nothing ever resolves it.
|
|
460 |
+ |
#[tokio::test]
|
|
461 |
+ |
async fn scan_download_failure_marks_the_job_failed_and_holds_the_entity() {
|
|
462 |
+ |
let mut h = TestHarness::with_storage_and_scanner().await;
|
|
463 |
+ |
let (item_id, _key) = queue_one_scan_job(&mut h).await;
|
|
464 |
+ |
let storage = h.storage.clone().expect("scanner harness provides storage");
|
|
465 |
+ |
|
|
466 |
+ |
// Both scanner read paths (`download_object_buf_capped` for small files,
|
|
467 |
+ |
// `download_stream` for spooled ones) bottom out in `download_stream`, so
|
|
468 |
+ |
// one rule covers the branch either size takes.
|
|
469 |
+ |
storage
|
|
470 |
+ |
.faults()
|
|
471 |
+ |
.fail_always("download_stream", storage_unavailable);
|
|
472 |
+ |
|
|
473 |
+ |
let err = h
|
|
474 |
+ |
.try_process_one_scan_job()
|
|
475 |
+ |
.await
|
|
476 |
+ |
.expect_err("a failing download must surface as a job error");
|
|
477 |
+ |
|
|
478 |
+ |
let (status, attempts, last_error) = job_row(&h, &item_id).await;
|
|
479 |
+ |
assert_eq!(status, "failed", "the job records its own failure");
|
|
480 |
+ |
assert_eq!(attempts, 1, "the claim consumed exactly one attempt");
|
|
481 |
+ |
assert!(
|
|
482 |
+ |
last_error.is_some_and(|e| !e.is_empty()),
|
|
483 |
+ |
"last_error is what an admin has to work from"
|
|
484 |
+ |
);
|
|
485 |
+ |
|
|
486 |
+ |
let scan_status: String =
|
|
487 |
+ |
sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
|
|
488 |
+ |
.bind(&item_id)
|
|
489 |
+ |
.fetch_one(&h.db)
|
|
490 |
+ |
.await
|
|
491 |
+ |
.unwrap();
|
|
492 |
+ |
assert_eq!(
|
|
493 |
+ |
scan_status, "held_for_review",
|
|
494 |
+ |
"a failed scan must not leave the entity stuck at 'scanning'"
|
|
495 |
+ |
);
|
|
496 |
+ |
assert!(
|
|
497 |
+ |
err.contains("S3") || err.contains("torage"),
|
|
498 |
+ |
"the error should name the failing dependency, got: {err}"
|
|
499 |
+ |
);
|
|
500 |
+ |
}
|
|
501 |
+ |
|
|
502 |
+ |
/// A worker that dies mid-scan leaves its row `running` forever; `reap_stuck` is
|
|
503 |
+ |
/// what returns it to the queue. Below the attempt ceiling that is a requeue,
|
|
504 |
+ |
/// and the retry then succeeds once storage is back. Nothing asserted the
|
|
505 |
+ |
/// recovery half before, which is the half the budget exists for.
|
|
506 |
+ |
#[tokio::test]
|
|
507 |
+ |
async fn a_reaped_scan_job_is_requeued_and_succeeds_when_storage_recovers() {
|
|
508 |
+ |
let mut h = TestHarness::with_storage_and_scanner().await;
|
|
509 |
+ |
let (item_id, _key) = queue_one_scan_job(&mut h).await;
|
|
510 |
+ |
let storage = h.storage.clone().expect("scanner harness provides storage");
|
|
511 |
+ |
|
|
512 |
+ |
// Claim the job the way a worker would, then abandon it: no mark_done, no
|
|
513 |
+ |
// mark_failed, exactly what a killed process leaves behind.
|
|
514 |
+ |
let job = db::scan_jobs::claim_next(&h.db)
|
|
515 |
+ |
.await
|
|
516 |
+ |
.unwrap()
|
|
517 |
+ |
.expect("the confirm queued a job");
|
|
518 |
+ |
sqlx::query("UPDATE scan_jobs SET heartbeat_at = NOW() - INTERVAL '1 hour' WHERE id = $1")
|
|
519 |
+ |
.bind(job.id)
|
|
520 |
+ |
.execute(&h.db)
|
|
521 |
+ |
.await
|
|
522 |
+ |
.unwrap();
|
|
523 |
+ |
|
|
524 |
+ |
let reaped = db::scan_jobs::reap_stuck(&h.db, 60).await.unwrap();
|
|
525 |
+ |
assert_eq!(reaped, 1, "the stale heartbeat is what the reaper keys on");
|
|
526 |
+ |
|
|
527 |
+ |
let (status, attempts, _) = job_row(&h, &item_id).await;
|
|
528 |
+ |
assert_eq!(
|
|
529 |
+ |
status, "queued",
|
|
530 |
+ |
"below the ceiling a reaped job goes back to the queue, not to failed"
|
|
531 |
+ |
);
|
|
532 |
+ |
assert_eq!(attempts, 1, "the abandoned attempt is still spent");
|
|
533 |
+ |
|
|
534 |
+ |
// Storage is healthy again; the retry must complete the job.
|
|
535 |
+ |
assert!(
|
|
536 |
+ |
storage.faults().calls("download_stream") == 0,
|
|
537 |
+ |
"no fault installed, the first attempt never reached the backend"
|
|
538 |
+ |
);
|
|
539 |
+ |
h.drain_scan_jobs().await;
|
|
540 |
+ |
|
|
541 |
+ |
let (status, attempts, _) = job_row(&h, &item_id).await;
|
|
542 |
+ |
assert_eq!(status, "done", "the retry completes the job");
|
|
543 |
+ |
assert_eq!(attempts, 2, "the retry consumed a second attempt");
|
|
544 |
+ |
}
|
|
545 |
+ |
|
|
546 |
+ |
/// The ceiling is what stops a job that reliably kills its worker from being
|
|
547 |
+ |
/// re-attempted forever. At `MAX_SCAN_ATTEMPTS` the reaper retires the row to
|
|
548 |
+ |
/// `failed` rather than requeueing it, and `claim_next` will not hand it out
|
|
549 |
+ |
/// again.
|
|
550 |
+ |
#[tokio::test]
|
|
551 |
+ |
async fn a_scan_job_at_its_attempt_ceiling_is_retired_not_requeued() {
|
|
552 |
+ |
let mut h = TestHarness::with_storage_and_scanner().await;
|
|
553 |
+ |
let (item_id, _key) = queue_one_scan_job(&mut h).await;
|
|
554 |
+ |
|
|
555 |
+ |
// Spend the budget down to its last attempt, then claim, which takes it.
|
|
556 |
+ |
sqlx::query("UPDATE scan_jobs SET attempts = $1 WHERE target_id = $2::uuid")
|
|
557 |
+ |
.bind(db::scan_jobs::MAX_SCAN_ATTEMPTS - 1)
|
|
558 |
+ |
.bind(&item_id)
|
|
559 |
+ |
.execute(&h.db)
|
|
560 |
+ |
.await
|
|
561 |
+ |
.unwrap();
|
|
562 |
+ |
let job = db::scan_jobs::claim_next(&h.db)
|
|
563 |
+ |
.await
|
|
564 |
+ |
.unwrap()
|
|
565 |
+ |
.expect("a job one under the ceiling is still claimable");
|
|
566 |
+ |
assert_eq!(job.attempts, db::scan_jobs::MAX_SCAN_ATTEMPTS);
|
|
567 |
+ |
|
|
568 |
+ |
sqlx::query("UPDATE scan_jobs SET heartbeat_at = NOW() - INTERVAL '1 hour' WHERE id = $1")
|
|
569 |
+ |
.bind(job.id)
|
|
570 |
+ |
.execute(&h.db)
|
|
571 |
+ |
.await
|
|
572 |
+ |
.unwrap();
|
|
573 |
+ |
assert_eq!(db::scan_jobs::reap_stuck(&h.db, 60).await.unwrap(), 1);
|
|
574 |
+ |
|
|
575 |
+ |
let (status, _, last_error) = job_row(&h, &item_id).await;
|
|
576 |
+ |
assert_eq!(
|
|
577 |
+ |
status, "failed",
|
|
578 |
+ |
"at the ceiling the reaper retires the job instead of requeueing it"
|
|
579 |
+ |
);
|
|
580 |
+ |
assert!(
|
|
581 |
+ |
last_error.is_some_and(|e| e.contains("max scan attempts")),
|
|
582 |
+ |
"the retirement reason must be legible to an admin"
|
|
583 |
+ |
);
|
|
584 |
+ |
|
|
585 |
+ |
assert!(
|
|
586 |
+ |
db::scan_jobs::claim_next(&h.db).await.unwrap().is_none(),
|
|
587 |
+ |
"a retired job must never be claimed again"
|
|
588 |
+ |
);
|
|
589 |
+ |
}
|