Files
Psychotoxical-psysonic/src-tauri/crates/psysonic-library/src/canonical.rs
T
cucadmuh b2a5baa48d fix(library-db): name slow-write ops for macOS stall diagnosis (#1043)
* 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.
2026-06-09 11:03:22 +03:00

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");
}
}