From 3f8f4308d41c9db4be3ec128441b827a67209464 Mon Sep 17 00:00:00 2001
From: Brian Williams <bwilliams@gitlab.com>
Date: Mon, 20 May 2024 19:08:18 +0000
Subject: [PATCH] Add dependency aggregations finder

This finder implements a more performance version of the query used for
`Group#sbom_occurrences`. Is able to perform aggregations one page at a
time, while the current implementation of `Group#sbom_occurrences` must
read all the records in the group before aggregating them and then
finally applying the limit.
---
 ..._component_version_id_and_traversal_ids.rb |  19 ++
 ...index_sbom_occurrences_for_aggregations.rb |  19 ++
 db/schema_migrations/20240506202834           |   1 +
 db/schema_migrations/20240506202928           |   1 +
 db/structure.sql                              |   4 +
 .../groups/dependencies_controller.rb         |  21 +-
 .../aggregations_finder.rb                    |  70 ++++
 ee/app/models/sbom/occurrence.rb              |  15 +
 ee/app/serializers/dependency_entity.rb       |  10 +-
 .../rewrite_sbom_occurrences_query.yml        |   9 +
 ee/spec/factories/sbom/occurrences.rb         |   7 +-
 .../user_sees_dependency_list_spec.rb         |  47 +++
 .../aggregations_finder_spec.rb               |  70 ++++
 ee/spec/models/sbom/occurrence_spec.rb        |  12 +
 .../groups/dependencies_controller_spec.rb    | 311 ++++++++++--------
 ee/spec/serializers/dependency_entity_spec.rb |  31 --
 .../project_dependencies_service_spec.rb      |   2 +-
 .../sbom/sync_archived_status_service_spec.rb |   5 +-
 .../sync_project_traversal_ids_worker_spec.rb |   2 +-
 .../helpers/database/duplicate_indexes.yml    |   3 +
 spec/support/matchers/exceed_query_limit.rb   |   9 +-
 21 files changed, 492 insertions(+), 176 deletions(-)
 create mode 100644 db/post_migrate/20240506202834_index_sbom_occurrences_on_component_version_id_and_traversal_ids.rb
 create mode 100644 db/post_migrate/20240506202928_index_sbom_occurrences_for_aggregations.rb
 create mode 100644 db/schema_migrations/20240506202834
 create mode 100644 db/schema_migrations/20240506202928
 create mode 100644 ee/app/finders/dependency_management/aggregations_finder.rb
 create mode 100644 ee/config/feature_flags/gitlab_com_derisk/rewrite_sbom_occurrences_query.yml
 create mode 100644 ee/spec/features/groups/security/user_sees_dependency_list_spec.rb
 create mode 100644 ee/spec/finders/dependency_management/aggregations_finder_spec.rb

diff --git a/db/post_migrate/20240506202834_index_sbom_occurrences_on_component_version_id_and_traversal_ids.rb b/db/post_migrate/20240506202834_index_sbom_occurrences_on_component_version_id_and_traversal_ids.rb
new file mode 100644
index 0000000000000..088138b8ff876
--- /dev/null
+++ b/db/post_migrate/20240506202834_index_sbom_occurrences_on_component_version_id_and_traversal_ids.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class IndexSbomOccurrencesOnComponentVersionIdAndTraversalIds < Gitlab::Database::Migration[2.2]
+  INDEX_NAME = 'index_unarchived_occurrences_on_version_id_and_traversal_ids'
+
+  milestone '17.0'
+
+  disable_ddl_transaction!
+
+  def up
+    add_concurrent_index :sbom_occurrences, [:component_version_id, :traversal_ids],
+      where: 'archived = false',
+      name: INDEX_NAME
+  end
+
+  def down
+    remove_concurrent_index_by_name :sbom_occurrences, INDEX_NAME
+  end
+end
diff --git a/db/post_migrate/20240506202928_index_sbom_occurrences_for_aggregations.rb b/db/post_migrate/20240506202928_index_sbom_occurrences_for_aggregations.rb
new file mode 100644
index 0000000000000..a01c3029b97e6
--- /dev/null
+++ b/db/post_migrate/20240506202928_index_sbom_occurrences_for_aggregations.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class IndexSbomOccurrencesForAggregations < Gitlab::Database::Migration[2.2]
+  INDEX_NAME = 'index_unarchived_sbom_occurrences_for_aggregations'
+
+  milestone '17.0'
+
+  disable_ddl_transaction!
+
+  def up
+    add_concurrent_index :sbom_occurrences, [:traversal_ids, :component_id, :component_version_id],
+      where: 'archived = false',
+      name: INDEX_NAME
+  end
+
+  def down
+    remove_concurrent_index_by_name :sbom_occurrences, INDEX_NAME
+  end
+end
diff --git a/db/schema_migrations/20240506202834 b/db/schema_migrations/20240506202834
new file mode 100644
index 0000000000000..23d7973e89852
--- /dev/null
+++ b/db/schema_migrations/20240506202834
@@ -0,0 +1 @@
+0c07c0a916056fd73717c65348dcefd31deffc1bd291bf938ebee41ec23bf919
\ No newline at end of file
diff --git a/db/schema_migrations/20240506202928 b/db/schema_migrations/20240506202928
new file mode 100644
index 0000000000000..4d021c7ea4282
--- /dev/null
+++ b/db/schema_migrations/20240506202928
@@ -0,0 +1 @@
+10f2898e5a02425aca29e3fde273fc96ca3a862d681f05d777c696e3a1aca5ad
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 9b6edbf6a37ab..4012118ceae3c 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -27637,6 +27637,10 @@ CREATE INDEX index_topics_total_projects_count ON topics USING btree (total_proj
 
 CREATE UNIQUE INDEX index_trending_projects_on_project_id ON trending_projects USING btree (project_id);
 
+CREATE INDEX index_unarchived_occurrences_on_version_id_and_traversal_ids ON sbom_occurrences USING btree (component_version_id, traversal_ids) WHERE (archived = false);
+
+CREATE INDEX index_unarchived_sbom_occurrences_for_aggregations ON sbom_occurrences USING btree (traversal_ids, component_id, component_version_id) WHERE (archived = false);
+
 CREATE UNIQUE INDEX index_uniq_ci_runners_on_token ON ci_runners USING btree (token);
 
 CREATE UNIQUE INDEX index_uniq_ci_runners_on_token_encrypted ON ci_runners USING btree (token_encrypted);
diff --git a/ee/app/controllers/groups/dependencies_controller.rb b/ee/app/controllers/groups/dependencies_controller.rb
index cfadee01e8e6a..11353e3130643 100644
--- a/ee/app/controllers/groups/dependencies_controller.rb
+++ b/ee/app/controllers/groups/dependencies_controller.rb
@@ -3,6 +3,7 @@
 module Groups
   class DependenciesController < Groups::ApplicationController
     include GovernUsageGroupTracking
+    include Gitlab::Utils::StrongMemoize
 
     before_action only: :index do
       push_frontend_feature_flag(:group_level_dependencies_filtering_by_component, group)
@@ -27,7 +28,6 @@ def index
           render status: :ok
         end
         format.json do
-          dependencies = dependencies_finder.execute.with_component.with_version.with_source.with_project_route
           render json: dependencies_serializer.represent(dependencies)
         end
       end
@@ -72,8 +72,18 @@ def validate_project_ids_limit!
       )
     end
 
