Store the label event reference when the event is written

This adds the entry-point writes that the rest of the stack depends on. Instead of leaving resource_label_events.reference to be filled in and saved the first time a note renders, compute and store it at the points that create or move the row:

  • ResourceEvents::ChangeLabelsService resolves the reachable labels once for the batch and puts the reference straight into the bulk insert. The resolving itself moves into a new service, Issuables::LabelEventReferencesService, which takes the parent and a set of labels and returns a reference per label id; it wraps the ResourceLabelEvent.reference_for(label, parent, local_label_ids) class method this MR adds, plus the LabelsFinder lookup that decides which labels are local. Both entry points below call that same service, so the two paths cannot drift apart.
  • Issuable::Clone::CopyResourceEventsService recomputes the reference against the new parent when copying events to a cloned issuable, rather than copying a value computed for the old parent that may not resolve there.
  • Labels::PromoteService repoints the reference at the group label in the same batched update that already repoints label_id, splitting it so that events on issuables outside the group get the qualified form. This also fixes a standing bug on its own: promoting a project label to a group label left every event pointing at the destroyed project label, and the note rendered that dead reference forever.
  • A before_validation ... on: :create guard on ResourceLabelEvent covers paths that create one row at a time. It skips rows written during an import (unless: :importing?), so the GitHub importer and project import leave the reference NULL, as they do today; !259406 fills those rows once the import finishes. Bulk inserts skip callbacks, so those paths set the value themselves inline.

The read path is untouched here. Removing the lazy write happens later in the stack (MR 7), after the entry points cover new rows and the backfill (MR 6) covers old ones.

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

Database

See the database review guidelines.

Plans below are EXPLAIN (ANALYZE, BUFFERS) from a Database Lab thin clone (gitlab.com production-sized data, via pgai). I ran each one twice and kept the warm plan, noting cold timings separately. Queries come from bin/rails runner ... to_sql on this branch, with gitlab.com ids substituted in. Long id lists are shortened to ... in the plans. resource_label_events has about 672M rows, labels about 42M.

New queries

This MR adds one LabelsFinder lookup per write in ChangeLabelsService and CopyResourceEventsService (reachable labels, id_in(...) plus pluck_primary_key), and one Label load in the copy path.

How large can the IN list get

The IN list holds only the labels being written in that request, never every label in the namespace. The plucked set is a subset of it, and Postgres probes labels_pkey per id.

In ChangeLabelsService, the list is the labels added or removed in one update. The only cap is Issue::MAX_NUMBER_OF_LABELS = 100, and that sits behind the default-off limit_labels_per_work_item flag and only applies to issues. Otherwise the list is bounded by the labels a user can apply at all, about 5.7k for gitlab-org/gitlab (1464 project labels plus 4212 group labels). The same request already inserts that many label events, so this is not a new order of magnitude.

In CopyResourceEventsService, the list is the distinct labels ever applied to the source issue. Across all of gitlab-org/gitlab: p50 is 11, p99 is 30, max is 91 (issue id 24698079). Across the most recent 20M events globally, the max is 67. I planned at 500 ids for headroom: 1.4 ms warm, 176 ms cold.

Every plan is index-backed and under 10 ms warm at realistic sizes; the finder lookups are labels_pkey scans under 2 ms.

ChangeLabelsService

