active_storage: refactor concerns

Follow-up of #5953.

Refactor the concerns with two goals:

- Getting closer from the way ActiveStorage adds its own hooks.
  Usually ActiveStorage does this using an `Attachment#after_create`
  hook, which then delegates to the blob to enqueue the job.
- Enqueuing each job only once. By hooking on `Attachment#after_create`,
  we guarantee each job will be added only once.

We then let the jobs themselves check if they are relevant or not, and
retry or discard themselves if necessary.

We also need to update the tests a bit, because Rails'
`perform_enqueued_jobs(&block)` test helper doesn't honor the `retry_on`
clause of jobs. Instead it forwards the exception to the caller – which
makes the test fail.

Instead we use the inline version of `perform_enqueued_jobs()`, without
a block, which properly ignores errors catched by retry_on.
This commit is contained in:
Pierre de La Morinerie 2021-03-11 13:42:57 +00:00
parent c14720d915
commit 75a1046315
13 changed files with 130 additions and 115 deletions

View file

@ -1,9 +1,23 @@
class TitreIdentiteWatermarkJob < ApplicationJob class TitreIdentiteWatermarkJob < ApplicationJob
class FileNotScannedYetError < StandardError
end
# If by the time the job runs the blob has been deleted, ignore the error
discard_on ActiveRecord::RecordNotFound
# If the file is deleted during the scan, ignore the error
discard_on ActiveStorage::FileNotFoundError
# If the file is not analyzed or scanned for viruses yet, retry later
# (to avoid modifying the file while it is being scanned).
retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10
MAX_IMAGE_SIZE = 1500 MAX_IMAGE_SIZE = 1500
SCALE = 0.9 SCALE = 0.9
WATERMARK = Rails.root.join("app/assets/images/#{WATERMARK_FILE}") WATERMARK = Rails.root.join("app/assets/images/#{WATERMARK_FILE}")
def perform(blob) def perform(blob)
if blob.virus_scanner.pending? then raise FileNotScannedYetError end
if blob.watermark_done? then return end
blob.open do |file| blob.open do |file|
watermark = resize_watermark(file) watermark = resize_watermark(file)

View file

@ -1,15 +1,22 @@
class VirusScannerJob < ApplicationJob class VirusScannerJob < ApplicationJob
class FileNotAnalyzedYetError < StandardError
end
queue_as :active_storage_analysis queue_as :active_storage_analysis
# If by the time the job runs the blob has been deleted, ignore the error # If by the time the job runs the blob has been deleted, ignore the error
discard_on ActiveRecord::RecordNotFound discard_on ActiveRecord::RecordNotFound
# If the file is deleted during the scan, ignore the error # If the file is deleted during the scan, ignore the error
discard_on ActiveStorage::FileNotFoundError discard_on ActiveStorage::FileNotFoundError
# If the file is not analyzed yet, retry later (to avoid clobbering metadata)
retry_on FileNotAnalyzedYetError, wait: :exponentially_longer, attempts: 10
# If for some reason the file appears invalid, retry for a while # If for some reason the file appears invalid, retry for a while
retry_on ActiveStorage::IntegrityError, attempts: 10, wait: 5.seconds retry_on ActiveStorage::IntegrityError, attempts: 10, wait: 5.seconds
def perform(blob) def perform(blob)
if !blob.analyzed? then raise FileNotAnalyzedYetError end
if blob.virus_scanner.done? then return end
metadata = extract_metadata_via_virus_scanner(blob) metadata = extract_metadata_via_virus_scanner(blob)
blob.update!(metadata: blob.metadata.merge(metadata)) blob.update!(metadata: blob.metadata.merge(metadata))
end end

View file

@ -0,0 +1,17 @@
# Request a watermark on files attached to a `Champs::TitreIdentiteChamp`.
#
# We're using a class extension here, but we could as well have a periodic
# job that watermarks relevant attachments.
module AttachmentTitreIdentiteWatermarkConcern
extend ActiveSupport::Concern
included do
after_create_commit :watermark_later
end
private
def watermark_later
blob&.watermark_later
end
end

