From 1e78b395bbc29cce9db1542df6b6c4489d47b23e Mon Sep 17 00:00:00 2001
From: Albert Salim <asalim@gitlab.com>
Date: Thu, 10 Feb 2022 11:51:26 +0000
Subject: [PATCH] Update namespace statistics when project is destroyed

When a project is destroyed, Projects::ProjectDeletedEvent is published.

A Namespaces::UpdateRootStatisticsWorker is subscribed to
Projects::ProjectDeletedEvent. The worker will
enqueue Namespaces::ScheduleAggregationWorker to
update the namespace's root statistics.

This change is behind a feature flag for publishing ProjectDeletedEvent.

Changelog: fixed
---
 app/events/projects/project_deleted_event.rb  | 16 +++++++++
 app/services/projects/destroy_service.rb      |  8 +++++
 app/workers/all_queues.yml                    |  9 +++++
 .../update_root_statistics_worker.rb          | 17 ++++++++++
 .../publish_project_deleted_event.yml         |  8 +++++
 config/sidekiq_queues.yml                     |  2 ++
 lib/gitlab/event_store.rb                     |  1 +
 .../projects/project_deleted_event_spec.rb    | 34 +++++++++++++++++++
 .../services/projects/destroy_service_spec.rb | 26 +++++++++++---
 spec/support/matchers/event_store.rb          | 12 +++++++
 spec/tooling/quality/test_level_spec.rb       |  4 +--
 spec/workers/every_sidekiq_worker_spec.rb     |  1 +
 .../update_root_statistics_worker_spec.rb     | 23 +++++++++++++
 tooling/quality/test_level.rb                 |  1 +
 14 files changed, 156 insertions(+), 6 deletions(-)
 create mode 100644 app/events/projects/project_deleted_event.rb
 create mode 100644 app/workers/namespaces/update_root_statistics_worker.rb
 create mode 100644 config/feature_flags/development/publish_project_deleted_event.yml
 create mode 100644 spec/events/projects/project_deleted_event_spec.rb
 create mode 100644 spec/support/matchers/event_store.rb
 create mode 100644 spec/workers/namespaces/update_root_statistics_worker_spec.rb

diff --git a/app/events/projects/project_deleted_event.rb b/app/events/projects/project_deleted_event.rb
new file mode 100644
index 000000000000..ac58c5c6755d
--- /dev/null
+++ b/app/events/projects/project_deleted_event.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Projects
+  class ProjectDeletedEvent < ::Gitlab::EventStore::Event
+    def schema
+      {
+        'type' => 'object',
+        'properties' => {
+          'project_id' => { 'type' => 'integer' },
+          'namespace_id' => { 'type' => 'integer' }
+        },
+        'required' => %w[project_id namespace_id]
+      }
+    end
+  end
+end
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 733a4b45cb2d..95af5a6863ff 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -37,6 +37,8 @@ def execute
       system_hook_service.execute_hooks_for(project, :destroy)
       log_info("Project \"#{project.full_path}\" was deleted")
 
+      publish_project_deleted_event_for(project) if Feature.enabled?(:publish_project_deleted_event, default_enabled: :yaml)
+
       current_user.invalidate_personal_projects_count
 
       true
@@ -260,6 +262,12 @@ def raise_error(message)
     def flush_caches(project)
       Projects::ForksCountService.new(project).delete_cache
     end
+
+    def publish_project_deleted_event_for(project)
+      data = { project_id: project.id, namespace_id: project.namespace_id }
+      event = Projects::ProjectDeletedEvent.new(data: data)
+      Gitlab::EventStore.publish(event)
+    end
   end
 end
 
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 18646e5320b5..f39aee14a69a 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -2569,6 +2569,15 @@
   :weight: 1
   :idempotent: true
   :tags: []
+- :name: namespaces_update_root_statistics
+  :worker_name: Namespaces::UpdateRootStatisticsWorker
+  :feature_category: :source_code_management
+  :has_external_dependencies:
+  :urgency: :low
+  :resource_boundary: :unknown
+  :weight: 1
+  :idempotent: true
+  :tags: []
 - :name: new_issue
   :worker_name: NewIssueWorker
   :feature_category: :team_planning
