From e789b8ea52162dab597b7ce9e4ef704bded77a8a Mon Sep 17 00:00:00 2001
From: Jens Kraemer <jk@jkraemer.net>
Date: Mon, 30 Oct 2023 13:25:01 +0800
Subject: [PATCH 1/2] work around nested set related deadlocks in MySQL /
 MariaDB

- do not skip issue nested set concurrency tests on these databases
  anymore
- instead of attempting to lock just the issue records we are concerned
  with, use a lock on the contents of the settings table as a global lock
- also incorporates the improved (but MySQL-specific) locking code from
  #23318
---
 lib/redmine/nested_set/issue_nested_set.rb    | 10 ++++
 .../unit/issue_nested_set_concurrency_test.rb | 52 ++++++++++++++++++-
 2 files changed, 61 insertions(+), 1 deletion(-)

diff --git a/lib/redmine/nested_set/issue_nested_set.rb b/lib/redmine/nested_set/issue_nested_set.rb
index ede66064e..c97bb7cd2 100644
--- a/lib/redmine/nested_set/issue_nested_set.rb
+++ b/lib/redmine/nested_set/issue_nested_set.rb
@@ -166,6 +166,16 @@ module Redmine
           # before locking
           sets_to_lock = [root_id, parent.try(:root_id)].compact.uniq
           self.class.reorder(:id).where(:root_id => sets_to_lock).lock(lock).ids
+        elsif Redmine::Database.mysql?
+          # (ab)use settings as a global lock to prevent deadlocks
+          Setting.lock.ids
+
+          # still lock the issues in question, for good measure
+          sets_to_lock = [id, parent_id].compact
+          inner_join_statement = self.class.select(:root_id).where(id: sets_to_lock).distinct(:root_id).to_sql
+          self.class.reorder(:id).
+            joins("INNER JOIN (#{inner_join_statement}) as i2 ON #{self.class.table_name}.root_id = i2.root_id").
+            lock.ids
         else
           sets_to_lock = [id, parent_id].compact
           self.class.reorder(:id).where("root_id IN (SELECT root_id FROM #{self.class.table_name} WHERE id IN (?))", sets_to_lock).lock.ids
diff --git a/test/unit/issue_nested_set_concurrency_test.rb b/test/unit/issue_nested_set_concurrency_test.rb
index 9400b62cc..6acaf8bf5 100644
--- a/test/unit/issue_nested_set_concurrency_test.rb
+++ b/test/unit/issue_nested_set_concurrency_test.rb
@@ -29,7 +29,7 @@ class IssueNestedSetConcurrencyTest < ActiveSupport::TestCase
   self.use_transactional_tests = false
 
   def setup
-    skip if sqlite? || mysql?
+    skip if sqlite?
     User.current = nil
     CustomField.delete_all
   end
@@ -74,6 +74,56 @@ class IssueNestedSetConcurrencyTest < ActiveSupport::TestCase
     assert_equal (2..61).to_a, children_bounds
   end
 
+  def test_concurrent_subtask_removal
+    with_settings :notified_events => [] do
+      root = Issue.generate!
+
+      60.times do
+        Issue.generate! :parent_issue_id => root.id
+      end
+
+      # pick 40 random subtask ids
+      child_ids = Issue.where(root_id: root.id, parent_id: root.id).pluck(:id)
+      ids_to_remove = child_ids.sample(40).shuffle
+      ids_to_keep = child_ids - ids_to_remove
+
+      # remove these from the set, using four parallel threads
+      threads = []
+      ids_to_remove.each_slice(10) do |ids|
+        threads << Thread.new do
+          ActiveRecord::Base.connection_pool.with_connection do
+            begin
+              ids.each do |id|
+                Issue.find(id).update(parent_id: nil)
+              end
+            rescue => e
+              Thread.current[:exception] = e.message
+            end
+          end
+        end
+      end
+
+      threads.each do |thread|
+        thread.join
+        assert_nil thread[:exception]
+      end
+
+      assert_equal 20, Issue.where(parent_id: root.id).count
+      Issue.where(id: ids_to_remove).each do |issue|
+        assert_nil issue.parent_id
+        assert_equal issue.id, issue.root_id
+        assert_equal 1, issue.lft
+        assert_equal 2, issue.rgt
+      end
+
+      root.reload
+      assert_equal [1, 42], [root.lft, root.rgt]
+      children_bounds = root.children.sort_by(&:lft).map {|c| [c.lft, c.rgt]}.flatten
+      assert_equal (2..41).to_a, children_bounds
+
+    end
+  end
+
   private
 
   def threaded(count, &block)
-- 
2.42.0

