fix: re-fetch queued retries whose local media did not survive a restart

A queued retry that holds a local file — the ugoira MP4, a bsky remux, or a
temp file the reupload fallback downloaded — could never succeed after a
restart: those files live in the system temp dir and `send::KEEP_ALIVE`, the
registry that keeps them alive for the retry, is in memory. The row retried
into an upload error, said nothing about why, and dead-lettered the user's
link even though the payload carries the `source_url`.

`handlers::repair_lost_local_media` now runs in `main` before any worker
starts (so no row can be leased while it writes payloads, which is why it can
replace them without the lease guard a worker's write-back carries):

- `Task::local_media_paths` decides which rows are affected: any local path
  that is gone. A partially delivered album is left alone — its remaining
  batches cannot be reconciled with a fresh media list without risking a
  second copy of what the user already received.
- The post is re-fetched from `source_url` through the ordinary `site::fetch`,
  so a repaired task looks like a first send: fresh media, the chat's caption
  format, a fresh link-cache snapshot, and a new keep-alive entry when the
  re-fetch produced another local file.
- The delivery envelope (chat, reply, forward/edit settings, notify targets) is
  kept, the attempt budget restarts, and nothing counts as sent.
- A post that cannot be fetched again (gone, withheld, site down) notifies the
  user with that reason instead of letting the retry die on a missing file.

New queue plumbing: `runnable_rows()` (pending + in-progress rows, read before
the workers exist) and `replace_payload()` (rewrites the payload, resets
`attempts`, marks the row pending).

Verified: 5 new offline tests (the two decisions above against a real temp
file, the queue scan/replace, and the envelope-preserving rewrite) plus
`a_lost_local_media_row_is_refetched_from_its_post`, a live test that seeds a
row pointing at a missing file with a real bsky post as its source and asserts
the row now carries http(s) media and that nothing was sent — run against the
live API here. `cargo fmt`, `cargo clippy --workspace --all-targets --locked --
-D warnings` and `cargo test --workspace --locked` (187 passed, 15 ignored)
are clean.
This commit is contained in:
2026-09-21 01:48:50 +08:00
parent 024dfd50b3
commit d540fc31e9
6 changed files with 531 additions and 4 deletions
+36 -3
View File
@@ -29,7 +29,7 @@ use upload::{FallbackError, PreparedItem, prepare_upload_item, send_batch_via_up
// parts other modules use so call sites stay `send::x`.
pub(crate) use post_send::{
EDIT_PROMPT_EXPIRED_TEXT, KEEP_ALIVE, Settled, dead_letter_notify, enqueue_retry, handle_task,
post_send_actions, settle_task,
notify_failure, post_send_actions, settle_task,
};
/// One process-wide Bot for queue workers. Building a fresh Bot (and its HTTP
@@ -143,7 +143,7 @@ impl Task {
}
}
fn source_url(&self) -> Option<&str> {
pub(crate) fn source_url(&self) -> Option<&str> {
match self {
Task::SendMediaSequence { source_url, .. } | Task::SendAnimation { source_url, .. } => {
Some(source_url)
@@ -173,9 +173,42 @@ impl Task {
}
}
/// The chat this task delivers media to (`None` for a channel copy, which
/// names two chats instead).
pub(crate) fn chat_id(&self) -> Option<i64> {
match self {
Task::SendMediaSequence { chat_id, .. } | Task::SendAnimation { chat_id, .. } => {
Some(*chat_id)
}
Task::ForwardMessages { .. } => None,
}
}
/// Where a failure notice for this task goes (both `None` for a copy with
/// nothing to notify).
pub(crate) fn notify_target(&self) -> (Option<i64>, Option<i64>) {
match self {
Task::SendMediaSequence {
notify_chat_id,
notify_message_id,
..
}
| Task::SendAnimation {
notify_chat_id,
notify_message_id,
..
}
| Task::ForwardMessages {
notify_chat_id,
notify_message_id,
..
} => (*notify_chat_id, *notify_message_id),
}
}
/// Local file paths referenced by this task's media (ugoira / bsky remux
/// MP4 and the like); empty for URL or Telegram file-id sends.
fn local_media_paths(&self) -> Vec<std::path::PathBuf> {
pub(crate) fn local_media_paths(&self) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for item in self.media_items() {
let is_file_id = match item {