fix: dedup claims and make the unique constraint NULLS NOT DISTINCT
claims declared UNIQUE (..., source, user_id). Every objective claim has
user_id IS NULL, and under default NULLS DISTINCT semantics Postgres treats
those rows as unique, so the ON CONFLICT DO UPDATE / DO NOTHING clauses in
db.service and mb-spine-writer never fired. Re-enrichment inserted a fresh
duplicate every run instead of reinforcing.
Live data: 236 duplicate groups, 2110 excess rows, worst single claim 86
copies, ~15% of 13,910 claims. claim_fusion is SUM(trust * confidence *
recency), so one edge could carry 86x its intended weight — the likely
cause of repetitive recommendations, and almost certainly the root of
d497588 (claim_fusion MV duplicate-key failure).
Migration 20260730_claims_dedup_nulls_not_distinct, two phases in one
transaction. Dedup MUST precede the constraint or adding it fails.
Phase 1 collapses each group into its most recently reinforced row,
carrying forward MAX(last_reinforced_at), MAX(evidence_at) and
MAX(confidence) — reinforcement recency would otherwise be lost by simply
deleting extras. The MAX(...) OVER grp and ROW_NUMBER() OVER ordered
windows are deliberately separate: an ORDER BY inside the window makes the
default frame UNBOUNDED PRECEDING TO CURRENT ROW, which turns MAX() into a
running maximum and would silently keep the wrong confidence.
Phase 2 drops the old constraint by matching its definition rather than its
name, because the live DB has drifted and its autogenerated name is
truncated at 63 characters.
Verified on a scratch PG16 seeded with the old schema plus a 3-row
duplicate group, a distinct-source singleton and a real-user_id row:
UPDATE 3 / DELETE 2, keeper retained the group max of each field from three
different rows, re-run is a no-op, and a subsequent ON CONFLICT DO UPDATE
with user_id = NULL fires correctly.
REVIEW-2026-07-30.md finding 4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -340,7 +340,13 @@ CREATE TABLE IF NOT EXISTS claims (
|
|||||||
last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
raw JSONB,
|
raw JSONB,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
|
-- NULLS NOT DISTINCT (PG15+) is load-bearing: user_id is NULL for every
|
||||||
|
-- objective claim, and with default NULLS DISTINCT semantics the
|
||||||
|
-- ON CONFLICT clauses in upsertClaim() / MbSpineWriter never fire, so
|
||||||
|
-- re-enrichment inserts duplicates instead of reinforcing.
|
||||||
|
CONSTRAINT claims_edge_source_user_key
|
||||||
|
UNIQUE NULLS NOT DISTINCT
|
||||||
|
(subject_type, subject_id, predicate, object_type, object_id, source, user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims (subject_type, subject_id, predicate);
|
CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims (subject_type, subject_id, predicate);
|
||||||
|
|||||||
@@ -504,6 +504,90 @@ const MIGRATIONS: { id: string; sql: string }[] = [
|
|||||||
JOIN artists a ON a.id = cf.object_id;
|
JOIN artists a ON a.id = cf.object_id;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: '20260730_claims_dedup_nulls_not_distinct',
|
||||||
|
sql: `
|
||||||
|
-- The claims uniqueness constraint was declared as a plain
|
||||||
|
-- UNIQUE (subject_type, subject_id, predicate, object_type, object_id,
|
||||||
|
-- source, user_id). Every objective claim (MB, Discogs, tags) has
|
||||||
|
-- user_id IS NULL, and Postgres treats NULLs as distinct, so none of the
|
||||||
|
-- ON CONFLICT clauses in upsertClaim() / MbSpineWriter ever fired:
|
||||||
|
-- re-enrichment inserted a fresh duplicate row every time instead of
|
||||||
|
-- reinforcing. claim_fusion is SUM(trust * confidence * recency), so a
|
||||||
|
-- duplicated edge carried N times its intended weight.
|
||||||
|
--
|
||||||
|
-- Two phases, in this order (the constraint cannot be added while
|
||||||
|
-- duplicates exist):
|
||||||
|
-- 1. collapse each duplicate group into its most recently reinforced
|
||||||
|
-- row, carrying forward MAX(last_reinforced_at) / MAX(evidence_at) /
|
||||||
|
-- MAX(confidence) so reinforcement recency is not lost;
|
||||||
|
-- 2. replace the constraint with a NULLS NOT DISTINCT version (PG15+).
|
||||||
|
|
||||||
|
-- Phase 1: dedup.
|
||||||
|
CREATE TEMP TABLE claims_dedup ON COMMIT DROP AS
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ROW_NUMBER() OVER ordered AS rn,
|
||||||
|
-- These MUST use the unordered window: an ORDER BY in the window spec
|
||||||
|
-- makes the default frame "UNBOUNDED PRECEDING TO CURRENT ROW", turning
|
||||||
|
-- MAX() into a running maximum rather than a per-group one.
|
||||||
|
MAX(last_reinforced_at) OVER grp AS max_last_reinforced_at,
|
||||||
|
MAX(evidence_at) OVER grp AS max_evidence_at,
|
||||||
|
MAX(confidence) OVER grp AS max_confidence
|
||||||
|
FROM claims
|
||||||
|
WINDOW
|
||||||
|
grp AS (
|
||||||
|
PARTITION BY subject_type, subject_id, predicate, object_type, object_id,
|
||||||
|
source,
|
||||||
|
COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||||
|
),
|
||||||
|
ordered AS (
|
||||||
|
grp ORDER BY last_reinforced_at DESC, evidence_at DESC, id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Keeper of each group absorbs the group's best values.
|
||||||
|
UPDATE claims c
|
||||||
|
SET last_reinforced_at = d.max_last_reinforced_at,
|
||||||
|
evidence_at = d.max_evidence_at,
|
||||||
|
confidence = d.max_confidence
|
||||||
|
FROM claims_dedup d
|
||||||
|
WHERE c.id = d.id
|
||||||
|
AND d.rn = 1;
|
||||||
|
|
||||||
|
DELETE FROM claims c
|
||||||
|
USING claims_dedup d
|
||||||
|
WHERE c.id = d.id
|
||||||
|
AND d.rn > 1;
|
||||||
|
|
||||||
|
-- Phase 2: replace the constraint. The live DB has drifted from
|
||||||
|
-- schema.sql, so find the existing constraint by its definition rather
|
||||||
|
-- than assuming Postgres' auto-generated name.
|
||||||
|
DO $mig$
|
||||||
|
DECLARE
|
||||||
|
cname TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR cname IN
|
||||||
|
SELECT con.conname
|
||||||
|
FROM pg_constraint con
|
||||||
|
WHERE con.conrelid = 'claims'::regclass
|
||||||
|
AND con.contype = 'u'
|
||||||
|
AND pg_get_constraintdef(con.oid) LIKE '%subject_type%'
|
||||||
|
AND pg_get_constraintdef(con.oid) NOT LIKE '%NULLS NOT DISTINCT%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE claims DROP CONSTRAINT %I', cname);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'claims'::regclass AND conname = 'claims_edge_source_user_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE claims ADD CONSTRAINT claims_edge_source_user_key
|
||||||
|
UNIQUE NULLS NOT DISTINCT
|
||||||
|
(subject_type, subject_id, predicate, object_type, object_id, source, user_id);
|
||||||
|
END IF;
|
||||||
|
END $mig$;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export class DbService {
|
export class DbService {
|
||||||
|
|||||||
Reference in New Issue
Block a user