Project

General

Profile

Defect #44375 » 44375-atomic_mark_project_scheduled_for_deletion.patch

Felix Schäfer, 2026-08-25 16:26

View differences:

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,
    (1-1/1)