Geo: Add UNIQUE indexes for package_file_registry table
What does this MR do and why?
Geo secondary sites can accumulate duplicate rows in the
package_file_registry table (same package_file_id, multiple registry
rows). The table only has a non-unique index on package_file_id, so
nothing at the database level prevents this. Duplicates come from a
find-or-insert race: concurrent sync jobs (or a sync racing the
Geo::RegistryConsistencyWorker bulk insert path) can both miss the
existing row and both insert.
This MR follows the documented deduplication strategy with two post-deployment migrations on the Geo tracking database:
DeduplicatePackageFileRegistry- batched removal of duplicate rows, keeping the best row perpackage_file_id: synced rows first, then verified rows, then highestid, so a completed sync or verification is never discarded in favour of a lesser duplicate.AddUniqueIndexToPackageFileRegistry- replaces the non-unique index onpackage_file_idwith a unique index, making duplicates impossible from then on.
The old index is named index_package_file_registry_on_repository_id
despite being on package_file_id (a historical copy-paste artifact), so
this MR also gives the replacement index an accurate name:
index_package_file_registry_on_package_file_id_unique.
This is the same approach previously approved for project_repository_states
in !227637 (merged) and applied to job_artifact_registry in !246511 (merged).
Relates to #606429 (closed)
Changelog: fixed EE: true
Database considerations
Destructive action
The DeduplicatePackageFileRegistry migration deletes duplicate rows from
package_file_registry on the Geo tracking database.
- How the data could be recovered: registry rows are derived tracking
metadata, not source data. Geo recreates any missing registry row
automatically through
Geo::RegistryConsistencyWorker, which runs every minute on the secondary. If the migration deleted too many rows, the worker would re-register the package files and they would re-sync. No primary data is touched. - Approximate number of records affected: only rows with a duplicated
package_file_idare deleted, and only the extra copies (synced rows preferred, then verified rows, then highestid). Observed duplicate counts on affected sites are small - expected range is zero to a few hundred rows per secondary. GitLab.com is unaffected: Geo is not enabled there and the Geo tracking database does not exist on .com. - User experience impact: none. Duplicate registry rows are redundant tracking metadata on a secondary site. Removing them does not affect the package files themselves or the primary site.
Race window between the two migrations
A duplicate could in theory be created after the dedup migration has scanned
past a package_file_id and before the unique index build starts enforcing
uniqueness (which happens at the start of CREATE INDEX CONCURRENTLY, before
the build completes). The window is bounded by the dedup migration runtime -
minutes on the largest known Geo sites.
We considered the advisory-lock pattern from !223413 (merged)
(prevent_concurrent_inserts + model uniqueness validation) to close this
window and decided against it:
- Duplicates historically appear at a rate of a handful per site over years. A collision landing inside a minutes-wide, one-off window is very unlikely.
- A model uniqueness validation cannot prevent this race: concurrent
transactions both pass the validation check before either commits - it is
the same check-then-insert race that created the existing duplicates. And
one of the two registry insert paths is
bulk_insert!(RegistryConsistencyWorker), where the per-record advisory-lock workaround from !223413 (merged) is impractical. The unique index is the actual fix; the lock would be dead code one release later (!223413 (merged)'s lock was removed in !231718 (merged) at the first opportunity).
If the index build does hit a duplicate created in this window, the migration
fails leaving an INVALID index behind; the fix is to re-run the dedup
migration and then re-run this migration - add_concurrent_index drops the
invalid index on retry (the spec covers this recovery path).
Migration runtime
The dedup migration is batched (each_batch, 10,000 rows per batch) with
disable_ddl_transaction!, so each batch commits on its own and every query
is index-driven (plans below). It completes well within post-deployment
migration guidelines on typical Geo sites, and runs only on Geo secondaries'
tracking databases (GitLab Dedicated DR sites and self-managed Geo
installations), never on GitLab.com. It takes row locks only on the duplicate
rows it deletes.
The index swap uses add_concurrent_index/remove_concurrent_index_by_name
on a single integer column - no write locks.
Query plans
Database review notes:
- Geo is not active on GitLab.com, so the Geo tracking database and the
package_file_registrytable do not exist in the postgres.ai snapshot. The table was created via DDL (schema fromee/db/geo/structure.sql, including the current non-unique index) and seeded manually.- Seeded 1,000,000 rows with states interleaved via modulo (85% synced, 5% failed, 10% pending) and timestamps decorrelated from the primary key via
random(), plus 500 duplicatepackage_file_idrows scattered across the id range via(n * 1999) % 1000000- not appended contiguously - so the plans are not flattered by PK/filter correlation.ANALYZEwas run after seeding.
Query 1: batched duplicate detection (SELECT)
Executed once per batch of 10,000 rows by the dedup migration. The plans
below were captured with a 1,000-row batch range; !246511 (merged) captured the
identical query shape on job_artifact_registry at the 10,000-row batch
size (dupes batch,
clean batch -
~19-21 ms, still index-driven).
SELECT "package_file_registry"."package_file_id"
FROM "package_file_registry"
WHERE "package_file_registry"."package_file_id" IN
(SELECT "package_file_registry"."package_file_id"
FROM "package_file_registry"
WHERE "package_file_registry"."id" >= 1000001
AND "package_file_registry"."id" < 1001001)
GROUP BY "package_file_registry"."package_file_id"
HAVING (COUNT(*) > 1);Batch containing duplicates (500 found): https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57492/commands/162066 (5.0 ms, index scans only)
Batch with no duplicates (the common case): https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57492/commands/162067 (2.7 ms)
Query 2: duplicate removal (DELETE)
Executed only for batches where duplicates were found.
WITH duplicated_records AS MATERIALIZED (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY package_file_id ORDER BY (state = 2) DESC, (verification_state = 2) DESC, id DESC) AS row_number
FROM package_file_registry
WHERE package_file_id IN (2000, 3999, 5998, 7997, 9996, 11995, 13994, 15993, 17992, 19991)
)
DELETE FROM package_file_registry
WHERE id IN (SELECT id FROM duplicated_records WHERE row_number > 1);Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/57492/commands/162068 (1.3 ms, index-driven, deletes via CTE)
How to set up and validate locally
-
On a GDK with Geo (or using the geo tracking test database), create duplicate registry rows in a Rails console:
Geo::PackageFileRegistry.create!(package_file_id: 1) Geo::PackageFileRegistry.create!(package_file_id: 1) Geo::PackageFileRegistry.create!(package_file_id: 2) -
Run the migrations:
bin/rails db:migrate:geo -
Verify the duplicates are gone (highest id kept) and the unique index is in place:
Geo::PackageFileRegistry.where(package_file_id: 1).count # => 1 Geo::PackageFileRegistry.create!(package_file_id: 1) # => raises RecordNotUnique -
Verify rollback restores the non-unique index:
bin/rails db:migrate:down:geo VERSION=20260915172257 bin/rails db:migrate:down:geo VERSION=20260915172237
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
Related to #606429 (closed)