diff --git a/app/workers/namespaces/update_root_statistics_worker.rb b/app/workers/namespaces/update_root_statistics_worker.rb
new file mode 100644
index 000000000000..9fdf8e2506b4
--- /dev/null
+++ b/app/workers/namespaces/update_root_statistics_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Namespaces
+  class UpdateRootStatisticsWorker
+    include Gitlab::EventStore::Subscriber
+
+    data_consistency :always
+
+    idempotent!
+
+    feature_category :source_code_management
+
+    def handle_event(event)
+      ScheduleAggregationWorker.perform_async(event.data[:namespace_id])
+    end
+  end
+end
diff --git a/config/feature_flags/development/publish_project_deleted_event.yml b/config/feature_flags/development/publish_project_deleted_event.yml
new file mode 100644
index 000000000000..1287ebe9f662
--- /dev/null
+++ b/config/feature_flags/development/publish_project_deleted_event.yml
@@ -0,0 +1,8 @@
+---
+name: publish_project_deleted_event
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78862
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/351073
+milestone: '14.8'
+type: development
+group: group::pipeline insights
+default_enabled: false
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index d52659d80e76..9c5294b6b2b8 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -295,6 +295,8 @@
   - 1
 - - namespaces_sync_namespace_name
   - 1
+- - namespaces_update_root_statistics
+  - 1
 - - new_epic
   - 2
 - - new_issue
diff --git a/lib/gitlab/event_store.rb b/lib/gitlab/event_store.rb
index 7dbbcdbb1a78..e20ea1c73650 100644
--- a/lib/gitlab/event_store.rb
+++ b/lib/gitlab/event_store.rb
@@ -34,6 +34,7 @@ def self.configure!(store)
       # Add subscriptions here:
 
       store.subscribe ::MergeRequests::UpdateHeadPipelineWorker, to: ::Ci::PipelineCreatedEvent
+      store.subscribe ::Namespaces::UpdateRootStatisticsWorker, to: ::Projects::ProjectDeletedEvent
     end
     private_class_method :configure!
   end
diff --git a/spec/events/projects/project_deleted_event_spec.rb b/spec/events/projects/project_deleted_event_spec.rb
new file mode 100644
index 000000000000..fd8cec7271b5
--- /dev/null
+++ b/spec/events/projects/project_deleted_event_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::ProjectDeletedEvent do
+  where(:data, :valid) do
+    [
+      [{ project_id: 1, namespace_id: 2 }, true],
+      [{ project_id: 1 }, false],
+      [{ namespace_id: 1 }, false],
+      [{ project_id: 'foo', namespace_id: 2 }, false],
+      [{ project_id: 1, namespace_id: 'foo' }, false],
+      [{ project_id: [], namespace_id: 2 }, false],
+      [{ project_id: 1, namespace_id: [] }, false],
+      [{ project_id: {}, namespace_id: 2 }, false],
+      [{ project_id: 1, namespace_id: {} }, false],
+      ['foo', false],
+      [123, false],
+      [[], false]
+    ]
+  end
+
+  with_them do
+    it 'validates data' do
+      constructor = -> { described_class.new(data: data) }
+
+      if valid
+        expect { constructor.call }.not_to raise_error
+      else
+        expect { constructor.call }.to raise_error(Gitlab::EventStore::InvalidEvent)
+      end
+    end
+  end
+end
diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb
index a6aa76c7e6ce..0beb5157ed6b 100644
--- a/spec/services/projects/destroy_service_spec.rb
+++ b/spec/services/projects/destroy_service_spec.rb
@@ -18,17 +18,35 @@
   end
 
   shared_examples 'deleting the project' do
-    before do
-      # Run sidekiq immediately to check that renamed repository will be removed
+    it 'deletes the project', :sidekiq_inline do
       destroy_project(project, user, {})
-    end
 
-    it 'deletes the project', :sidekiq_inline do
       expect(Project.unscoped.all).not_to include(project)
 
       expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey
       expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_falsey
     end
+
+    it 'publishes a ProjectDeleted event with project id and namespace id' do
+      expected_data = { project_id: project.id, namespace_id: project.namespace_id }
+      expect(Gitlab::EventStore)
+        .to receive(:publish)
+        .with(event_type(Projects::ProjectDeletedEvent).containing(expected_data))
+
+      destroy_project(project, user, {})
+    end
+
+    context 'when feature flag publish_project_deleted_event is disabled' do
+      before do
+        stub_feature_flags(publish_project_deleted_event: false)
+      end
+
+      it 'does not publish an event' do
+        expect(Gitlab::EventStore).not_to receive(:publish)
+
+        destroy_project(project, user, {})
+      end
+    end
   end
 
   shared_examples 'deleting the project with pipeline and build' do
