Use set lookups for dropped identifier resolution
🤖 AI wrote this code from my instructions.Unreviewed AI output often lands on reviewers and costs them time.
This MR is human verified: I read the full diff and ran the tests myself, because your time is as valuable as mine.
What does this MR do and why?
ScheduleMarkDroppedAsResolvedService decides which vulnerabilities a scan no longer reports, so they can be auto-resolved. Two predicates run once per existing identifier, and each rescanned the whole primary_identifiers array. primary_identifier_types was not memoized either, so it rebuilt its array on every call.
That is O(n*m) comparisons for n reported and m existing identifiers, plus m array allocations of size n. Both numbers reach the thousands on a project that has accumulated vulnerabilities.
Build the two lookups once as memoized sets. to_set also drops the now redundant uniq. Behaviour is unchanged, since primary_identifiers is never mutated inside the service.
One gap this MR does not close: the type_unresovable? branch has no spec coverage. It had none before this change either. A follow-up MR adds it.
References
None.
How to set up and validate locally
Specs:
bundle exec rspec ee/spec/services/security/ingestion/schedule_mark_dropped_as_resolved_service_spec.rbMeasured with benchmark-swap(note that, this is not a microbenchmark. This measures the overall performance improvement for the entire service.), which swaps the method bodies in place and runs both sides through benchmark-ips. original is this branch, swapped is master, so a slower line on swapped is the improvement.
| Existing vulnerabilities | Scanned identifiers | original | swapped | |
|---|---|---|---|---|
| 50 | 200 | 206.1 i/s | 132.4 i/s | 1.56x |
| 200 | 1000 | 98.4 i/s | 14.9 i/s | 6.62x |
| 500 | 2000 | 43.2 i/s | 3.2 i/s | 13.64x |
At 500 and 2000 that is 23 ms against 313 ms per execute. The gain grows with the number of vulnerabilities a project has accumulated. On a small project it is close to noise, because the database work is the same on both sides and dominates.
Reproduction script
Save as ee/spec/services/security/ingestion/bench_dropped_identifiers_spec.rb, run it, then delete it. It has to sit under a recognised spec path or Quality::TestLevel refuses to load the file.
VULNS=500 SCANNED=2000 bundle exec rspec ee/spec/services/security/ingestion/bench_dropped_identifiers_spec.rbThe identifiers the scan reports never match the existing ones, so present_in_scan? never short-circuits. That is the worst case. A rescan where most identifiers still match shows less. Sidekiq enqueueing is stubbed on both sides so it is not measured. Benchmark.swap verifies both sides return the same result before measuring, and reported no mismatch at any size.
# frozen_string_literal: true
require 'spec_helper'
require 'benchmark/swap'
RSpec.describe Security::Ingestion::ScheduleMarkDroppedAsResolvedService,
feature_category: :static_application_security_testing do
VULNS = Integer(ENV.fetch('VULNS', 300))
SCANNED = Integer(ENV.fetch('SCANNED', 1000))
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
let_it_be(:existing) do
Array.new(VULNS) do |i|
identifier = create(:vulnerabilities_identifier,
project: project,
external_type: 'find_sec_bugs_type',
external_id: "EXISTING_#{i}")
finding = create(:vulnerabilities_finding,
project_id: project.id,
pipeline: pipeline,
primary_identifier_id: identifier.id,
identifiers: [identifier])
create(:vulnerability, :detected,
report_type: :sast,
resolved_on_default_branch: true,
project: project,
vulnerability_finding: finding,
findings: [finding]).tap { |vuln| finding.update!(vulnerability_id: vuln.id) }
end
end
let(:scanned_identifiers) do
Array.new(SCANNED) do |i|
build(:ci_reports_security_identifier,
external_type: 'find_sec_bugs_type',
external_id: "SCANNED_#{i}")
end
end
before do
allow(::Vulnerabilities::MarkDroppedAsResolvedWorker).to receive(:perform_in)
# master's implementations. benchmark-swap finds twins as :"#{name}#{suffix}",
# so a predicate twin is `type_unresovable?_perf`, which needs define_method.
described_class.class_eval do
define_method(:"type_unresovable?_perf") do |existing_identifier|
primary_identifiers.map(&:external_type).uniq.exclude?(existing_identifier.external_type)
end
private :"type_unresovable?_perf"
define_method(:"present_in_scan?_perf") do |existing_identifier|
primary_identifiers.any? do |incoming_identifier|
incoming_identifier.external_type == existing_identifier.external_type &&
incoming_identifier.external_id == existing_identifier.external_id
end
end
private :"present_in_scan?_perf"
end
end
after do
described_class.send(:remove_method, :"type_unresovable?_perf")
described_class.send(:remove_method, :"present_in_scan?_perf")
end
it 'compares the whole service' do
existing
puts "\nexisting vulnerabilities = #{VULNS}, scanned identifiers = #{SCANNED}"
Benchmark.swap(time: 5, warmup: 2) do
described_class.new(project.id, 'sast', scanned_identifiers).execute
end
end
endMR 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.