Defect #44375 » 44375-atomic_mark_project_scheduled_for_deletion.patch
| app/jobs/destroy_project_job.rb | ||
|---|---|---|
| 4 | 4 |
include Redmine::I18n |
| 5 | 5 | |
| 6 | 6 |
def self.schedule(project, user: User.current) |
| 7 |
# make the project (and any children) disappear immediately |
|
| 8 |
project.self_and_descendants.update_all status: Project::STATUS_SCHEDULED_FOR_DELETION |
|
| 7 |
Project.mark_for_deletion(project) |
|
| 9 | 8 |
perform_later project.id, user.id, user.remote_ip |
| 10 | 9 |
end |
| 11 | 10 | |
| app/jobs/destroy_projects_job.rb | ||
|---|---|---|
| 4 | 4 |
include Redmine::I18n |
| 5 | 5 | |
| 6 | 6 |
def self.schedule(projects_to_delete, user: User.current) |
| 7 |
# make the projects disappear immediately |
|
| 8 |
projects_to_delete.each do |project| |
|
| 9 |
project.self_and_descendants.update_all status: Project::STATUS_SCHEDULED_FOR_DELETION |
|
| 10 |
end |
|
| 7 |
Project.mark_for_deletion(projects_to_delete) |
|
| 11 | 8 |
perform_later(projects_to_delete.map(&:id), user.id, user.remote_ip) |
| 12 | 9 |
end |
| 13 | 10 | |
| app/models/project.rb | ||
|---|---|---|
| 410 | 410 |
self.status == STATUS_ARCHIVED |
| 411 | 411 |
end |
| 412 | 412 | |
| 413 |
# Atomically marks one or more projects (and all their descendants) as |
|
| 414 |
# scheduled for deletion. Holds the nested-set lock and resolves the |
|
| 415 |
# descendant set by id so a concurrent create/rename/move/destroy cannot |
|
| 416 |
# shift lft/rgt out from under a stale cached range. |
|
| 417 |
def self.mark_for_deletion(projects) |
|
| 418 |
transaction do |
|
| 419 |
order(:id).lock.ids |
|
| 420 |
ids = Array(projects).flat_map do |p| |
|
| 421 |
p.reload |
|
| 422 |
p.self_and_descendants.pluck(:id) |
|
| 423 |
end |
|
| 424 |
where(id: ids).update_all(status: STATUS_SCHEDULED_FOR_DELETION) |
|
| 425 |
end |
|
| 426 |
end |
|
| 427 | ||
| 413 | 428 |
def scheduled_for_deletion? |
| 414 | 429 |
self.status == STATUS_SCHEDULED_FOR_DELETION |
| 415 | 430 |
end |
| test/unit/jobs/destroy_project_job_test.rb | ||
|---|---|---|
| 36 | 36 |
end |
| 37 | 37 |
end |
| 38 | 38 | |
| 39 |
test "schedule must not mark unrelated projects when the nested set is rebalanced between load and update_all" do |
|
| 40 |
# Same race as the DestroyProjectsJob version, but on the |
|
| 41 |
# single-project path: the window is just the gap between the |
|
| 42 |
# controller load and the schedule call rather than spanning a loop. |
|
| 43 |
target = @project # Project 1 (eCookbook) |
|
| 44 |
victim = Project.find(2) # onlinestore, unrelated root |
|
| 45 |
cached_lft, cached_rgt = target.lft, target.rgt |
|
| 46 | ||
| 47 |
assert_not_equal Project::STATUS_SCHEDULED_FOR_DELETION, victim.status |
|
| 48 | ||
| 49 |
# trigger nested set rebalancing while target is loaded |
|
| 50 |
victim.update!(name: 'AAA renamed earlier than ecookbook') |
|
| 51 |
victim.reload |
|
| 52 |
assert victim.lft >= cached_lft && victim.rgt <= cached_rgt, |
|
| 53 |
"test setup: victim should now sit inside target's stale range" |
|
| 54 | ||
| 55 |
# target is intentionally NOT reloaded |
|
| 56 |
assert_equal cached_lft, target.lft |
|
| 57 |
assert_equal cached_rgt, target.rgt |
|
| 58 | ||
| 59 |
DestroyProjectJob.schedule target, user: @user |
|
| 60 | ||
| 61 |
victim.reload |
|
| 62 |
assert_not_equal Project::STATUS_SCHEDULED_FOR_DELETION, victim.status, |
|
| 63 |
"Project #{victim.id} (#{victim.name}) was marked for deletion even though it was not the project passed to schedule."
|
|
| 64 |
end |
|
| 65 | ||
| 39 | 66 |
test "schedule should enqueue job" do |
| 40 | 67 |
DestroyProjectJob.schedule @project, user: @user |
| 41 | 68 |
assert_enqueued_with( |
| test/unit/jobs/destroy_projects_job_test.rb | ||
|---|---|---|
| 37 | 37 |
end |
| 38 | 38 |
end |
| 39 | 39 | |
| 40 |
test "schedule must not mark unrelated projects when the nested set is rebalanced between load and update_all" do |
|
| 41 |
# Reproduces the race condition where projects[].self_and_descendants |
|
| 42 |
# uses cached in-memory lft/rgt to build a range UPDATE. When the |
|
| 43 |
# nested set is rebalanced (concurrent project create/rename/move/ |
|
| 44 |
# destroy) between the controller load and the update, the cached |
|
| 45 |
# range can match unrelated projects, which then get status=10 even |
|
| 46 |
# though their ids are never passed to perform_later -> the |
|
| 47 |
# "vanished but not deleted" customer-visible symptom. |
|
| 48 |
target = Project.find(1) # eCookbook, root w/ descendants |
|
| 49 |
victim = Project.find(2) # onlinestore, unrelated root |
|
| 50 |
cached_lft, cached_rgt = target.lft, target.rgt |
|
| 51 | ||
| 52 |
assert_not_equal Project::STATUS_SCHEDULED_FOR_DELETION, victim.status |
|
| 53 | ||
| 54 |
# Simulate a concurrent transaction renaming the unrelated root so it |
|
| 55 |
# sorts before `target`. before_update :move_in_nested_set fires (see |
|
| 56 |
# project_nested_set.rb:28-32) and rebalances the tree under |
|
| 57 |
# lock_nested_set. After this, the database lft/rgt of every project |
|
| 58 |
# has shifted, but our in-memory `target` still holds the values it |
|
| 59 |
# had when it was loaded. |
|
| 60 |
victim.update!(name: 'AAA renamed earlier than ecookbook') |
|
| 61 |
victim.reload |
|
| 62 |
assert victim.lft >= cached_lft && victim.rgt <= cached_rgt, |
|
| 63 |
"test setup: victim should now sit inside target's stale range" |
|
| 64 | ||
| 65 |
# in-memory target is intentionally NOT reloaded |
|
| 66 |
assert_equal cached_lft, target.lft |
|
| 67 |
assert_equal cached_rgt, target.rgt |
|
| 68 | ||
| 69 |
DestroyProjectsJob.schedule [target], user: @user |
|
| 70 | ||
| 71 |
victim.reload |
|
| 72 |
assert_not_equal Project::STATUS_SCHEDULED_FOR_DELETION, victim.status, |
|
| 73 |
"Project #{victim.id} (#{victim.name}) was marked for deletion even though it was not in the deletion id list. "
|
|
| 74 |
end |
|
| 75 | ||
| 40 | 76 |
test "schedule should enqueue job" do |
| 41 | 77 |
assert_enqueued_with( |
| 42 | 78 |
job: DestroyProjectsJob, |