Fix N+1 queries for linked items widget
What does this MR do and why?
Fixes Fix N+1 queries in work items field linkedItems (#512056 - closed)
The Problem
A fix was attempted before but had to be reverted because it produced incorrect results when the field was nested in queries.
Here's why this happened:
- The original implementation used the same batch loader key for all instances of
linkedItemsin a query - When linkedItems was nested (appeared multiple times in the query path), results from different nesting levels were mixed together
- This caused data from parent levels to incorrectly appear in child levels
The New Solution
The new approach creates unique batch loader keys based on the nesting level:
- We track the query's "current_path" to identify how deeply nested we are
- Each time we encounter a
linkedItemsfield, we assign it a batch key that includes its nesting level - This ensures that items at each nesting level are batched separately
Example
graphqlworkItems(iids: ['1', '2']) { # Uses batch key: `linked_items_level_0`
linkedItems {
workItem {
title
linkedItems { # Uses batch key: `linked_items_level_1`
workItem {
title
}
}
}
}
}
By using different batch keys for each nesting level, we maintain proper data isolation between levels while still solving the N+1 query problem at each level. Also, this time, I used the feature flag batch_load_linked_items to ensure we can test this safely.
DB queries before
WorkItem Load (3.2ms) SELECT issues.*, issue_links.id AS issue_link_id, issue_links.link_type as issue_link_type_value, issue_links.target_id as issue_link_source_id, issue_links.created_at as issue_link_created_at, issue_links.updated_at as issue_link_updated_at FROM "issues" INNER JOIN issue_links ON
(issue_links.source_id = issues.id AND issue_links.target_id = 965)
OR
(issue_links.target_id = issues.id AND issue_links.source_id = 965) ORDER BY "issue_links"."id" DESC LIMIT 101
WorkItem Load (3.3ms) SELECT issues.*, issue_links.id AS issue_link_id, issue_links.link_type as issue_link_type_value, issue_links.target_id as issue_link_source_id, issue_links.created_at as issue_link_created_at, issue_links.updated_at as issue_link_updated_at FROM "issues" INNER JOIN issue_links ON
(issue_links.source_id = issues.id AND issue_links.target_id = 966)
OR
(issue_links.target_id = issues.id AND issue_links.source_id = 966) ORDER BY "issue_links"."id" DESC LIMIT 101
WorkItem Load (5.0ms) SELECT issues.*, issue_links.id AS issue_link_id, issue_links.link_type as issue_link_type_value, issue_links.target_id as issue_link_source_id, issue_links.created_at as issue_link_created_at, issue_links.updated_at as issue_link_updated_at FROM "issues" INNER JOIN issue_links ON
(issue_links.source_id = issues.id AND issue_links.target_id = 967)
OR
(issue_links.target_id = issues.id AND issue_links.source_id = 967) ORDER BY "issue_links"."id" DESC LIMIT 101
DB query after
WorkItem Load (5.5ms) SELECT issues.*,
issue_links.id AS issue_link_id,
issue_links.link_type AS issue_link_type_value,
issue_links.target_id AS issue_link_source_id,
issue_links.source_id AS issue_link_target_id,
issue_links.created_at AS issue_link_created_at,
issue_links.updated_at AS issue_link_updated_at FROM "issues" INNER JOIN issue_links ON
(issue_links.source_id = issues.id AND issue_links.target_id IN (967,966,965))
OR
(issue_links.target_id = issues.id AND issue_links.source_id IN (967,966,965)) ORDER BY "issue_links"."id" DESC
Query plans
Without filtering by link type
WorkItem.linked_items_for(ids_array)
query
SELECT issues.*,
issue_links.id AS issue_link_id,
issue_links.link_type AS issue_link_type_value,
issue_links.target_id AS issue_link_source_id,
issue_links.source_id AS issue_link_target_id,
issue_links.created_at AS issue_link_created_at,
issue_links.updated_at AS issue_link_updated_at FROM "issues" INNER JOIN issue_links ON
(issue_links.source_id = issues.id AND issue_links.target_id IN (164551864,164474216,164474118,164118864,164118850,164118823,164051578,164051122,164044814,163836681,163836641,163831038,163572458,163564361,163551930,163551893,162732130,162314309,162128877,161963840,161962420,161961168,161805933,161805928,161716525,161695050,161560415,161119408,161119393,160974539,160836934,160836824,160552349,160552338,160350560,159001737,159001714,158852538,158695956,158658714,158029982,157928937,157928932,157928923,157341800,157341791,157341779,157313978,156996277,156920400,155335302,155204721,155143516,155130594,155129400,154971322,154948903,154948890,154948881,154870923,154870651,154869328,154865774,154855896,154854757,154530829,154529364,154493733,154388478,154352318,152623004,152005864,151225402))
OR
(issue_links.target_id = issues.id AND issue_links.source_id IN (164551864,164474216,164474118,164118864,164118850,164118823,164051578,164051122,164044814,163836681,163836641,163831038,163572458,163564361,163551930,163551893,162732130,162314309,162128877,161963840,161962420,161961168,161805933,161805928,161716525,161695050,161560415,161119408,161119393,160974539,160836934,160836824,160552349,160552338,160350560,159001737,159001714,158852538,158695956,158658714,158029982,157928937,157928932,157928923,157341800,157341791,157341779,157313978,156996277,156920400,155335302,155204721,155143516,155130594,155129400,154971322,154948903,154948890,154948881,154870923,154870651,154869328,154865774,154855896,154854757,154530829,154529364,154493733,154388478,154352318,152623004,152005864,151225402)) ORDER BY "issue_links"."id" DESC;
PLAN: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/37806/commands/115477
Filtered by relates_to link type
WorkItem.linked_items_for(ids_array, link_type: 'relates_to')
query
SELECT issues.*,
issue_links.id AS issue_link_id,
issue_links.link_type AS issue_link_type_value,
issue_links.target_id AS issue_link_source_id,
issue_links.source_id AS issue_link_target_id,
issue_links.created_at AS issue_link_created_at,
issue_links.updated_at AS issue_link_updated_at FROM "issues" INNER JOIN issue_links ON
(issue_links.source_id = issues.id AND issue_links.target_id IN (164551864,164474216,164474118,164118864,164118850,164118823,164051578,164051122,164044814,163836681,163836641,163831038,163572458,163564361,163551930,163551893,162732130,162314309,162128877,161963840,161962420,161961168,161805933,161805928,161716525,161695050,161560415,161119408,161119393,160974539,160836934,160836824,160552349,160552338,160350560,159001737,159001714,158852538,158695956,158658714,158029982,157928937,157928932,157928923,157341800,157341791,157341779,157313978,156996277,156920400,155335302,155204721,155143516,155130594,155129400,154971322,154948903,154948890,154948881,154870923,154870651,154869328,154865774,154855896,154854757,154530829,154529364,154493733,154388478,154352318,152623004,152005864,151225402) AND issue_links.link_type = 0)
OR
(issue_links.target_id = issues.id AND issue_links.source_id IN (164551864,164474216,164474118,164118864,164118850,164118823,164051578,164051122,164044814,163836681,163836641,163831038,163572458,163564361,163551930,163551893,162732130,162314309,162128877,161963840,161962420,161961168,161805933,161805928,161716525,161695050,161560415,161119408,161119393,160974539,160836934,160836824,160552349,160552338,160350560,159001737,159001714,158852538,158695956,158658714,158029982,157928937,157928932,157928923,157341800,157341791,157341779,157313978,156996277,156920400,155335302,155204721,155143516,155130594,155129400,154971322,154948903,154948890,154948881,154870923,154870651,154869328,154865774,154855896,154854757,154530829,154529364,154493733,154388478,154352318,152623004,152005864,151225402) AND issue_links.link_type = 0) ORDER BY "issue_links"."id" DESC;
PLAN: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/37806/commands/115479
Filtered by blocks link type
WorkItem.linked_items_for(ids_array, link_type: 'blocks')
query
SELECT issues.*,
issue_links.id AS issue_link_id,
issue_links.link_type AS issue_link_type_value,
issue_links.target_id AS issue_link_source_id,
issue_links.source_id AS issue_link_target_id,
issue_links.created_at AS issue_link_created_at,
issue_links.updated_at AS issue_link_updated_at FROM "issues" INNER JOIN issue_links ON issue_links.target_id = issues.id WHERE "issue_links"."source_id" IN (164551864, 164474216, 164474118, 164118864, 164118850, 164118823, 164051578, 164051122, 164044814, 163836681, 163836641, 163831038, 163572458, 163564361, 163551930, 163551893, 162732130, 162314309, 162128877, 161963840, 161962420, 161961168, 161805933, 161805928, 161716525, 161695050, 161560415, 161119408, 161119393, 160974539, 160836934, 160836824, 160552349, 160552338, 160350560, 159001737, 159001714, 158852538, 158695956, 158658714, 158029982, 157928937, 157928932, 157928923, 157341800, 157341791, 157341779, 157313978, 156996277, 156920400, 155335302, 155204721, 155143516, 155130594, 155129400, 154971322, 154948903, 154948890, 154948881, 154870923, 154870651, 154869328, 154865774, 154855896, 154854757, 154530829, 154529364, 154493733, 154388478, 154352318, 152623004, 152005864, 151225402) AND "issue_links"."link_type" = 1 ORDER BY "issue_links"."id" DESC;
PLAN: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/37806/commands/115480
Filtered by is_blocked_by link type
WorkItem.linked_items_for(ids_array, link_type: 'is_blocked_by')
query
SELECT issues.*,
issue_links.id AS issue_link_id,
issue_links.link_type AS issue_link_type_value,
issue_links.target_id AS issue_link_source_id,
issue_links.source_id AS issue_link_target_id,
issue_links.created_at AS issue_link_created_at,
issue_links.updated_at AS issue_link_updated_at FROM "issues" INNER JOIN issue_links ON issue_links.source_id = issues.id WHERE "issue_links"."target_id" IN (164551864, 164474216, 164474118, 164118864, 164118850, 164118823, 164051578, 164051122, 164044814, 163836681, 163836641, 163831038, 163572458, 163564361, 163551930, 163551893, 162732130, 162314309, 162128877, 161963840, 161962420, 161961168, 161805933, 161805928, 161716525, 161695050, 161560415, 161119408, 161119393, 160974539, 160836934, 160836824, 160552349, 160552338, 160350560, 159001737, 159001714, 158852538, 158695956, 158658714, 158029982, 157928937, 157928932, 157928923, 157341800, 157341791, 157341779, 157313978, 156996277, 156920400, 155335302, 155204721, 155143516, 155130594, 155129400, 154971322, 154948903, 154948890, 154948881, 154870923, 154870651, 154869328, 154865774, 154855896, 154854757, 154530829, 154529364, 154493733, 154388478, 154352318, 152623004, 152005864, 151225402) AND "issue_links"."link_type" = 1 ORDER BY "issue_links"."id" DESC;
PLAN: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/37806/commands/115481
References
Fix N+1 queries in work items field linkedItems (#512056 - closed)
Screenshots or screen recordings
No UI changes
How to set up and validate locally
- In rails console enable the FF and create work items with linked items:
Click to expand
Feature.enable(:batch_load_linked_items)
project, author = Project.last, User.first
item_a, item_b, item_c = %w[item_a item_b item_c].map do |title|
FactoryBot.create(:work_item, project: project, author: author, title: title)
end
related, blocking, blocked = %w[related_to_a blocking_b blocked_by_c].map do |title|
FactoryBot.create(:work_item, project: project, author: author, title: title)
end
nested1, nested2, nested3 = %w[nested1 nested2 nested3].map do |title|
FactoryBot.create(:work_item, project: project, author: author, title: title)
end
FactoryBot.create(:work_item_link, source: item_a, target: related)
FactoryBot.create(:work_item_link, source: blocking, target: item_b, link_type: 'blocks')
FactoryBot.create(:work_item_link, source: item_c, target: blocked, link_type: 'blocks')
FactoryBot.create(:work_item_link, source: related, target: nested1)
FactoryBot.create(:work_item_link, source: nested2, target: blocking)
FactoryBot.create(:work_item_link, source: blocked, target: nested3)
work_item_iids = [item_a, item_b, item_c].pluck(:iid).map(&:to_s)
linked_items_query = <<~QUERY
query allLinkedWorkItems {
project(fullPath: "#{project.full_path}") {
workItems(iids: #{work_item_iids}) {
edges {
node {
title
widgets(onlyTypes: [LINKED_ITEMS]) {
... on WorkItemWidgetLinkedItems {
linkedItems {
edges {
node {
linkType
workItem {
title
widgets(onlyTypes: [LINKED_ITEMS]) {
... on WorkItemWidgetLinkedItems {
linkedItems {
nodes {
linkType
workItem { title }
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
QUERY
print(linked_items_query)
- Visit
http://gdk.test:3000/-/graphql-explorerand run the generated query. Verify the response is correct
expected response
{
"data": {
"project": {
"workItems": {
"edges": [
{
"node": {
"title": "item_c",
"widgets": [
{
"linkedItems": {
"edges": [
{
"node": {
"linkType": "blocks",
"workItem": {
"title": "blocked_by_c",
"widgets": [
{
"linkedItems": {
"nodes": [
{
"linkType": "relates_to",
"workItem": {
"title": "nested3"
}
},
{
"linkType": "is_blocked_by",
"workItem": {
"title": "item_c"
}
}
]
}
}
]
}
}
}
]
}
}
]
}
},
{
"node": {
"title": "item_b",
"widgets": [
{
"linkedItems": {
"edges": [
{
"node": {
"linkType": "is_blocked_by",
"workItem": {
"title": "blocking_b",
"widgets": [
{
"linkedItems": {
"nodes": [
{
"linkType": "relates_to",
"workItem": {
"title": "nested2"
}
},
{
"linkType": "blocks",
"workItem": {
"title": "item_b"
}
}
]
}
}
]
}
}
}
]
}
}
]
}
},
{
"node": {
"title": "item_a",
"widgets": [
{
"linkedItems": {
"edges": [
{
"node": {
"linkType": "relates_to",
"workItem": {
"title": "related_to_a",
"widgets": [
{
"linkedItems": {
"nodes": [
{
"linkType": "relates_to",
"workItem": {
"title": "nested1"
}
},
{
"linkType": "relates_to",
"workItem": {
"title": "item_a"
}
}
]
}
}
]
}
}
}
]
}
}
]
}
}
]
}
}
}
}
screen recording
- Check
log/development.logto verify no N+1s are present forissue_linkstable - Disable the FF
Feature.disable(:batch_load_linked_items)and repeat the request to verify that the response doesn't change.
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.