Introduce and use lean findings finder in CompareSecurityReportsService
What does this MR do and why?
CompareSecurityReportsService loads findings for the base and head pipeline via Security::FindingsFinder, then SecurityFindingsReportsComparer compares them by uuid. The comparison only needs uuid, severity, and scanner_id, but today it loads every column (including the large finding_data JSON) and every association: scan, scanner, pipeline entities, vulnerability, state transitions, issue links, external issue links, merge request links, feedback. Vulnerabilities that flip between detected and resolved carry thousands of state transitions, so a single comparison can pull tens of thousands of rows into Ruby to compare a few hundred uuids, on every report refresh.
Behind a new default-off feature flag, lean_mr_security_report_comparison:
Security::FindingsFindergets aleanparam that selects onlyid, scanner_id, severity, uuid, context_unaware_uuid, partition_numberand skips all preloads.- The service passes
lean: trueand skipswith_api_scopesand the two policy preloads. - The comparer reloads only the capped added and fixed findings (at most 25 per list) with their associations, and runs policy checks on them before serialization.
Two behavior-neutral changes were needed for the lean finder:
- Partial scan filtering now compares
scanner_idagainst the project's scanner ids instead of scanner external ids, since lean findings don't have the scanner loaded. - The manual resolution check uses the comparison's report type instead of each finding's report type (all findings in a comparison share it).
Evidence
- Flag on is never slower end to end than flag off.
- With the full data set, flag on is about 10 s faster and loads 15,000 state transitions instead of 588,000.
hydratereloads at most 25 added + 25 fixed findings.- Serialized output is identical between flag on and off in every run.
- The reload DB query takes 16 ms.
- Most of the end-to-end time is serializing state transitions, equally in both modes. Part of it is an N+1 on the transition
author, which exists on master. Tracked in #631157.
Loading step only (local)
GDK, SAST comparison, 53 findings loaded across both pipelines, each vulnerability given 300 state transitions.
| Flag off | Flag on | |
|---|---|---|
| Findings loaded | 53 | 53 |
| State transitions loaded with them | 19,927 | 0 |
| Columns per finding | 12, including finding_data |
7 (6 since dropping scan_id) |
| Queries (CI / main / sec) | 13 / 8 / 15 | 10 / 0 / 6 |
| CPU | 0.79 s | 0.02 s |
| Objects allocated | 812,000 | 23,000 |
End-to-end benchmark (local)
GDK, run against MR head commit 1ac3c978. Flag off and flag on, one warm-up run each, query cache cleared before each run. SAST, every vulnerability has 300 state transitions. Runs repeated 2-3 times; wall times stable within about 2 s.
| Scenario | Flag | Wall | Allocations | Queries (ci / sec / main) | Findings / vulnerabilities / state transitions loaded |
|---|---|---|---|---|---|
| A: 50 shared, 3 fixed, 3 added | off | 3.7-4.0 s | 10.5 M | 24 / 31 / 11 | 106 / 106 / 31,800 |
| on | 3.1-3.2 s | 10.0 M | 24 / 33 / 11 | 112 / 6 / 1,800 | |
| B, worst case for lean: 30 fixed, 30 added, nothing shared | off | 25.8-27.9 s | 82.2 M | 24 / 75 / 12 | 60 / 60 / 18,000 |
| on | 25.4-25.8 s | 82.0 M | 24 / 77 / 12 | 110 / 50 / 15,000 | |
C: 950 shared, 30 fixed, 30 added, security_mr_reports_tab_full_data_set on |
off | 36.7-36.9 s | 91.5 M | 24 / 75 / 13 | 1,960 / 1,960 / 588,000 |
| on | 26.1-26.9 s | 82.1 M | 24 / 77 / 13 | 2,010 / 50 / 15,000 | |
C with security_mr_reports_tab_full_data_set off |
off | 1.1 s | 1.3 M | 18 / 16 / 5 | 312 / 312 / 93,600 |
| on | 0.02 s | 0.03 M | 12 / 6 / 0 | 312 / 0 / 0 |
- Flag-on counts include the up to 50 reloaded findings (lean rows plus reloads).
- The hydrated set is a subset of what flag off loads, so flag on never loads more state transitions; the only added cost is 2 extra sec queries.
- In C with the full data set flag off, neither mode shows added or fixed findings because of the existing per-severity cap (unrelated to this MR).
- Seeded head-only findings all have vulnerabilities, which is pessimistic for flag on; real added findings usually have none.
Seed and benchmark scripts
Run with bin/rails runner in the GDK.
seed_lean_comparison.rb
# Usage: SHARED=50 FIXED=3 ADDED=3 TRANSITIONS=300 NAME=lean-bench-a bin/rails runner seed_lean_comparison.rb
# Creates a project with a base and head pipeline, each with one latest successful SAST scan.
# SHARED findings appear in both pipelines, FIXED only in base, ADDED only in head.
# Every finding has a vulnerability with TRANSITIONS state transitions.
require 'sidekiq/testing'
Sidekiq::Testing.fake!
shared = ENV.fetch('SHARED').to_i
fixed = ENV.fetch('FIXED').to_i
added = ENV.fetch('ADDED').to_i
transitions = ENV.fetch('TRANSITIONS', '300').to_i
name = ENV.fetch('NAME')
user = User.find_by_username!('root')
severities = %i[critical high medium low info unknown]
project = FactoryBot.create(:project, name: name, path: name, namespace: user.namespace, creator: user)
project.add_owner(user) unless project.owner == user
scanner = FactoryBot.create(:vulnerabilities_scanner, project: project, external_id: 'semgrep', name: 'Semgrep')
def pipeline_with_scan(project, ref)
pipeline = FactoryBot.create(:ci_pipeline, :success, project: project, ref: ref, sha: SecureRandom.hex(20))
build = FactoryBot.create(:ci_build, :success, pipeline: pipeline, project: project, name: 'semgrep-sast')
scan = FactoryBot.create(:security_scan, :latest_successful, scan_type: :sast, build: build,
pipeline: pipeline, project: project, findings_partition_number: Security::Finding.active_partition_number)
[pipeline, scan]
end
base_pipeline, base_scan = pipeline_with_scan(project, 'master')
head_pipeline, head_scan = pipeline_with_scan(project, 'feature')
now = Time.current
make = ->(i, scans) do
severity = severities[i % severities.size]
vuln = FactoryBot.create(:vulnerability, :with_finding, :detected, project: project, author: user,
severity: severity, report_type: :sast)
vuln.finding.update_columns(scanner_id: scanner.id)
rows = Array.new(transitions) do |t|
from, to = t.even? ? [1, 3] : [3, 1] # detected <-> resolved
{ vulnerability_id: vuln.id, project_id: project.id, from_state: from, to_state: to,
author_id: user.id, created_at: now, updated_at: now }
end
Vulnerabilities::StateTransition.insert_all(rows) if rows.any?
scans.each do |scan|
FactoryBot.create(:security_finding, :with_finding_data, scan: scan, scanner: scanner,
severity: severity, uuid: vuln.finding.uuid, deduplicated: true)
end
end
i = 0
shared.times { make.call(i += 1, [base_scan, head_scan]) }
fixed.times { make.call(i += 1, [base_scan]) }
added.times { make.call(i += 1, [head_scan]) }
puts "PROJECT_ID=#{project.id} BASE=#{base_pipeline.id} HEAD=#{head_pipeline.id}"bench_lean_comparison.rb
# Usage (GDK):
# PROJECT_ID=1 BASE=10 HEAD=11 REPORT_TYPE=sast FULL_DATA_SET=0 \
# bin/rails runner /path/to/bench_lean_comparison.rb
#
# Measures the whole CompareSecurityReportsService#execute (finder -> comparer -> hydrate -> serializer),
# which is exactly what the reactive cache worker runs, with the lean flag off and on.
project = Project.find(ENV.fetch('PROJECT_ID'))
base = Ci::Pipeline.find(ENV.fetch('BASE'))
head = Ci::Pipeline.find(ENV.fetch('HEAD'))
user = project.first_owner
params = { report_type: ENV.fetch('REPORT_TYPE', 'sast'), scan_mode: ENV['SCAN_MODE'] }.compact
Feature.send(ENV['FULL_DATA_SET'] == '1' ? :enable : :disable, :security_mr_reports_tab_full_data_set, project)
def measure(project, user, params, base, head)
queries = Hash.new(0)
cache_hits = 0
Gitlab::Database.database_base_models.each_value { |model| model.connection.clear_query_cache }
rows = Hash.new(0)
sql_sub = ActiveSupport::Notifications.subscribe('sql.active_record') do |*, payload|
next if payload[:name] == 'SCHEMA'
next cache_hits += 1 if payload[:cached]
db = payload[:connection]&.pool&.db_config&.name || 'unknown'
queries[db] += 1
end
inst_sub = ActiveSupport::Notifications.subscribe('instantiation.active_record') do |*, payload|
rows[payload[:class_name]] += payload[:record_count]
end
GC.start
alloc_before = GC.stat(:total_allocated_objects)
cpu_before = Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID)
wall_before = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = Vulnerabilities::CompareSecurityReportsService.new(project, user, params).execute(base, head)
{
status: result[:status],
added: result.dig(:data, 'added')&.size,
fixed: result.dig(:data, 'fixed')&.size,
wall_s: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_before).round(3),
cpu_s: (Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID) - cpu_before).round(3),
allocations: GC.stat(:total_allocated_objects) - alloc_before,
queries: queries,
cache_hits: cache_hits,
rows: rows.slice('Security::Finding', 'Vulnerability', 'Vulnerabilities::StateTransition',
'Vulnerabilities::Feedback', 'Vulnerabilities::IssueLink', 'Vulnerabilities::MergeRequestLink'),
data: result[:data]
}
ensure
ActiveSupport::Notifications.unsubscribe(sql_sub)
ActiveSupport::Notifications.unsubscribe(inst_sub)
end
results = [false, true].to_h do |lean|
Feature.send(lean ? :enable : :disable, :lean_mr_security_report_comparison, project)
measure(project, user, params, base, head) # warm-up (class loading, schema cache)
[lean, measure(project, user, params, base, head)]
end
puts "Same serialized output: #{results[false][:data] == results[true][:data]}"
results.each do |lean, r|
puts "\n== lean #{lean ? 'ON' : 'OFF'} =="
r.except(:data).each { |k, v| puts " #{k}: #{v}" }
endDatabase queries
Lean finder query
SELECT "security_findings".*
FROM "security_scans",
unnest('{1,2,4,5,6,7}'::smallint[]) AS "severities" ("severity"),
LATERAL (
SELECT "security_findings"."id", "security_findings"."scan_id", "security_findings"."scanner_id",
"security_findings"."severity", "security_findings"."uuid",
"security_findings"."context_unaware_uuid", "security_findings"."partition_number"
FROM "security_findings"
WHERE "security_findings"."scan_id" = "security_scans"."id"
AND "security_findings"."severity" = "severities"."severity"
AND "security_findings"."partition_number" IN (SELECT DISTINCT findings_partition_number FROM security_scans WHERE pipeline_id = 2818521991)
AND "security_findings"."deduplicated" = TRUE
ORDER BY "security_findings"."severity" DESC, "security_findings"."id" ASC
LIMIT 1001
) AS "security_findings"
WHERE "security_scans"."pipeline_id" = 2818521991
AND "security_scans"."latest" = TRUE
AND "security_scans"."status" = 1
AND "security_scans"."scan_type" = 2
ORDER BY "security_findings"."severity" DESC, "security_findings"."id" ASCReload query
SELECT "security_findings".*
FROM "security_findings"
WHERE "security_findings"."partition_number" = 12
AND "security_findings"."id" IN (1, 2, 3)| Query | Rows | Buffers | Row width | Time (cold) | Plan |
|---|---|---|---|---|---|
| Current finder | 945 | 945 (623 hit + 322 read) | 1127 | 160 ms | plan |
| Lean finder | 945 | 945 (623 hit + 322 read) | 62 | 36 ms | plan |
| Reload of 50 findings | 50 | 429 (33 hit + 396 read), including the id lookup used only in this test | 1111 | 16 ms | plan |
Both finder queries read the same pages, since finding_data is stored inline in the row; the saving is in bytes returned and sorted (1,154 kB vs 98 kB of sort memory) and in the Ruby objects built from them, not in pages read. The reload runs once per comparison for at most 50 findings, replacing the association preloads that previously ran for every finding of both pipelines.
References
Related #628409
Screenshots or screen recordings
How to set up and validate locally
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.