-    def dependencies_finder
-      ::Sbom::DependenciesFinder.new(group, params: dependencies_finder_params)
+    def dependencies
+      if using_new_query?
+        ::DependencyManagement::AggregationsFinder.new(group, params: dependencies_finder_params).execute
+          .with_component
+          .with_version
+      else
+        ::Sbom::DependenciesFinder.new(group, params: dependencies_finder_params).execute
+          .with_component
+          .with_version
+          .with_source
+          .with_project_route
+      end
     end
 
     def dependencies_finder_params
@@ -125,5 +135,10 @@ def set_below_group_limit
     def below_group_limit?
       group.count_within_namespaces <= GROUP_COUNT_LIMIT
     end
+
+    def using_new_query?
+      ::Feature.enabled?(:rewrite_sbom_occurrences_query, group)
+    end
+    strong_memoize_attr :using_new_query?
   end
 end
diff --git a/ee/app/finders/dependency_management/aggregations_finder.rb b/ee/app/finders/dependency_management/aggregations_finder.rb
new file mode 100644
index 0000000000000..b6f0b650c2708
--- /dev/null
+++ b/ee/app/finders/dependency_management/aggregations_finder.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module DependencyManagement
+  # rubocop:disable CodeReuse/ActiveRecord -- Code won't be reused outside this context
+  class AggregationsFinder
+    DEFAULT_PAGE_SIZE = 20
+    MAX_PAGE_SIZE = 20
+    DISTINCT_BY = %i[component_id component_version_id].freeze
+
+    def initialize(namespace, params: {})
+      @namespace = namespace
+      @params = params
+    end
+
+    def execute
+      Sbom::Occurrence.with(namespaces_cte.to_arel)
+        .select(
+          'MIN(outer_occurrences.id)::bigint AS id',
+          'outer_occurrences.component_id',
+          'outer_occurrences.component_version_id',
+          'MIN(outer_occurrences.package_manager) AS package_manager',
+          'MIN(outer_occurrences.input_file_path) AS input_file_path',
+          'JSONB_AGG(outer_occurrences.licenses->0) AS licenses',
+          'SUM(counts.occurrence_count)::integer AS occurrence_count',
+          'SUM(counts.vulnerability_count)::integer AS vulnerability_count',
+          'SUM(counts.project_count)::integer AS project_count'
+        )
+        .from("(#{outer_occurrences.to_sql}) outer_occurrences, LATERAL (#{counts.to_sql}) counts")
+        .group('outer_occurrences.component_id', 'outer_occurrences.component_version_id')
+        .order('MIN(outer_occurrences.component_id) ASC', 'MIN(outer_occurrences.component_version_id) ASC')
+    end
+
+    private
+
+    attr_reader :namespace, :params
+
+    def namespaces_cte
+      ::Gitlab::SQL::CTE.new(:namespaces, namespace.self_and_descendants.select(:traversal_ids))
+    end
+
+    def inner_occurrences
+      Sbom::Occurrence.where('sbom_occurrences.traversal_ids = namespaces.traversal_ids::bigint[]')
+        .unarchived
+        .order(*DISTINCT_BY)
+        .limit(page_size)
+        .select_distinct(on: DISTINCT_BY)
+    end
+
+    def outer_occurrences
+      Sbom::Occurrence.select_distinct(on: DISTINCT_BY, table_name: '"inner_occurrences"')
+      .from("namespaces, LATERAL (#{inner_occurrences.to_sql}) inner_occurrences")
+      .order('inner_occurrences.component_id ASC', 'inner_occurrences.component_version_id ASC')
+      .limit(page_size)
+    end
+
+    def counts
+      Sbom::Occurrence.select('COUNT(project_id) AS occurrence_count')
+        .select('COUNT(DISTINCT project_id) project_count')
+        .select('SUM(vulnerability_count) vulnerability_count')
+        .for_namespace_and_descendants(namespace)
+        .unarchived
+        .where('sbom_occurrences.component_version_id = outer_occurrences.component_version_id')
+    end
+
+    def page_size
+      [params.fetch(:per_page, DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE].min
+    end
+  end
+  # rubocop:enable CodeReuse/ActiveRecord
+end
diff --git a/ee/app/models/sbom/occurrence.rb b/ee/app/models/sbom/occurrence.rb
index 6587ace5db522..5f01326d6bc81 100644
--- a/ee/app/models/sbom/occurrence.rb
+++ b/ee/app/models/sbom/occurrence.rb
@@ -77,8 +77,13 @@ class Occurrence < ApplicationRecord
       where(query_parts.join(' OR '), licenses: Array(ids))
     end
 
+    scope :unarchived, -> { where(archived: false) }
     scope :by_project_ids, ->(project_ids) { where(project_id: project_ids) }
     scope :by_uuids, ->(uuids) { where(uuid: uuids) }
+    scope :for_namespace_and_descendants, ->(namespace) do
+      where("traversal_ids >= ('{?}')", namespace.traversal_ids)
+        .where("traversal_ids < ('{?}')", namespace.next_traversal_ids)
+    end
 
     scope :filter_by_package_managers, ->(package_managers) do
       where(package_manager: package_managers)
@@ -137,6 +142,16 @@ class Occurrence < ApplicationRecord
         .where(project_authorizations: { user_id: user.id })
     end
 
+    def self.select_distinct(on:, table_name: quoted_table_name)
+      quote = ->(column) { "#{table_name}.#{connection.quote_column_name(column)}" }
+
+      distinct_values = on.map { |column| quote.call(column) }
+
+      select_values = column_names.map { |column| quote.call(column) }
+
+      select("DISTINCT ON (#{distinct_values.join(', ')}) #{select_values.join(', ')}")
+    end
+
     def location
       {
         blob_path: input_file_blob_path,
diff --git a/ee/app/serializers/dependency_entity.rb b/ee/app/serializers/dependency_entity.rb
index b26b91edcbd89..552893bce4f66 100644
--- a/ee/app/serializers/dependency_entity.rb
+++ b/ee/app/serializers/dependency_entity.rb
@@ -70,7 +70,7 @@ class ProjectEntity < Grape::Entity
   end
 
   expose :name, :packager, :version
-  expose :location, using: LocationEntity
+  expose :location, using: LocationEntity, if: ->(_) { !using_aggregations? }
   expose :vulnerabilities, using: VulnerabilityEntity, if: ->(_) { render_vulnerabilities? }
   expose :licenses, using: LicenseEntity, if: ->(_) { can_read_licenses? } do |object|
     object[:licenses].presence || [::Gitlab::LicenseScanning::PackageLicenses::UNKNOWN_LICENSE]
@@ -78,7 +78,7 @@ class ProjectEntity < Grape::Entity
   expose :occurrence_count, if: ->(_) { group? } do |object|
     object.respond_to?(:occurrence_count) ? object.occurrence_count : 1
   end
-  expose :project, using: ProjectEntity, if: ->(_) { group? }
+  expose :project, using: ProjectEntity, if: ->(_) { group? && !using_aggregations? }
   expose :project_count, if: ->(_) { group? } do |object|
     object.respond_to?(:project_count) ? object.project_count : 1
   end
@@ -125,4 +125,10 @@ def project_level_sbom_occurrences_enabled?
     project = request.try(:project)
     project.present? && Feature.enabled?(:project_level_sbom_occurrences, project)
   end
+
+  def using_aggregations?
+    return false unless group?
+
+    Feature.enabled?(:rewrite_sbom_occurrences_query, request.group)
+  end
 end
diff --git a/ee/config/feature_flags/gitlab_com_derisk/rewrite_sbom_occurrences_query.yml b/ee/config/feature_flags/gitlab_com_derisk/rewrite_sbom_occurrences_query.yml
new file mode 100644
index 0000000000000..c8a991c6c8f8c
--- /dev/null
+++ b/ee/config/feature_flags/gitlab_com_derisk/rewrite_sbom_occurrences_query.yml
@@ -0,0 +1,9 @@
+---
+name: rewrite_sbom_occurrences_query
+feature_issue_url: https://gitlab.com/groups/gitlab-org/-/epics/12371
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/145704
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/442672
+milestone: '17.0'
+group: group::threat insights
+type: gitlab_com_derisk
+default_enabled: false
diff --git a/ee/spec/factories/sbom/occurrences.rb b/ee/spec/factories/sbom/occurrences.rb
index fc6ec9b679c62..e462807a429e2 100644
--- a/ee/spec/factories/sbom/occurrences.rb
+++ b/ee/spec/factories/sbom/occurrences.rb
@@ -9,6 +9,8 @@
     component { component_version&.component || association(:sbom_component) }
     source { association :sbom_source, packager_name: packager_name }
     source_package { association :sbom_source_package }
+    traversal_ids { project.namespace.traversal_ids }
+    archived { project.archived }
 
     trait :os_occurrence do
       source do
@@ -18,10 +20,7 @@
     end
 
     trait :with_vulnerabilities do
-      transient do
-        vulnerability_count { 2 }
-      end
-
+      vulnerability_count { 2 }
       vulnerabilities { build_list(:vulnerability, vulnerability_count) }
     end
 
diff --git a/ee/spec/features/groups/security/user_sees_dependency_list_spec.rb b/ee/spec/features/groups/security/user_sees_dependency_list_spec.rb
new file mode 100644
index 0000000000000..865d7c1ea348f
--- /dev/null
+++ b/ee/spec/features/groups/security/user_sees_dependency_list_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "User sees dependency list", :js, feature_category: :vulnerability_management do
+  let_it_be(:owner) { create(:user) }
+  let_it_be(:group) { create(:group) }
+  let_it_be(:project) { create(:project, group: group) }
+  let_it_be(:ci_pipeline) { create(:ee_ci_pipeline, :with_cyclonedx_pypi_only, project: project) }
+
+  before do
+    stub_licensed_features(
+      security_dashboard: true,
+      dependency_scanning: true
+    )
+  end
+
+  before_all do
+    ::Sbom::Ingestion::IngestReportsService.execute(ci_pipeline)
+    group.add_owner(owner)
+    sign_in(owner)
+  end
+
+  it "shows the dependency list" do
+    visit(group_dependencies_path(group))
+
+    # The default sort order of the page is by 'Severity' which orders by
+    # `sbom_occurrences.hightest_severity_level NULL LAST`.  All of the test
+    # data is NULL for this field so the order is indeterminate.
+    # So just check for some content and the right number of elements.
+    within_testid("dependencies-table-content") do
+      expect(page).to have_content "beautifulsoup4"
+      expect(page).to have_content "soupsieve"
+      expect(page).to have_selector("tbody tr", count: 12)
+    end
+
+    within(".gl-sorting") do
+      click_on "Severity"
+      find("li", text: "Component name").click
+    end
+
+    within_testid("dependencies-table-content") do
+      expect(find("tbody tr:first-child")).to have_content "beautifulsoup4"
+      expect(find("tbody tr:last-child")).to have_content "soupsieve"
+    end
+  end
+end
diff --git a/ee/spec/finders/dependency_management/aggregations_finder_spec.rb b/ee/spec/finders/dependency_management/aggregations_finder_spec.rb
new file mode 100644
index 0000000000000..3ba3676bf4e5b
--- /dev/null
+++ b/ee/spec/finders/dependency_management/aggregations_finder_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe DependencyManagement::AggregationsFinder, feature_category: :dependency_management do
+  let_it_be(:target_group) { create(:group) }
+  let(:params) { {} }
+  let_it_be(:subgroup) { create(:group, parent: target_group) }
+  let_it_be(:outside_group) { create(:group) }
+  let_it_be(:outside_project) { create(:project, group: outside_group) }
+  let_it_be(:archived_project) { create(:project, :archived, group: target_group) }
+
+  let_it_be(:target_projects) do
+    [
+      create(:project, group: target_group),
+      create(:project, group: subgroup)
+    ]
+  end
+
+  let_it_be(:target_occurrences) do
+    target_projects.map do |project|
+      create(:sbom_occurrence, :with_vulnerabilities, project: project)
+    end
+  end
+
+  before_all do
+    # Records that should not be returned in the results
+    create(:sbom_occurrence, project: outside_project)
+    create(:sbom_occurrence, project: archived_project)
+  end
+
+  describe '#execute' do
+    subject(:execute) { described_class.new(target_group, params: params).execute }
+
+    it 'returns occurrences in target group hierarchy' do
+      expected = target_occurrences.map do |occurrence|
+        an_object_having_attributes(
+          component_id: occurrence.component_id,
+          component_version_id: occurrence.component_version_id,
+          occurrence_count: 1,
+          project_count: 1,
+          vulnerability_count: 2
+        )
+      end
+
+      expect(execute).to match_array(expected)
+    end
+
+    describe 'pagination' do
+      let(:params) { { per_page: 1 } }
+
+      it 'uses per_page to determine page size' do
+        expect(execute.to_a.size).to eq(1)
+      end
+
+      context 'when per_page is over max page size' do
+        let(:params) { { per_page: 2 } }
+        let(:max) { 1 }
+
+        before do
+          stub_const("#{described_class}::MAX_PAGE_SIZE", max)
+        end
+
+        it 'returns max number of items' do
+          expect(execute.to_a.size).to eq(max)
+        end
+      end
+    end
+  end
+end
diff --git a/ee/spec/models/sbom/occurrence_spec.rb b/ee/spec/models/sbom/occurrence_spec.rb
index ccfac010485e0..ff4cb0f995de1 100644
--- a/ee/spec/models/sbom/occurrence_spec.rb
+++ b/ee/spec/models/sbom/occurrence_spec.rb
@@ -306,6 +306,18 @@
     end
   end
 
+  describe '.unarchived' do
+    let_it_be(:unarchived_occurrence) { create(:sbom_occurrence) }
+
+    before_all do
+      create(:sbom_occurrence, project: create(:project, :archived))
+    end
+
+    it 'returns only unarchived occurrences' do
+      expect(described_class.unarchived).to eq([unarchived_occurrence])
+    end
+  end
+
   describe '.by_project_ids' do
     let_it_be(:occurrence_1) { create(:sbom_occurrence) }
     let_it_be(:occurrence_2) { create(:sbom_occurrence) }
diff --git a/ee/spec/requests/groups/dependencies_controller_spec.rb b/ee/spec/requests/groups/dependencies_controller_spec.rb
index e28f82b01678e..b68bfd9068e41 100644
--- a/ee/spec/requests/groups/dependencies_controller_spec.rb
+++ b/ee/spec/requests/groups/dependencies_controller_spec.rb
@@ -6,7 +6,10 @@
   let_it_be(:user) { create(:user) }
   let_it_be(:group) { create(:group) }
 
+  let(:using_new_query) { false }
+
   before do
+    stub_feature_flags(rewrite_sbom_occurrences_query: using_new_query)
     sign_in(user)
   end
 
@@ -127,6 +130,11 @@
             let_it_be(:project) { create(:project, group: group) }
             let_it_be(:sbom_occurrence_npm) { create(:sbom_occurrence, :mit, :npm, project: project) }
             let_it_be(:sbom_occurrence_bundler) { create(:sbom_occurrence, :apache_2, :bundler, project: project) }
+            let_it_be(:archived_occurrence) do
+              create(:sbom_occurrence, project: create(:project, :archived, group: group))
+            end
+
+            let(:using_new_query) { true }
 
             let(:expected_response) do
               {
@@ -135,7 +143,6 @@
                 },
                 'dependencies' => [
                   {
-                    'location' => sbom_occurrence_npm.location.as_json,
                     'name' => sbom_occurrence_npm.name,
                     'packager' => sbom_occurrence_npm.packager,
                     'version' => sbom_occurrence_npm.version,
@@ -148,13 +155,11 @@
                     ],
                     'occurrence_count' => 1,
                     'project_count' => 1,
-                    "project" => { "full_path" => project.full_path, "name" => project.name },
                     "component_id" => sbom_occurrence_npm.component_version_id,
                     "occurrence_id" => sbom_occurrence_npm.id,
                     "vulnerability_count" => 0
                   },
                   {
-                    'location' => sbom_occurrence_bundler.location.as_json,
                     'name' => sbom_occurrence_bundler.name,
                     'packager' => sbom_occurrence_bundler.packager,
                     'version' => sbom_occurrence_bundler.version,
@@ -167,7 +172,6 @@
                     ],
                     'occurrence_count' => 1,
                     'project_count' => 1,
-                    "project" => { "full_path" => project.full_path, "name" => project.name },
                     "component_id" => sbom_occurrence_bundler.component_version_id,
                     "occurrence_id" => sbom_occurrence_bundler.id,
                     "vulnerability_count" => 0
@@ -188,187 +192,234 @@
               expect(response).to include_pagination_headers
             end
 
-            it 'avoids N+1 database queries related to projects and routes', :aggregate_failures do
-              control = ActiveRecord::QueryRecorder.new(skip_cached: false) { subject }
+            it 'avoids N+1 database queries', :aggregate_failures do
+              recording = ActiveRecord::QueryRecorder.new { subject }
 
-              project_routes_count = control.log.count do |entry|
-                entry[/\Aselect.+routes.+from.+routes.+where.+routes(.+source_id|.+source_type.+project){2}/i]
-              end
-              project_count = control.log.count do |entry|
-                entry[/\Aselect.+projects.+from.+projects.+where.+projects.+id/i]
-              end
-              component_versions_count = control.log.count do |entry|
-                entry[/\Aselect.+sbom_component_versions.+from.+sbom_component_versions/i]
-              end
-              sbom_sources_count = control.log.count do |entry|
-                entry[/\Aselect.+sbom_sources.+from.+sbom_sources/i]
-              end
-              sbom_components_count = control.log.count do |entry|
-                entry[/\Aselect.+sbom_components.+from.+sbom_components/i]
-              end
-
-              expect(project_routes_count).to eq(1)
-              expect(project_count).to eq(1)
-              expect(component_versions_count).to eq(1)
-              expect(sbom_sources_count).to eq(1)
-              expect(sbom_components_count).to eq(1)
+              expect(recording).not_to exceed_all_query_limit(1).for_model(::Sbom::Component)
+              expect(recording).not_to exceed_all_query_limit(1).for_model(::Sbom::ComponentVersion)
+              expect(recording).not_to exceed_all_query_limit(1).for_model(::Sbom::Source)
             end
 
-            context 'with sorting params' do
-              context 'when sorted by packager in ascending order' do
-                let(:params) { { group_id: group.to_param, sort_by: 'packager', sort: 'asc' } }
+            context 'when using old query' do
+              let(:using_new_query) { false }
 
-                it 'returns sorted list' do
-                  subject
+              let(:expected_response) do
+                {
+                  'report' => {
+                    'status' => 'ok'
+                  },
+                  'dependencies' => [
+                    {
+                      'location' => sbom_occurrence_npm.location.as_json,
+                      'name' => sbom_occurrence_npm.name,
+                      'packager' => sbom_occurrence_npm.packager,
+                      'version' => sbom_occurrence_npm.version,
+                      'licenses' => [
+                        {
+                          'spdx_identifier' => 'MIT',
+                          'name' => 'MIT License',
+                          'url' => 'https://spdx.org/licenses/MIT.html'
+                        }
+                      ],
+                      'occurrence_count' => 1,
+                      'project_count' => 1,
+                      "project" => { "full_path" => project.full_path, "name" => project.name },
+                      "component_id" => sbom_occurrence_npm.component_version_id,
+                      "occurrence_id" => sbom_occurrence_npm.id,
+                      "vulnerability_count" => 0
+                    },
+                    {
+                      'location' => sbom_occurrence_bundler.location.as_json,
+                      'name' => sbom_occurrence_bundler.name,
+                      'packager' => sbom_occurrence_bundler.packager,
+                      'version' => sbom_occurrence_bundler.version,
+                      'licenses' => [
+                        {
+                          'spdx_identifier' => 'Apache-2.0',
+                          'name' => 'Apache 2.0 License',
+                          'url' => 'https://spdx.org/licenses/Apache-2.0.html'
+                        }
+                      ],
+                      'occurrence_count' => 1,
+                      'project_count' => 1,
+                      "project" => { "full_path" => project.full_path, "name" => project.name },
+                      "component_id" => sbom_occurrence_bundler.component_version_id,
+                      "occurrence_id" => sbom_occurrence_bundler.id,
+                      "vulnerability_count" => 0
+                    }
+                  ]
+                }
+              end
 
-                  expect(json_response['dependencies'].first['packager']).to eq('bundler')
-                  expect(json_response['dependencies'].last['packager']).to eq('npm')
-                end
+              before_all do
+                # The old version of the query has a functional bug where it returns records from archived projects.
+                # This oversight is fixed in the new version of the query. Once all tests are migrated over to the
+                # new query, this line should be deleted.
+                archived_occurrence.destroy!
+              end
+
+              it 'returns the expected response' do
+                subject
+
+                expect(json_response).to eq(expected_response)
               end
 
-              context 'when sorted by packager in descending order' do
-                let(:params) { { group_id: group.to_param, sort_by: 'packager', sort: 'desc' } }
+              context 'with sorting params' do
+                context 'when sorted by packager in ascending order' do
+                  let(:params) { { group_id: group.to_param, sort_by: 'packager', sort: 'asc' } }
 
-                it 'returns sorted list' do
-                  subject
+                  it 'returns sorted list' do
+                    subject
 
-                  expect(json_response['dependencies'].first['packager']).to eq('npm')
-                  expect(json_response['dependencies'].last['packager']).to eq('bundler')
+                    expect(json_response['dependencies'].first['packager']).to eq('bundler')
+                    expect(json_response['dependencies'].last['packager']).to eq('npm')
+                  end
                 end
-              end
 
-              context 'when sorted by name in ascending order' do
-                let(:params) { { group_id: group.to_param, sort_by: 'name', sort: 'asc' } }
+                context 'when sorted by packager in descending order' do
+                  let(:params) { { group_id: group.to_param, sort_by: 'packager', sort: 'desc' } }
 
-                it 'returns sorted list' do
-                  subject
+                  it 'returns sorted list' do
+                    subject
 
-                  expect(json_response['dependencies'].first['name']).to eq(sbom_occurrence_npm.name)
-                  expect(json_response['dependencies'].last['name']).to eq(sbom_occurrence_bundler.name)
+                    expect(json_response['dependencies'].first['packager']).to eq('npm')
+                    expect(json_response['dependencies'].last['packager']).to eq('bundler')
+                  end
                 end
-              end
 
-              context 'when sorted by name in descending order' do
-                let(:params) { { group_id: group.to_param, sort_by: 'name', sort: 'desc' } }
+                context 'when sorted by name in ascending order' do
+                  let(:params) { { group_id: group.to_param, sort_by: 'name', sort: 'asc' } }
 
-                it 'returns sorted list' do
-                  subject
+                  it 'returns sorted list' do
+                    subject
 
-                  expect(json_response['dependencies'].first['name']).to eq(sbom_occurrence_bundler.name)
-                  expect(json_response['dependencies'].last['name']).to eq(sbom_occurrence_npm.name)
+                    expect(json_response['dependencies'].first['name']).to eq(sbom_occurrence_npm.name)
+                    expect(json_response['dependencies'].last['name']).to eq(sbom_occurrence_bundler.name)
+                  end
                 end
-              end
 
-              context 'when sorted by license in ascending order' do
-                let(:params) { { group_id: group.to_param, sort_by: 'license', sort: 'asc' } }
+                context 'when sorted by name in descending order' do
+                  let(:params) { { group_id: group.to_param, sort_by: 'name', sort: 'desc' } }
 
-                it 'returns sorted list' do
-                  subject
+                  it 'returns sorted list' do
+                    subject
 
-                  expect(json_response['dependencies'].first['licenses'][0]['spdx_identifier']).to eq('Apache-2.0')
-                  expect(json_response['dependencies'].last['licenses'][0]['spdx_identifier']).to eq('MIT')
+                    expect(json_response['dependencies'].first['name']).to eq(sbom_occurrence_bundler.name)
+                    expect(json_response['dependencies'].last['name']).to eq(sbom_occurrence_npm.name)
+                  end
                 end
-              end
 
-              context 'when sorted by license in descending order' do
-                let(:params) { { group_id: group.to_param, sort_by: 'license', sort: 'desc' } }
+                context 'when sorted by license in ascending order' do
+                  let(:params) { { group_id: group.to_param, sort_by: 'license', sort: 'asc' } }
 
-                it 'returns sorted list' do
-                  subject
+                  it 'returns sorted list' do
+                    subject
 
-                  expect(json_response['dependencies'].first['licenses'][0]['spdx_identifier']).to eq('MIT')
-                  expect(json_response['dependencies'].last['licenses'][0]['spdx_identifier']).to eq('Apache-2.0')
+                    expect(json_response['dependencies'].first['licenses'][0]['spdx_identifier']).to eq('Apache-2.0')
+                    expect(json_response['dependencies'].last['licenses'][0]['spdx_identifier']).to eq('MIT')
+                  end
                 end
-              end
-            end
 
-            context 'with filtering params' do
-              context 'when filtered by package managers' do
-                let(:params) { { group_id: group.to_param, package_managers: ['npm'] } }
+                context 'when sorted by license in descending order' do
+                  let(:params) { { group_id: group.to_param, sort_by: 'license', sort: 'desc' } }
 
-                it 'returns filtered list' do
-                  subject
+                  it 'returns sorted list' do
+                    subject
 
-                  expect(json_response['dependencies'].count).to eq(1)
-                  expect(json_response['dependencies'].pluck('packager')).to eq(['npm'])
+                    expect(json_response['dependencies'].first['licenses'][0]['spdx_identifier']).to eq('MIT')
+                    expect(json_response['dependencies'].last['licenses'][0]['spdx_identifier']).to eq('Apache-2.0')
+                  end
                 end
               end
 
-              context 'when filtered by component_names' do
-                let(:params) do
-                  {
-                    group_id: group.to_param,
-                    component_names: [sbom_occurrence_bundler.name]
-                  }
-                end
+              context 'with filtering params' do
+                context 'when filtered by package managers' do
+                  let(:params) { { group_id: group.to_param, package_managers: ['npm'] } }
 
-                it 'returns filtered list' do
-                  subject
+                  it 'returns filtered list' do
+                    subject
 
-                  expect(json_response['dependencies'].count).to eq(1)
-                  expect(json_response['dependencies'].pluck('name')).to eq([sbom_occurrence_bundler.name])
+                    expect(json_response['dependencies'].count).to eq(1)
+                    expect(json_response['dependencies'].pluck('packager')).to eq(['npm'])
+                  end
                 end
 
-                context 'when the group hierarchy depth is too high' do
-                  before do
-                    stub_const('::Groups::DependenciesController::GROUP_COUNT_LIMIT', 0)
+                context 'when filtered by component_names' do
+                  let(:params) do
+                    {
+                      group_id: group.to_param,
+                      component_names: [sbom_occurrence_bundler.name]
+                    }
                   end
 
-                  it 'ignores the filter' do
+                  it 'returns filtered list' do
                     subject
 
-                    expect(json_response['dependencies'].pluck('name')).to match_array([
-                      sbom_occurrence_bundler.component_name,
-                      sbom_occurrence_npm.component_name
-                    ])
+                    expect(json_response['dependencies'].count).to eq(1)
+                    expect(json_response['dependencies'].pluck('name')).to eq([sbom_occurrence_bundler.name])
                   end
-                end
-              end
 
-              context 'when filtered by licenses' do
-                let(:params) do
-                  {
-                    group_id: group.to_param,
-                    licenses: ['Apache-2.0']
-                  }
-                end
+                  context 'when the group hierarchy depth is too high' do
+                    before do
+                      stub_const('::Groups::DependenciesController::GROUP_COUNT_LIMIT', 0)
+                    end
 
-                it 'returns a filtered list' do
-                  subject
+                    it 'ignores the filter' do
+                      subject
 
-                  expect(json_response['dependencies'].count).to eq(1)
-                  expect(json_response['dependencies'].pluck('name')).to eq([sbom_occurrence_bundler.name])
+                      expect(json_response['dependencies'].pluck('name')).to match_array([
+                        sbom_occurrence_bundler.component_name,
+                        sbom_occurrence_npm.component_name
+                      ])
+                    end
+                  end
                 end
-              end
 
-              context 'when filtered by unknown licenses' do
-                let_it_be(:sbom_occurrence_unknown) { create(:sbom_occurrence, project: project) }
+                context 'when filtered by licenses' do
+                  let(:params) do
+                    {
+                      group_id: group.to_param,
+                      licenses: ['Apache-2.0']
+                    }
+                  end
 
-                let(:params) do
-                  {
-                    group_id: group.to_param,
-                    licenses: ['unknown']
-                  }
+                  it 'returns a filtered list' do
+                    subject
+
+                    expect(json_response['dependencies'].count).to eq(1)
+                    expect(json_response['dependencies'].pluck('name')).to eq([sbom_occurrence_bundler.name])
+                  end
                 end
 
-                it 'returns a filtered list' do
-                  subject
+                context 'when filtered by unknown licenses' do
+                  let_it_be(:sbom_occurrence_unknown) { create(:sbom_occurrence, project: project) }
 
-                  expect(json_response['dependencies'].pluck('occurrence_id')).to eq([sbom_occurrence_unknown.id])
+                  let(:params) do
+                    {
+                      group_id: group.to_param,
+                      licenses: ['unknown']
+                    }
+                  end
+
+                  it 'returns a filtered list' do
+                    subject
+
+                    expect(json_response['dependencies'].pluck('occurrence_id')).to eq([sbom_occurrence_unknown.id])
+                  end
                 end
-              end
 
-              context 'when filtered by projects' do
-                let_it_be(:other_project) { create(:project, group: group) }
-                let_it_be(:occurrence_from_other_project) { create(:sbom_occurrence, project: other_project) }
+                context 'when filtered by projects' do
+                  let_it_be(:other_project) { create(:project, group: group) }
+                  let_it_be(:occurrence_from_other_project) { create(:sbom_occurrence, project: other_project) }
 
-                let(:params) { { project_ids: [other_project.id] } }
+                  let(:params) { { project_ids: [other_project.id] } }
 
-                it 'returns a filtered list' do
-                  subject
+                  it 'returns a filtered list' do
+                    subject
 
-                  expect(json_response['dependencies'].count).to eq(1)
-                  expect(json_response['dependencies'].pluck('name')).to eq([occurrence_from_other_project.name])
+                    expect(json_response['dependencies'].count).to eq(1)
+                    expect(json_response['dependencies'].pluck('name')).to eq([occurrence_from_other_project.name])
+                  end
                 end
 
                 context 'when trying to search for too many projects' do
diff --git a/ee/spec/serializers/dependency_entity_spec.rb b/ee/spec/serializers/dependency_entity_spec.rb
index 15bccb96d5665..e0cea9357fcf2 100644
--- a/ee/spec/serializers/dependency_entity_spec.rb
+++ b/ee/spec/serializers/dependency_entity_spec.rb
@@ -72,27 +72,6 @@
           is_expected.to eq(dependency.except(:vulnerabilities, :package_manager, :iid))
         end
       end
-
-      context 'with project' do
-        let(:project) { create(:project, :repository, :private, :in_group) }
-        let(:dependency) { build(:dependency, project: project) }
-
-        before do
-          allow(request).to receive(:project).and_return(nil)
-          allow(request).to receive(:group).and_return(project.group)
-        end
-
-        it 'includes project name and full_path' do
-          result = subject
-
-          expect(result.dig(:project, :full_path)).to eq(project.full_path)
-          expect(result.dig(:project, :name)).to eq(project.name)
-        end
-
-        it 'includes component_id' do
-          expect(subject.keys).to include(:component_id)
-        end
-      end
     end
 
     context 'when all required features are unavailable' do
@@ -136,20 +115,10 @@
           "name" => sbom_occurrence.name,
           "occurrence_count" => 1,
           "packager" => sbom_occurrence.packager,
-          "project" => {
-            "name" => project.name,
-            "full_path" => project.full_path
-          },
           "project_count" => 1,
           "version" => sbom_occurrence.version,
           "licenses" => sbom_occurrence.licenses,
           "component_id" => sbom_occurrence.component_version_id,
-          "location" => {
-            "ancestors" => sbom_occurrence.ancestors,
-            "blob_path" => sbom_occurrence.location[:blob_path],
-            "path" => sbom_occurrence.location[:path],
-            "top_level" => sbom_occurrence.location[:top_level]
-          },
           "vulnerability_count" => 0,
           "occurrence_id" => sbom_occurrence.id
         })
