Geo: Self-heal persistent checksum mismatches
What does this MR do?
Adds an automatic self-heal loop for Geo checksum mismatches that never resolve because the primary's stored checksum is stale rather than the secondary's copy being wrong. Today this requires manual intervention (Rails console or the admin data-management API) per resource.
- A secondary that fails checksum verification for the same resource
checksum_mismatch_report_thresholdtimes in a row reports it to the primary (Redis-deduplicated, at most once perchecksum_mismatch_self_heal_cooldown_minutes). - The primary receives these reports on a new generic
POST /api/v4/geo/failuresendpoint (deliberately not checksum-specific, since REST API v4 endpoints are expensive to change once shipped) and, if the record is still eligible (its current checksum still matches what the secondary saw, and it isn't within its own cooldown window), flips it back toverification_pending. - The existing verification pipeline then recomputes the checksum and propagates it to secondaries exactly as it does today — that part was already implemented and is untouched by this MR.
Both thresholds are configurable per Geo node (geo_nodes columns, exposed via the geo_nodes/geo_sites REST APIs and GraphQL, following the existing minimum_reverification_interval pattern).
Everything is behind the geo_self_heal_checksum_mismatch ops flag, disabled by default.
Follow-ups / open questions
Prerequisite for enabling the flag: !248366 (merged) must land first. verification_retry_count is currently reset to 0 (and checksum_mismatch back to false) by the resync that every verification failure triggers, so the checksum_mismatch + verification_retry_count >= threshold scope here cannot match anything until that is fixed. Details in that MR and in the discussion below.
- Default threshold (3) and cooldown (60 min) values are placeholders — need to be validated against real-world retry cadence before enabling broadly.
- High-traffic replicables (frequent commits to a project repository) can fail verification because the secondary cannot catch up, not because of corruption — #556537. Fixing it there stops those mismatches from ever being recorded as verification failures, so they never reach this scan's scope; the primary-side
eligible?checksum comparison is a second guard in the meantime. Revisit here only if 556537 does not land. - Whether secondary-side reporting should live in its own cron worker (as implemented here) or fold into the existing
Geo::MetricsUpdateWorkercycle. - Once #548532 (typed Geo error classes) lands, swap the
error_typestring /checksum_mismatchboolean check for the typedGeo::Errors::ChecksumMismatchError— no wire-format change needed since the string values are already aligned. - Once #602803 (Geo cleanup rake tasks) lands, consider whether the self-heal logic should be shared with its manual
Geo::Tools::Resolutions.
Database
Raw SQL and query plan for the new persistent_checksum_mismatches scope (ee/app/models/concerns/geo/verifiable_registry.rb), used via .order(:verification_retry_at).limit(BATCH_SIZE * CANDIDATE_MULTIPLIER) in Geo::ChecksumMismatchReportingService#candidates_for. This scope only ever runs against repository replicator registries (Gitlab::Geo.repository_replicator_classes) — persistent stale-primary-checksum mismatches are a phenomenon specific to git-repository checksums, not blob checksums, so the reporting service only scans those registry tables: project_repository_registry, project_wiki_repository_registry, design_management_repository_registry, group_wiki_repository_registry, snippet_repository_registry.
Raw SQL (generated for Geo::ProjectRepositoryRegistry, representative of the other repository registry tables — identical shape):
SELECT "project_repository_registry".*
FROM "project_repository_registry"
WHERE "project_repository_registry"."checksum_mismatch" = TRUE
AND "project_repository_registry"."verification_retry_count" >= 3
ORDER BY "project_repository_registry"."verification_retry_at" ASC
LIMIT 300Caveat on scale: registry tables live in each Geo secondary's own tracking database, not gitlab.com's main production database, so Database Lab/postgres.ai doesn't cover them and I don't have access to a GitLab.com-scale Geo tracking database. To get a real (not fabricated) plan, I seeded a synthetic 2,000,000-row project_repository_registry locally and ran EXPLAIN (ANALYZE, BUFFERS). This shows the pattern, not a substitute for a plan pulled from an actual secondary's tracking DB — flagging this explicitly for whoever picks up the database review.
I initially assumed checksum_mismatch = true rows would have state = 2 (synced), verification_state = 3 (failed), matching the existing ..._failed_verification partial index's predicate. That's wrong: before_verification_failed (ee/app/models/concerns/geo/verifiable_registry.rb) unconditionally fires the sync state event failed whenever verification fails (ee/app/models/concerns/geo/replicable_registry.rb's event :failed do transition [:pending, :started, :synced, :failed] => :failed end covers every prior state), so a mismatched row is actually state = 3, verification_state = 3 — confirmed both by reading the transition table and empirically against a local tracking DB. The existing partial index can never match these rows, which is exactly why the plan below shows a sequential scan despite that index existing.
Plan without a checksum_mismatch-covering index:
Limit (cost=32039.41..32059.25 rows=170 width=210) (actual time=30.838..32.850 rows=300 loops=1)
Buffers: shared hit=20696
-> Gather Merge (cost=32039.41..32059.25 rows=170 width=210) (actual time=30.837..32.832 rows=300 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Sort (cost=31039.39..31039.60 rows=85 width=210) (actual time=29.085..29.094 rows=239 loops=3)
Sort Key: verification_retry_at
Sort Method: top-N heapsort Memory: 91kB
-> Parallel Seq Scan on project_repository_registry (cost=0.00..31036.67 rows=85 width=210) (actual time=0.012..28.861 rows=3353 loops=3)
Filter: (checksum_mismatch AND (verification_retry_count >= 3))
Rows Removed by Filter: 663313
Planning Time: 0.079 ms
Execution Time: 32.876 msGiven the reporting worker runs every minute (ee/config/schedule.yml: geo_checksum_mismatch_reporting_worker, cron */1 * * * *), this MR now adds a dedicated partial index per repository registry table (ee/db/geo/post_migrate/20260728120000..20260728120004):
add_concurrent_index :project_repository_registry, :verification_retry_at,
where: 'checksum_mismatch = true', name: 'index_project_repository_registry_checksum_mismatch'Plan with the new index:
Limit (cost=0.29..9166.77 rows=193 width=210) (actual time=0.011..0.279 rows=300 loops=1)
Buffers: shared hit=363 read=2
-> Index Scan using index_project_repository_registry_checksum_mismatch on project_repository_registry (cost=0.29..9166.77 rows=193 width=210) (actual time=0.011..0.270 rows=300 loops=1)
Filter: (verification_retry_count >= 3)
Rows Removed by Filter: 63
Planning Time: 0.227 ms
Execution Time: 0.290 ms~113x fewer buffer reads (365 vs 20,696) and ~113x faster (0.29ms vs 32.9ms) in this local test. Migrated and rolled back locally to confirm reversibility.
References
Screenshots or screen recordings
Not applicable (backend-only change).
How to set up and validate locally
-
Enable the flag:
Feature.enable(:geo_self_heal_checksum_mismatch) -
Pick a project that has already synced and verified successfully on the secondary (
project.verification_succeeded?istrue).project_states.verification_checksum(the primary's stored repository checksum, delegated ontoProject) is a regular main-database table, replicated to secondaries via normal PostgreSQL streaming replication — unlike the git data itself, no Geo-specific sync is involved, so corrupting it on the primary is instantly visible to secondaries.On the primary, corrupt the stored checksum without touching the repository itself, to simulate the primary's checksum going stale relative to its (unchanged) real data:
project = Project.find_by_full_path('group/project') project.verification_succeeded? # => true project.update!(verification_checksum: project.verification_checksum.reverse) # still valid-looking hex, now wrongOn the secondary, force verification attempts directly instead of waiting for the cron cadence — each call computes the secondary's real (unchanged, correct) checksum and compares it against the now-corrupted primary value, so it fails every time:
replicator = Geo::ProjectRepositoryReplicator.new(model_record: Project.find_by_full_path('group/project')) Gitlab::Geo.current_node.checksum_mismatch_report_threshold.times { replicator.verify } registry = Geo::ProjectRepositoryRegistry.find_by(project_id: project.id) registry.checksum_mismatch # => true registry.verification_retry_count # => >= checksum_mismatch_report_thresholdThen trigger the reporting worker directly rather than waiting up to a minute for its cron:
Geo::ChecksumMismatchReportingWorker.new.perform -
Confirm the primary flips the resource to
verification_pendingand the secondary eventually reportsverification_succeeded.
Automated coverage: ee/spec/services/geo/checksum_mismatch_reporting_service_spec.rb, ee/spec/services/geo/checksum_mismatch_self_heal_service_spec.rb, ee/spec/workers/geo/checksum_mismatch_reporting_worker_spec.rb, ee/spec/workers/geo/verification_failure_report_worker_spec.rb, ee/spec/requests/api/geo_spec.rb.