//! Latency harness for the whole discography-load pipeline — the three //! backend stages the artist page runs, in the order the frontend runs them: //! //! 1. `catalog_artist_overview` — the first-paint content stage //! 2. `catalog_artist` — the exhaustive classification page //! 3. `catalog_artist_identity` — the MusicBrainz supplement/type-hint pass //! //! Companion to `album_info_bench` (descriptions). Run with //! `SOULSTREAM_STAGE_TRACE=1` to attribute time inside each stage. //! //! Usage: //! cargo run -p metadata --example artist_page_bench -- "Artist" ["Artist" ...] use metadata::MetadataClient; use std::sync::Arc; use std::time::Instant; #[tokio::main] async fn main() { let artists: Vec = std::env::args().skip(1).collect(); if artists.is_empty() { eprintln!("usage: artist_page_bench -- \"Artist\" [\"Artist\" ...]"); return; } for artist in &artists { let client = Arc::new(MetadataClient::new()); let search = client.catalog_search(artist).await; let Some(id) = search .artists .iter() .find(|a| normalize(&a.name) == normalize(artist)) .map(|a| a.id) else { println!("== {artist}: no exact-name artist found, skipping ==\n"); continue; }; println!("== {artist} (deezer {id}) =="); // The frontend overlaps overview + page, then runs the identity pass // after the page lands. Reproduce exactly that shape. let cold_started = Instant::now(); let (overview, page) = tokio::join!( async { let t = Instant::now(); let overview = client.catalog_artist_overview(id).await; (t.elapsed().as_millis(), overview) }, async { let t = Instant::now(); let page = client.catalog_artist(id).await; (t.elapsed().as_millis(), page) }, ); let overlap_ms = cold_started.elapsed().as_millis(); println!( " cold overview: {} ms ({} albums) cold page: {} ms ({} albums) overlapped: {overlap_ms} ms", overview.0, overview.1.as_ref().map(|o| o.albums.len()).unwrap_or(0), page.0, page.1.as_ref().map(|p| p.albums.len()).unwrap_or(0), ); let t = Instant::now(); let identity = client.catalog_artist_identity(id, artist).await; let identity_ms = t.elapsed().as_millis(); println!( " cold identity pass: {identity_ms} ms (supplement {} albums, {} type hints)", identity.albums.len(), identity.type_hints.len(), ); println!( " COLD TOTAL to fully sorted (page ∥ overview, then identity): {} ms", overlap_ms + identity_ms ); let t = Instant::now(); let _ = client.catalog_artist(id).await; println!(" warm page: {} ms", t.elapsed().as_millis()); let t = Instant::now(); let _ = client.catalog_artist_identity(id, artist).await; println!(" warm identity: {} ms", t.elapsed().as_millis()); // The new frontend shape: the identity pass is kicked off BESIDE the // page load instead of after it, so cold time-to-sorted is max(), not // sum(). A fresh client so nothing above warms it. let fresh = Arc::new(MetadataClient::new()); let t = Instant::now(); let (_page, _identity) = tokio::join!( fresh.catalog_artist(id), fresh.catalog_artist_identity(id, artist), ); println!( " cold page ∥ identity (new frontend overlap): {} ms", t.elapsed().as_millis() ); // Restart warmth: two separate clients sharing one disk cache dir — // the second models an app relaunch. let dir = std::env::temp_dir().join("soulstream-artist-page-bench-cache"); let _ = std::fs::remove_dir_all(&dir); let writer = Arc::new(MetadataClient::new().with_disk_cache(dir.clone())); let _ = writer.catalog_artist_identity(id, artist).await; let restarted = Arc::new(MetadataClient::new().with_disk_cache(dir.clone())); let t = Instant::now(); let _ = restarted.catalog_artist_identity(id, artist).await; println!( " identity after app restart (disk cache): {} ms\n", t.elapsed().as_millis() ); } } fn normalize(s: &str) -> String { s.to_lowercase() .chars() .filter(|c| c.is_alphanumeric()) .collect() }