diff --git a/ee/app/models/vulnerabilities/detection_transition.rb b/ee/app/models/vulnerabilities/detection_transition.rb index f2879c9c21627e22dd4670dadbdd97522fbdffff..f2683f3d5ecc37eb2341e8240305180812685b10 100644 --- a/ee/app/models/vulnerabilities/detection_transition.rb +++ b/ee/app/models/vulnerabilities/detection_transition.rb @@ -2,6 +2,9 @@ module Vulnerabilities class DetectionTransition < ::SecApplicationRecord + include EachBatch + include BulkInsertSafe + self.table_name = 'vulnerability_detection_transitions' belongs_to :finding, diff --git a/ee/app/services/vulnerabilities/detection_transitions/insert_service.rb b/ee/app/services/vulnerabilities/detection_transitions/insert_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..6820347efbbf0c2ec4a76ec741746ec6fe32d745 --- /dev/null +++ b/ee/app/services/vulnerabilities/detection_transitions/insert_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Vulnerabilities + module DetectionTransitions + class InsertService + MAX_BATCH_SIZE = 1000 + + def initialize(vulnerability_findings, detected:) + @vulnerability_findings = Array(vulnerability_findings) + @detected = detected + @timestamp = Time.current + end + + def execute + return if vulnerability_findings.empty? || detected.nil? + + vulnerability_findings.each_slice(MAX_BATCH_SIZE) do |batch| + records = build_detection_transition_records(batch) + + next if records.empty? + + insert_records(records) + + log_updates(batch.map(&:id)) + end + + sync_elasticsearch(vulnerability_findings.map(&:vulnerability_id)) + + ServiceResponse.success + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, vulnerability_finding_ids: vulnerability_findings.map(&:id)) + ServiceResponse.error(message: e.message) + end + + private + + attr_reader :vulnerability_findings, :detected, :timestamp + + def build_detection_transition_records(findings) + findings.map do |finding| + ::Vulnerabilities::DetectionTransition.new( + vulnerability_occurrence_id: finding.id, + project_id: finding.project_id, + detected: detected, + created_at: timestamp, + updated_at: timestamp + ) + end + end + + def insert_records(records) + ::Vulnerabilities::DetectionTransition.bulk_insert!(records) + end + + def log_updates(ids) + Gitlab::AppLogger.info( + class: self.class.name, + message: "Vulnerability finding detection transitions inserted", + finding_ids: ids, + timestamp: timestamp + ) + end + + def sync_elasticsearch(vulnerability_ids) + Vulnerabilities::EsHelper.sync_elasticsearch(vulnerability_ids) + end + end + end +end diff --git a/ee/spec/services/vulnerabilities/detection_transitions/insert_service_spec.rb b/ee/spec/services/vulnerabilities/detection_transitions/insert_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d7227f26518b174d27c3946035de473dcdf30163 --- /dev/null +++ b/ee/spec/services/vulnerabilities/detection_transitions/insert_service_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::DetectionTransitions::InsertService, feature_category: :vulnerability_management do + subject(:service) { described_class.new(findings_list, detected: detected) } + + let_it_be(:project) { create(:project) } + let_it_be(:detected) { true } + let_it_be(:findings_list) { [] } + + describe '#execute' do + context "when vulnerabilities exist" do + let_it_be(:vulnerability_list) { create_list(:vulnerability, 2, project: project) } + + let_it_be(:findings_list) do + [ + create(:vulnerabilities_finding, vulnerability: vulnerability_list[0], project: project), + create(:vulnerabilities_finding, vulnerability: vulnerability_list[1], project: project) + ] + end + + let(:vulnerability_ids) { vulnerability_list.map(&:id) } + + it "inserts the corresponding detection transitions", :freeze_time do + expect(Vulnerabilities::DetectionTransition).to receive(:bulk_insert!) + + service.execute + end + + it "syncs to elasticsearch" do + expect(Vulnerabilities::EsHelper).to receive(:sync_elasticsearch) + .with(vulnerability_ids) + + service.execute + end + end + + context "when vulnerability findings list is empty" do + let(:findings_list) { [] } + + it "does not insert any records" do + expect(Vulnerabilities::DetectionTransition).not_to receive(:bulk_insert!) + + service.execute + end + end + + context "when detected is empty" do + let(:detected) { nil } + + it "does not insert any records" do + expect(Vulnerabilities::DetectionTransition).not_to receive(:bulk_insert!) + + service.execute + end + end + + context "when batch size exceeds MAX_BATCH_SIZE" do + let_it_be(:vulnerability_list) { create_list(:vulnerability, 4, project: project) } + let_it_be(:findings_list) do + vulnerability_list.map do |vulnerability| + create(:vulnerabilities_finding, vulnerability: vulnerability, project: project) + end + end + + it "processes records in batches" do + stub_const("#{described_class}::MAX_BATCH_SIZE", 2) + expect(Vulnerabilities::DetectionTransition).to receive(:bulk_insert!).twice + + service.execute + end + end + + context "when an error occurs" do + let(:vulnerability_finding_ids) { findings_list.map(&:id) } + + let_it_be(:findings_list) { [create(:vulnerabilities_finding, project: project)] } + + before do + allow(::Vulnerabilities::DetectionTransition).to receive(:bulk_insert!) + .and_raise(StandardError, "Database error") + end + + it "tracks the exception" do + expect(Gitlab::ErrorTracking).to receive(:track_exception) + .with(instance_of(StandardError), vulnerability_finding_ids: vulnerability_finding_ids) + + service.execute + end + + it "returns an error response" do + result = service.execute + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq("Database error") + end + end + end +end