Skip to content

Commit 84deb7e

Browse files
authored
fix: merge case-duplicate github repos and enforce unique lower(url) [CM-1344] (#4383)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent e021298 commit 84deb7e

2 files changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
-- GitHub paths are case-insensitive, but repos.url is UNIQUE case-sensitively.
2+
-- Writers that predate URL lowercasing (cargo initial sync on 2026-06-19, maven
3+
-- enrichment before CM-1305) inserted mixed-case variants of rows that already
4+
-- existed lowercase: ~40k duplicate groups, ~10k of them serving duplicate
5+
-- security contacts from both variants. The CM-1305 backfills re-pointed
6+
-- package links to the lowercase rows but left the stale variants behind.
7+
--
8+
-- Keeper per group: the all-lowercase row when present (links were already
9+
-- re-pointed to it), else the lowest id. Only package_repos is primary data
10+
-- and gets re-pointed (repo_docker comes along since it is a free UPDATE);
11+
-- contacts, snapshots, and scorecard rows on losers are derived and re-fill
12+
-- via the regular sweeps, so they are dropped with the loser rows.
13+
14+
CREATE TEMP TABLE repo_merge_members AS
15+
SELECT id AS repo_id, keeper_id, id = keeper_id AS is_keeper
16+
FROM (
17+
SELECT
18+
id,
19+
FIRST_VALUE(id) OVER (
20+
PARTITION BY LOWER(url)
21+
ORDER BY (url = LOWER(url)) DESC, id
22+
) AS keeper_id,
23+
COUNT(*) OVER (PARTITION BY LOWER(url)) AS group_size
24+
FROM repos
25+
WHERE host = 'github'
26+
) grouped
27+
WHERE group_size > 1;
28+
29+
CREATE INDEX ON repo_merge_members (repo_id);
30+
ANALYZE repo_merge_members;
31+
32+
-- Keep one link per (package, group), ranked the way consumers pick links
33+
-- (sqlFragments bestRepoLink: confidence DESC, declared-on-ties) so the merge
34+
-- preserves the strongest provenance; keeper status only breaks full ties.
35+
-- ROW_NUMBER instead of an EXISTS check against the keeper because 3-variant
36+
-- groups exist: two losers linking the same package would collide on
37+
-- UNIQUE (package_id, repo_id) after the re-point below.
38+
DELETE FROM package_repos
39+
WHERE id IN (
40+
SELECT id
41+
FROM (
42+
SELECT pr.id,
43+
ROW_NUMBER() OVER (
44+
PARTITION BY m.keeper_id, pr.package_id
45+
ORDER BY pr.confidence DESC, (pr.source = 'declared') DESC,
46+
m.is_keeper DESC, pr.verified_at DESC, pr.id
47+
) AS rn
48+
FROM package_repos pr
49+
JOIN repo_merge_members m ON m.repo_id = pr.repo_id
50+
) ranked
51+
WHERE rn > 1
52+
);
53+
54+
-- Re-point surviving loser links via insert + delete, never an UPDATE:
55+
-- repo_id is part of the Tinybird packageRepos sorting key (CDC models key
56+
-- changes as delete + insert; a key-mutating UPDATE would leave both links
57+
-- live under FINAL with no way to reconcile). The fresh row streams cleanly
58+
-- with a new id and a NOW() version; the stale loser keys (these deletes
59+
-- included) are removed by the post-migration Tinybird datasource rebuild.
60+
INSERT INTO package_repos (package_id, repo_id, source, confidence, verified_at, created_at)
61+
SELECT pr.package_id, m.keeper_id, pr.source, pr.confidence, NOW(), NOW()
62+
FROM package_repos pr
63+
JOIN repo_merge_members m ON m.repo_id = pr.repo_id
64+
WHERE NOT m.is_keeper;
65+
66+
DELETE FROM package_repos pr
67+
USING repo_merge_members m
68+
WHERE pr.repo_id = m.repo_id
69+
AND NOT m.is_keeper;
70+
71+
UPDATE repo_docker d
72+
SET repo_id = m.keeper_id
73+
FROM repo_merge_members m
74+
WHERE d.repo_id = m.repo_id
75+
AND NOT m.is_keeper;
76+
77+
DELETE FROM repo_scorecard_checks c
78+
USING repo_merge_members m
79+
WHERE c.repo_id = m.repo_id
80+
AND NOT m.is_keeper;
81+
82+
-- packages.repository_url is denormalized and must keep matching the
83+
-- canonical repos.url (the maven backfill updates the two atomically for the
84+
-- same reason). Joined on repos directly, not the merge map, so it also
85+
-- covers packages pointing at mixed-case singletons normalized below; runs
86+
-- while loser urls still exist to match against. last_synced_at is the
87+
-- packages watermark, bumping it ships the correction to Tinybird.
88+
UPDATE packages p
89+
SET repository_url = LOWER(p.repository_url), last_synced_at = NOW()
90+
FROM repos r
91+
WHERE r.host = 'github'
92+
AND p.repository_url = r.url
93+
AND p.repository_url <> LOWER(p.repository_url);
94+
95+
-- Cascades security_contacts and repo_activity_snapshot on losers.
96+
DELETE FROM repos r
97+
USING repo_merge_members m
98+
WHERE r.id = m.repo_id
99+
AND NOT m.is_keeper;
100+
101+
-- Lowercase every surviving mixed-case github url: keepers whose group had
102+
-- no lowercase variant, plus mixed-case singletons that never had a
103+
-- duplicate. Singletons must be normalized before the guard index exists —
104+
-- writers upsert with ON CONFLICT (url), which does not arbitrate on the
105+
-- LOWER(url) index, so a later insert of the same repo's canonical lowercase
106+
-- url (all writers lowercase now) would raise unique_violation instead of
107+
-- upserting. Safe against UNIQUE (url): any two rows sharing LOWER(url) were
108+
-- a group above, and their losers are gone by now.
109+
UPDATE repos r
110+
SET url = LOWER(r.url), updated_at = NOW()
111+
WHERE r.host = 'github'
112+
AND r.url <> LOWER(r.url);
113+
114+
-- The recurrence-guard unique index lives in the next migration: it must be
115+
-- built CONCURRENTLY (repos takes continuous worker writes), which cannot run
116+
-- inside this transaction.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- Recurrence guard for case-duplicate GitHub repos, split from the merge in
2+
-- V1784718693: CONCURRENTLY cannot run inside a transaction, and under
3+
-- flyway's mixed=true a shared file would demote the merge itself to
4+
-- autocommit, losing its atomicity. The concurrent build keeps repos writable
5+
-- for the workers during the scan, and doubles as verification of the merge —
6+
-- a surviving duplicate fails the build.
7+
--
8+
-- GitHub-only: the only host with both confirmed duplicates and guaranteed
9+
-- lowercase-on-write today (CASE_INSENSITIVE_HOSTS in canonicalizeRepoUrl
10+
-- also covers gitlab.com, whose merge is a follow-up). On other hosts case
11+
-- can be significant and writers do not normalize, so a wider index could
12+
-- reject legitimately distinct repos.
13+
--
14+
-- A failed concurrent build leaves an INVALID index behind; the DROP lets a
15+
-- re-run replace it, where IF NOT EXISTS would silently keep the broken one.
16+
-- Also CONCURRENTLY: a plain drop takes an ACCESS EXCLUSIVE lock on repos,
17+
-- stalling the workers on the retry path this exists for.
18+
DROP INDEX CONCURRENTLY IF EXISTS repos_github_lower_url_uq;
19+
20+
CREATE UNIQUE INDEX CONCURRENTLY repos_github_lower_url_uq
21+
ON repos (LOWER(url))
22+
WHERE host = 'github';

0 commit comments

Comments
 (0)