diff --git a/ee/spec/services/dependencies/export_serializers/project_dependencies_service_spec.rb b/ee/spec/services/dependencies/export_serializers/project_dependencies_service_spec.rb
index 7eab1d6e9dc49..f2f7a1e485325 100644
--- a/ee/spec/services/dependencies/export_serializers/project_dependencies_service_spec.rb
+++ b/ee/spec/services/dependencies/export_serializers/project_dependencies_service_spec.rb
@@ -61,7 +61,7 @@ def json_dependency(occurrence)
           },
           'licenses' => occurrence.licenses,
           'vulnerabilities' => vulnerabilities,
-          'vulnerability_count' => 0
+          'vulnerability_count' => 2
         }
       end
 
diff --git a/ee/spec/services/sbom/sync_archived_status_service_spec.rb b/ee/spec/services/sbom/sync_archived_status_service_spec.rb
index b26f5228e5397..aa38549f3ca5c 100644
--- a/ee/spec/services/sbom/sync_archived_status_service_spec.rb
+++ b/ee/spec/services/sbom/sync_archived_status_service_spec.rb
@@ -4,8 +4,8 @@
 
 RSpec.describe Sbom::SyncArchivedStatusService, feature_category: :dependency_management do
   let_it_be(:project) { create(:project, :archived) }