Reachable labels, project parent (Issuables::LabelEventReferencesService#local_label_ids_for, reached from ResourceEvents::ChangeLabelsService). Project gitlab-org/gitlab (278964), 5 project labels plus 5 labels of group gitlab-org (9970):

SELECT "labels"."id" FROM "labels"
WHERE ("labels"."type" = 'GroupLabel' AND "labels"."group_id" IN (9970)
       OR "labels"."type" = 'ProjectLabel' AND "labels"."project_id" = 278964)
  AND "labels"."id" IN (53934305, 53932318, 53932313, 53926719, 53923408,
                        53902837, 53891105, 53888137, 53888075, 53888069);
Plan: 10 ids, 0.08 ms warm, 13.7 ms cold
 Index Scan using labels_pkey on public.labels  (cost=0.44..23.72 rows=1 width=4) (actual time=0.025..0.053 rows=10 loops=1)
   Output: id
   Index Cond: (labels.id = ANY ('{53934305,...,53888069}'::integer[]))
   Filter: ((((labels.type)::text = 'GroupLabel'::text) AND (labels.group_id = 9970)) OR (((labels.type)::text = 'ProjectLabel'::text) AND (labels.project_id = 278964)))
   Buffers: shared hit=31
 Planning Time: 0.170 ms
 Execution Time: 0.076 ms
Plan: 50 ids, 0.29 ms warm, 67 ms cold
 Index Scan using labels_pkey on public.labels  (cost=0.44..113.75 rows=1 width=4) (actual time=0.031..0.259 rows=50 loops=1)
   Output: id
   Index Cond: (labels.id = ANY ('{149397,354884,...,2534166}'::integer[]))
   Filter: ((((labels.type)::text = 'GroupLabel'::text) AND (labels.group_id = 9970)) OR (((labels.type)::text = 'ProjectLabel'::text) AND (labels.project_id = 278964)))
   Buffers: shared hit=170
 Planning Time: 0.179 ms
 Execution Time: 0.289 ms
Plan: project 47441940, nine levels deep under gitlab-org, 10 ids, 0.07 ms warm, 2.9 ms cold
 Index Scan using labels_pkey on public.labels  (cost=0.46..23.77 rows=1 width=4) (actual time=0.023..0.050 rows=10 loops=1)
   Output: id
   Index Cond: (labels.id = ANY ('{53902837,...,53780937}'::integer[]))
   Filter: ((((labels.type)::text = 'GroupLabel'::text) AND (labels.group_id = ANY ('{9970,11787569,11805471,14491403,61550135,69731974,69732123,69732124,69732127}'::integer[]))) OR (((labels.type)::text = 'ProjectLabel'::text) AND (labels.project_id = 47441940)))
   Buffers: shared hit=25
 Planning Time: 0.145 ms
 Execution Time: 0.069 ms

Reachable labels, group parent (group level work item on gitlab-org, only_group_labels: true). The second UNION branch is existing LabelsFinder behaviour:

SELECT "labels"."id" FROM (
  (SELECT "labels".* FROM "labels" WHERE "labels"."group_id" = 9970)
  UNION
  (SELECT "labels".* FROM "labels" WHERE "labels"."group_id" IN (
     SELECT "projects"."namespace_id" FROM "projects"
     INNER JOIN "namespaces" ON "namespaces"."id" = "projects"."namespace_id"
     WHERE "projects"."namespace_id" = 9970 AND "namespaces"."type" = 'Group'))
) labels
WHERE "labels"."id" IN (53934305, 53932318, 53932313, 53926719, 53923408,
                        53902837, 53891105, 53888137, 53888075, 53888069);
Plan: 10 ids, 0.19 ms warm, 6.1 ms cold

The remaining Sort feeds Unique, which is how Postgres removes UNION duplicates. It is not an ordering of the result.

 Subquery Scan on labels  (cost=55.43..55.53 rows=2 width=4) (actual time=0.148..0.156 rows=5 loops=1)
   Buffers: shared hit=113
   ->  Unique  (cost=55.43..55.51 rows=2 width=1171) (actual time=0.147..0.155 rows=5 loops=1)
         Buffers: shared hit=113
         ->  Sort  (cost=55.43..55.43 rows=2 width=1171) (actual time=0.146..0.148 rows=10 loops=1)
               Sort Key: labels_1.id, labels_1.title, ... (every labels column)
               Sort Method: quicksort  Memory: 27kB
               Buffers: shared hit=113
               ->  Append  (cost=0.44..55.42 rows=2 width=1171) (actual time=0.021..0.118 rows=10 loops=1)
                     Buffers: shared hit=113
                     ->  Index Scan using labels_pkey on public.labels labels_1  (cost=0.44..23.65 rows=1 width=206) (actual time=0.020..0.044 rows=5 loops=1)
                           Index Cond: (labels_1.id = ANY ('{53934305,...,53888069}'::integer[]))
                           Filter: (labels_1.group_id = 9970)
                           Rows Removed by Filter: 5
                           Buffers: shared hit=31
                     ->  Nested Loop Semi Join  (cost=1.57..31.76 rows=1 width=206) (actual time=0.032..0.071 rows=5 loops=1)
                           Buffers: shared hit=82
                           ->  Index Scan using labels_pkey on public.labels labels_2  (cost=0.44..23.65 rows=1 width=206) (actual time=0.007..0.017 rows=5 loops=1)
                                 Index Cond: (labels_2.id = ANY ('{53934305,...,53888069}'::integer[]))
                                 Filter: (labels_2.group_id = 9970)
                                 Rows Removed by Filter: 5
                                 Buffers: shared hit=31
                           ->  Nested Loop  (cost=1.13..8.09 rows=34 width=4) (actual time=0.010..0.010 rows=1 loops=5)
                                 Buffers: shared hit=51
                                 ->  Index Scan using namespaces_pkey on public.namespaces  (cost=0.57..3.59 rows=1 width=4) (actual time=0.003..0.003 rows=1 loops=5)
                                       Index Cond: (namespaces.id = 9970)
                                       Filter: ((namespaces.type)::text = 'Group'::text)
                                       Buffers: shared hit=25
                                 ->  Index Only Scan using index_projects_on_namespace_id_and_id on public.projects  (cost=0.56..4.16 rows=34 width=4) (actual time=0.007..0.007 rows=1 loops=5)
                                       Index Cond: (projects.namespace_id = 9970)
                                       Heap Fetches: 5
                                       Buffers: shared hit=26
 Planning Time: 0.574 ms
 Execution Time: 0.191 ms
Plan: 50 ids, 0.64 ms
 Subquery Scan on labels  (cost=234.88..234.98 rows=2 width=4) (actual time=0.572..0.600 rows=30 loops=1)
   Buffers: shared hit=641
   ->  Unique -> Sort (UNION dedup, every labels column) -> Append  (actual time=0.034..0.494 rows=60 loops=1)
         ->  Index Scan using labels_pkey on public.labels labels_1  (actual time=0.033..0.193 rows=30 loops=1)
               Index Cond: (labels_1.id = ANY ('{149397,...,2534166}'::integer[]))
               Filter: (labels_1.group_id = 9970)
               Rows Removed by Filter: 20
               Buffers: shared hit=170
         ->  Nested Loop Semi Join  (actual time=0.042..0.294 rows=30 loops=1)
               Buffers: shared hit=471
               ->  Index Scan using labels_pkey on public.labels labels_2  (actual time=0.011..0.068 rows=30 loops=1)
               ->  Nested Loop  (actual time=0.007..0.007 rows=1 loops=30)
                     ->  Index Scan using namespaces_pkey (namespaces.id = 9970)  loops=30
                     ->  Index Only Scan using index_projects_on_namespace_id_and_id (projects.namespace_id = 9970)  loops=30, Heap Fetches: 30
 Planning Time: 0.583 ms
 Execution Time: 0.640 ms

CopyResourceEventsService

Labels of the source issue (Gitlab::Issuable::Clone::CopyResourceEventsService#copy_resource_label_events):

SELECT "labels".* FROM "labels"
WHERE "labels"."id" IN (SELECT "resource_label_events"."label_id" FROM "resource_label_events"
                        WHERE "resource_label_events"."issue_id" = 24698079)
ORDER BY "labels"."title" ASC;
Plan: issue 24698079, the most distinct labels in gitlab-org/gitlab (91), 0.78 ms warm, 266 ms cold
 Sort  (cost=454.08..454.37 rows=119 width=207) (actual time=0.732..0.737 rows=91 loops=1)
   Sort Key: labels.title
   Buffers: shared hit=533
   ->  Nested Loop  (actual time=0.105..0.603 rows=91 loops=1)
         ->  HashAggregate  (actual time=0.095..0.107 rows=92 loops=1)
               Group Key: resource_label_events.label_id
               ->  Index Only Scan using index_resource_label_events_issue_id_label_id_action on public.resource_label_events (actual time=0.020..0.067 rows=183 loops=1)
                     Index Cond: (resource_label_events.issue_id = 24698079)
                     Heap Fetches: 1
                     Buffers: shared hit=169
         ->  Index Scan using labels_pkey on public.labels (actual time=0.005..0.005 rows=1 loops=92)
               Index Cond: (labels.id = resource_label_events.label_id)
               Buffers: shared hit=364
 Planning Time: 0.370 ms
 Execution Time: 0.780 ms
Plan: issue 203588034, the most label events found (18,621, 7 labels), 9.3 ms warm, 2.6 s cold

The cold cost is reading the event rows, which the existing find_in_batches copy reads right after anyway.

 Sort  (cost=73474.63..73526.48 rows=20742 width=207) (actual time=8.874..8.876 rows=7 loops=1)
   Buffers: shared hit=8608
   ->  Nested Loop  (actual time=8.720..8.861 rows=7 loops=1)
         ->  HashAggregate  (actual time=8.706..8.824 rows=7 loops=1)
               ->  Index Only Scan using index_resource_label_events_issue_id_label_id_action on public.resource_label_events (actual time=0.012..6.892 rows=18621 loops=1)
                     Index Cond: (resource_label_events.issue_id = 203588034)
                     Heap Fetches: 8401
                     Buffers: shared hit=8580
         ->  Index Scan using labels_pkey on public.labels (actual time=0.004..0.004 rows=1 loops=7)
 Planning Time: 0.381 ms
 Execution Time: 9.290 ms

Reachable labels in the target, project parent. Issue 24698079 moved to gitlab-com/www-gitlab-com (project 7764, group 6543):

SELECT "labels"."id" FROM "labels"
WHERE ("labels"."type" = 'GroupLabel' AND "labels"."group_id" IN (6543)
       OR "labels"."type" = 'ProjectLabel' AND "labels"."project_id" = 7764)
  AND "labels"."id" IN (10690780, 10690781, ... 91 ids);
Plan: 91 ids, 0.40 ms warm, 44 ms cold
 Index Scan using labels_pkey on public.labels  (cost=0.44..209.28 rows=1 width=4) (actual time=0.064..0.375 rows=1 loops=1)
   Output: id
   Index Cond: (labels.id = ANY ('{10690780,10690781,...}'::integer[]))
   Filter: ((((labels.type)::text = 'GroupLabel'::text) AND (labels.group_id = 6543)) OR (((labels.type)::text = 'ProjectLabel'::text) AND (labels.project_id = 7764)))
   Rows Removed by Filter: 90
   Buffers: shared hit=343
 Planning Time: 0.176 ms
 Execution Time: 0.395 ms

Same plan shape at other sizes: 91 ids within gitlab-org/gitlab 0.43 ms, 500 ids 1.4 ms warm and 176 ms cold.

Reachable labels in the target, group parent (group gitlab-org, 9970):

SELECT "labels"."id" FROM (
  (SELECT "labels".* FROM "labels" WHERE "labels"."group_id" IN (9970))
  UNION
  (SELECT "labels".* FROM "labels" WHERE "labels"."group_id" IN (
     SELECT "projects"."namespace_id" FROM "projects"
     INNER JOIN "namespaces" ON "namespaces"."id" = "projects"."namespace_id"
     WHERE "projects"."namespace_id" = 9970 AND "namespaces"."type" = 'Group'))
) labels
WHERE "labels"."id" IN (10690780, ... 91 ids);
Plan: 91 ids, 1.4 ms
 Subquery Scan on labels  (cost=425.32..425.42 rows=2 width=4) (actual time=1.313..1.381 rows=88 loops=1)
   Buffers: shared hit=1567
   ->  Unique -> Sort (UNION dedup, every labels column) -> Append (actual time=0.024..1.140 rows=176 loops=1)
         ->  Index Scan using labels_pkey on public.labels labels_1 (actual time=0.023..0.359 rows=88 loops=1)
               Index Cond: (labels_1.id = ANY ('{10690780,10690781,...}'))  Filter: (labels_1.group_id = 9970)
               Rows Removed by Filter: 3
         ->  Nested Loop Semi Join (actual time=0.049..0.765 rows=88 loops=1)
               ->  Index Scan using labels_pkey on public.labels labels_2 (rows=88)
               ->  Nested Loop (loops=88)
                     ->  Index Scan using namespaces_pkey (Index Cond: id = 9970)
                     ->  Index Only Scan using index_projects_on_namespace_id_and_id (Index Cond: namespace_id = 9970)
 Planning Time: 0.590 ms
 Execution Time: 1.425 ms

For subgroup 1755573 with 500 ids the planner switches to a BitmapAnd of index_labels_on_group_id and labels_pkey: 2.6 ms warm, 9.5 ms cold.

Label promotion

Master already runs one unbatched update_all over every event of the promoted labels. This MR sets reference too and splits the update in two by namespace, but touches the same rows.

After the first statement only events outside the group still match the label ids, so the second statement needs no namespace filter. self_and_descendants(skope: Namespace) compiles to traversal_ids @>, served by the GIN index.

Cost against master, on a real 120,356-row label in gitlab-org/gitlab: 3.4 s master warm vs 3.7 s branch warm, and the second statement then finds 0 rows in 54 ms.

Measured on label 28392176 in gitlab-org/gitlab, 120,356 events, promoted to a group label in gitlab-org. Every statement was wrapped in BEGIN; ... ROLLBACK;.

Before (master):

UPDATE "resource_label_events" SET "label_id" = 992791
WHERE "resource_label_events"."label_id" IN (28392176);
Plan: 3.4 s warm, 19.4 s cold
 Update on resource_label_events  (cost=0.57..260961.65 rows=0 width=0) (actual time=2668.846..2668.847 rows=0 loops=1)
   Buffers: shared hit=4093845 read=1134 dirtied=1362 written=628
   I/O Timings: shared read=408.255 write=11.520
   ->  Index Scan using index_resource_label_events_on_label_id_and_action on resource_label_events  (cost=0.57..260961.65 rows=172492 width=10) (actual time=0.017..75.749 rows=120356 loops=1)
         Index Cond: (label_id = 28392176)
         Buffers: shared hit=61584 dirtied=21
 Planning Time: 0.086 ms
 Trigger for constraint fk_rails_b126799f57: time=697.601 calls=120356
 Execution Time: 3375.732 ms

After, events inside the group:

UPDATE "resource_label_events" SET "label_id" = 992791, "reference" = '~992791'
WHERE "resource_label_events"."label_id" IN (28392176)
  AND "resource_label_events"."namespace_id" IN (
    SELECT "namespaces"."id" FROM "namespaces" WHERE (traversal_ids @> ('{9970}')));
Plan: 3.7 s warm and cold (the clone was already warm from the master run)
 Update on resource_label_events  (cost=16769.65..278183.52 rows=0 width=0) (actual time=2952.276..2952.279 rows=0 loops=1)
   Buffers: shared hit=4170164 read=1090 dirtied=1587 written=894
   I/O Timings: shared read=312.484 write=16.665
   ->  Hash Join  (cost=16769.65..278183.52 rows=18 width=48) (actual time=16.090..124.824 rows=120356 loops=1)
         Hash Cond: (resource_label_events.namespace_id = namespaces.id)
         Buffers: shared hit=71237
         ->  Index Scan using index_resource_label_events_on_label_id_and_action on resource_label_events  (cost=0.57..260961.65 rows=172492 width=14) (actual time=0.014..79.029 rows=120356 loops=1)
               Index Cond: (label_id = 28392176)
               Buffers: shared hit=61584
         ->  Hash  (cost=16647.98..16647.98 rows=9688 width=10) (actual time=16.058..16.059 rows=10605 loops=1)
               Buckets: 16384  Batches: 1  Memory Usage: 584kB
               Buffers: shared hit=9653
               ->  Bitmap Heap Scan on namespaces  (cost=358.62..16647.98 rows=9688 width=10) (actual time=4.522..14.635 rows=10605 loops=1)
                     Recheck Cond: (traversal_ids @> '{9970}'::integer[])
                     Heap Blocks: exact=9477
                     Buffers: shared hit=9653
                     ->  Bitmap Index Scan on index_namespaces_on_traversal_ids  (cost=0.00..356.20 rows=9688 width=0) (actual time=3.207..3.208 rows=10605 loops=1)
                           Index Cond: (traversal_ids @> '{9970}'::integer[])
                           Buffers: shared hit=176
 Planning Time: 0.524 ms
 Trigger for constraint fk_rails_b126799f57: time=693.848 calls=120356
 Execution Time: 3654.945 ms

After, events outside the group. Run after the first statement in the same transaction, as in the service:

UPDATE "resource_label_events" SET "label_id" = 992791, "reference" = 'gitlab-org~992791'
WHERE "resource_label_events"."label_id" IN (28392176);
Plan: 54 ms, 0 rows left to update
 Update on resource_label_events  (cost=0.57..260961.65 rows=0 width=0) (actual time=53.885..53.886 rows=0 loops=1)
   Buffers: shared hit=61511 read=73
   I/O Timings: shared read=1.196
   ->  Index Scan using index_resource_label_events_on_label_id_and_action on resource_label_events  (cost=0.57..260961.65 rows=172492 width=42) (actual time=53.883..53.883 rows=0 loops=1)
         Index Cond: (label_id = 28392176)
         Buffers: shared hit=61511 read=73
         I/O Timings: shared read=1.196
 Planning Time: 0.108 ms
 Execution Time: 53.921 ms

For a small label (label 23984398, 9 events) the first statement takes 2.5 ms warm and 28.7 ms cold, against 0.6 ms and 73 ms on master.

Points from the review

  1. reference_for loaded each non-local label's container and route one at a time, so 2 queries per label. This hit cross-group moves: work item data sync passes up to 100 labels per batch (up to about 200 queries), and the copy service (91 labels on the worst issue I found). Master does not issue these queries at all. Fixed: ResourceLabelEvent.preload_reference_containers preloads group: :route for group labels and project: [:route, { namespace: :route }] for project labels, called from Issuables::LabelEventReferencesService#execute for the labels outside the parent, which is the one place both ChangeLabelsService and CopyResourceEventsService go through. Two query-count specs cover it, one per service, each comparing 2 labels against 6 labels outside the parent. Before the fix: "Expected a maximum of 8 queries, got 16" (ChangeLabelsService) and "Expected a maximum of 17 queries, got 25" (CopyResourceEventsService). Both pass now.
  2. Promotion's event update is not batched by row. That is pre-existing, not something this MR adds. The largest case I found, the title issue in one customer group at 3.7M rows, hits the 15s statement timeout on master and on this branch alike, inside the promote transaction on a web request. Fixing it is a follow-up.
  3. Also fixed: the second promote UPDATE dropped its NOT IN filter. After the first statement moves the in-group rows, only events outside the group still match the label ids, so the filter was redundant.

For reference: the bumped query thresholds in two request specs (+2) come from the finder's ancestor-group load plus the pluck, not from route lookups.

Requesting a database review.

How to reproduce and check by hand

Two things to check. The reference is now stored when the row is created, not the first time someone opens the page. And promoting a project label to a group label used to leave the stored reference pointing at the project label that the promotion destroys, so the note rendered a dead reference from then on.

In the UI:

  1. Create a project inside a group, create a project label, and apply it to an issue.
  2. Open the issue once, so the note renders.
  3. Promote the label to group level from the project label list, then reload the issue.

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: 'bug').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])
  event = issue.resource_label_events.last
  puts "1. reference at creation:        #{event.reference.inspect}"

  # an event copied onto an issue in another group, as a move does
  other_group = Groups::CreateService.new(user, name: "other-#{suffix}", path: "other-#{suffix}",
                                          organization_id: org.id).execute[:group]
  other_group.add_owner(user)
  other_project = Projects::CreateService.new(user, name: "other-#{suffix}", path: "other-#{suffix}",
                                              namespace_id: other_group.id, organization_id: org.id).execute
  other_issue = other_project.issues.create!(author: user, title: 'moved copy')
  copied = ResourceLabelEvent.create!(issue: other_issue, label: label, user: user, action: 'add')

  notes.call(issue) && notes.call(other_issue)   # first render, as a visitor would trigger
  puts "2. reference after first render: #{event.reload.reference.inspect}"
  puts "   project label id:             #{label.id}"

  Labels::PromoteService.new(project, user).execute(label)

  puts "3. group label id:               #{group.labels.find_by(title: 'bug').id}"
  puts "4. reference, same group:        #{event.reload.reference.inspect}"
  puts "5. reference, other group:       #{copied.reload.reference.inspect}"
  puts "6. note, same group:             #{notes.call(issue)}"
  puts "7. note, other group:            #{notes.call(other_issue)}"
  puts "   project label still exists?   #{Label.exists?(label.id)}"

  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-01-work-item-note-parent (MR 1, now merged into master):

