attachment_batch.rb 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. # frozen_string_literal: true
  2. class AttachmentBatch
  3. # Maximum amount of objects you can delete in an S3 API call. It's
  4. # important to remember that this does not correspond to the number
  5. # of records in the batch, since records can have multiple attachments
  6. LIMIT = ENV.fetch('S3_BATCH_DELETE_LIMIT', 1000).to_i
  7. MAX_RETRY = ENV.fetch('S3_BATCH_DELETE_RETRY', 3).to_i
  8. # Attributes generated and maintained by Paperclip (not all of them
  9. # are always used on every class, however)
  10. NULLABLE_ATTRIBUTES = %w(
  11. file_name
  12. content_type
  13. file_size
  14. fingerprint
  15. created_at
  16. updated_at
  17. ).freeze
  18. # Styles that are always present even when not explicitly defined
  19. BASE_STYLES = %i(original).freeze
  20. attr_reader :klass, :records, :storage_mode
  21. def initialize(klass, records)
  22. @klass = klass
  23. @records = records
  24. @storage_mode = Paperclip::Attachment.default_options[:storage]
  25. @attachment_names = klass.attachment_definitions.keys
  26. end
  27. def delete
  28. remove_files
  29. batch.delete_all
  30. end
  31. def clear
  32. remove_files
  33. batch.update_all(nullified_attributes) # rubocop:disable Rails/SkipsModelValidations
  34. end
  35. private
  36. def batch
  37. klass.where(id: records.map(&:id))
  38. end
  39. def remove_files
  40. keys = []
  41. logger.debug { "Preparing to delete attachments for #{records.size} records" }
  42. records.each do |record|
  43. @attachment_names.each do |attachment_name|
  44. attachment = record.public_send(attachment_name)
  45. styles = BASE_STYLES | attachment.styles.keys
  46. next if attachment.blank?
  47. styles.each do |style|
  48. case @storage_mode
  49. when :s3
  50. logger.debug { "Adding #{attachment.path(style)} to batch for deletion" }
  51. keys << attachment.style_name_as_path(style)
  52. when :filesystem
  53. logger.debug { "Deleting #{attachment.path(style)}" }
  54. path = attachment.path(style)
  55. FileUtils.remove_file(path, true)
  56. begin
  57. FileUtils.rmdir(File.dirname(path), parents: true)
  58. rescue Errno::EEXIST, Errno::ENOTEMPTY, Errno::ENOENT, Errno::EINVAL, Errno::ENOTDIR, Errno::EACCES
  59. # Ignore failure to delete a directory, with the same ignored errors
  60. # as Paperclip
  61. end
  62. when :fog
  63. logger.debug { "Deleting #{attachment.path(style)}" }
  64. begin
  65. attachment.send(:directory).files.new(key: attachment.path(style)).destroy
  66. rescue Fog::Storage::OpenStack::NotFound
  67. # Ignore failure to delete a file that has already been deleted
  68. end
  69. when :azure
  70. logger.debug { "Deleting #{attachment.path(style)}" }
  71. attachment.destroy
  72. end
  73. end
  74. end
  75. end
  76. return unless storage_mode == :s3
  77. # We can batch deletes over S3, but there is a limit of how many
  78. # objects can be processed at once, so we have to potentially
  79. # separate them into multiple calls.
  80. retries = 0
  81. keys.each_slice(LIMIT) do |keys_slice|
  82. logger.debug { "Deleting #{keys_slice.size} objects" }
  83. bucket.delete_objects(delete: {
  84. objects: keys_slice.map { |key| { key: key } },
  85. quiet: true,
  86. })
  87. rescue => e
  88. retries += 1
  89. if retries < MAX_RETRY
  90. logger.debug "Retry #{retries}/#{MAX_RETRY} after #{e.message}"
  91. sleep 2**retries
  92. retry
  93. else
  94. logger.error "Batch deletion from S3 failed after #{e.message}"
  95. raise e
  96. end
  97. end
  98. end
  99. def bucket
  100. @bucket ||= records.first.public_send(@attachment_names.first).s3_bucket
  101. end
  102. def nullified_attributes
  103. @attachment_names.flat_map { |attachment_name| NULLABLE_ATTRIBUTES.map { |attribute| "#{attachment_name}_#{attribute}" } & klass.column_names }.index_with(nil)
  104. end
  105. def logger
  106. Rails.logger
  107. end
  108. end