forked from Keelrleek/SoulStream
metadata: a lost probe race no longer writes an image host off for five minutes
The image-host liveness probe has a 3-second budget so a page never waits on it — but losing that race was recorded as "the host is down" and cached for the full five-minute TTL, turning one bad sample into square portraits on every artist page in the window. Measured on the machine that reported it: all 213 cached assets.fanart.tv urls answered in ~130ms while the machine's own first DNS resolver ate 1-3.6s of the budget before a byte was sent — and the A record's TTL is 300s, exactly the probe TTL, so every probe replayed that lottery on an expired name. The fast verdict still stands for the page that paid for it — it falls through to the next provider immediately, as before — but a background confirmation with a 12-second budget now decides whether the verdict persists, and the warning reports what was measured (timeout, transport error chain, or 5xx) instead of asserting an outage it cannot see. A regression test stalls a local socket exactly once and proves the host comes back without waiting out the TTL; the fanart_probe_live harness prints the reqwest error chain four ways for the next time a provider is suspected on this machine.
This commit is contained in:
1 parent
9aeb6c39c7
commit
3aaec30dc6
1 file changed
+340
-17
+340
-17
@@ -213,6 +213,13 @@ fn stage_trace(stage: &str, started: Instant, detail: &str) {
|
||||
const IMAGE_HOST_PROBE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
/// A liveness probe races the page, so it must never become the page's latency.
|
||||
const IMAGE_HOST_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// The confirming probe runs after the page has already moved on, so it can
|
||||
/// afford to wait out congestion the fast one could not.
|
||||
const IMAGE_HOST_CONFIRM_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
/// Last liveness verdict per image host, shared by the fast probe and by the
|
||||
/// background confirmation that is allowed to overturn it.
|
||||
static IMAGE_HOST_HEALTH: std::sync::OnceLock<Mutex<HashMap<String, (bool, Instant)>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// Cached catalog responses expire after this long.
|
||||
const CATALOG_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
@@ -3272,8 +3279,6 @@ impl MetadataClient {
|
||||
/// CDN is down" into "this provider returned nothing", which is precisely
|
||||
/// the state the existing fallback chain already knows how to handle.
|
||||
pub async fn image_host_alive(&self, url: &str) -> bool {
|
||||
static HEALTH: std::sync::OnceLock<Mutex<HashMap<String, (bool, Instant)>>> =
|
||||
std::sync::OnceLock::new();
|
||||
let Some(host) = reqwest::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|parsed| parsed.host_str().map(String::from))
|
||||
@@ -3281,32 +3286,132 @@ impl MetadataClient {
|
||||
// Not a URL this can reason about; never block art on that.
|
||||
return true;
|
||||
};
|
||||
let health = HEALTH.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let health = IMAGE_HOST_HEALTH.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some((alive, checked)) = health.lock().await.get(&host).copied() {
|
||||
if checked.elapsed() < IMAGE_HOST_PROBE_TTL {
|
||||
return alive;
|
||||
}
|
||||
}
|
||||
// A one-byte ranged GET: proof the host serves image bytes without
|
||||
// pulling a 4K background to find out. Any HTTP answer means the host
|
||||
// is up — a 404 for one image is not an outage — so only a transport
|
||||
// failure or a server error counts as dead.
|
||||
let alive = tokio::time::timeout(
|
||||
IMAGE_HOST_PROBE_TIMEOUT,
|
||||
self.http
|
||||
match self.probe_image_host(url, IMAGE_HOST_PROBE_TIMEOUT).await {
|
||||
Ok(()) => {
|
||||
health.lock().await.insert(host, (true, Instant::now()));
|
||||
true
|
||||
}
|
||||
Err(why) => {
|
||||
// Say what was measured, not what it means. "The host is down"
|
||||
// is a conclusion this single 3-second sample cannot support,
|
||||
// and stating it as fact sent a later reader hunting an outage
|
||||
// at Fanart.tv that never happened.
|
||||
log::warn!(
|
||||
"image host {host} failed a liveness probe ({why}); \
|
||||
falling through to next provider"
|
||||
);
|
||||
health
|
||||
.lock()
|
||||
.await
|
||||
.insert(host.clone(), (false, Instant::now()));
|
||||
self.confirm_image_host_failure(host, url);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One liveness request, with the failure kept instead of collapsed to a
|
||||
/// bool. A one-byte ranged GET is proof the host serves image bytes without
|
||||
/// pulling a 4K background to find out. Any HTTP answer means the host is
|
||||
/// up — a 404 for one image is not an outage — so only a transport failure
|
||||
/// or a server error counts as dead.
|
||||
async fn probe_image_host(&self, url: &str, budget: Duration) -> Result<(), String> {
|
||||
let started = Instant::now();
|
||||
let sent = self
|
||||
.http
|
||||
.get(url)
|
||||
.header(reqwest::header::RANGE, "bytes=0-0")
|
||||
.send();
|
||||
match tokio::time::timeout(budget, sent).await {
|
||||
Err(_) => Err(format!("no answer inside {}ms", budget.as_millis())),
|
||||
Ok(Err(err)) => {
|
||||
let mut why = format!("{err}");
|
||||
let mut source = std::error::Error::source(&err);
|
||||
while let Some(inner) = source {
|
||||
why.push_str(&format!(": {inner}"));
|
||||
source = std::error::Error::source(inner);
|
||||
}
|
||||
Err(format!("{why} after {}ms", started.elapsed().as_millis()))
|
||||
}
|
||||
Ok(Ok(resp)) if resp.status().is_server_error() => Err(format!(
|
||||
"HTTP {} after {}ms",
|
||||
resp.status(),
|
||||
started.elapsed().as_millis()
|
||||
)),
|
||||
Ok(Ok(_)) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask a second time, off the page's critical path, before a host stays
|
||||
/// written off for [`IMAGE_HOST_PROBE_TTL`].
|
||||
///
|
||||
/// The probe's budget is a LATENCY rule — the page must not wait on it —
|
||||
/// and losing that race is not the same fact as the CDN being down. What
|
||||
/// ate the budget in the one case chased to the bottom was **name
|
||||
/// resolution**, before a single byte was sent.
|
||||
///
|
||||
/// Measured 2026-08-03, on the machine that reported the warning: 213
|
||||
/// `assets.fanart.tv` urls all answered in ~130ms (cache MISS included),
|
||||
/// from curl and from this exact client, cold, warm and 12-way concurrent —
|
||||
/// the host was never down. But the active adapter listed a dead resolver
|
||||
/// first — no ping, every direct query timing out at 12s — and eight cold
|
||||
/// lookups of the same name through the normal path came back **1028ms**,
|
||||
/// 20ms, 30ms, 24ms, 25ms, **2029ms**, 28ms, **1035ms**: the Windows
|
||||
/// resolver's 1s/2s retransmit schedule, paid before falling through to a
|
||||
/// server that answers in 21ms.
|
||||
/// The A record's own TTL is 300s, exactly [`IMAGE_HOST_PROBE_TTL`], so
|
||||
/// every probe lands on an expired name and pays that lottery again. A
|
||||
/// single unlucky draw then blacked out Fanart.tv for five minutes and gave
|
||||
/// every artist a blown-up square portrait instead of a hero.
|
||||
///
|
||||
/// A slow resolver is not this app's to fix, and the fast verdict still
|
||||
/// stands for the page that paid for it — it falls through to the next
|
||||
/// provider immediately, as before. What changes is how long one lost race
|
||||
/// is believed: a background confirmation with a budget that can absorb a
|
||||
/// DNS retry decides whether the verdict persists, so a blip costs one page
|
||||
/// instead of five minutes.
|
||||
fn confirm_image_host_failure(&self, host: String, url: &str) {
|
||||
static CONFIRMING: std::sync::OnceLock<Mutex<HashSet<String>>> = std::sync::OnceLock::new();
|
||||
let confirming = CONFIRMING.get_or_init(|| Mutex::new(HashSet::new()));
|
||||
let http = self.http.clone();
|
||||
let url = url.to_string();
|
||||
tokio::spawn(async move {
|
||||
// One confirmation per host at a time: an artist page probes
|
||||
// banners and logos, twice each, and four identical retries would
|
||||
// add exactly the load that is the prime suspect.
|
||||
if !confirming.lock().await.insert(host.clone()) {
|
||||
return;
|
||||
}
|
||||
let outcome = tokio::time::timeout(
|
||||
IMAGE_HOST_CONFIRM_TIMEOUT,
|
||||
http.get(&url)
|
||||
.header(reqwest::header::RANGE, "bytes=0-0")
|
||||
.send(),
|
||||
)
|
||||
.await;
|
||||
let recovered = matches!(outcome, Ok(Ok(ref resp)) if !resp.status().is_server_error());
|
||||
if recovered {
|
||||
log::warn!(
|
||||
"image host {host} answered the confirming probe; \
|
||||
the fast probe lost a race, not the host — restoring it"
|
||||
);
|
||||
if let Some(health) = IMAGE_HOST_HEALTH.get() {
|
||||
health
|
||||
.lock()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|sent| sent.ok())
|
||||
.is_some_and(|resp| !resp.status().is_server_error());
|
||||
if !alive {
|
||||
log::warn!("image host {host} is not serving images; falling through to next provider");
|
||||
.insert(host.clone(), (true, Instant::now()));
|
||||
}
|
||||
health.lock().await.insert(host, (alive, Instant::now()));
|
||||
alive
|
||||
} else {
|
||||
log::warn!("image host {host} failed the confirming probe too; staying written off");
|
||||
}
|
||||
confirming.lock().await.remove(&host);
|
||||
});
|
||||
}
|
||||
|
||||
/// Can this already-chosen artwork actually be shown right now?
|
||||
@@ -12653,6 +12758,224 @@ fn urlencode(s: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// Is a provider's image host actually failing, or is this machine?
|
||||
///
|
||||
/// Run this the next time a `failed a liveness probe` warning appears. It
|
||||
/// reproduces [`super::MetadataClient::image_host_alive`]'s exact request
|
||||
/// four ways — cold client, warm client, the concurrent burst an artist
|
||||
/// page produces, and a probe interleaved with full-size hero downloads —
|
||||
/// and prints the reqwest error chain the probe otherwise reduces to a
|
||||
/// bool.
|
||||
///
|
||||
/// 2026-08-03, the run that answered the fanart.tv report: every one of
|
||||
/// these came back 206 in ~30-170ms, and 4.3MB of hero downloaded
|
||||
/// alongside them in 340ms. The host was innocent; the machine's first DNS
|
||||
/// server was dead and name resolution alone was spending up to 3.6s. When
|
||||
/// this test is clean, look at `Resolve-DnsName` timings before suspecting
|
||||
/// the provider.
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -p metadata fanart_probe_live -- --ignored --nocapture
|
||||
/// ```
|
||||
#[tokio::test]
|
||||
#[ignore = "hits assets.fanart.tv"]
|
||||
async fn fanart_probe_live() {
|
||||
use std::time::{Duration, Instant};
|
||||
// A real url out of this machine's own artist-art manifest.
|
||||
const URL: &str = "https://assets.fanart.tv/fanart/justice-4e1c8af0c25fc.jpg";
|
||||
|
||||
fn chain(err: &reqwest::Error) -> String {
|
||||
let mut out = format!(
|
||||
"{err} [timeout={} connect={} request={} body={}]",
|
||||
err.is_timeout(),
|
||||
err.is_connect(),
|
||||
err.is_request(),
|
||||
err.is_body(),
|
||||
);
|
||||
let mut src = std::error::Error::source(err);
|
||||
while let Some(e) = src {
|
||||
out.push_str(&format!("\n caused by: {e}"));
|
||||
src = std::error::Error::source(e);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(super::USER_AGENT)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn probe(http: &reqwest::Client, label: String) {
|
||||
let started = Instant::now();
|
||||
let outcome = tokio::time::timeout(
|
||||
super::IMAGE_HOST_PROBE_TIMEOUT,
|
||||
http.get(URL)
|
||||
.header(reqwest::header::RANGE, "bytes=0-0")
|
||||
.send(),
|
||||
)
|
||||
.await;
|
||||
let ms = started.elapsed().as_millis();
|
||||
match outcome {
|
||||
Err(_) => println!("{label:<18} {ms:>6}ms DEAD — probe timeout elapsed"),
|
||||
Ok(Err(err)) => println!("{label:<18} {ms:>6}ms DEAD — {}", chain(&err)),
|
||||
Ok(Ok(resp)) => println!(
|
||||
"{label:<18} {ms:>6}ms {} {:?} (server_error={})",
|
||||
resp.status(),
|
||||
resp.version(),
|
||||
resp.status().is_server_error(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
println!("-- cold client per request (first TLS handshake every time)");
|
||||
for i in 0..4 {
|
||||
probe(&client(), format!("cold #{i}")).await;
|
||||
}
|
||||
|
||||
println!("-- one warm client, sequential");
|
||||
let warm = client();
|
||||
for i in 0..4 {
|
||||
probe(&warm, format!("warm #{i}")).await;
|
||||
}
|
||||
|
||||
println!("-- 12 concurrent on one client (an artist page's shape)");
|
||||
let burst = client();
|
||||
let mut tasks = Vec::new();
|
||||
for i in 0..12 {
|
||||
let http = burst.clone();
|
||||
tasks.push(tokio::spawn(
|
||||
async move { probe(&http, format!("burst #{i:<2}")).await },
|
||||
));
|
||||
}
|
||||
for t in tasks {
|
||||
t.await.unwrap();
|
||||
}
|
||||
|
||||
// The suspect: the probe does not race the CDN, it races THIS APP. A
|
||||
// cold artist page has several multi-megabyte hero candidates in flight
|
||||
// on this same client while the one-byte probe is queued behind them.
|
||||
println!("-- probing every 250ms while 8 full-size heroes download on the same client");
|
||||
let loaded = client();
|
||||
let bulk: Vec<&str> = vec![
|
||||
"https://assets.fanart.tv/fanart/acdc-4fcad35eb2643.jpg",
|
||||
"https://assets.fanart.tv/fanart/acdc-5467b70af2ff0.jpg",
|
||||
"https://assets.fanart.tv/fanart/acdc-4e19b4d51609c.jpg",
|
||||
"https://assets.fanart.tv/fanart/acdc-54575601f4146.jpg",
|
||||
"https://assets.fanart.tv/fanart/tame-impala-515a1f8fc9345.jpg",
|
||||
"https://assets.fanart.tv/fanart/tame-impala-4fd09eab05fc1.jpg",
|
||||
"https://assets.fanart.tv/fanart/fleetwood-mac-4e7ec8bebeba1.jpg",
|
||||
"https://assets.fanart.tv/fanart/queen-4fc9a7ea0e0f0.jpg",
|
||||
];
|
||||
let mut downloads = Vec::new();
|
||||
for url in bulk {
|
||||
let http = loaded.clone();
|
||||
downloads.push(tokio::spawn(async move {
|
||||
let started = Instant::now();
|
||||
match http.get(url).send().await {
|
||||
Ok(resp) => {
|
||||
let bytes = resp.bytes().await.map(|b| b.len()).unwrap_or(0);
|
||||
(bytes, started.elapsed().as_millis())
|
||||
}
|
||||
Err(_) => (0, started.elapsed().as_millis()),
|
||||
}
|
||||
}));
|
||||
}
|
||||
for i in 0..16 {
|
||||
probe(&loaded, format!("under-load #{i:<2}")).await;
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
let mut total = 0;
|
||||
for d in downloads {
|
||||
let (bytes, ms) = d.await.unwrap();
|
||||
total += bytes;
|
||||
print!("[{bytes}B in {ms}ms] ");
|
||||
}
|
||||
println!("\n {total} bytes of hero pulled alongside the probes");
|
||||
}
|
||||
|
||||
/// A host that stalls once must not stay written off for
|
||||
/// [`super::IMAGE_HOST_PROBE_TTL`].
|
||||
///
|
||||
/// This is the fanart.tv failure reduced to its mechanism: the first
|
||||
/// request gets no answer inside the probe's 3-second budget (on the real
|
||||
/// machine, a dead first resolver eating 1-3.6s of name resolution), while
|
||||
/// the host itself is fine and answers the very next request instantly.
|
||||
/// Before the confirming probe, that single sample cost five minutes of
|
||||
/// every artist page falling back to a blown-up square portrait.
|
||||
///
|
||||
/// Runs against a local socket, so it asserts the policy and never the
|
||||
/// weather.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn one_stalled_probe_does_not_write_off_a_host() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let served = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut socket, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let nth = served.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::spawn(async move {
|
||||
let mut scratch = [0u8; 1024];
|
||||
let _ = socket.read(&mut scratch).await;
|
||||
// Only the first caller is made to wait, and for longer
|
||||
// than the fast probe will ever wait for it.
|
||||
if nth == 0 {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
let _ = socket
|
||||
.write_all(
|
||||
b"HTTP/1.1 206 Partial Content\r\n\
|
||||
Content-Range: bytes 0-0/511115\r\n\
|
||||
Content-Length: 1\r\n\r\nX",
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let client = super::MetadataClient::new();
|
||||
let url = format!("http://127.0.0.1:{port}/fanart/justice-4e1c8af0c25fc.jpg");
|
||||
|
||||
// The page still gets its fast answer and falls through to the next
|
||||
// provider — that part is deliberately unchanged.
|
||||
assert!(
|
||||
!client.image_host_alive(&url).await,
|
||||
"a host that misses the probe budget must not hold up the page",
|
||||
);
|
||||
|
||||
// ...but the verdict is confirmed off the critical path, and the host
|
||||
// comes back without waiting out the five-minute TTL.
|
||||
let mut restored = false;
|
||||
for _ in 0..40 {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
if client.image_host_alive(&url).await {
|
||||
restored = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
restored,
|
||||
"the confirming probe answered, so the host must be usable again \
|
||||
long before {:?}",
|
||||
super::IMAGE_HOST_PROBE_TTL,
|
||||
);
|
||||
assert!(
|
||||
seen.load(Ordering::SeqCst) >= 2,
|
||||
"the confirmation has to actually ask the host a second time",
|
||||
);
|
||||
}
|
||||
|
||||
/// The search page's listener bars, end to end against the live providers.
|
||||
///
|
||||
/// Ignored by default — it spends real Deezer, MusicBrainz and Last.fm
|
||||
|
||||
Reference in new issue
Block a user