1. reference at creation:        nil
2. reference after first render: "~8"
   project label id:             8
3. group label id:               9
4. reference, same group:        "~8"
5. reference, other group:       "repro-5b7a1327/proj-5b7a1327~8"
6. note, same group:             added ~8 label
7. note, other group:            added repro-5b7a1327/proj-5b7a1327~8 label
   project label still exists?   false

After, on 628135-03-write-reference-at-entry-points:

1. reference at creation:        "~15"
2. reference after first render: "~15"
   project label id:             15
3. group label id:               16
4. reference, same group:        "~16"
5. reference, other group:       "repro-830babb9~16"
6. note, same group:             added ~16 label
7. note, other group:            added repro-830babb9~16 label
   project label still exists?   false

The issue activity feed after the promotion:

Before, on MR 1's branch (now master) After, on 628135-03-write-reference-at-entry-points
mr03-before-activity mr03-after-activity
Nothing renders. The stored ~8 points at a label that no longer exists. The reference was repointed to the surviving group label, so the entry renders as a working chip.

Line 1 is the change itself: the reference is stored when the row is written, not left for the first render to fill in. Lines 4 and 5 are the promotion: on the parent branch both still name label 8, which the promotion destroyed, so both notes render a dead reference. On this branch the promotion repoints them, and the copy sitting on an issue in another group gets the qualified repro-830babb9~16 because a bare id does not resolve from outside the group.

After the label is promoted away, the timeline entry does not render at all in the work item activity feed on the parent branch. On this branch it renders as a working label chip.

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

New examples in change_labels_service_spec, promote_service_spec, copy_resource_events_service_spec, and resource_label_event_spec all pass, along with a new spec/services/issuables/label_event_references_service_spec.rb covering the extracted service directly. Rubocop is clean on every changed file.

Related to #628135

Edited by Alexandru Croitor

Merge request reports

Loading
Loading