diff --git a/src/main.rs b/src/main.rs index c749b18c..3289517e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ async fn serve(config: Config, state: waveflow_server::AppState) -> anyhow::Resu // reclaim it until somebody offered another file. state.services.spawn_upload_sweeper(); state.services.spawn_canvas_sweeper(); + state.services.spawn_artwork_sweeper(); state.services.spawn_library_event_purge(); state.db.spawn_authorization_pruning(); let router = waveflow_server::app(&config, state); diff --git a/src/services/artwork.rs b/src/services/artwork.rs new file mode 100644 index 00000000..de696999 --- /dev/null +++ b/src/services/artwork.rs @@ -0,0 +1,291 @@ +//! Collecting the covers and thumbnails nothing names any more. +//! +//! `artwork_dir` has grown in one direction since the beginning: `upsert_artwork` +//! inserts `ON CONFLICT DO NOTHING` and no statement anywhere deletes from +//! `artwork`, while `waveflow_core` writes `.` beside two +//! thumbnails and nothing ever unlinks any of the three. A library that is +//! rescanned after its covers change keeps every cover it has ever held. +//! +//! # Why this is not `sweep_canvas_store` with another directory +//! +//! The canvas store is written by this process, so a placement and a sweep can +//! take the same per-hash lock and the race between them is closed. Covers are +//! written by `waveflow_core::scanner::extract_cover`, inside a blocking task, +//! from a crate that knows nothing about [`DomainServices`] — there is no lock +//! to take, and taking the writer gate is not an answer: file I/O has no +//! business happening while the process-wide gate is held, which is the rule +//! `upload_locks` and `canvas_locks` both exist to follow. +//! +//! So age stands in for the lock, exactly as it does for the canvas *working* +//! files, and for the same stated reason — the name belongs to a writer this +//! module cannot synchronise with. A file younger than [`WRITE_GRACE`] is left +//! alone whatever the database says, which covers the window between +//! `extract_cover` writing its bytes and `apply_catalog_track` committing the +//! row that names them. +//! +//! # The window age does not close, and why it is survivable here +//! +//! `extract_cover` writes only `if !out_path.exists()`, so re-encountering a +//! cover already in the store refreshes no timestamp. A sweep that reads "no +//! row" for an old file, and a scan that commits a row for that same content an +//! instant later, still cross: the unlink then carries off the file of a live +//! row and leaves a dead link. +//! +//! That is the failure the canvas sweep calls unrecoverable, and here it is +//! not. A cover's bytes are not a gift from a client that will never come +//! again — they are in the audio file, which is read-only and still on disk. +//! The store is reconstructible: with the file gone, `out_path.exists()` is +//! false and the next scan to read that track writes it back. So the cost of +//! the race is a cover that may be missing until the next scan reaches it, +//! reported as a dead link meanwhile, rather than bytes that no longer exist +//! anywhere. +//! +//! That is an argument for tolerating the window, not for pretending it is +//! shut. It is narrow, it needs a scan and a sweep to interleave at one +//! instant, and no test here forces that ordering. + +use std::collections::HashSet; + +use super::{DomainServices, ServiceError}; + +/// How long a file is left alone regardless of what the database says. +/// +/// It has to outlast the gap between `extract_cover` writing bytes and +/// `apply_catalog_track` committing the row that names them — one track's +/// processing, milliseconds in practice. An hour is the same figure the canvas +/// working files use, and is not a tuning knob: nothing legitimate spends it. +const WRITE_GRACE: std::time::Duration = std::time::Duration::from_secs(60 * 60); + +/// How often the store is walked. +/// +/// Daily, as the canvas store is. Unlike that one this is housekeeping rather +/// than only repair — covers stop being referenced through ordinary use, when a +/// rescan finds new art or a library is deleted — but the cost is one directory +/// listing either way. +const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); + +/// What one pass of the artwork sweep found. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ArtworkSweep { + /// `artwork` rows no artist, album or track named any more. + pub rows_removed: usize, + /// Cover files whose row is gone, and whose bytes are now gone with it. + pub covers_removed: usize, + /// Thumbnails of a cover that is no longer named. + /// + /// Counted apart because nothing in the database ever named them: they are + /// derived files, found by their stem and removed with what they derive + /// from. + pub thumbnails_removed: usize, + /// Rows naming a file that is not there. + /// + /// Counted and never repaired, as in the canvas store. Deleting the row to + /// tidy the count would answer a cover that fails to load by removing the + /// album's art outright, and the next scan to read the track puts the file + /// back. + pub dead_links: usize, + /// Names the sweep did not recognise, left exactly where they were. + pub unknown: usize, +} + +/// What a name in `artwork_dir` turned out to be. +#[derive(Debug, PartialEq, Eq)] +enum StoreEntry { + /// `<64 hex>.` — the cover itself, named by an `artwork` row. + Cover { hash: String }, + /// `<64 hex>_1x.jpg` or `<64 hex>_2x.jpg` — derived, named by nothing. + Thumbnail { hash: String }, + /// Anything else, including a stem that is not a hash. + Unknown, +} + +impl DomainServices { + /// Walks the artwork store on a timer. The shape `spawn_canvas_sweeper` + /// uses: a pass at boot, then one per interval. + pub fn spawn_artwork_sweeper(&self) { + let services = self.clone(); + tokio::spawn(async move { + services.sweep_artwork_now().await; + let mut ticker = tokio::time::interval(SWEEP_INTERVAL); + ticker.tick().await; + loop { + ticker.tick().await; + services.sweep_artwork_now().await; + } + }); + } + + async fn sweep_artwork_now(&self) { + match self.sweep_artwork_store().await { + Ok(swept) if swept == ArtworkSweep::default() => {} + Ok(swept) => tracing::info!( + rows = swept.rows_removed, + covers = swept.covers_removed, + thumbnails = swept.thumbnails_removed, + dead_links = swept.dead_links, + unknown = swept.unknown, + "artwork store swept" + ), + Err(error) => tracing::warn!(%error, "could not sweep the artwork store"), + } + } + + /// One pass. Public so a test can run it rather than wait a day for it. + /// + /// **Nothing is removed that this does not recognise.** `artwork_dir` lives + /// under the operator's `data/`, and a sweep that deletes what it cannot + /// name is a sweep nobody should run. A file is a candidate only if it is + /// `<64 hex>.` for a format [`crate::media::artwork_mime`] admits, + /// or one of the two thumbnails derived from such a hash. Everything else is + /// counted and left where it is. + pub async fn sweep_artwork_store(&self) -> Result { + // The rows first, so the walk below asks a database that no longer + // names what nothing references. Safe under the writer gate without a + // grace period of its own: `upsert_artwork` inserts the row and the + // column that names it inside one transaction, so a committed row is + // already referenced and there is no window where a live cover looks + // unreferenced. + let mut swept = ArtworkSweep { + rows_removed: self.forget_unreferenced_artwork().await?, + ..ArtworkSweep::default() + }; + + let named: HashSet = sqlx::query_scalar("SELECT hash FROM artwork") + .fetch_all(self.db.pool()) + .await? + .into_iter() + .collect(); + + let mut entries = match tokio::fs::read_dir(&self.artwork_dir).await { + Ok(entries) => entries, + // No store yet is not a failure: nothing has been scanned. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(swept), + Err(error) => { + tracing::warn!(%error, "cannot read the artwork store"); + return Err(ServiceError::Unavailable); + } + }; + + let mut seen = HashSet::new(); + while let Some(entry) = entries.next_entry().await.map_err(|error| { + tracing::warn!(%error, "cannot walk the artwork store"); + ServiceError::Unavailable + })? { + let name = entry.file_name().to_string_lossy().into_owned(); + match classify_store_entry(&name) { + StoreEntry::Cover { hash } => { + if named.contains(&hash) { + seen.insert(hash); + } else if discard_aged_file(&entry.path()).await { + swept.covers_removed += 1; + } + } + StoreEntry::Thumbnail { hash } => { + if !named.contains(&hash) && discard_aged_file(&entry.path()).await { + swept.thumbnails_removed += 1; + } + } + StoreEntry::Unknown => { + tracing::warn!(file = %name, "unrecognised file left in the artwork store"); + swept.unknown += 1; + } + } + } + + // The other direction, reported and never repaired. + for hash in &named { + if !seen.contains(hash) { + tracing::warn!(%hash, "an artwork row names a file the store does not hold"); + swept.dead_links += 1; + } + } + Ok(swept) + } + + /// Drops every `artwork` row no artist, album or track names, and says how + /// many went. + /// + /// Three columns rather than the canvas store's one link table, and all + /// three are `ON DELETE SET NULL` — which is the reason this is needed at + /// all. Deleting an album returns its column to `NULL` and tells nobody, so + /// the row it used to name has been unreferenced ever since with no event + /// anywhere to notice it. There is no unlink path to hang a reference count + /// on; there is only asking. + async fn forget_unreferenced_artwork(&self) -> Result { + let _writer = self.db.writer_guard().await; + let removed = sqlx::query( + "DELETE FROM artwork WHERE \ + NOT EXISTS (SELECT 1 FROM artist WHERE artist.artwork_hash = artwork.hash) \ + AND NOT EXISTS (SELECT 1 FROM album WHERE album.artwork_hash = artwork.hash) \ + AND NOT EXISTS (SELECT 1 FROM track WHERE track.artwork_hash = artwork.hash)", + ) + .execute(self.db.pool()) + .await? + .rows_affected(); + Ok(usize::try_from(removed).unwrap_or(usize::MAX)) + } +} + +/// Removes a file the database no longer names, once it is old enough that no +/// writer can still be between its bytes and its row. +async fn discard_aged_file(path: &std::path::Path) -> bool { + let Ok(metadata) = tokio::fs::metadata(path).await else { + return false; + }; + let aged = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age > WRITE_GRACE); + if !aged { + return false; + } + match tokio::fs::remove_file(path).await { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + tracing::warn!(%error, "cannot remove an unreferenced artwork file"); + false + } + } +} + +/// What a name in the store is, by its shape alone. +/// +/// The thumbnail suffixes are `waveflow_core`'s, and they are matched before +/// the hash is read: `_1x` is not a hash, so a classifier that only knew +/// covers would call both thumbnails unknown and leave them behind forever — +/// two files per cover, growing exactly as the covers do. +fn classify_store_entry(name: &str) -> StoreEntry { + let Some((stem, extension)) = name.rsplit_once('.') else { + return StoreEntry::Unknown; + }; + if extension == "jpg" { + for suffix in ["_1x", "_2x"] { + if let Some(hash) = stem.strip_suffix(suffix) { + return if is_hash(hash) { + StoreEntry::Thumbnail { + hash: hash.to_owned(), + } + } else { + StoreEntry::Unknown + }; + } + } + } + if crate::media::artwork_mime(extension).is_some() && is_hash(stem) { + return StoreEntry::Cover { + hash: stem.to_owned(), + }; + } + StoreEntry::Unknown +} + +/// A BLAKE3 digest as `extract_cover` renders it, and as the `artwork` table +/// checks it: sixty-four lowercase hex characters. +fn is_hash(stem: &str) -> bool { + stem.len() == 64 + && stem + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} diff --git a/src/services/canvas.rs b/src/services/canvas.rs index 864e1d20..1a0c9311 100644 --- a/src/services/canvas.rs +++ b/src/services/canvas.rs @@ -15,9 +15,10 @@ //! The file first and the row second when placing; the row first and the file //! second when removing. Both orders leave the same failure, and it is the //! recoverable *kind* — bytes nothing names, in a store that is -//! content-addressed and therefore enumerable, so a sweep could find them. No -//! sweep exists yet; RFC-009 leaves it open, as `artwork_dir` has left it open -//! since the beginning. A row naming an absent file is the failure that could +//! content-addressed and therefore enumerable, so a sweep can find them. +//! [`DomainServices::sweep_canvas_store`] is that sweep; `artwork_dir`, which +//! had left the same question open since the beginning, has its own in +//! [`super::artwork`]. A row naming an absent file is the failure that could //! not be recovered even in principle, because it is a dead link every read //! runs into. //! @@ -518,11 +519,9 @@ impl DomainServices { /// /// Failing here is not an error the caller can act on: the link is already /// gone, which is what they asked for. What it does leave is bytes nothing - /// names, and **nothing collects those today** — RFC-009 leaves the store - /// sweep open, noting that `artwork_dir` has had exactly the same property - /// from the start. The consolation is only that the store is - /// content-addressed and therefore enumerable, so a sweep remains possible - /// to write; it is not one that exists. + /// names, and [`DomainServices::sweep_canvas_store`] is what collects those + /// — the store is content-addressed and therefore enumerable, which is what + /// made the sweep writable at all. async fn release_canvas_blob(&self, hash: &str) { // Scoped for the same reason as the placing path: the entry goes back // to the map only once this call holds nothing. @@ -772,11 +771,14 @@ impl DomainServices { /// two mutually exclusive, and the count is re-read inside it rather than /// trusted from the walk. /// - /// **The suite covers the re-read and not the lock.** Removing the count - /// fails a test — a referenced blob is taken. Removing the lock fails - /// nothing, because the damage needs a placement and a sweep to interleave - /// at one instant, and no test here can force that ordering. The lock is - /// reasoned, not demonstrated. + /// **Both halves are covered now.** Removing the count fails a test — a + /// referenced blob is taken. Removing the lock fails + /// `tests::a_placement_that_commits_while_the_sweep_waits_keeps_its_bytes`, + /// which does not race for the interleaving but arranges it: the test holds + /// this lock, which is what a placement holds between its bytes and its + /// row, and the sweep has to wait. That needed a unit test rather than an + /// integration one — the lock is private, and reaching it is the whole + /// trick. async fn discard_orphan_blob(&self, hash: &str, path: &std::path::Path) -> bool { let removed = { let lock = self.canvas_lock(hash); @@ -1102,3 +1104,99 @@ fn blob_from_row(row: Option) -> Result>>>, canvas: crate::config::CanvasLimits, canvas_dir: PathBuf, + /// Where covers and their thumbnails live. Read by the sweep only: the + /// scanner writes this directory through `waveflow_core`, which is the + /// whole reason the sweep cannot hold a lock over the writer. + artwork_dir: PathBuf, ffprobe_path: PathBuf, ffmpeg_path: PathBuf, /// One lock per canvas blob, keyed by its hash. @@ -882,6 +886,7 @@ impl From for ServiceError { mod admin; mod albums; mod artists; +mod artwork; mod bookmarks; mod canvas; mod catalog; @@ -919,6 +924,7 @@ impl DomainServices { upload_locks: Arc::new(dashmap::DashMap::new()), canvas: config.canvas, canvas_dir: config.canvas_dir.clone(), + artwork_dir: config.artwork_dir.clone(), ffprobe_path: config.ffprobe_path.clone(), ffmpeg_path: config.ffmpeg_path.clone(), canvas_locks: Arc::new(dashmap::DashMap::new()), diff --git a/tests/scanner.rs b/tests/scanner.rs index 90ace84b..77ff8f26 100644 --- a/tests/scanner.rs +++ b/tests/scanner.rs @@ -657,3 +657,267 @@ async fn a_re_encoded_file_that_moved_keeps_its_track_and_its_favourite() { "a favourite must survive the move it was never told about" ); } + +/// Sets a file's modification time back far enough to clear `WRITE_GRACE`. +/// +/// The sweep leaves a young file alone whatever the database says, which is +/// what stands in for the lock it cannot take over `waveflow_core`'s writer. A +/// test that wants to see a removal has to age the file rather than wait an +/// hour for it. +fn age_beyond_the_grace(path: &std::path::Path) { + let old = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60); + let file = std::fs::File::options().write(true).open(path).unwrap(); + file.set_times(std::fs::FileTimes::new().set_modified(old)) + .unwrap(); +} + +/// Fails if anything has touched a file since [`age_beyond_the_grace`] set it +/// back. +/// +/// `extract_cover` spawns a detached thread that writes `_1x.jpg` and +/// `_2x.jpg` into the same directory, and it writes them under the names +/// this test uses. It produces nothing for these fixtures — the store holds the +/// cover alone, checked at two seconds — so the files below are this test's +/// alone. Depending on that silently is the problem: if the thread ever did +/// write, it would restore a fresh timestamp and the sweep would spare a file +/// the test expects it to take, intermittently and for a reason nothing states. +/// So the ages are read back where they matter, and interference fails loudly +/// on the pass it happens rather than flaking on some later one. +fn assert_still_aged(path: &std::path::Path) { + let age = std::fs::metadata(path) + .unwrap() + .modified() + .unwrap() + .elapsed() + .unwrap(); + assert!( + age > std::time::Duration::from_secs(60 * 60), + "{} was touched after it was aged: something else writes this name", + path.display() + ); +} + +/// The one name in the store the sweep must never take: a cover in use. +/// +/// `artwork_dir` had no sweep at all, so the first thing worth proving is not +/// that the new one collects — it is that it collects nothing. A pass over a +/// freshly scanned library must leave every byte where it is, and must not +/// report the store as broken while doing it. +#[tokio::test] +async fn the_artwork_sweep_takes_nothing_a_library_still_names() { + let (_temp, config, state) = test_app().await; + let hash = security::hash_password("correct horse battery staple").unwrap(); + let owner = state + .db + .create_account("sweep-live", &hash, AccountRole::Admin, now_ms()) + .await + .unwrap(); + let music = config.data_dir.join("sweep-live"); + std::fs::create_dir_all(&music).unwrap(); + write_test_wav(&music.join("One.wav")); + write_test_png(&music.join("cover.png")); + let root = std::fs::canonicalize(&music).unwrap(); + let library_id = state + .db + .create_library(owner, "Live", &root, LibraryVisibility::Private, now_ms()) + .await + .unwrap(); + run_scan( + &state, + owner, + LibraryRecord { + id: library_id, + name: "Live".into(), + root_path: root, + }, + ) + .await; + + // The name comes from the scan, never from this test: `extract_cover` + // chooses it, and a fixture that spelled it independently would agree with + // itself while the server did something else. + let cover = std::fs::read_dir(&config.artwork_dir) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| path.extension().is_some_and(|extension| extension == "png")) + .expect("the scan wrote a cover"); + let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM artwork") + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(stored, 1); + + // Aged deliberately: the grace period must not be what saves it here, or + // this test would pass on a sweep that takes live covers a day later. + age_beyond_the_grace(&cover); + let swept = state.services.sweep_artwork_store().await.unwrap(); + assert_eq!(swept.rows_removed, 0); + assert_eq!(swept.covers_removed, 0); + assert_eq!(swept.dead_links, 0, "the row and the file agree"); + assert!(cover.exists(), "a cover in use is not the sweep's to take"); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM artwork") + .fetch_one(state.db.pool()) + .await + .unwrap(), + 1 + ); + + // A name the sweep cannot read is left where it is and said out loud. The + // store sits under the operator's `data/`, and the alternative — deleting + // the unrecognised — is how a sweep eats something it was never told about. + let stranger = config.artwork_dir.join("operator-notes.txt"); + std::fs::write(&stranger, b"not mine to remove").unwrap(); + age_beyond_the_grace(&stranger); + let swept = state.services.sweep_artwork_store().await.unwrap(); + assert_eq!(swept.unknown, 1); + assert_eq!(swept.covers_removed, 0); + assert!(stranger.exists()); +} + +/// A cover nothing names any more goes, and takes its thumbnails with it. +/// +/// This is the property `artwork_dir` never had. Three columns are +/// `ON DELETE SET NULL`, so a library that is deleted or rescanned returns them +/// to `NULL` and tells nobody — the row stays, the bytes stay, and both stay +/// for the life of the instance. +#[tokio::test] +async fn an_unreferenced_cover_and_its_thumbnails_go_once_they_are_old_enough() { + let (_temp, config, state) = test_app().await; + let hash = security::hash_password("correct horse battery staple").unwrap(); + let owner = state + .db + .create_account("sweep-dead", &hash, AccountRole::Admin, now_ms()) + .await + .unwrap(); + let music = config.data_dir.join("sweep-dead"); + std::fs::create_dir_all(&music).unwrap(); + write_test_wav(&music.join("One.wav")); + write_test_png(&music.join("cover.png")); + let root = std::fs::canonicalize(&music).unwrap(); + let library_id = state + .db + .create_library(owner, "Dead", &root, LibraryVisibility::Private, now_ms()) + .await + .unwrap(); + run_scan( + &state, + owner, + LibraryRecord { + id: library_id, + name: "Dead".into(), + root_path: root, + }, + ) + .await; + + let stored_hash: String = sqlx::query_scalar("SELECT hash FROM artwork") + .fetch_one(state.db.pool()) + .await + .unwrap(); + let cover = std::fs::read_dir(&config.artwork_dir) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| path.extension().is_some_and(|extension| extension == "png")) + .expect("the scan wrote a cover"); + + // The thumbnail names come from `waveflow_core`, which is what writes them, + // rather than from a convention spelled out here — a fixture that spelled + // `_1x` itself would agree with itself while the library did something else. + // + // The bytes are written here rather than waited for, because the real job is + // a detached thread with no handle to join: `spawn_thumbnail_job` returns + // `()`. `assert_still_aged` is what keeps that from being a silent bet. + let thumbnails: Vec = [ + waveflow_core::artwork::thumbnails::THUMB_SMALL, + waveflow_core::artwork::thumbnails::THUMB_MEDIUM, + ] + .into_iter() + .map(|size| { + let path = waveflow_core::artwork::thumbnails::thumbnail_path( + &config.artwork_dir, + &stored_hash, + size, + ); + std::fs::write(&path, b"thumbnail bytes").unwrap(); + path + }) + .collect(); + + // What deleting an album does, without deleting an album: the columns go + // back to NULL and nothing anywhere is told. + // Spelled out three times rather than looped over a table name: sqlx takes + // static SQL only, which is the same rule that keeps the projection macros + // injection-proof by construction. + for statement in [ + "UPDATE track SET artwork_hash = NULL", + "UPDATE album SET artwork_hash = NULL", + "UPDATE artist SET artwork_hash = NULL", + ] { + sqlx::query(statement) + .execute(state.db.pool()) + .await + .unwrap(); + } + + // The row goes at once — it is a database fact under the writer gate, with + // no window to wait out. The files do not, because they are young, and the + // sweep cannot tell a cover abandoned a second ago from one a scan is at + // this moment about to name. + let swept = state.services.sweep_artwork_store().await.unwrap(); + assert_eq!(swept.rows_removed, 1); + assert_eq!(swept.covers_removed, 0, "the grace period holds the bytes"); + assert_eq!(swept.thumbnails_removed, 0); + assert!(cover.exists()); + + age_beyond_the_grace(&cover); + for thumbnail in &thumbnails { + age_beyond_the_grace(thumbnail); + } + assert_still_aged(&cover); + for thumbnail in &thumbnails { + assert_still_aged(thumbnail); + } + let swept = state.services.sweep_artwork_store().await.unwrap(); + assert_eq!(swept.rows_removed, 0, "the row went on the pass before"); + assert_eq!(swept.covers_removed, 1); + assert_eq!( + swept.thumbnails_removed, 2, + "a thumbnail is named by nothing, so only its stem can save it" + ); + assert!(!cover.exists()); + for thumbnail in &thumbnails { + assert!(!thumbnail.exists()); + } + + // The other direction, which is reported and never repaired: a row naming a + // file the store does not hold. Deleting the row would answer a cover that + // fails to load by removing the album's art outright. + sqlx::query( + "INSERT INTO artwork (hash, format, source, byte_size, created_at) \ + VALUES (?, 'png', 'embedded', 1, ?)", + ) + .bind("b".repeat(64)) + .bind(now_ms()) + .execute(state.db.pool()) + .await + .unwrap(); + sqlx::query("UPDATE track SET artwork_hash = ?") + .bind("b".repeat(64)) + .execute(state.db.pool()) + .await + .unwrap(); + let swept = state.services.sweep_artwork_store().await.unwrap(); + assert_eq!(swept.dead_links, 1); + assert_eq!(swept.rows_removed, 0, "a named row is not an orphan"); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM artwork") + .fetch_one(state.db.pool()) + .await + .unwrap(), + 1, + "the dead link is reported, not repaired" + ); +}