Queue BBM to clean up dual sharding keys in notes table
What does this MR do and why?
Queue a batched background migration (BBM) that nulls namespace_id on notes rows where both namespace_id and project_id are populated, and fix the write paths that still dual-key, so a follow-up MR can tighten the multi-column NOT NULL constraint from >= 1 to = 1.
Scope note: the
num_nonnulls(...) = 1NOT VALID constraint was removed from this MR (commita45ec885) and deferred to #601451. Enforcing= 1while ~2.29B dual-key rows still exist would breakResolvableNote#resolve!/unresolve!(which useupdate_all, so Postgres re-checks the NOT VALID constraint on every UPDATE of an already-violating row →PG::CheckViolation) during the multi-week BBM window. The constraint MR is opened only after this BBM drains dual-key rows on GitLab.com.
What
- Adds
Gitlab::BackgroundMigration::CleanupDualShardingKeysInNotes, a BBM that runsUPDATE notes SET namespace_id = NULL WHERE namespace_id IS NOT NULL AND project_id IS NOT NULLin sub-batches. - Adds the post-deployment migration to enqueue the BBM, plus the BBM YAML doc and schema_migrations entry.
- Adds a wiki user-mention trigger fallback migration and a synchronized temporary index.
- Repairs
Note#ensure_namespace_id/ensure_organization_id/save_markdownso creates and updates set exactly one sharding key and clear stray keys (pre-existing dual-key rows are repaired when next saved) — this makes the follow-up= 1constraint safe once it lands. - Fixes the project/epic import paths so imported notes are single-keyed.
- Adds RSpec coverage for the BBM class and the queue migration.
This MR does not add the = 1 constraint — see the scope note above.
Reviewer's guide (file groups)
This MR makes notes single-sharding-keyed on the write path and cleans up existing dual-key rows via a BBM. The write-path changes (groups 1 & 2) fix every place that still dual-keys, so that once the BBM has drained the backlog a follow-up MR (#601451) can safely add the num_nonnulls(...) = 1 constraint. The constraint is intentionally not in this MR — see the scope note at the top.
The behaviour-neutral spec fixtures (note_metadata, editable, snippet fixture, vulnerability-states, the serializer shared example) were split out and merged separately in !246000 (merged), so they no longer appear here.
1. Note model single-keying (write-path normalization)
app/models/note.rb,ee/app/models/ee/note.rb—ensure_namespace_id/ensure_organization_id/save_markdownset exactly one sharding key (project_idXORnamespace_idXORorganization_id) and clear stray keys, on create and update (so pre-existing dual-key rows are repaired when next saved).- Specs:
spec/models/note_spec.rb,ee/spec/models/note_spec.rb,spec/models/concerns/mentionable_spec.rb,spec/services/organizations/transfer/concerns/organization_updater_spec.rb,spec/support/shared_examples/models/concerns/notes/model_with_associated_note_shared_examples.rb,spec/support/helpers/notes_sharding_key_helpers.rb,spec/spec_helper.rb.
2. Import path fixes
lib/gitlab/import_export/{project/relation_factory,base/relation_object_saver,group/relation_tree_restorer}.rb,ee/app/services/work_items/legacy_epics/imports/create_from_imported_epic_service.rb— project/epic import produce single-keyed notes (drop the exportednamespace_idfor project notes; attach the noteable before validating).
3. BBM + migrations (the cleanup)
lib/gitlab/background_migration/cleanup_dual_sharding_keys_in_notes.rb+db/post_migrate/20260724100002_queue_cleanup_dual_sharding_keys_in_notes.rb— the cleanup BBM and its queue migration.db/post_migrate/20260724100000_add_project_fallback_to_wiki_user_mention_trigger.rb,20260724100001_sync_tmp_index_...— wiki user-mention trigger fallback and the synchronized temp index (the temp index makes the BBM's batch-bound lookups cheap).db/{structure.sql,schema_migrations/*},db/docs/*.yml, migration specs,spec/db/schema_spec.rb, and the BBM/backfill specs.
The num_nonnulls(...) = 1 constraint is deferred to #601451 and is not part of this MR.
Why
Part of the GitLab database sharding strategy (see issue #569520). The notes table uses project_id as the authoritative sharding key for project-scoped notes. A previous BBM (MR !191155 (merged), milestone 18.4) backfilled namespace_id on rows that had only project_id. That left ~2.29B rows with both keys populated. The existing NOT VALID multi-column constraint (>= 1 of project_id, namespace_id, organization_id) was added in migration 20251017182313. To tighten it to = 1 in a follow-up MR, we must first clear the redundant namespace_id from dual-populated rows.
Phase 1 findings
- ~2.94B total rows in
notes; ~2.29B (~78%) have bothnamespace_idandproject_idpopulated. - Spot-check on
id BETWEEN 1 AND 10_000_000: 4,008,252 dual-populated rows; only 4 had anamespace_idthat did not matchprojects.project_namespace_id(~1 per million mismatch rate). - Because
project_idis authoritative and the constraint flip only requires exactly one sharding key, the few mismatchednamespace_idvalues are discarded by the cleanup anyway. No per-row reconciliation againstprojectsis needed. A straightSET NULLis correct and safe. - A JOIN-based reconciliation would cost ~7.4 min and ~19 GB read per 10M
idwindow — intentionally avoided.
Migration sizing
| Parameter | Value |
|---|---|
BATCH_SIZE |
25,000 |
SUB_BATCH_SIZE |
250 |
DELAY_INTERVAL |
2 minutes |
SUB_BATCH_SIZE was sized down from 1,000 to 250 based on EXPLAIN ANALYZE evidence (see below) to keep per-statement latency under the BBM ~100ms guideline on a table with 16 indexes.
Wall-clock duration will be weeks to months given the ~2.29B rows to process. DBRE (@gitlab-com/gl-infra/database-reliability) should review the rollout plan and WAL impact before enabling on GitLab.com.
Out of scope / follow-ups
- The
num_nonnulls(...) = 1NOT VALID constraint — deferred to #601451. Added in a separate MR once this BBM has drained the dual-key rows on GitLab.com, to avoidPG::CheckViolationonupdate_allwrite paths (e.g.ResolvableNote#resolve!) during the multi-week backfill window. - Constraint validation (
VALIDATE CONSTRAINT+ dropping the old>= 1constraint) — follows the= 1constraint MR above, also tracked in #601451. - Cascading tables (
system_note_metadata,award_emoji): separate follow-up issues.
Queries and query plans
All plans captured on a gitlab-production-main clone in postgres.ai (session #52028). The sample id range 2_900_000_000..2_900_101_000 sits in a densely populated, recent region of notes (MAX(id) ≈ 2.94B, ~99% dual-populated per the Rows Removed by Filter counts).
Sub-batch UPDATE (SUB_BATCH_SIZE = 250)
UPDATE notes
SET namespace_id = NULL
WHERE notes.id BETWEEN <batch_min> AND <batch_max>
AND (namespace_id IS NOT NULL AND project_id IS NOT NULL)
AND notes.id >= <sub_batch_min>
AND notes.id < <sub_batch_max>;- Warm cache (representative steady-state): ~34ms execution, 208 rows updated, 638 KiB WAL, 58 buffers dirtied, 55 FPI
- Cold cache (first-touch on the clone): ~2.8s execution, 1284 FPI, 8.6 MiB WAL
Steady-state cost is well within the BBM ~100ms per-statement target. All 16 index RowExclusiveLocks are acquired via fastpath.
Sub-batch boundary lookup (each_sub_batch OFFSET probe)
SELECT notes.id
FROM notes
WHERE notes.id BETWEEN <batch_min> AND <batch_max>
AND (namespace_id IS NOT NULL AND project_id IS NOT NULL)
AND notes.id >= <sub_batch_min>
ORDER BY notes.id ASC
LIMIT 1
OFFSET 250;Why SUB_BATCH_SIZE = 250 and not 1000
For reviewer context, we tested SUB_BATCH_SIZE = 1000 first:
- Warm-cache plan at 1000: ~398ms execution, 934 rows updated, 3.1 MiB WAL, 297 dirtied buffers
That is ~4× over the ~100ms BBM guideline on a hot table. Per-statement cost scaled near-linearly with sub-batch size, so dropping to 250 brings the steady-state UPDATE comfortably under the guideline without changing total migration work (only the size and duration of each lock window).
Migration testing pipeline (against the previous SUB_BATCH_SIZE=1000 configuration): https://ops.gitlab.net/gitlab-com/database-team/gitlab-com-database-testing/-/pipelines/5806774. A fresh testing run will be triggered once this MR is pushed with the updated constant.
References
Related to #569520
Screenshots or screen recordings
N/A — database migration only.
How to set up and validate locally
- Run the post-deployment migration:
bundle exec rails db:migrate:post_deploy - Verify the BBM is queued:
Gitlab::Database::BackgroundMigration::BatchedMigration.find_by(job_class_name: 'CleanupDualShardingKeysInNotes') - Run the BBM spec:
bundle exec rspec spec/lib/gitlab/background_migration/cleanup_dual_sharding_keys_in_notes_spec.rb - Run the queue migration spec:
bundle exec rspec spec/migrations/20260724100002_queue_cleanup_dual_sharding_keys_in_notes_spec.rb
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.