Skip to content

Commit d01f1d0

Browse files
committed
feat(soft_delete): soft-delete variants alongside the parent blob
1 parent 90faab0 commit d01f1d0

2 files changed

Lines changed: 70 additions & 7 deletions

File tree

app/jobs/delayed_purge_job.rb

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,29 @@ def soft_delete
5252
# We should replicate the same behavior here.
5353
# https://github.com/rails/rails/blob/ef88965e8a0c72496c210a5a0a48b85ec9a2ed17/activestorage/app/models/active_storage/blob.rb#L53-L55
5454
return if blob.attachments.exists?
55+
return if !expire_file(blob)
56+
57+
blob.update_column(:soft_delete_at, Time.current)
58+
soft_delete_variants if blob.image?
59+
end
60+
61+
def soft_delete_variants
62+
variant_blob_ids = ActiveStorage::Attachment
63+
.where(record_type: 'ActiveStorage::VariantRecord', record_id: blob.variant_records.ids)
64+
.pluck(:blob_id)
65+
66+
ActiveStorage::Blob.where(id: variant_blob_ids).find_each { expire_file(it) }
67+
end
68+
69+
# Copy the blob's file onto itself with an X-Delete-At header so Swift expires
70+
# it after the retention delay. Returns whether Swift accepted the request and
71+
# reports to Sentry on failure.
72+
def expire_file(blob)
5573
headers = { "Content-Type" => blob.content_type, 'X-Delete-At' => delay.to_s }
56-
excon_response = client.copy_object(container, key, container, key, headers)
57-
if excon_response.status != 201
58-
Sentry.capture_message("Can't expire blob", extra: { key:, headers: })
59-
else
60-
blob.update_column(:soft_delete_at, Time.current)
61-
service.delete_prefixed("variants/#{key}/") if blob.image?
62-
end
74+
return true if client.copy_object(container, blob.key, container, blob.key, headers).status == 201
75+
76+
Sentry.capture_message("Can't expire blob", extra: { key: blob.key, headers: })
77+
false
6378
end
6479

6580
def soft_delete_enabled?

spec/jobs/delayed_purge_job_spec.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,54 @@
6565
end
6666
end
6767

68+
context 'when a soft-deleted image has variants' do
69+
let(:container) { "bucket" }
70+
let(:client) { double("client") }
71+
72+
# Blobs are created against the real service, then the OpenStack service is
73+
# mocked; the parent copy_object is stubbed to succeed.
74+
def setup_image_with_variant
75+
image_blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new("img"), filename: "i.png", content_type: "image/png")
76+
variant_blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new("variant"), filename: "v.png", content_type: "image/png")
77+
variant_record = ActiveStorage::VariantRecord.create!(blob: image_blob, variation_digest: "digest")
78+
image_attachment = ActiveStorage::Attachment.create!(name: "image", record: variant_record, blob: variant_blob)
79+
80+
allow_any_instance_of(ActiveStorage::Blob).to receive(:service).and_return(double(name: :openstack, container:))
81+
job = described_class.new(image_blob)
82+
allow(job).to receive(:client).and_return(client)
83+
allow(client).to receive(:copy_object).and_return(double(status: 201))
84+
85+
{ job:, image_blob:, variant_blob:, variant_record:, image_attachment: }
86+
end
87+
88+
it 'marks the variant files for expiration and keeps their rows for the cron' do
89+
ctx = setup_image_with_variant
90+
expect(client).to receive(:copy_object)
91+
.with(container, ctx[:variant_blob].key, container, ctx[:variant_blob].key, hash_including('X-Delete-At'))
92+
.and_return(double(status: 201))
93+
94+
ctx[:job].perform_now
95+
96+
expect(ActiveStorage::Blob.where(id: ctx[:variant_blob].id)).to exist
97+
expect(ActiveStorage::VariantRecord.where(id: ctx[:variant_record].id)).to exist
98+
expect(ActiveStorage::Attachment.where(id: ctx[:image_attachment].id)).to exist
99+
expect(ActiveStorage::Blob.where(id: ctx[:image_blob].id)).to exist # parent soft-deleted
100+
end
101+
102+
it 'reports to Sentry when a variant copy fails, keeping the rows' do
103+
ctx = setup_image_with_variant
104+
allow(client).to receive(:copy_object)
105+
.with(container, ctx[:variant_blob].key, container, ctx[:variant_blob].key, anything)
106+
.and_return(double(status: 500))
107+
expect(Sentry).to receive(:capture_message).with("Can't expire blob", extra: hash_including(key: ctx[:variant_blob].key))
108+
109+
ctx[:job].perform_now
110+
111+
expect(ActiveStorage::VariantRecord.where(id: ctx[:variant_record].id)).to exist
112+
expect(ActiveStorage::Blob.where(id: ctx[:variant_blob].id)).to exist
113+
end
114+
end
115+
68116
context 'when destroying an instance' do
69117
it 'uses our custom job' do
70118
expect { dossier.destroy }.to have_enqueued_job(DelayedPurgeJob)

0 commit comments

Comments
 (0)