View file

@ -0,0 +1,20 @@
# Run a virus scan on all attachments after they are analyzed.
#
# We're using a class extension to ensure that all attachments get scanned,
# regardless on how they were created. This could be an ActiveStorage::Analyzer,
# but as of Rails 6.1 only the first matching analyzer is ever run on
# a blob (and we may want to analyze the dimension of a picture as well
# as scanning it).
module AttachmentVirusScannerConcern
extend ActiveSupport::Concern
included do
after_create_commit :scan_for_virus_later
end
private
def scan_for_virus_later
blob&.scan_for_virus_later
end
end

View file

@ -1,38 +1,21 @@
# Request a watermark on blobs attached to a `Champs::TitreIdentiteChamp`
# after the virus scan has run.
#
# We're using a class extension here, but we could as well have a periodic
# job that watermarks relevant attachments.
#
# The `after_commit` hook is triggered, among other cases, when
# the analyzer or virus scan updates the blob metadata. When both the analyzer
# and the virus scan have run, it is now safe to start the watermarking,
# without risking to replace the picture while it is being scanned in a
# concurrent job.
module BlobTitreIdentiteWatermarkConcern module BlobTitreIdentiteWatermarkConcern
extend ActiveSupport::Concern
included do
after_commit :enqueue_watermark_job
end
def watermark_pending? def watermark_pending?
watermark_required? && !watermark_done? watermark_required? && !watermark_done?
end end
def watermark_done?
metadata[:watermark]
end
def watermark_later
if watermark_required?
TitreIdentiteWatermarkJob.perform_later(self)
end
end
private private
def watermark_required? def watermark_required?
attachments.any? { |attachment| attachment.record.class.name == 'Champs::TitreIdentiteChamp' } attachments.any? { |attachment| attachment.record.class.name == 'Champs::TitreIdentiteChamp' }
end end
def watermark_done?
metadata[:watermark]
end
def enqueue_watermark_job
if analyzed? && virus_scanner.done? && watermark_pending?
TitreIdentiteWatermarkJob.perform_later(self)
end
end
end end

View file

@ -1,36 +1,21 @@
# Run a virus scan on all blobs after they are analyzed.
#
# We're using a class extension to ensure that all blobs get scanned,
# regardless on how they were created. This could be an ActiveStorage::Analyzer,
# but as of Rails 6.1 only the first matching analyzer is ever run on
# a blob (and we may want to analyze the dimension of a picture as well
# as scanning it).
#
# The `after_commit` hook is triggered, among other cases, when
# the analyzer updates the blob metadata. When the analyzer has run,
# it is now safe to start our own scanning, without risking to have
# two concurrent jobs overwriting the metadata of the blob.
module BlobVirusScannerConcern module BlobVirusScannerConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
before_create :set_pending before_create :set_pending
after_commit :enqueue_virus_scan
end end
def virus_scanner def virus_scanner
ActiveStorage::VirusScanner.new(self) ActiveStorage::VirusScanner.new(self)
end end
def scan_for_virus_later
VirusScannerJob.perform_later(self)
end
private private
def set_pending def set_pending
self.metadata[:virus_scan_result] ||= ActiveStorage::VirusScanner::PENDING metadata[:virus_scan_result] ||= ActiveStorage::VirusScanner::PENDING
end
def enqueue_virus_scan
if analyzed? && !virus_scanner.done?
VirusScannerJob.perform_later(self)
end
end end
end end

View file

@ -4,9 +4,14 @@ Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer
Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer
ActiveSupport.on_load(:active_storage_blob) do ActiveSupport.on_load(:active_storage_blob) do
include BlobSignedIdConcern
include BlobVirusScannerConcern
include BlobTitreIdentiteWatermarkConcern include BlobTitreIdentiteWatermarkConcern
include BlobVirusScannerConcern
include BlobSignedIdConcern
end
ActiveSupport.on_load(:active_storage_attachment) do
include AttachmentTitreIdentiteWatermarkConcern
include AttachmentVirusScannerConcern
end end
# When an OpenStack service is initialized it makes a request to fetch # When an OpenStack service is initialized it makes a request to fetch

