fix: let a successful send return a degraded entry to the fast path

Degrading a link-cache entry (previous commit) closed the "user retries right
after a failure" case, but left a new one open: the entry could never regain
file ids. `cache_sent_task` skipped *every* cached send — its rule was "the
entry already holds the ids the next repeat wants" — which is true for a
healthy entry and false for a degraded one. So a degraded entry (pixiv's
hotlink-protected media, say) kept sending by URL forever, and every repeat
paid a download and an upload through the fallback that the file ids would
have avoided. Worse than the re-fetch it replaced.

The rule is now the one it always meant: a send whose cache snapshot still
carries file ids leaves the entry alone, and a send served from a degraded
entry writes back the ids it produced (the URLs in the rewritten entry come
from that send's own items, so they stay correct).

Verified by a test that drives `cache_sent_task` directly with both task
shapes: the degraded one updates the entry to the fresh id, the healthy one
leaves it untouched. 125 bot tests + 91 x-media tests, live suite (14) pass.

`cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D
warnings` and `cargo test --workspace --locked` clean.
This commit is contained in:
2026-09-21 14:38:26 +08:00
parent 64cf43dc01
commit 670351d436
2 changed files with 69 additions and 4 deletions
+62 -1
View File
@@ -787,7 +787,7 @@ pub async fn forward_messages(ctx: &AppContext<'_>, task: &Task) -> Result<(), S
#[cfg(test)]
mod tests {
use super::post_send::build_edit_markup;
use super::post_send::{build_edit_markup, cache_sent_task};
use super::upload::sniff_ext;
use super::*;
use crate::ctx::test_support::{TestStores, cached_photo};
@@ -1645,6 +1645,67 @@ mod tests {
}
}
#[tokio::test]
async fn a_degraded_entry_regains_the_file_ids_a_send_produced() {
let sender = MockSender::scripted(vec![], media_fetch_error);
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
// A degraded entry: no file ids, URL only (what `invalidate_cache`
// leaves behind).
let mut degraded = cached_photo();
degraded.media[0].file_id.clear();
stores.link_cache().put("twitter:1", &degraded).await;
let mut task = cached_sequence_task();
if let Task::SendMediaSequence { cache_data, .. } = &mut task {
*cache_data = Some(degraded.clone());
}
cache_sent_task(
&ctx,
&task,
vec![CachedMedia {
kind: CachedMediaKind::Photo,
file_id: "fresh-id".into(),
url: "https://pbs.twimg.com/media/photo.jpg".into(),
}],
)
.await;
let entry = stores
.link_cache()
.get("twitter:1", Duration::from_secs(3600))
.await
.expect("the entry must still be there");
assert_eq!(
entry.media[0].file_id, "fresh-id",
"a degraded entry must take the ids its send produced"
);
// A send served from a healthy entry must not rewrite it: the ids it
// already holds are exactly what the next repeat wants. Which of the
// two a send was is the *task's* cache snapshot — a healthy one carries
// file ids.
cache_sent_task(
&ctx,
&cached_sequence_task(),
vec![CachedMedia {
kind: CachedMediaKind::Photo,
file_id: "other-id".into(),
url: String::new(),
}],
)
.await;
let entry = stores
.link_cache()
.get("twitter:1", Duration::from_secs(3600))
.await
.unwrap();
assert_eq!(
entry.media[0].file_id, "fresh-id",
"a healthy entry is left alone"
);
}
#[tokio::test]
async fn settled_sent_keeps_the_cache_entry() {
let sender = MockSender::scripted(vec![], media_fetch_error);
+7 -3
View File
@@ -15,13 +15,17 @@ use std::collections::HashMap;
use std::sync::LazyLock;
use teloxide::types::{ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId};
/// Persists a successful send under the post's cache key. Only runs for a
/// fresh (non-resumed) task that carried raw cache data with no file ids yet.
/// Persists a successful send under the post's cache key. Skips a send that was
/// served from the cache — its entry already holds the file ids the next repeat
/// wants — *unless* the entry was degraded (no file ids left, see
/// `invalidate_cache`): then the ids this send just produced are written back,
/// which is what returns a degraded entry to the fast path instead of leaving
/// it to re-upload the media on every repeat.
pub(super) async fn cache_sent_task(ctx: &AppContext<'_>, task: &Task, media: Vec<CachedMedia>) {
let Some(cache_data) = task.cache_data() else {
return;
};
if !cache_data.media.is_empty() || media.is_empty() {
if cache_data.media.iter().any(|m| !m.file_id.is_empty()) || media.is_empty() {
return;
}
let mut post = cache_data.clone();