-  let_it_be(:sbom_occurrence) { create(:sbom_occurrence, project: project) }
-  let_it_be(:sbom_occurrence_2) { create(:sbom_occurrence, project: project) }
+  let_it_be(:sbom_occurrence) { create(:sbom_occurrence, archived: false, project: project) }
+  let_it_be(:sbom_occurrence_2) { create(:sbom_occurrence, archived: false, project: project) }
 
   let(:project_id) { project.id }
 
@@ -13,6 +13,7 @@
 
   it 'updates sbom_occurrences.archived' do
     expect { sync }.to change { sbom_occurrence.reload.archived }.from(false).to(true)
+      .and change { sbom_occurrence_2.reload.archived }.from(false).to(true)
   end
 
   context 'when project does not exist with id' do
diff --git a/ee/spec/workers/sbom/sync_project_traversal_ids_worker_spec.rb b/ee/spec/workers/sbom/sync_project_traversal_ids_worker_spec.rb
index 81432c5a07ae8..fdf43f3e49b7d 100644
--- a/ee/spec/workers/sbom/sync_project_traversal_ids_worker_spec.rb
+++ b/ee/spec/workers/sbom/sync_project_traversal_ids_worker_spec.rb
@@ -4,7 +4,7 @@
 
 RSpec.describe Sbom::SyncProjectTraversalIdsWorker, feature_category: :dependency_management, type: :worker do
   let_it_be(:project) { create(:project) }
