Draft: Backfill the reference on existing label events

This backfills resource_label_events.reference for rows written before MR 3 started setting it at write time, plus rows left with a stale reference by the label-delete behavior MR 4 replaces. A batched background migration, BackfillResourceLabelEventsReference, is queued at milestone 19.6 (db/post_migrate/20261008131500_queue_backfill_resource_label_events_reference.rb) with BATCH_SIZE 50_000 and SUB_BATCH_SIZE 1_000, restricted to gitlab_main_org.

The derivation is a single SQL UPDATE per sub-batch with a CASE expression mirroring ResourceLabelEvent#label_reference, not a Ruby call into Label#to_reference and LabelsFinder, since a background migration has to keep working long after that application code has moved on. It targets exactly the rows #outdated_reference? picks out: reference IS NULL OR (label_id IS NULL AND reference <> ''). A project label on a group-level issuable always gets the qualified form, because a bare form that resolves today is not guaranteed to stay reachable. format: :id keeps the label title out of the stored value, so a later rename cannot invalidate it. Rows left NULL afterward have a label container with no routes row, a pre-existing broken record rather than anything the application writes.

This has to merge after MR 5: before that fix, a note on a group-level work item rendered a backfilled qualified reference as a chip pointing at /groups/<name>/-/work_items, which does not exist. Backfilling first would have turned plain text into a dead link.

One open item. Finalizing the migration cannot land before 19.9. The required upgrade stops are 19.5, 19.8 and 19.11, and prevent_early_finalization! raises unless the queueing milestone is <= Gitlab::Database.upgrade_path.last_required_stop. That method only counts stops strictly below the running version, so with a 19.6 queueing milestone the check passes only once the application is past the 19.8 stop. 19.9 is the first release after it. MR 4c validates the foreign key in 19.6, so the validation lands well before the finalize.

Needs a database review.

Part of the stack for #628135 (INC-12449), targeting 628135-05-label-ref-url. Merge in this order. The numbers follow the branch names, so there is no MR 2: that change was folded into MR 3 and !255083 (closed) is closed.

  • MR 1 (!255082 (merged)): Render group level work item label notes against the group - merged 2026-09-21
  • MR 3 (!255091 (merged)): Store the label event reference when the event is written - merged 2026-10-07
  • MR 4 (!255092): Delete label events when their label is deleted
    • MR 4c (!257979): Validate the cascade foreign key, %19.6, branches off MR 4 and merges after MR 4 is deployed
  • MR 4b (!255238): Record deleted label events in the label audit event
  • MR 5 (!255093): Link a project label in a group context to its project
  • MR 6 (!255096): Backfill the reference on existing label events <-- this MR
  • MR 7 (!255097): Stop writing the label event reference on a read request

How to reproduce and check by hand

What matters here is parity: the migration has to store what the application would derive at render time, including the awkward case of a project label on a group-level work item.

In the UI there is nothing to trigger directly. Watch the job under Admin > Monitoring > Background migrations, then reload a work item whose label note used to render as raw text.

In a rails console (gdk rails console), running one batch by hand, wrapped in a transaction that rolls back, so it leaves nothing behind:

ActiveRecord::Base.transaction do
  user   = User.find_by_username('root')
  org    = user.organizations.first
  suffix = SecureRandom.hex(4)

  group = Groups::CreateService.new(user, name: "repro-#{suffix}", path: "repro-#{suffix}",
                                    organization_id: org.id).execute[:group]
  group.add_owner(user)
  project = Projects::CreateService.new(user, name: "proj-#{suffix}", path: "proj-#{suffix}",
                                        namespace_id: group.id, organization_id: org.id).execute
  issue  = project.issues.create!(author: user, title: 'repro 628135')
  plabel = Labels::CreateService.new(user, title: 'bug').execute(project: project)
  glabel = Labels::CreateService.new(user, title: 'grouplabel').execute(group: group)
  epic   = WorkItem.create!(
    namespace: group, author: user, title: 'epic',
    work_item_type: WorkItems::TypesFramework::Provider.new(group).find_by_base_type(:epic)
  )

  events = [
    ResourceLabelEvent.create!(issue: issue, label: plabel, user: user, action: 'add'),
    ResourceLabelEvent.create!(issue: epic,  label: glabel, user: user, action: 'add'),
    ResourceLabelEvent.create!(issue: epic,  label: plabel, user: user, action: 'add')
  ]
  events.each { |event| event.update_column(:reference, nil) }
  puts "before backfill: #{events.map { |event| event.reload.reference.inspect }.join(', ')}"

  Gitlab::BackgroundMigration::BackfillResourceLabelEventsReference.new(
    start_cursor: [events.map(&:id).min],
    end_cursor: [events.map(&:id).max],
    batch_table: 'resource_label_events',
    batch_column: 'id',
    sub_batch_size: 100,
    pause_ms: 0,
    connection: ApplicationRecord.connection
  ).perform

  events.each do |event|
    stored  = event.reload.reference
    derived = ResourceLabelEvent.find(event.id).send(:label_reference)
    puts format('stored=%-24s derived=%-24s match=%s', stored.inspect, derived.inspect, stored == derived)
  end

  raise ActiveRecord::Rollback
end

Observed output

The script above was run on the parent branch and on this branch; what follows is the actual output from both runs.

Before, on 628135-05-label-ref-url:

before backfill: nil, nil, nil
/tmp/run_mr06.rb:39:in `block in <main>': uninitialized constant Gitlab::BackgroundMigration::BackfillResourceLabelEventsReference (NameError)

  Gitlab::BackgroundMigration::BackfillResourceLabelEventsReference.new(
                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Did you mean?  Gitlab::BackgroundMigration::BackfillImportSourceUserPlaceholderReferencesExpiresAt

After, on 628135-06-backfill-reference:

before backfill: nil, nil, nil
stored="~33"                    derived="~33"                    match=true
stored="~34"                    derived="~34"                    match=true
stored="proj-194424be~33"       derived="proj-194424be~33"       match=true

On the parent branch the migration class does not exist yet, so the run ends in NameError after printing the three null references. The third row is the one that matters: a project label on a group-level epic, which must get the qualified form.

Both runs were done on a disposable GDK box whose GDK was at master 8d8af6729f, with each branch applied over it as a runtime-only patch and migrations run for real where the branch adds them.

Testing

The spec includes a parity block that asserts the SQL output matches ResourceLabelEvent#label_reference for every shape it covers, plus an idempotence check. Full stack spec set: 283 examples, 0 failures, covering label_spec, the backfill and queue-migration specs, both label_reference_filter specs, promote_service, and change_labels_service.

Related to #628135

Edited by Alexandru Croitor

Merge request reports

Loading
Loading