|
| 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 |
0 commit comments