Delete label events when their label is deleted

Behavior change: this changes what happens when a label is deleted. Today, deleting a label NULLs resource_label_events.label_id through the ON DELETE SET NULL foreign key, and the row survives as an orphan. ResourceEvents::SyntheticLabelNotesBuilderService turns it into an "added 1 deleted label" note, but the work item activity feed renders nothing at all for it, so the entry is invisible rather than misleading. After this MR the events are deleted along with the label and stop being built in the first place. Rows that already carry a NULL label_id from a past deletion are untouched; only deletions from here on behave differently.

Mechanically: Label switches from BatchNullifyDependentAssociations to the existing BatchDeleteDependentAssociations concern, which does limit(N).delete_all in a loop instead of find_each(&:destroy), so it does not fire the per-row after_commit :broadcast_notes_changed. The resource_label_events association becomes dependent: :delete_all. Two post-deploy migrations swap the foreign key from ON DELETE SET NULL to ON DELETE CASCADE as a backstop for deletes that bypass the model: the new FK is added with add_concurrent_foreign_key(validate: false), validation is scheduled with prepare_async_foreign_key_validation because resource_label_events is over the size limit for a synchronous validation, and then the old FK is dropped. The cascade key therefore merges NOT VALID and stays that way; MR 4c (!257979) runs the synchronous validate_foreign_key in 19.6, once the async run has done the scan on GitLab.com. Labels::DestroyService declares handles_removal_of :resource_label_events, and the table comes off spec/support/database/destroy_service_foreign_keys_todo.yml.

The NULL-label_id rows this used to produce are exactly what the lazy refresh-on-GET write in LabelNote exists to repair. Cutting off new orphans here is part of why the read path can stop writing later in this stack.

Needs a database review (foreign key swap via two post-deploy migrations, db/post_migrate/20261008115100_add_cascade_fk_on_resource_label_events_label_id.rb and db/post_migrate/20261008115104_remove_set_null_fk_on_resource_label_events_label_id.rb, both milestone '19.6').

Part of the stack for #628135 (INC-12449), targeting master. 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 <-- this MR
    • 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
  • MR 7 (!255097): Stop writing the label event reference on a read request

How to reproduce and check by hand

This is the behavior change itself, so what to check is the behavior. Deleting a label used to leave its timeline entries behind with a null label id, rendering as "1 deleted label" from then on. Now the entries go with the label.

In the UI:

  1. Create a project label, apply it to an issue, then remove it, so there are two timeline entries.
  2. Delete the label from the project label list.
  3. Reload the issue activity feed.

In a rails console (gdk rails console), 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')
  label = Labels::CreateService.new(user, title: 'doomed').execute(project: project)

  notes = ->(issuable) do
    ResourceEvents::SyntheticLabelNotesBuilderService.new(issuable.reset, user).execute.map(&:note).join(' | ')
  end

  ResourceEvents::ChangeLabelsService.new(issue, user).execute(added_labels: [label])
  ResourceEvents::ChangeLabelsService.new(issue, user).execute(removed_labels: [label])
  puts "1. events before delete:    #{issue.resource_label_events.count}"
  puts "2. note before delete:      #{notes.call(issue)}"

  Labels::DestroyService.new(user, label).execute

  puts "3. events after delete:     #{issue.resource_label_events.count}"
  puts "4. rows with a null label:  #{ResourceLabelEvent.where(issue_id: issue.id, label_id: nil).count}"
  puts "5. note after delete:       #{notes.call(issue).inspect}"

  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-03-write-reference-at-entry-points:

1. events before delete:    2
2. note before delete:      added ~17 label | removed ~17 label
3. events after delete:     2
4. rows with a null label:  2
5. note after delete:       "added 1 deleted label | removed 1 deleted label"

After, on 628135-04-cascade-delete-on-label-delete:

1. events before delete:    2
2. note before delete:      added ~22 label | removed ~22 label
3. events after delete:     0
4. rows with a null label:  0
5. note after delete:       ""

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

Rubocop is clean on every changed file. ./scripts/regenerate-schema exits 0 with no schema drift. label_spec and label_note_spec (CE and EE) are updated to assert the new deletion behavior.

Related to #628135

Edited by Alexandru Croitor

Merge request reports

Loading
Loading