-  let_it_be(:sbom_occurrence) { create(:sbom_occurrence, project: project) }
+  let_it_be(:sbom_occurrence) { create(:sbom_occurrence, traversal_ids: [], project: project) }
 
   let(:job_args) { project.id }
 
diff --git a/spec/support/helpers/database/duplicate_indexes.yml b/spec/support/helpers/database/duplicate_indexes.yml
index a010f048453bd..26631b407bb44 100644
--- a/spec/support/helpers/database/duplicate_indexes.yml
+++ b/spec/support/helpers/database/duplicate_indexes.yml
@@ -107,6 +107,9 @@ requirements_management_test_reports:
 sbom_component_versions:
   index_sbom_component_versions_on_component_id_and_version:
     - index_sbom_component_versions_on_component_id
+sbom_occurrences:
+  index_unarchived_occurrences_on_version_id_and_traversal_ids:
+    - index_sbom_occurrences_on_component_version_id
 search_namespace_index_assignments:
   index_search_namespace_index_assignments_uniqueness_index_type:
     - index_search_namespace_index_assignments_on_namespace_id
diff --git a/spec/support/matchers/exceed_query_limit.rb b/spec/support/matchers/exceed_query_limit.rb
index cc912d8de661f..71cdac317e720 100644
--- a/spec/support/matchers/exceed_query_limit.rb
+++ b/spec/support/matchers/exceed_query_limit.rb
@@ -194,7 +194,7 @@ def log_message
       #{sections.join("\n\n")}
       MSG
     else
-      @recorder.log_message
+      query_log(@recorder).join("\n\n")
     end
   end
 
@@ -348,7 +348,12 @@ def skip_cached
   include ExceedQueryLimitHelpers
 
   match do |block|
-    verify_count(&block)
+    if block.is_a?(ActiveRecord::QueryRecorder)
+      @recorder = block
+      verify_count
+    else
+      verify_count(&block)
+    end
   end
 
   failure_message_when_negated do |actual|
-- 
GitLab