mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(library): prune orphaned cluster identity keys on rebuild (#1255)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user