View file

@ -98,14 +98,10 @@ describe Instructeurs::AvisController, type: :controller do
end end
context 'with attachment' do context 'with attachment' do
include ActiveJob::TestHelper
let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') } let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') }
before do before do
expect(ClamavService).to receive(:safe_file?).and_return(true) post :update, params: { id: avis_without_answer.id, procedure_id: procedure.id, avis: { answer: 'answer', piece_justificative_file: file } }
perform_enqueued_jobs do
post :update, params: { id: avis_without_answer.id, procedure_id: procedure.id, avis: { answer: 'answer', piece_justificative_file: file } }
end
avis_without_answer.reload avis_without_answer.reload
end end
@ -126,7 +122,6 @@ describe Instructeurs::AvisController, type: :controller do
subject { post :create_commentaire, params: { id: avis_without_answer.id, procedure_id: procedure.id, commentaire: { body: 'commentaire body', piece_jointe: file } } } subject { post :create_commentaire, params: { id: avis_without_answer.id, procedure_id: procedure.id, commentaire: { body: 'commentaire body', piece_jointe: file } } }
before do before do
allow(ClamavService).to receive(:safe_file?).and_return(scan_result)
Timecop.freeze(now) Timecop.freeze(now)
end end

View file

@ -25,9 +25,8 @@ feature 'Inviting an expert:' do
check 'avis_invite_linked_dossiers' check 'avis_invite_linked_dossiers'
page.select 'confidentiel', from: 'avis_confidentiel' page.select 'confidentiel', from: 'avis_confidentiel'
perform_enqueued_jobs do click_on 'Demander un avis'
click_on 'Demander un avis' perform_enqueued_jobs
end
expect(page).to have_content('Une demande d\'avis a été envoyée') expect(page).to have_content('Une demande d\'avis a été envoyée')
expect(page).to have_content('Avis des invités') expect(page).to have_content('Avis des invités')
@ -38,7 +37,8 @@ feature 'Inviting an expert:' do
end end
expect(Avis.count).to eq(4) expect(Avis.count).to eq(4)
expect(all_emails.size).to eq(2) expect(emails_sent_to('expert1@exemple.fr').size).to eq(1)
expect(emails_sent_to('expert2@exemple.fr').size).to eq(1)
invitation_email = open_email('expert2@exemple.fr') invitation_email = open_email('expert2@exemple.fr')
avis = Avis.find_by(email: 'expert2@exemple.fr', dossier: dossier) avis = Avis.find_by(email: 'expert2@exemple.fr', dossier: dossier)

View file

