From b8013dd47ee9be3a8b318bb8aa55a91664312133 Mon Sep 17 00:00:00 2001
From: pbair <pbair@gitlab.com>
Date: Thu, 19 Aug 2021 12:58:38 -0400
Subject: [PATCH] Add rubocop to prevent use of subtransactions

Add a new rubocop rule that prevents direct use of subtransactions,
meaning any call to #transaction with the options `requires_new: true`.
---
 .rubocop.yml                                  |  5 ++
 app/models/application_record.rb              |  6 +-
 app/models/application_setting.rb             |  2 +-
 app/models/internal_id.rb                     |  4 +-
 .../move_deploy_keys_projects_service.rb      |  2 +-
 app/services/projects/move_forks_service.rb   |  2 +-
 .../move_lfs_objects_projects_service.rb      |  2 +-
 .../move_notification_settings_service.rb     |  2 +-
 .../move_project_authorizations_service.rb    |  2 +-
 .../move_project_group_links_service.rb       |  2 +-
 .../projects/move_project_members_service.rb  |  2 +-
 .../move_users_star_projects_service.rb       |  2 +-
 .../users/migrate_to_ghost_user_service.rb    |  2 +-
 .../20201106134950_deduplicate_epic_iids.rb   |  2 +-
 .../vulnerabilities/create_service.rb         |  2 +-
 .../migrate_approver_to_approval_rules.rb     |  2 +-
 .../backfill_design_internal_ids.rb           |  2 +-
 .../backfill_project_repositories.rb          |  2 +-
 lib/gitlab/database/with_lock_retries.rb      |  2 +-
 .../active_record_subtransactions.rb          | 30 +++++++++
 .../active_record_subtransactions_spec.rb     | 62 +++++++++++++++++++
 21 files changed, 118 insertions(+), 21 deletions(-)
 create mode 100644 rubocop/cop/performance/active_record_subtransactions.rb
 create mode 100644 spec/rubocop/cop/performance/active_record_subtransactions_spec.rb

diff --git a/.rubocop.yml b/.rubocop.yml
index a82910d89fbc1..6a460e30a9553 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -712,3 +712,8 @@ QA/SelectorUsage:
     - 'ee/spec/**/*.rb'
   Exclude:
     - 'spec/rubocop/**/*_spec.rb'
+
+Performance/ActiveRecordSubtransactions:
+  Exclude:
+    - 'spec/**/*.rb'
+    - 'ee/spec/**/*.rb'
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 29ef76547e066..2353a514097f0 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -31,7 +31,7 @@ def self.pluck_primary_key
   end
 
   def self.safe_ensure_unique(retries: 0)
-    transaction(requires_new: true) do
+    transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
       yield
     end
   rescue ActiveRecord::RecordNotUnique
@@ -55,7 +55,7 @@ def self.safe_find_or_create_by!(*args, &block)
   # currently one third of the default 15-second timeout
   def self.with_fast_read_statement_timeout(timeout_ms = 5000)
     ::Gitlab::Database::LoadBalancing::Session.current.fallback_to_replicas_for_ambiguous_queries do
-      transaction(requires_new: true) do
+      transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         connection.exec_query("SET LOCAL statement_timeout = #{timeout_ms}")
 
         yield
@@ -80,7 +80,7 @@ def self.optimized_safe_find_or_create_by(*args, &block)
     #
     # When calling this method on an association, just calling `self.create` would call `ActiveRecord::Persistence.create`
     # and that skips some code that adds the newly created record to the association.
-    transaction(requires_new: true) { all.create(*args, &block) }
+    transaction(requires_new: true) { all.create(*args, &block) } # rubocop:disable Performance/ActiveRecordSubtransactions
   rescue ActiveRecord::RecordNotUnique
     find_by(*args)
   end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index c4b6bcb9395f0..8ed408d2c23d0 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -622,7 +622,7 @@ def instance_review_permitted?
   def self.create_from_defaults
     check_schema!
 
-    transaction(requires_new: true) do
+    transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
       super
     end
   rescue ActiveRecord::RecordNotUnique
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index 107b1914af434..9cbb5f92bbdd6 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -239,7 +239,7 @@ def create_record
         lookup
       else
         begin
