fix claim_fusion MV duplicate-key failure on concurrent refresh
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled

The MV SELECTed COALESCE(user_id, zero-uuid) but GROUPed BY raw user_id,
so a global enrichment claim (user_id NULL) and a default-user behavior
claim (user_id = zero-uuid) for the same edge produced two rows that
collide on idx_claim_fusion_pk, breaking REFRESH ... CONCURRENTLY.
Exposed by the #219 listener_behavior same_scene_as/alias_of writes.

New migration rebuilds the MV grouping by the COALESCE'd user_id so the
two fuse into one row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-17 13:38:12 +04:00
parent 7005756684
commit d497588c87
+61
View File
@@ -443,6 +443,67 @@ const MIGRATIONS: { id: string; sql: string }[] = [
ON artists (mbid) WHERE mbid IS NOT NULL;
`,
},
{
id: '20260717_claim_fusion_group_by_coalesce',
sql: `
-- Fix duplicate-key failures in REFRESH MATERIALIZED VIEW CONCURRENTLY.
-- The MV SELECTs COALESCE(user_id, zero-uuid) but GROUPed BY the raw
-- user_id, so a global enrichment claim (user_id NULL) and a default-user
-- behavior claim (user_id = zero-uuid, e.g. listener_behavior same_scene_as)
-- for the same edge fell into separate groups yet collapsed to the same
-- output key → two rows violating idx_claim_fusion_pk. Group by the same
-- COALESCE'd expression so they fuse into one row.
DROP MATERIALIZED VIEW IF EXISTS claim_fusion CASCADE;
CREATE MATERIALIZED VIEW claim_fusion AS
SELECT
c.subject_type,
c.subject_id,
c.predicate,
c.object_type,
c.object_id,
COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id,
SUM(
st.trust * c.confidence *
GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0)
) AS fused_value,
COUNT(*) AS claim_count,
MAX(c.last_reinforced_at) AS last_reinforced_at
FROM claims c
JOIN source_trust st ON st.key = c.source
GROUP BY c.subject_type, c.subject_id, c.predicate, c.object_type, c.object_id,
COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid);
CREATE UNIQUE INDEX idx_claim_fusion_pk
ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, user_id);
CREATE OR REPLACE VIEW track_artists_v2 AS
SELECT t.id AS track_id,
a.id AS artist_id,
a.name AS artist_name,
CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role,
cf.fused_value AS confidence
FROM tracks t
JOIN claim_fusion cf
ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
JOIN artists a ON a.id = cf.object_id;
CREATE OR REPLACE VIEW album_artists_v2 AS
SELECT al.id AS album_id,
a.id AS artist_id,
a.name AS artist_name,
CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role,
cf.fused_value AS confidence
FROM albums al
JOIN claim_fusion cf
ON cf.subject_type = 'album' AND cf.subject_id = al.id
AND cf.predicate IN ('credited_main_on_album', 'featured_on_album')
AND cf.object_type = 'artist'
JOIN artists a ON a.id = cf.object_id;
`,
},
];
export class DbService {