| 469 |
469 |
|
// Carry (key, bucket) pairs: the deletion is bucket-scoped so a key present
|
| 470 |
470 |
|
// in two buckets only clears the record for the bucket we reaped (B7).
|
| 471 |
471 |
|
let mut keys_to_delete: Vec<(String, String)> = Vec::with_capacity(stale.len());
|
|
472 |
+ |
// Transient S3 delete failures handed off to the durable deletion queue
|
|
473 |
+ |
// (which has a retry + dead-letter backstop) instead of dropping the only
|
|
474 |
+ |
// tracking row — clearing it on a transient failure permanently leaked the
|
|
475 |
+ |
// object (Run #2 Storage SERIOUS; the sibling retry worker already routes
|
|
476 |
+ |
// failures into this ladder).
|
|
477 |
+ |
let mut failed_keys: Vec<(String, String)> = Vec::new();
|
| 472 |
478 |
|
|
| 473 |
479 |
|
for (s3_key, bucket) in &stale {
|
| 474 |
480 |
|
let s3_client = match bucket.as_str() {
|
| 486 |
492 |
|
}
|
| 487 |
493 |
|
// Live row owns the key now: clear the stale record, keep object.
|
| 488 |
494 |
|
GuardedDelete::SkippedLive => keys_to_delete.push((s3_key.clone(), bucket.clone())),
|
| 489 |
|
- |
// Delete failed: still clear the record so we don't retry forever.
|
| 490 |
|
- |
GuardedDelete::Failed => keys_to_delete.push((s3_key.clone(), bucket.clone())),
|
|
495 |
+ |
// Delete failed (transient): hand off to the durable deletion
|
|
496 |
+ |
// queue rather than dropping the tracking row and leaking.
|
|
497 |
+ |
GuardedDelete::Failed => failed_keys.push((s3_key.clone(), bucket.clone())),
|
| 491 |
498 |
|
}
|
| 492 |
499 |
|
} else {
|
| 493 |
500 |
|
// S3 not configured for this bucket; remove the DB record anyway
|
| 495 |
502 |
|
}
|
| 496 |
503 |
|
}
|
| 497 |
504 |
|
|
|
505 |
+ |
// Enqueue transient failures into the durable deletion queue BEFORE clearing
|
|
506 |
+ |
// their tracking rows, so a key is never dropped by both. If the handoff
|
|
507 |
+ |
// itself fails, leave the pending_uploads rows for the next reaper tick
|
|
508 |
+ |
// rather than leaking.
|
|
509 |
+ |
if !failed_keys.is_empty() {
|
|
510 |
+ |
match db::pending_s3_deletions::enqueue_deletions(&state.db, &failed_keys, "orphan-reaper-retry").await {
|
|
511 |
+ |
Ok(()) => {
|
|
512 |
+ |
tracing::warn!(
|
|
513 |
+ |
count = failed_keys.len(),
|
|
514 |
+ |
"orphan reaper: S3 delete failed; handed off to durable deletion queue for retry"
|
|
515 |
+ |
);
|
|
516 |
+ |
keys_to_delete.extend(failed_keys);
|
|
517 |
+ |
}
|
|
518 |
+ |
Err(e) => {
|
|
519 |
+ |
tracing::error!(
|
|
520 |
+ |
error = ?e, count = failed_keys.len(),
|
|
521 |
+ |
"orphan reaper: could not enqueue transient failures to deletion queue; leaving tracking rows for next tick"
|
|
522 |
+ |
);
|
|
523 |
+ |
}
|
|
524 |
+ |
}
|
|
525 |
+ |
}
|
|
526 |
+ |
|
| 498 |
527 |
|
if !keys_to_delete.is_empty()
|
| 499 |
528 |
|
&& let Err(e) = db::pending_uploads::delete_pending_uploads(&state.db, &keys_to_delete).await
|
| 500 |
529 |
|
{
|