Geo: Add targeted container repository reverification task
What does this MR do and why?
When container repository replication is disabled on a Geo primary (via the geo_container_repository_replication ops feature flag), pushes to existing container repositories don't emit Geo::Event records, so secondary sites are never told to resync them. New repositories are backfilled automatically, but updated ones stay marked synced on secondaries until the periodic reverification cycle (default: every 7 days) eventually detects the checksum mismatch. The Geo admin UI only offers "Reverify all", which is a heavy hammer on large registries.
However, while replication is disabled, registry notifications still reach the primary, and — provided geo_container_repository_force_primary_checksumming remains enabled (the default) — the primary still re-checksums each pushed repository, updating verified_at on its container_repository_states record. This gives us a precise marker of which repositories changed during the outage window so that we know which ones to resync.
This is useful particularly in Dedicated cutover situations (when a customer is moving from self managed to Dedicated) where a cutover deadline is looming meaning there may not be enough time to simply "resync everything".
This MR adds a targeted recovery rake task that exploits the verified_at marker:
sudo gitlab-rake "gitlab:geo:reverify_container_repositories_since[7]"It synchronously marks for reverification only the container repositories the primary verified in the last N days (default 7), then reports how many were marked. The periodic verification workers re-checksum them; the updated checksums propagate and secondaries detect the mismatch and resync only the affected repositories.
The marking uses the "slow iteration" batching pattern: the table is iterated in primary-key batches (filter-free boundary queries), and the filters are applied only within each PK-bounded batch, so every statement stays cheap regardless of data distribution. No background job or cursor is needed — re-running after an interruption resumes naturally, because already-marked rows become verification_pending and fall out of the scope.
A DRY_RUN=true mode reports how many repositories would be marked without changing anything, using the same batched iteration.
Changes:
- New
verified_afterscope onGeo::VerificationStateDefinition - New
Geo::ReverifyContainerRepositoriesService: synchronous, batched (slow iteration), withexecuteanddry_runmodes - New rake task
gitlab:geo:reverify_container_repositories_since[days](primary-only, licence-gated, validates the days argument) - Documentation for the recovery procedure in the Geo container registry replication docs
No new index is added — see the database review section for the rationale and query plans.
Relates to #591222 (closed)
Changelog: added EE: true
Database review
Database review notes:
- Addresses the previous review round: the earlier design applied the
verified_at >= ?andverification_state != 0filters in theeach_batchboundary queries, which had no index coverage (worst case: scan the entire PK with heap lookups). Per the reviewer's suggestion, this now uses slow iteration: iteration runs over the bare table on the primary key only, and both filters are applied inside each PK-bounded batch.- Consequently the boundary queries (1, 2, 6 below) carry no filters at all — they are pure PK index-only scans whose cost is independent of data distribution by construction. The filtered statements (3, 4, 5) are always bounded to a ≤1,000-row PK range; the measured worst case (a batch where nothing matches the filter) is 457 buffers. Adverse data distributions can only increase the number of cheap batches, never the cost of any single statement.
- No new index is added for
verified_at: it is rewritten on every successful verification (one of the hottest-written columns on this table), so a dedicated index would tax every checksum cycle to serve a rarely-run, manually-invoked recovery rake task. With slow iteration, no statement needs it.- The task runs synchronously in the rake process, one batch at a time (
BATCH_SIZE = 1_000) — no Sidekiq, no Redis cursor. Batches commit independently.- Geo is not active on GitLab.com (only GitLab Dedicated and Self-Managed), so
container_repository_stateshas negligible production data in the postgres.ai snapshot (~560k rows, allverification_state = 0/verified_at IS NULL).- We therefore seeded 1,000,000 rows (plus 1M parent
container_repositoriesrows to satisfy the FK) with a deliberately adversarial distribution — addressing the previous round's observation that favourable data distribution can flatter plans: verification states interleaved across the PK via modulo (85%verification_state = 2, 5%= 3, 10%= 0— no contiguous state ranges), andverified_atdecorrelated from the PK viarandom()over a 30-day window, so a 7-day cutoff matches ~23% of non-pending rows scattered across the whole table.ANALYZE container_repository_stateswas run after seeding, before capturing plans.
Query 1: first batch boundary (once per task run)
each_batch opening boundary. No filters — pure PK index-only scan.
SELECT "container_repository_states"."container_repository_id"
FROM "container_repository_states"
ORDER BY "container_repository_states"."container_repository_id" ASC
LIMIT 1;Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54011/commands/156389 — 1.9ms, 4 buffers, PK index-only scan, no filter
Query 2: per-batch boundary lookup (once per 1,000-row batch)
The query shape flagged in the previous review round — now filter-free. Reads exactly BATCH_SIZE + 1 index entries regardless of data distribution.
SELECT "container_repository_states"."container_repository_id"
FROM "container_repository_states"
WHERE "container_repository_states"."container_repository_id" >= 1000000000
ORDER BY "container_repository_states"."container_repository_id" ASC
LIMIT 1 OFFSET 1000;Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54011/commands/156390 — 0.8ms, 9 buffers, PK index-only scan, no filter
Query 3: per-batch COUNT (dry run, once per batch)
Both filters applied inside the PK-bounded batch. 214 of 1,000 rows matched — the interleaved seeding at work.
SELECT COUNT(*)
FROM "container_repository_states"
WHERE "container_repository_states"."container_repository_id" >= 1000500000
AND "container_repository_states"."container_repository_id" < 1000501000
AND "container_repository_states"."verification_state" != 0
AND "container_repository_states"."verified_at" >= '2026-07-16 17:00:00';Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54011/commands/156391 — 1.2ms, 20 buffers, bounded PK index scan
Query 4: per-batch UPDATE (once per batch)
UPDATE "container_repository_states"
SET "verification_state" = 0
WHERE "container_repository_states"."container_repository_id" >= 1000500000
AND "container_repository_states"."container_repository_id" < 1000501000
AND "container_repository_states"."verification_state" != 0
AND "container_repository_states"."verified_at" >= '2026-07-16 17:00:00';Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54011/commands/156392 — 4.9ms, 214 rows updated, ~134KB WAL, bounded PK index scan
Query 5: worst case — zero-match batch
Same COUNT with a future cutoff so the filter matches nothing: every row in the batch requires a heap visit and is removed by the filter. This is the per-statement ceiling — still bounded by the 1,000-row PK range.
SELECT COUNT(*)
FROM "container_repository_states"
WHERE "container_repository_states"."container_repository_id" >= 1000500000
AND "container_repository_states"."container_repository_id" < 1000501000
AND "container_repository_states"."verification_state" != 0
AND "container_repository_states"."verified_at" >= '2026-08-01 00:00:00';Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54011/commands/156393 — 1.4ms, 457 buffers, 0 rows matched (1,000 removed by filter)
Query 6: worst case — final batch at the table tail
Boundary lookup where fewer than BATCH_SIZE rows remain: scans the remaining entries and stops.
SELECT "container_repository_states"."container_repository_id"
FROM "container_repository_states"
WHERE "container_repository_states"."container_repository_id" >= 1000999500
ORDER BY "container_repository_states"."container_repository_id" ASC
LIMIT 1 OFFSET 1000;Plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54011/commands/156394 — 0.8ms, 9 buffers, PK index-only scan, no filter
References
Screenshots or screen recordings
| Before | After |
|---|---|
How to set up and validate locally
Requires a GDK with Geo primary configured and the container registry enabled (registry.enabled: true in gdk.yml). You only need the primary — the whole change is primary-side.
1. Configure registry notifications (if not already set up)
GDK doesn't configure these by default. Append to <gdk-root>/registry/config.yml:
notifications:
endpoints:
- name: geo_event
url: http://127.0.0.1:3000/api/v4/container_registry_event/events
timeout: 2s
threshold: 5
backoff: 1s
headers:
Authorization: [notifications_secret]The notifications_secret value must match registry.notification_secret in gitlab/config/gitlab.yml (GDK's default is literally notifications_secret). Then gdk restart registry.
2. Create a repo and push an image (baseline)
docker login 127.0.0.1:5100 -u root -p <PAT with api,read_registry,write_registry>
docker pull alpine:latest
docker tag alpine:latest 127.0.0.1:5100/root/<project-path>:v1
docker push 127.0.0.1:5100/root/<project-path>:v1In rails console, force a checksum so the record has a baseline verified_at:
cr = ContainerRepository.last
cr.replicator.verify
st = Geo::ContainerRepositoryState.find_by(container_repository_id: cr.id)
st.verification_state_name # => :verification_succeeded
st.verified_at # note this timestamp3. Simulate the outage scenario
Feature.disable(:geo_container_repository_replication)
# force-checksumming must stay enabled (it's on by default):
Geo::ContainerRepositoryReplicator.verification_enabled? # => true
events_before = Geo::Event.where(replicable_name: 'container_repository').countPush an updated image (must have new content, e.g. retag a different image as :v2) and wait ~30s for the notification to process. Note: Flipper caches flag state for ~1 min in running processes — wait a couple of minutes after disabling the flag before pushing, or the push may still emit an event.
Confirm the issue's premise:
# No replication event was emitted (this is the bug being recovered from):
Geo::Event.where(replicable_name: 'container_repository').count # == events_before
# But the push still marked the record for re-checksumming:
st.reload.verification_state_name # => :verification_pending
# Re-checksum (or wait for the periodic VerificationBatchWorker):
Geo::ContainerRepositoryReplicator.verify_batch
st.reload.verified_at # => advanced past the baseline timestamp4. Validate the rake task
The task runs synchronously and reports the count when done — no Sidekiq involvement.
# Dry run — counts without changing anything:
DRY_RUN=true bundle exec rake "gitlab:geo:reverify_container_repositories_since[1]"
# => DRY RUN: 1 container repositories would be marked for reverification. (verified since <cutoff>)
# Real run:
bundle exec rake "gitlab:geo:reverify_container_repositories_since[1]"
# => Marked 1 container repositories for reverification. (verified since <cutoff>)Confirm the targeted record was reset while others were untouched:
st.reload.verification_state_name # => :verification_pendingRe-running the task immediately reports Marked 0 ... — already-marked rows are pending and fall out of the scope, which is what makes an interrupted run safely resumable. (If you wait long enough for the periodic verification workers to re-checksum the record first, it legitimately re-enters the scope with a fresh verified_at, and re-marking it is harmless.)
5. Error paths (optional)
bundle exec rake "gitlab:geo:reverify_container_repositories_since[0]" # => aborts: Days must be a positive integer
bundle exec rake "gitlab:geo:reverify_container_repositories_since[abc]" # => aborts: Days must be a positive integerOn a secondary GDK the task aborts with This command is only available on a primary node.
Cleanup
Feature.enable(:geo_container_repository_replication)Revert the registry/config.yml change if you don't want notifications enabled permanently.
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 #591222 (closed)