@ -1,48 +1,49 @@
RSpec.describe VirusScannerJob, type: :job do describe VirusScannerJob, type: :job do
include ActiveJob::TestHelper let(:blob) do
ActiveStorage::Blob.create_and_upload!(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain")
let(:champ) do
champ = create(:champ_piece_justificative)
champ.piece_justificative_file.attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain")
champ.save
champ
end end
subject do subject do
perform_enqueued_jobs do VirusScannerJob.perform_now(blob)
VirusScannerJob.perform_later(champ.piece_justificative_file.blob) end
context "when the blob is not analyzed yet" do
it "retries the job later" do
expect { subject }.to have_enqueued_job(VirusScannerJob)
end end
end end
context "when no virus is found" do context "when the blob has been analyzed" do
let(:virus_found?) { true }
before do before do
allow(ClamavService).to receive(:safe_file?).and_return(virus_found?) blob.analyze
subject
end end
it { expect(champ.reload.piece_justificative_file.virus_scanner.safe?).to be_truthy } context "when no virus is found" do
end before do
allow(ClamavService).to receive(:safe_file?).and_return(true)
subject
end
context "when a virus is found" do it { expect(blob.virus_scanner.safe?).to be_truthy }
let(:virus_found?) { false }
before do
allow(ClamavService).to receive(:safe_file?).and_return(virus_found?)
subject
end end
it { expect(champ.reload.piece_justificative_file.virus_scanner.infected?).to be_truthy } context "when a virus is found" do
end before do
allow(ClamavService).to receive(:safe_file?).and_return(false)
subject
end
context "when the blob has been deleted" do it { expect(blob.virus_scanner.infected?).to be_truthy }
before do
Champ.find(champ.id).piece_justificative_file.purge
end end
it "ignores the error" do context "when the blob has been deleted" do
expect { subject }.not_to raise_error before do
ActiveStorage::Blob.find(blob.id).purge
end
it "ignores the error" do
expect { subject }.not_to raise_error
end
end end
end end
end end

View file

@ -458,7 +458,8 @@ describe Champ do
end end
it 'marks the file as safe once the scan completes' do it 'marks the file as safe once the scan completes' do
perform_enqueued_jobs { subject } subject
perform_enqueued_jobs
expect(champ.reload.piece_justificative_file.virus_scanner.safe?).to be_truthy expect(champ.reload.piece_justificative_file.virus_scanner.safe?).to be_truthy
end end
end end
@ -480,13 +481,15 @@ describe Champ do
champ champ
end end
it 'enqueues a watermark job on file attachment' do it 'marks the file as needing watermarking' do
expect(subject.piece_justificative_file.watermark_pending?).to be_truthy expect(subject.piece_justificative_file.watermark_pending?).to be_truthy
end end
it 'watermarks the file' do it 'watermarks the file' do
perform_enqueued_jobs { subject } subject
expect(champ.reload.piece_justificative_file.blob.metadata[:watermark]).to be_truthy perform_enqueued_jobs
expect(champ.reload.piece_justificative_file.watermark_pending?).to be_falsy
expect(champ.reload.piece_justificative_file.blob.watermark_done?).to be_truthy
end end
end end
end end

View file

@ -1386,27 +1386,21 @@ describe Dossier do
it "clean up titres identite on accepter" do it "clean up titres identite on accepter" do
expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy
expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey
perform_enqueued_jobs do dossier.accepter!(dossier.followers_instructeurs.first, "yolo!")
dossier.accepter!(dossier.followers_instructeurs.first, "yolo!")
end
expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey
end end
it "clean up titres identite on refuser" do it "clean up titres identite on refuser" do
expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy
expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey
perform_enqueued_jobs do dossier.refuser!(dossier.followers_instructeurs.first, "yolo!")
dossier.refuser!(dossier.followers_instructeurs.first, "yolo!")
end
expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey
end end
it "clean up titres identite on classer_sans_suite" do it "clean up titres identite on classer_sans_suite" do
expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy
expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey
perform_enqueued_jobs do dossier.classer_sans_suite!(dossier.followers_instructeurs.first, "yolo!")
dossier.classer_sans_suite!(dossier.followers_instructeurs.first, "yolo!")
end
expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey
end end
@ -1416,9 +1410,7 @@ describe Dossier do
it "clean up titres identite on accepter_automatiquement" do it "clean up titres identite on accepter_automatiquement" do
expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy
expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey
perform_enqueued_jobs do dossier.accepter_automatiquement!
dossier.accepter_automatiquement!
end
expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey
end end
end end

View file

@ -29,15 +29,8 @@ describe CommentaireService do
context 'when it has a file' do context 'when it has a file' do
let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') } let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') }
before do it 'attaches the file' do
expect(ClamavService).to receive(:safe_file?).and_return(true) expect(commentaire.piece_jointe.attached?).to be_truthy
end
it 'saves the attached file' do
perform_enqueued_jobs do
commentaire.save
expect(commentaire.piece_jointe.attached?).to be_truthy
end
end end
end end
end end