fix(library): prune orphaned cluster identity keys on rebuild (#1255)

This commit is contained in:
cucadmuh
2026-07-07 19:15:36 +03:00
committed by GitHub
parent 6445e12eb4
commit fbd3aaa094
3 changed files with 156 additions and 0 deletions
+6
View File
@@ -203,6 +203,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* When an artist is renamed on the server (minting a new artist id), the album's stored artist link no longer stays stuck on the old id and dead-ending at "Artist not found". Album metadata now follows the server's `getAlbum` for the artist reference, so a resync updates it instead of keeping the pre-rename id indefinitely. Complements the earlier ghost-row prune (#1253) and the local-index fallback (#1254), which did not clear the stale reference itself.
### Library — multi-library dedup sidecar no longer accumulates dead identity keys
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1255](https://github.com/Psychotoxical/psysonic/pull/1255)**
* The precomputed `library-cluster.db` identity keys used for cross-library dedup are now pruned on rebuild when their track no longer exists (removed, or dropped when a server mints a fresh id on rename). Previously the rebuild only refreshed live tracks and never deleted stale rows, so the sidecar grew with library churn until it was recreated wholesale (server switch / restore / import). The rows were inert (reads only ever join live tracks), so dedup and browse results are unchanged — this just stops the sidecar from bloating.
## [1.49.0] - 2026-06-29
@@ -120,6 +120,31 @@ pub fn rebuild_cluster_keys(
drop(rows);
drop(stmt);
drop(upsert);
// Prune keys whose track no longer exists (soft-deleted via tombstone, or
// dropped when a server mints a fresh id on rename). The UPSERT above only
// refreshes live rows; without this, orphaned keys accumulate forever and
// are only reclaimed when the whole sidecar is dropped (swap/restore/import).
// Reads join `cluster.track_cluster_key` against `track WHERE deleted = 0`,
// so these rows are inert — this is bloat cleanup, scoped to the rebuilt
// server(s) so a single-server rebuild never touches other servers' keys.
if let Some(sid) = server_id {
tx.execute(
"DELETE FROM cluster.track_cluster_key \
WHERE server_id = ?1 \
AND track_id NOT IN (\
SELECT id FROM track WHERE deleted = 0 AND server_id = ?1\
)",
params![sid],
)?;
} else {
tx.execute(
"DELETE FROM cluster.track_cluster_key \
WHERE (server_id, track_id) NOT IN (\
SELECT server_id, id FROM track WHERE deleted = 0\
)",
[],
)?;
}
set_cluster_meta(&tx)?;
tx.commit()?;
Ok(upserted)
@@ -321,6 +346,130 @@ mod tests {
assert_eq!(first, second);
}
#[test]
fn rebuild_prunes_orphaned_cluster_keys() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_row("s1", "t1", "T1", Some("A"), "Al", None, 100, "lib"),
track_row("s1", "t2", "T2", Some("B"), "Al", None, 120, "lib"),
])
.unwrap();
rebuild_cluster_keys(&store, Some("s1")).unwrap();
assert!(store
.with_read_conn(|c| read_cluster_row(c, "s1", "t2"))
.unwrap()
.is_some());
// Soft-delete t2 (tombstone) → its stale cluster key must be pruned on
// the next rebuild, not linger forever.
store
.with_conn_mut("test.soft_delete", |c| {
c.execute(
"UPDATE track SET deleted = 1 WHERE server_id = 's1' AND id = 't2'",
[],
)
})
.unwrap();
rebuild_cluster_keys(&store, Some("s1")).unwrap();
assert!(
store
.with_read_conn(|c| read_cluster_row(c, "s1", "t1"))
.unwrap()
.is_some(),
"live track key must remain"
);
assert!(
store
.with_read_conn(|c| read_cluster_row(c, "s1", "t2"))
.unwrap()
.is_none(),
"orphaned cluster key must be pruned"
);
}
#[test]
fn global_rebuild_prunes_orphans_across_servers() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_row("s1", "t1", "T1", Some("A"), "Al", None, 100, "lib"),
track_row("s2", "t2", "T2", Some("B"), "Al", None, 120, "lib"),
])
.unwrap();
rebuild_cluster_keys(&store, None).unwrap();
assert!(store
.with_read_conn(|c| read_cluster_row(c, "s1", "t1"))
.unwrap()
.is_some());
assert!(store
.with_read_conn(|c| read_cluster_row(c, "s2", "t2"))
.unwrap()
.is_some());
// Both tracks go to tombstone; a global (server_id = None) rebuild must
// prune the orphan on every server via the tuple-scoped DELETE branch.
store
.with_conn_mut("test.del", |c| {
c.execute("UPDATE track SET deleted = 1 WHERE id IN ('t1', 't2')", [])
})
.unwrap();
rebuild_cluster_keys(&store, None).unwrap();
assert!(
store
.with_read_conn(|c| read_cluster_row(c, "s1", "t1"))
.unwrap()
.is_none(),
"global rebuild must prune s1 orphan"
);
assert!(
store
.with_read_conn(|c| read_cluster_row(c, "s2", "t2"))
.unwrap()
.is_none(),
"global rebuild must prune s2 orphan"
);
}
#[test]
fn per_server_rebuild_leaves_other_server_keys() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_row("s1", "t1", "T1", Some("A"), "Al", None, 100, "lib"),
track_row("s2", "t2", "T2", Some("B"), "Al", None, 120, "lib"),
])
.unwrap();
rebuild_cluster_keys(&store, None).unwrap();
// Both tracks go to tombstone, but we rebuild only s1: s1's orphan is
// pruned while s2's key is untouched (single global norm stamp, but the
// prune is scoped to the rebuilt server).
store
.with_conn_mut("test.del", |c| {
c.execute("UPDATE track SET deleted = 1 WHERE id IN ('t1', 't2')", [])
})
.unwrap();
rebuild_cluster_keys(&store, Some("s1")).unwrap();
assert!(
store
.with_read_conn(|c| read_cluster_row(c, "s1", "t1"))
.unwrap()
.is_none(),
"rebuilt server's orphan must be pruned"
);
assert!(
store
.with_read_conn(|c| read_cluster_row(c, "s2", "t2"))
.unwrap()
.is_some(),
"single-server rebuild must not prune another server's keys"
);
}
#[test]
fn norm_version_gate_and_bump() {
let store = LibraryStore::open_in_memory();
+1
View File
@@ -192,6 +192,7 @@ const CONTRIBUTOR_ENTRIES = [
'Library — prune orphaned artist browse rows after server-side renames (fixes "Artist not found" ghosts) on full/delta sync + one-time startup reconcile; refresh Artists/Albums catalog on sync-idle (report: Seraphim on Psysonic Discord, PR #1253)',
'Artist detail — fall back to the local library index when getArtist 404s an album-artist id (fixes Random Albums artist links dead-ending at "Artist not found"); album browse + detail cover resolution fall back to the album\'s first track cover for rows synced without one (fixes missing Random Albums tile art) (report: tummydummy, PR #1254)',
'Library — follow getAlbum authoritatively for album artist reference so a server-side artist rename heals on resync instead of dead-ending at "Artist not found" (PR #1256)',
'Library — prune orphaned identity keys from the multi-library dedup sidecar (library-cluster.db) on rebuild so it no longer bloats with rows for removed/renamed tracks (PR #1255)',
],
},
{