summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMarius Balteanu <marius.balteanu@zitec.com>2023-11-19 11:02:24 +0000
committerMarius Balteanu <marius.balteanu@zitec.com>2023-11-19 11:02:24 +0000
commit4daed3c8678d2afce69cbd626d309b6ce3cfc913 (patch)
tree690c8be1648d457b6cf5f684ea08bb0fd19569ea
parent85f5cbc77880dff69fc12716b8157fb0bdaaa664 (diff)
downloadredmine-4daed3c8678d2afce69cbd626d309b6ce3cfc913.tar.gz
redmine-4daed3c8678d2afce69cbd626d309b6ce3cfc913.zip
Merged r22458, r22459, r22460, r22461, r22462 and r22464 from trunk to 5.1-stable (#39437).
git-svn-id: https://svn.redmine.org/redmine/branches/5.1-stable@22467 e93f8b46-1217-0410-a6f0-8f06a7374b81
-rw-r--r--Gemfile1
-rw-r--r--app/models/issue.rb30
-rw-r--r--lib/redmine/database.rb4
-rw-r--r--lib/redmine/nested_set/issue_nested_set.rb73
-rw-r--r--test/test_helper.rb4
-rw-r--r--test/unit/issue_nested_set_concurrency_test.rb50
6 files changed, 128 insertions, 34 deletions
diff --git a/Gemfile b/Gemfile
index 5b789f927..f8fbd04cd 100644
--- a/Gemfile
+++ b/Gemfile
@@ -69,6 +69,7 @@ if File.exist?(database_file)
case adapter
when 'mysql2'
gem "mysql2", "~> 0.5.0", :platforms => [:mri, :mingw, :x64_mingw]
+ gem "with_advisory_lock"
when /postgresql/
gem 'pg', '~> 1.5.3', :platforms => [:mri, :mingw, :x64_mingw]
when /sqlite3/
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 8e131118c..8c8947bac 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -119,13 +119,14 @@ class Issue < ActiveRecord::Base
after_save :reschedule_following_issues, :update_nested_set_attributes,
:update_parent_attributes, :delete_selected_attachments, :create_journal
# Should be after_create but would be called before previous after_save callbacks
- after_save :after_create_from_copy, :create_parent_issue_journal
- after_destroy :update_parent_attributes, :create_parent_issue_journal
+ after_save :after_create_from_copy
+ after_destroy :update_parent_attributes
# add_auto_watcher needs to run before sending notifications, thus it needs
# to be added after send_notification (after_ callbacks are run in inverse order)
# https://api.rubyonrails.org/v5.2.3/classes/ActiveSupport/Callbacks/ClassMethods.html#method-i-set_callback
after_create_commit :send_notification
after_create_commit :add_auto_watcher
+ after_commit :create_parent_issue_journal
# Returns a SQL conditions string used to find all issues visible by the specified user
def self.visible_condition(user, options={})
@@ -2027,15 +2028,24 @@ class Issue < ActiveRecord::Base
[nil, parent_id]
end
- if old_parent_id.present? && old_parent_issue = Issue.visible.find_by_id(old_parent_id)
- old_parent_issue.init_journal(User.current)
- old_parent_issue.current_journal.__send__(:add_attribute_detail, 'child_id', child_id, nil)
- old_parent_issue.save
+ if old_parent_id.present?
+ Issue.transaction do
+ if old_parent_issue = Issue.visible.lock.find_by_id(old_parent_id)
+ old_parent_issue.init_journal(User.current)
+ old_parent_issue.current_journal.__send__(:add_attribute_detail, 'child_id', child_id, nil)
+ old_parent_issue.save
+ end
+ end
end
- if new_parent_id.present? && new_parent_issue = Issue.visible.find_by_id(new_parent_id)
- new_parent_issue.init_journal(User.current)
- new_parent_issue.current_journal.__send__(:add_attribute_detail, 'child_id', nil, child_id)
- new_parent_issue.save
+
+ if new_parent_id.present?
+ Issue.transaction do
+ if new_parent_issue = Issue.visible.lock.find_by_id(new_parent_id)
+ new_parent_issue.init_journal(User.current)
+ new_parent_issue.current_journal.__send__(:add_attribute_detail, 'child_id', nil, child_id)
+ new_parent_issue.save
+ end
+ end
end
end
diff --git a/lib/redmine/database.rb b/lib/redmine/database.rb
index a3c12a4a6..0081bfa90 100644
--- a/lib/redmine/database.rb
+++ b/lib/redmine/database.rb
@@ -61,6 +61,10 @@ module Redmine
/mysql/i.match?(ActiveRecord::Base.connection.adapter_name)
end
+ def mysql_version
+ mysql? ? ActiveRecord::Base.connection.select_value("SELECT VERSION()") : nil
+ end
+
# Returns a SQL statement for case/accent (if possible) insensitive match
def like(left, right, options={})
neg = (options[:match] == false ? 'NOT ' : '')
diff --git a/lib/redmine/nested_set/issue_nested_set.rb b/lib/redmine/nested_set/issue_nested_set.rb
index ede66064e..5812d337f 100644
--- a/lib/redmine/nested_set/issue_nested_set.rb
+++ b/lib/redmine/nested_set/issue_nested_set.rb
@@ -51,7 +51,14 @@ module Redmine
end
def add_to_nested_set(lock=true)
- lock_nested_set if lock
+ if lock
+ lock_nested_set { add_to_nested_set_without_lock }
+ else
+ add_to_nested_set_without_lock
+ end
+ end
+
+ def add_to_nested_set_without_lock
parent.send :reload_nested_set_values
self.root_id = parent.root_id
self.lft = target_lft
@@ -73,15 +80,16 @@ module Redmine
end
def handle_parent_change
- lock_nested_set
- reload_nested_set_values
- if parent_id_was
- remove_from_nested_set
- end
- if parent
- move_to_nested_set
+ lock_nested_set do
+ reload_nested_set_values
+ if parent_id_was
+ remove_from_nested_set
+ end
+ if parent
+ move_to_nested_set
+ end
+ reload_nested_set_values
end
- reload_nested_set_values
end
def move_to_nested_set
@@ -124,20 +132,22 @@ module Redmine
end
def destroy_children
- unless @without_nested_set_update
- lock_nested_set
- reload_nested_set_values
- end
- children.each {|c| c.send :destroy_without_nested_set_update}
- reload
- unless @without_nested_set_update
- self.class.where(:root_id => root_id).where("lft > ? OR rgt > ?", lft, lft).update_all(
- [
- "lft = CASE WHEN lft > :lft THEN lft - :shift ELSE lft END, " +
- "rgt = CASE WHEN rgt > :lft THEN rgt - :shift ELSE rgt END",
- {:lft => lft, :shift => rgt - lft + 1}
- ]
- )
+ if @without_nested_set_update
+ children.each {|c| c.send :destroy_without_nested_set_update}
+ reload
+ else
+ lock_nested_set do
+ reload_nested_set_values
+ children.each {|c| c.send :destroy_without_nested_set_update}
+ reload
+ self.class.where(:root_id => root_id).where("lft > ? OR rgt > ?", lft, lft).update_all(
+ [
+ "lft = CASE WHEN lft > :lft THEN lft - :shift ELSE lft END, " +
+ "rgt = CASE WHEN rgt > :lft THEN rgt - :shift ELSE rgt END",
+ {:lft => lft, :shift => rgt - lft + 1}
+ ]
+ )
+ end
end
end
@@ -166,9 +176,26 @@ 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
+ yield
+ elsif Redmine::Database.mysql?
+ # Use a global lock to prevent concurrent modifications - MySQL row locks are broken, this will run into
+ # deadlock errors all the time otherwise.
+ # Trying to lock just the sets in question (by basing the lock name on root_id and parent&.root_id) will run
+ # into the same issues as the sqlserver branch above
+ Issue.with_advisory_lock!("lock_issues", timeout_seconds: 30) do
+ # 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
+
+ yield
+ end
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
+ yield
end
end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index c5fe079c8..19fa1ca4f 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -203,6 +203,10 @@ class ActiveSupport::TestCase
Redmine::Database.mysql?
end
+ def mysql8?
+ Gem::Version.new(Redmine::Database.mysql_version) >= Gem::Version.new('8.0.0')
+ end
+
def postgresql?
Redmine::Database.postgresql?
end
diff --git a/test/unit/issue_nested_set_concurrency_test.rb b/test/unit/issue_nested_set_concurrency_test.rb
index 9400b62cc..7f45a4b8d 100644
--- a/test/unit/issue_nested_set_concurrency_test.rb
+++ b/test/unit/issue_nested_set_concurrency_test.rb
@@ -29,7 +29,12 @@ class IssueNestedSetConcurrencyTest < ActiveSupport::TestCase
self.use_transactional_tests = false
def setup
- skip if sqlite? || mysql?
+ skip if sqlite?
+ if mysql?
+ connection = ActiveRecord::Base.connection_db_config.configuration_hash.deep_dup
+ connection[:variables] = mysql8? ? { transaction_isolation: "READ-COMMITTED" } : { tx_isolation: "READ-COMMITTED" }
+ ActiveRecord::Base.establish_connection connection
+ end
User.current = nil
CustomField.delete_all
end
@@ -74,6 +79,49 @@ 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)