mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
b2a5baa48d
* fix(library-db): name slow-write ops for macOS stall diagnosis (#1040) Replace generic op=misc labels on production library-db write paths with stable module.action names (sync_state.*, track.*, tombstone.*, cmd.*, …) so SLOW write logs pinpoint the call site. Document the naming convention on LibraryStore::with_conn. Diagnostic step for #1040 — no behaviour change. * docs: add CHANGELOG and credits for PR #1043 Library-db slow-write op naming diagnostic for issue #1040.
265 lines
9.5 KiB
Rust
265 lines
9.5 KiB
Rust
//! H1 — `CanonicalMatcher`: cross-server track identity by **strong keys
|
|
//! only** (ISRC, then MBID recording), per spec §5.5A / P13.
|
|
//!
|
|
//! Runs inline on ingest and is O(1) per track — a deterministic canonical
|
|
//! id (`{kind}:{value}`) plus `INSERT … ON CONFLICT DO NOTHING`, so there's
|
|
//! no lookup-then-create race and no fuzzy loop on the 500k upsert path
|
|
//! (the fuzzy / album-similarity layer is search-time only — §5.5B / H3).
|
|
//!
|
|
//! v1 does **not** merge across kinds (a recording seen with an ISRC on one
|
|
//! server and only an MBID on another yields two canonical rows). Spec §5.5A
|
|
//! accepts this; a future merge layer would remap ids without a schema break.
|
|
|
|
use rusqlite::{params, OptionalExtension, Transaction};
|
|
|
|
pub const KIND_ISRC: &str = "isrc";
|
|
pub const KIND_MBID: &str = "mbid_recording";
|
|
|
|
/// Resolve (create-if-absent) the canonical id for one strong identity.
|
|
/// Idempotent: the deterministic id means re-running over an existing
|
|
/// identity is a pair of no-op upserts.
|
|
pub fn resolve_canonical_id(
|
|
tx: &Transaction<'_>,
|
|
kind: &str,
|
|
value: &str,
|
|
now: i64,
|
|
) -> rusqlite::Result<String> {
|
|
let canonical_id = format!("{kind}:{value}");
|
|
tx.execute(
|
|
"INSERT INTO canonical_track (id, created_at, updated_at) VALUES (?1, ?2, ?2) \
|
|
ON CONFLICT(id) DO NOTHING",
|
|
params![canonical_id, now],
|
|
)?;
|
|
tx.execute(
|
|
"INSERT INTO canonical_identity (canonical_id, kind, value, confidence) \
|
|
VALUES (?1, ?2, ?3, 1.0) \
|
|
ON CONFLICT(kind, value) DO NOTHING",
|
|
params![canonical_id, kind, value],
|
|
)?;
|
|
Ok(canonical_id)
|
|
}
|
|
|
|
/// Link `(server_id, track_id)` to its canonical id for whichever strong key
|
|
/// it carries — ISRC preferred, then MBID recording (§5.5A). No-op (returns
|
|
/// `Ok(None)`) when the track has neither. Idempotent on re-ingest.
|
|
pub fn link_track(
|
|
tx: &Transaction<'_>,
|
|
server_id: &str,
|
|
track_id: &str,
|
|
isrc: Option<&str>,
|
|
mbid_recording: Option<&str>,
|
|
now: i64,
|
|
) -> rusqlite::Result<Option<String>> {
|
|
let (kind, value) = match (
|
|
isrc.filter(|s| !s.is_empty()),
|
|
mbid_recording.filter(|s| !s.is_empty()),
|
|
) {
|
|
(Some(isrc), _) => (KIND_ISRC, isrc),
|
|
(None, Some(mbid)) => (KIND_MBID, mbid),
|
|
(None, None) => return Ok(None),
|
|
};
|
|
|
|
let canonical_id = resolve_canonical_id(tx, kind, value, now)?;
|
|
tx.execute(
|
|
"INSERT INTO track_canonical_link \
|
|
(server_id, track_id, canonical_id, match_method, confidence, linked_at) \
|
|
VALUES (?1, ?2, ?3, ?4, 1.0, ?5) \
|
|
ON CONFLICT(server_id, track_id) DO UPDATE SET \
|
|
canonical_id = excluded.canonical_id, \
|
|
match_method = excluded.match_method, \
|
|
confidence = excluded.confidence, \
|
|
linked_at = excluded.linked_at",
|
|
params![server_id, track_id, canonical_id, kind, now],
|
|
)?;
|
|
Ok(Some(canonical_id))
|
|
}
|
|
|
|
/// Link every track on `server_id` that carries a strong identity key.
|
|
/// Used once after IS-3 bulk ingest instead of per-row inline linking.
|
|
pub fn link_all_tracks_for_server(
|
|
store: &crate::store::LibraryStore,
|
|
server_id: &str,
|
|
now: i64,
|
|
) -> Result<u32, String> {
|
|
store.with_conn_mut("canonical.link_all_tracks", |conn| {
|
|
let tx = conn.transaction()?;
|
|
let mut stmt = tx.prepare(
|
|
"SELECT id, isrc, mbid_recording FROM track \
|
|
WHERE server_id = ?1 AND deleted = 0 \
|
|
AND (\
|
|
(isrc IS NOT NULL AND isrc != '') \
|
|
OR (mbid_recording IS NOT NULL AND mbid_recording != '')\
|
|
)",
|
|
)?;
|
|
let rows: Vec<(String, Option<String>, Option<String>)> = stmt
|
|
.query_map(params![server_id], |r| {
|
|
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
|
})?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
drop(stmt);
|
|
let mut linked = 0u32;
|
|
for (track_id, isrc, mbid) in rows {
|
|
if link_track(
|
|
&tx,
|
|
server_id,
|
|
&track_id,
|
|
isrc.as_deref(),
|
|
mbid.as_deref(),
|
|
now,
|
|
)?
|
|
.is_some()
|
|
{
|
|
linked = linked.saturating_add(1);
|
|
}
|
|
}
|
|
tx.commit()?;
|
|
Ok(linked)
|
|
})
|
|
}
|
|
|
|
/// Read a track's canonical id, if linked. Convenience for tests / callers.
|
|
pub fn canonical_id_for(
|
|
tx: &Transaction<'_>,
|
|
server_id: &str,
|
|
track_id: &str,
|
|
) -> rusqlite::Result<Option<String>> {
|
|
tx.query_row(
|
|
"SELECT canonical_id FROM track_canonical_link WHERE server_id = ?1 AND track_id = ?2",
|
|
params![server_id, track_id],
|
|
|r| r.get(0),
|
|
)
|
|
.optional()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::store::LibraryStore;
|
|
|
|
fn with_tx<R>(store: &LibraryStore, f: impl FnOnce(&Transaction<'_>) -> R) -> R {
|
|
store
|
|
.with_conn_mut("misc", |conn| {
|
|
let tx = conn.transaction()?;
|
|
let out = f(&tx);
|
|
tx.commit()?;
|
|
Ok(out)
|
|
})
|
|
.unwrap()
|
|
}
|
|
|
|
/// `track_canonical_link` has an FK to `track` — seed a minimal row so
|
|
/// the link insert is valid (foreign_keys are ON in-memory).
|
|
fn seed_track(tx: &Transaction<'_>, server: &str, id: &str) {
|
|
tx.execute(
|
|
"INSERT INTO track (server_id, id, title, synced_at, raw_json) \
|
|
VALUES (?1, ?2, 'T', 1, '{}')",
|
|
params![server, id],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_is_deterministic_and_idempotent() {
|
|
let store = LibraryStore::open_in_memory();
|
|
let (a, b) = with_tx(&store, |tx| {
|
|
let a = resolve_canonical_id(tx, KIND_ISRC, "USRC123", 1).unwrap();
|
|
let b = resolve_canonical_id(tx, KIND_ISRC, "USRC123", 2).unwrap();
|
|
(a, b)
|
|
});
|
|
assert_eq!(a, "isrc:USRC123");
|
|
assert_eq!(a, b, "same identity → same canonical id");
|
|
|
|
let (tracks, identities): (i64, i64) = store
|
|
.with_conn("misc", |c| {
|
|
Ok((
|
|
c.query_row("SELECT COUNT(*) FROM canonical_track", [], |r| r.get(0))?,
|
|
c.query_row("SELECT COUNT(*) FROM canonical_identity", [], |r| r.get(0))?,
|
|
))
|
|
})
|
|
.unwrap();
|
|
assert_eq!(tracks, 1, "idempotent — no duplicate canonical_track");
|
|
assert_eq!(identities, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn link_prefers_isrc_over_mbid() {
|
|
let store = LibraryStore::open_in_memory();
|
|
let cid = with_tx(&store, |tx| {
|
|
seed_track(tx, "s1", "t1");
|
|
link_track(tx, "s1", "t1", Some("USRC1"), Some("mbid-1"), 1).unwrap()
|
|
});
|
|
assert_eq!(cid.as_deref(), Some("isrc:USRC1"));
|
|
let stored =
|
|
with_tx(&store, |tx| canonical_id_for(tx, "s1", "t1").unwrap());
|
|
assert_eq!(stored.as_deref(), Some("isrc:USRC1"));
|
|
}
|
|
|
|
#[test]
|
|
fn link_uses_mbid_when_no_isrc() {
|
|
let store = LibraryStore::open_in_memory();
|
|
let cid = with_tx(&store, |tx| {
|
|
seed_track(tx, "s1", "t1");
|
|
link_track(tx, "s1", "t1", None, Some("mbid-9"), 1).unwrap()
|
|
});
|
|
assert_eq!(cid.as_deref(), Some("mbid_recording:mbid-9"));
|
|
}
|
|
|
|
#[test]
|
|
fn link_is_noop_without_strong_keys() {
|
|
let store = LibraryStore::open_in_memory();
|
|
let cid = with_tx(&store, |tx| {
|
|
// empty strings count as absent
|
|
link_track(tx, "s1", "t1", Some(""), None, 1).unwrap()
|
|
});
|
|
assert!(cid.is_none());
|
|
let count: i64 = store
|
|
.with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_canonical_link", [], |r| r.get(0)))
|
|
.unwrap();
|
|
assert_eq!(count, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn same_isrc_across_servers_shares_one_canonical_id() {
|
|
let store = LibraryStore::open_in_memory();
|
|
let (a, b) = with_tx(&store, |tx| {
|
|
seed_track(tx, "s1", "t1");
|
|
seed_track(tx, "s2", "t9");
|
|
let a = link_track(tx, "s1", "t1", Some("USRC7"), None, 1).unwrap();
|
|
let b = link_track(tx, "s2", "t9", Some("USRC7"), None, 1).unwrap();
|
|
(a, b)
|
|
});
|
|
assert_eq!(a, b);
|
|
let canon_count: i64 = store
|
|
.with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM canonical_track", [], |r| r.get(0)))
|
|
.unwrap();
|
|
assert_eq!(canon_count, 1, "one canonical row shared across two servers");
|
|
}
|
|
|
|
#[test]
|
|
fn relink_updates_existing_row_in_place() {
|
|
let store = LibraryStore::open_in_memory();
|
|
with_tx(&store, |tx| {
|
|
seed_track(tx, "s1", "t1");
|
|
link_track(tx, "s1", "t1", None, Some("mbid-old"), 1).unwrap();
|
|
// Later ingest now carries an ISRC — re-link overwrites.
|
|
link_track(tx, "s1", "t1", Some("USRCnew"), Some("mbid-old"), 2).unwrap();
|
|
});
|
|
let (cid, method): (String, String) = store
|
|
.with_conn("misc", |c| {
|
|
c.query_row(
|
|
"SELECT canonical_id, match_method FROM track_canonical_link \
|
|
WHERE server_id = 's1' AND track_id = 't1'",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
)
|
|
})
|
|
.unwrap();
|
|
assert_eq!(cid, "isrc:USRCnew");
|
|
assert_eq!(method, "isrc");
|
|
let link_count: i64 = store
|
|
.with_conn("misc", |c| c.query_row("SELECT COUNT(*) FROM track_canonical_link", [], |r| r.get(0)))
|
|
.unwrap();
|
|
assert_eq!(link_count, 1, "re-link updates in place, no duplicate");
|
|
}
|
|
}
|