-          subject.transaction(requires_new: true) do
+          subject.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
             InternalId.create!(
               **scope,
               usage: usage_value,
@@ -362,7 +362,7 @@ def create_record!(subject, scope, usage, value)
         value
       else
         begin
-          subject.transaction(requires_new: true) do
+          subject.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
             internal_id = InternalId.create!(**scope, usage: usage, last_value: value)
             internal_id.last_value
           end
diff --git a/app/services/projects/move_deploy_keys_projects_service.rb b/app/services/projects/move_deploy_keys_projects_service.rb
index 51d84af249e25..17513f0ba11f8 100644
--- a/app/services/projects/move_deploy_keys_projects_service.rb
+++ b/app/services/projects/move_deploy_keys_projects_service.rb
@@ -5,7 +5,7 @@ class MoveDeployKeysProjectsService < BaseMoveRelationsService
     def execute(source_project, remove_remaining_elements: true)
       return unless super
 
-      Project.transaction(requires_new: true) do
+      Project.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         move_deploy_keys_projects
         remove_remaining_deploy_keys_projects if remove_remaining_elements
 
diff --git a/app/services/projects/move_forks_service.rb b/app/services/projects/move_forks_service.rb
index 33f0bab12c95c..f61552f661f95 100644
--- a/app/services/projects/move_forks_service.rb
+++ b/app/services/projects/move_forks_service.rb
@@ -5,7 +5,7 @@ class MoveForksService < BaseMoveRelationsService
     def execute(source_project, remove_remaining_elements: true)
       return unless super && source_project.fork_network
 
-      Project.transaction(requires_new: true) do
+      Project.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         move_fork_network_members
         update_root_project
         refresh_forks_count
diff --git a/app/services/projects/move_lfs_objects_projects_service.rb b/app/services/projects/move_lfs_objects_projects_service.rb
index 57a8d3d69c6fb..ca8b4bfde6878 100644
--- a/app/services/projects/move_lfs_objects_projects_service.rb
+++ b/app/services/projects/move_lfs_objects_projects_service.rb
@@ -5,7 +5,7 @@ class MoveLfsObjectsProjectsService < BaseMoveRelationsService
     def execute(source_project, remove_remaining_elements: true)
       return unless super
 
-      Project.transaction(requires_new: true) do
+      Project.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         move_lfs_objects_projects
         remove_remaining_lfs_objects_project if remove_remaining_elements
 
diff --git a/app/services/projects/move_notification_settings_service.rb b/app/services/projects/move_notification_settings_service.rb
index efe06f158ccd2..fc268f5762b88 100644
--- a/app/services/projects/move_notification_settings_service.rb
+++ b/app/services/projects/move_notification_settings_service.rb
@@ -5,7 +5,7 @@ class MoveNotificationSettingsService < BaseMoveRelationsService
     def execute(source_project, remove_remaining_elements: true)
       return unless super
 
-      Project.transaction(requires_new: true) do
+      Project.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         move_notification_settings
         remove_remaining_notification_settings if remove_remaining_elements
 
diff --git a/app/services/projects/move_project_authorizations_service.rb b/app/services/projects/move_project_authorizations_service.rb
index c95ad60ab5e22..30f3b892131c1 100644
--- a/app/services/projects/move_project_authorizations_service.rb
+++ b/app/services/projects/move_project_authorizations_service.rb
@@ -9,7 +9,7 @@ class MoveProjectAuthorizationsService < BaseMoveRelationsService
     def execute(source_project, remove_remaining_elements: true)
       return unless super
 
-      Project.transaction(requires_new: true) do
+      Project.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         move_project_authorizations
 
         remove_remaining_authorizations if remove_remaining_elements
diff --git a/app/services/projects/move_project_group_links_service.rb b/app/services/projects/move_project_group_links_service.rb
index 349953ff973d4..8344b0b404b95 100644
--- a/app/services/projects/move_project_group_links_service.rb
+++ b/app/services/projects/move_project_group_links_service.rb
@@ -9,7 +9,7 @@ class MoveProjectGroupLinksService < BaseMoveRelationsService
     def execute(source_project, remove_remaining_elements: true)
       return unless super
 
-      Project.transaction(requires_new: true) do
+      Project.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         move_group_links
         remove_remaining_project_group_links if remove_remaining_elements
 
diff --git a/app/services/projects/move_project_members_service.rb b/app/services/projects/move_project_members_service.rb
index 9a1b7c6d1b623..11629c3fd7e8e 100644
--- a/app/services/projects/move_project_members_service.rb
+++ b/app/services/projects/move_project_members_service.rb
@@ -9,7 +9,7 @@ class MoveProjectMembersService < BaseMoveRelationsService
     def execute(source_project, remove_remaining_elements: true)
       return unless super
 
-      Project.transaction(requires_new: true) do
+      Project.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         move_project_members
         remove_remaining_members if remove_remaining_elements
 
diff --git a/app/services/projects/move_users_star_projects_service.rb b/app/services/projects/move_users_star_projects_service.rb
index 20121d429e2f5..b8564a02301cf 100644
--- a/app/services/projects/move_users_star_projects_service.rb
+++ b/app/services/projects/move_users_star_projects_service.rb
@@ -9,7 +9,7 @@ def execute(source_project, remove_remaining_elements: true)
 
       return unless user_stars.any?
 
-      Project.transaction(requires_new: true) do
+      Project.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         user_stars.update_all(project_id: @project.id)
 
         Project.reset_counters @project.id, :users_star_projects
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
index a471f55e644a2..09fd74e9818f6 100644
--- a/app/services/users/migrate_to_ghost_user_service.rb
+++ b/app/services/users/migrate_to_ghost_user_service.rb
@@ -39,7 +39,7 @@ def execute
     private
 
     def migrate_records_in_transaction
-      user.transaction(requires_new: true) do
+      user.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         @ghost_user = User.ghost
 
         migrate_records
diff --git a/db/post_migrate/20201106134950_deduplicate_epic_iids.rb b/db/post_migrate/20201106134950_deduplicate_epic_iids.rb
index bc7daf9329d65..8fddc81057b6b 100644
--- a/db/post_migrate/20201106134950_deduplicate_epic_iids.rb
+++ b/db/post_migrate/20201106134950_deduplicate_epic_iids.rb
@@ -85,7 +85,7 @@ def create_record
 
       instance = subject.is_a?(::Class) ? nil : subject
 
-      subject.transaction(requires_new: true) do
+      subject.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         InternalId.create!(
           **scope,
           usage: usage_value,
diff --git a/ee/app/services/vulnerabilities/create_service.rb b/ee/app/services/vulnerabilities/create_service.rb
index e9b27bee2c75a..02aad1506e459 100644
--- a/ee/app/services/vulnerabilities/create_service.rb
+++ b/ee/app/services/vulnerabilities/create_service.rb
@@ -16,7 +16,7 @@ def execute
 
       vulnerability = Vulnerability.new
 
-      Vulnerabilities::Finding.transaction(requires_new: true) do
+      Vulnerabilities::Finding.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
         save_vulnerability(vulnerability, finding)
         Statistics::UpdateService.update_for(vulnerability)
         HistoricalStatistics::UpdateService.update_for(@project)
diff --git a/ee/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules.rb b/ee/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules.rb
index 62b0219e62fa0..10c77813d9977 100644
--- a/ee/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules.rb
+++ b/ee/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules.rb
@@ -65,7 +65,7 @@ def self.safe_find_or_create_by(*args)
           end
 
           def self.safe_ensure_unique(retries: 0)
-            transaction(requires_new: true) do
+            transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
               yield
             end
           rescue ActiveRecord::RecordNotUnique
diff --git a/lib/gitlab/background_migration/backfill_design_internal_ids.rb b/lib/gitlab/background_migration/backfill_design_internal_ids.rb
index 6d1df95c66d92..236c6b6eb9a72 100644
--- a/lib/gitlab/background_migration/backfill_design_internal_ids.rb
+++ b/lib/gitlab/background_migration/backfill_design_internal_ids.rb
@@ -73,7 +73,7 @@ def usage_value
         # violation. We can safely roll-back the nested transaction and perform
         # a lookup instead to retrieve the record.
         def create_record
-          subject.transaction(requires_new: true) do
+          subject.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
             InternalId.create!(
               **scope,
               usage: usage_value,
diff --git a/lib/gitlab/background_migration/backfill_project_repositories.rb b/lib/gitlab/background_migration/backfill_project_repositories.rb
index f5c8796bd18ec..a9eaeb0562dc5 100644
--- a/lib/gitlab/background_migration/backfill_project_repositories.rb
+++ b/lib/gitlab/background_migration/backfill_project_repositories.rb
@@ -21,7 +21,7 @@ def find_shard_id(name)
           shard_id = shards.fetch(name, nil)
           return shard_id if shard_id.present?
 
-          Shard.transaction(requires_new: true) do
+          Shard.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
             create!(name)
           end
         rescue ActiveRecord::RecordNotUnique
diff --git a/lib/gitlab/database/with_lock_retries.rb b/lib/gitlab/database/with_lock_retries.rb
index bbf8f133f0fc7..70acbeac9e151 100644
--- a/lib/gitlab/database/with_lock_retries.rb
+++ b/lib/gitlab/database/with_lock_retries.rb
@@ -122,7 +122,7 @@ def run_block
       end
 
       def run_block_with_lock_timeout
-        ActiveRecord::Base.transaction(requires_new: true) do
+        ActiveRecord::Base.transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
           execute("SET LOCAL lock_timeout TO '#{current_lock_timeout_in_ms}ms'")
 
           log(message: 'Lock timeout is set', current_iteration: current_iteration, lock_timeout_in_ms: current_lock_timeout_in_ms)
diff --git a/rubocop/cop/performance/active_record_subtransactions.rb b/rubocop/cop/performance/active_record_subtransactions.rb
new file mode 100644
index 0000000000000..a550b558e52a1
--- /dev/null
+++ b/rubocop/cop/performance/active_record_subtransactions.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module RuboCop
+  module Cop
+    module Performance
+      class ActiveRecordSubtransactions < RuboCop::Cop::Cop
+        MSG = 'Subtransactions should not be used. ' \
+          'For more information see: https://gitlab.com/gitlab-org/gitlab/-/issues/338346'
+
+        def_node_matcher :match_transaction_with_options, <<~PATTERN
+          (send _ :transaction (hash $...))
+        PATTERN
+
+        def_node_matcher :subtransaction_option?, <<~PATTERN
+          (pair (:sym :requires_new) (true))
+        PATTERN
+
+        def on_send(node)
+          match_transaction_with_options(node) do |option_nodes|
+            option_nodes.each do |option_node|
+              next unless subtransaction_option?(option_node)
+
+              add_offense(option_node)
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/rubocop/cop/performance/active_record_subtransactions_spec.rb b/spec/rubocop/cop/performance/active_record_subtransactions_spec.rb
new file mode 100644
index 0000000000000..0da2e30062a2f
--- /dev/null
+++ b/spec/rubocop/cop/performance/active_record_subtransactions_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require_relative '../../../../rubocop/cop/performance/active_record_subtransactions'
+
+RSpec.describe RuboCop::Cop::Performance::ActiveRecordSubtransactions do
+  subject(:cop) { described_class.new }
+
+  let(:message) { described_class::MSG }
+
+  context 'when calling #transaction with only requires_new: true' do
+    it 'registers an offense' do
+      expect_offense(<<~RUBY)
+        ApplicationRecord.transaction(requires_new: true) do
+                                      ^^^^^^^^^^^^^^^^^^ #{message}
+          Project.create!(name: 'MyProject')
+        end
+      RUBY
+    end
+  end
+
+  context 'when passing multiple arguments to #transaction, including requires_new: true' do
+    it 'registers an offense' do
+      expect_offense(<<~RUBY)
+        ApplicationRecord.transaction(isolation: :read_committed, requires_new: true) do
+                                                                  ^^^^^^^^^^^^^^^^^^ #{message}
+          Project.create!(name: 'MyProject')
+        end
+      RUBY
+    end
+  end
+
+  context 'when calling #transaction with requires_new: false' do
+    it 'does not register an offense' do
+      expect_no_offenses(<<~RUBY)
+        ApplicationRecord.transaction(requires_new: false) do
+          Project.create!(name: 'MyProject')
+        end
+      RUBY
+    end
+  end
+
+  context 'when calling #transaction with other options' do
+    it 'does not register an offense' do
+      expect_no_offenses(<<~RUBY)
+        ApplicationRecord.transaction(isolation: :read_committed) do
+          Project.create!(name: 'MyProject')
+        end
+      RUBY
+    end
+  end
+
+  context 'when calling #transaction with no arguments' do
+    it 'does not register an offense' do
+      expect_no_offenses(<<~RUBY)
+        ApplicationRecord.transaction do
+          Project.create!(name: 'MyProject')
+        end
+      RUBY
+    end
+  end
+end
-- 
GitLab