Skip to content

Commit b202de5

Browse files
committed
[E] Improve soft deletion process
* We need to destroy certain large / unwieldy associations outside of the transaction that happens during the `destroy!` process. This prevents performance issues that come from locking too many rows at once. Records that reach this job already are intended to be taken out of the database, so we do not need to follow the normal type of data integrity concerns in order to remove them. * Slight adjustment to stylesheets to prevent the styles from blowing up the console when inspecting stylesheet records.
1 parent e05c0c5 commit b202de5

File tree

13 files changed

+386
-4
lines changed

13 files changed

+386
-4
lines changed

api/app/jobs/soft_deletions/purge_job.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ module SoftDeletions
55
class PurgeJob < ApplicationJob
66
queue_as :deletions
77

8-
discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound, ActiveRecord::RecordNotDestroyed
8+
discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound, ActiveRecord::RecordNotDestroyed,
9+
SoftDeletions::Unpurgeable
910

1011
# @param [SoftDeletable] record
1112
# @return [void]
1213
def perform(record)
13-
record.destroy! if record.marked_for_purge?
14+
record.soft_deletion_purge!
1415
end
1516
end
1617
end

api/app/models/concerns/soft_deletable.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ module SoftDeletable
66
SOFT_DELETABLE_DEPENDENTS = %i[destroy delete_all].freeze
77

88
include Filterable
9+
include SoftDeletionSupport
910

1011
included do
1112
define_model_callbacks :mark_for_purge, :soft_delete
@@ -78,6 +79,13 @@ def soft_delete!
7879
soft_delete or raise ActiveRecord::RecordNotDestroyed.new("Failed to destroy the record", self)
7980
end
8081

82+
# @see SoftDeletions::Purge
83+
# @see SoftDeletions::Purger
84+
# @return [void]
85+
def soft_deletion_purge!
86+
ManifoldApi::Container["soft_deletions.purge"].call(self).value!
87+
end
88+
8189
private
8290

8391
# @return [void]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
# Support methods for detecting if soft-deletion is happening.
4+
# Models do not have to be soft-deletable to take advantage of this concern.
5+
module SoftDeletionSupport
6+
extend ActiveSupport::Concern
7+
8+
# @see SoftDeletions::Current
9+
def soft_deleting?
10+
SoftDeletions::Current.active?
11+
end
12+
end

api/app/models/stylesheet.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ class Stylesheet < ApplicationRecord
1212
include SerializedAbilitiesFor
1313
self.authorizer_name = "ProjectChildAuthorizer"
1414

15+
self.filter_attributes = [/\Araw_styles\z/, /\Astyles\z/].freeze
16+
1517
# Associations
1618
belongs_to :text
1719
has_one :project, through: :text

api/app/models/text.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ class Text < ApplicationRecord
7575
has_many :text_sections, -> { order(position: :asc) }, dependent: :destroy,
7676
inverse_of: :text, autosave: true
7777
has_one_readonly :text_section_aggregation, inverse_of: :text
78+
has_many :text_section_nodes, -> { reorder(nil) }, through: :text_sections
79+
has_many :text_section_stylesheets, -> { reorder(nil) }, through: :text_sections
7880
has_many :stylesheets, -> { order(position: :asc) }, dependent: :destroy,
7981
inverse_of: :text
8082
has_many :favorites, as: :favoritable, dependent: :destroy, inverse_of: :favoritable

api/app/models/text_section.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class TextSection < ApplicationRecord
1212
include HasKeywordSearch
1313
include SearchIndexable
1414
include SerializedAbilitiesFor
15+
include SoftDeletionSupport
1516

1617
KIND_COVER_IMAGE = "cover_image"
1718
KIND_NAVIGATION = "navigation"
@@ -47,7 +48,7 @@ class TextSection < ApplicationRecord
4748
has_many :annotations, dependent: :nullify
4849
has_many :resources, through: :annotations
4950
has_many :resource_collections, through: :annotations
50-
has_many :text_section_nodes, inverse_of: :text_section, dependent: :destroy
51+
has_many :text_section_nodes, inverse_of: :text_section, dependent: :delete_all
5152
has_many :text_section_stylesheets, dependent: :destroy
5253
has_many :stylesheets,
5354
-> { order(position: :asc) },
@@ -76,7 +77,7 @@ class TextSection < ApplicationRecord
7677
validates :slug, presence: true, allow_nil: true
7778

7879
before_validation :update_body_json
79-
after_destroy :remove_linked_toc_entries
80+
after_destroy :remove_linked_toc_entries, unless: :skip_removal_of_toc_entries?
8081
after_save_commit :asynchronously_index_nodes!
8182
after_commit :maybe_adopt_or_orphan_annotations!, on: [:update, :destroy]
8283

@@ -119,6 +120,10 @@ def packaging_identifier
119120
has_unique_source_identifier? ? source_identifier : slug
120121
end
121122