diff --git a/spec/support/matchers/event_store.rb b/spec/support/matchers/event_store.rb
new file mode 100644
index 000000000000..96a71ae3c22f
--- /dev/null
+++ b/spec/support/matchers/event_store.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+RSpec::Matchers.define :event_type do |event_class|
+  match do |actual|
+    actual.instance_of?(event_class) &&
+      actual.data == @expected_data
+  end
+
+  chain :containing do |expected_data|
+    @expected_data = expected_data
+  end
+end
diff --git a/spec/tooling/quality/test_level_spec.rb b/spec/tooling/quality/test_level_spec.rb
index 8a944a473d73..33d3a5b49b31 100644
--- a/spec/tooling/quality/test_level_spec.rb
+++ b/spec/tooling/quality/test_level_spec.rb
@@ -28,7 +28,7 @@
     context 'when level is unit' do
       it 'returns a pattern' do
         expect(subject.pattern(:unit))
-          .to eq("spec/{bin,channels,config,db,dependencies,elastic,elastic_integration,experiments,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,metrics_server,models,policies,presenters,rack_servers,replicators,routing,rubocop,scripts,serializers,services,sidekiq,sidekiq_cluster,spam,support_specs,tasks,uploaders,validators,views,workers,tooling}{,/**/}*_spec.rb")
+          .to eq("spec/{bin,channels,config,db,dependencies,elastic,elastic_integration,experiments,events,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,metrics_server,models,policies,presenters,rack_servers,replicators,routing,rubocop,scripts,serializers,services,sidekiq,sidekiq_cluster,spam,support_specs,tasks,uploaders,validators,views,workers,tooling}{,/**/}*_spec.rb")
       end
     end
 
@@ -110,7 +110,7 @@
     context 'when level is unit' do
       it 'returns a regexp' do
         expect(subject.regexp(:unit))
-          .to eq(%r{spec/(bin|channels|config|db|dependencies|elastic|elastic_integration|experiments|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|metrics_server|models|policies|presenters|rack_servers|replicators|routing|rubocop|scripts|serializers|services|sidekiq|sidekiq_cluster|spam|support_specs|tasks|uploaders|validators|views|workers|tooling)})
+          .to eq(%r{spec/(bin|channels|config|db|dependencies|elastic|elastic_integration|experiments|events|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|metrics_server|models|policies|presenters|rack_servers|replicators|routing|rubocop|scripts|serializers|services|sidekiq|sidekiq_cluster|spam|support_specs|tasks|uploaders|validators|views|workers|tooling)})
       end
     end
 
diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb
index a21cf3ed5f65..1cd5d23d8fc9 100644
--- a/spec/workers/every_sidekiq_worker_spec.rb
+++ b/spec/workers/every_sidekiq_worker_spec.rb
@@ -349,6 +349,7 @@
         'Namespaces::OnboardingPipelineCreatedWorker' => 3,
         'Namespaces::OnboardingProgressWorker' => 3,
         'Namespaces::OnboardingUserAddedWorker' => 3,
+        'Namespaces::RefreshRootStatisticsWorker' => 3,
         'Namespaces::RootStatisticsWorker' => 3,
         'Namespaces::ScheduleAggregationWorker' => 3,
         'NetworkPolicyMetricsWorker' => 3,
diff --git a/spec/workers/namespaces/update_root_statistics_worker_spec.rb b/spec/workers/namespaces/update_root_statistics_worker_spec.rb
new file mode 100644
index 000000000000..a525904b7578
--- /dev/null
+++ b/spec/workers/namespaces/update_root_statistics_worker_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Namespaces::UpdateRootStatisticsWorker do
+  let(:namespace_id) { 123 }
+
+  let(:event) do
+    Projects::ProjectDeletedEvent.new(data: { project_id: 1, namespace_id: namespace_id })
+  end
+
+  subject { consume_event(event) }
+
+  def consume_event(event)
+    described_class.new.perform(event.class.name, event.data)
+  end
+
+  it 'enqueues ScheduleAggregationWorker' do
+    expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async).with(namespace_id)
+
+    subject
+  end
+end
diff --git a/tooling/quality/test_level.rb b/tooling/quality/test_level.rb
index 50cbc69beb21..624564ecd059 100644
--- a/tooling/quality/test_level.rb
+++ b/tooling/quality/test_level.rb
@@ -24,6 +24,7 @@ class TestLevel
         elastic
         elastic_integration
         experiments
+        events
         factories
         finders
         frontend
-- 
GitLab