123+
def skip_removal_of_toc_entries?
124+
destroyed_by_association || soft_deleting?
125+
end
126+
122127
def slug_candidates
123128
reserved_words = %w(all new edit session login logout users admin
124129
stylesheets assets javascripts)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
module SoftDeletions
4+
# @see SoftDeletable
5+
# @see SoftDeletions::PurgeJob
6+
# @see SoftDeletions::Purger
7+
class Purge
8+
def call(...)
9+
SoftDeletions::Purger.new(...).call
10+
end
11+
end
12+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
module SoftDeletions
4+
# Current attributes for tracking the status of the soft-deletion process.
5+
#
6+
# @see SoftDeletionSupport
7+
class Current < ActiveSupport::CurrentAttributes
8+
attribute :active
9+
10+
class << self
11+
alias active? active
12+
13+
# @return [void]
14+
def active!
15+
set(active: true) do
16+
yield
17+
end
18+
end
19+
end
20+
end
21+
end
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# frozen_string_literal: true
2+
3+
module SoftDeletions
4+
# We use this service to delete large objects in the background in order to avoid
5+
# long-running requests, and specifically to avoid massive table locks happening
6+
# within a single transaction during a soft-deletion.
7+
#
8+
# Normally we would want to keep a transaction active around processes like this
9+
# in order to ensure data integrity, but if a model has reached this object, it
10+
# is already marked for deletion and we don't need to maintain the ability to
11+
# roll things back.
12+
#
13+
# @see SoftDeletable
14+
# @see SoftDeletions::PurgeJob
15+
# @see SoftDeletions::Purge
16+
class Purger
17+
extend ActiveModel::Callbacks
18+
19+
include Dry::Monads[:result]
20+
include Dry::Initializer[undefined: false].define -> do
21+
param :original_record, SoftDeletions::Types::SoftDeletable
22+
end
23+
24+
define_model_callbacks :perform
25+
26+
around_perform :activate_soft_deletion!
27+
28+
around_perform :disable_acts_as_list!
29+
30+
# @raise [SoftDeletions::Unpurgeable] if the record could not be purged
31+
# @return [Dry::Monads::Success(void)]
32+
def call
33+
# :nocov:
34+
return Success() unless original_record.marked_for_purge?
35+
# :nocov:
36+
37+
handle! original_record
38+
rescue ActiveRecord::RecordNotDestroyed => e
39+
raise SoftDeletions::Unpurgeable, "Could not soft delete #{original_record.class.name}(#{original_record.id.inspect}): #{e.message}"
40+
else
41+
Success()
42+
end
43+
44+
private
45+
46+
# @param [ApplicationRecord] record
47+
# @return [void]
48+
def handle!(record)
49+
case record
50+
when Annotation
51+
on_annotation! record
52+
when Comment
53+
on_comment! record
54+
when Project
55+
on_project! record
56+
when ReadingGroup
57+
on_reading_group! record
58+
when Text
59+
on_text! record
60+
when TextSection
61+
on_text_section! record
62+
when User
63+
on_user! record
64+
end
65+
66+
record.destroy!
67+
end
68+
69+
# @return [void]
70+
def activate_soft_deletion!
71+
SoftDeletions::Current.active! do
72+
yield
73+
end
74+
end
75+
76+
def disable_acts_as_list!
77+
TextSection.acts_as_list_no_update do
78+
yield
79+
end
80+
end
81+
82+
# @param [Annotation] record
83+
# @return [void]
84+
def on_annotation!(record)
85+
record.comments.where(parent_id: nil).find_each do |comment|
86+
handle! comment
87+
end
88+
end
89+
90+
# @param [Comment] record
91+
# @return [void]
92+
def on_comment!(record)
93+
record.children.find_each do |child_comment|
94+
handle! child_comment
95+
end
96+
end
97+
98+
# @param [Project] record
99+
# @return [void]
100+
def on_project!(record)
101+
record.texts.find_each do |text|
102+
handle!(text)
103+
end
104+
end
105+
106+
# @param [ReadingGroup] record
107+
# @return [void]
108+
def on_reading_group!(record)
109+
# We perform this outside of the transaction even though it's part of
110+
# the callback.
111+
record.update_annotations_privacy
112+
record.reading_group_memberships.reorder(nil).delete_all
113+
record.reading_group_categories.reorder(nil).delete_all
114+
end
115+
116+
# @param [Text] record
117+
# @return [void]
118+
def on_text!(record)
119+
# These are the biggest offender. Some texts can have tens of thousands of nodes,
120+
# and we want to delete them outside of any transactions that lock other updates.
121+
TextSectionNode.where(id: record.text_section_nodes.select(:id)).delete_all
122+
123+
TextSectionStylesheet.where(id: record.text_section_stylesheets.select(:id)).delete_all
124+
125+
record.action_callouts.destroy_all
126+
127+
record.text_sections.find_each do |text_section|
128+
handle!(text_section)
129+
end
130+
131+
record.stylesheets.delete_all
132+
end
133+
134+
# @param [TextSection] record
135+
# @return [void]
136+
def on_text_section!(record)
137+
record.text_section_nodes.delete_all
138+
record.text_section_stylesheets.delete_all
139+
record.annotations.update_all(text_section_id: nil)
140+
end
141+
142+
# @param [User] record
143+
# @return [void]
144+
def on_user!(record)
145+
record.annotations.find_each do |annotation|
146+
handle! annotation
147+
end
148+
end
149+
end
150+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
module SoftDeletions
4+
module Types
5+
include Dry.Types
6+
7+
SoftDeletable = Types.Instance(::SoftDeletable)
8+
end
9+
end

0 commit comments

Comments
 (0)