Skip to content

Commit c57f56f

Browse files
authored
Merge pull request #13421 from demarche-numerique/track_and_discard_purged_blob
Tech: corrige des problèmes avec le soft delete des blobs openstack
2 parents f8a1e84 + 1107820 commit c57f56f

10 files changed

Lines changed: 390 additions & 19 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
class Cron::PurgeSoftDeletedBlobsJob < Cron::CronJob
4+
self.schedule_expression = "every day at 01:00" # after PurgeUnattachedBlobsJob (00:30)
5+
6+
# Swift bulk delete accepts up to 10_000 objects per request; stay well under.
7+
BULK_DELETE_LIMIT = 1000
8+
9+
def perform
10+
return if ENV['PURGE_LATER_DELAY_IN_DAY'].blank?
11+
return if ActiveStorage::Blob.service.name != :openstack
12+
13+
retention = Integer(ENV['PURGE_LATER_DELAY_IN_DAY']).days
14+
15+
ActiveStorage::Blob
16+
.where(service_name: :openstack, soft_delete_at: ..retention.ago)
17+
.in_batches(of: BULK_DELETE_LIMIT) { purge_batch(it.ids) }
18+
end
19+
20+
private
21+
22+
# For a batch of expired blobs
23+
# - add all the related variant blobs
24+
# - delete all the corresponding files on the bucket
25+
# - delete in db attachment / variant / blob
26+
def purge_batch(parent_ids)
27+
variant_record_ids = ActiveStorage::VariantRecord.where(blob_id: parent_ids).ids
28+
image_attachments = ActiveStorage::Attachment
29+
.where(record_type: 'ActiveStorage::VariantRecord', record_id: variant_record_ids)
30+
blob_ids = image_attachments.pluck(:blob_id) + parent_ids
31+
32+
delete_files(blob_ids)
33+
34+
# delete_all bypasses callbacks, so we drop the rows ourselves, in FK order:
35+
# image attachments -> variant records -> blobs (variants + parents).
36+
image_attachments.delete_all
37+
ActiveStorage::VariantRecord.where(id: variant_record_ids).delete_all
38+
ActiveStorage::Blob.where(id: blob_ids).delete_all
39+
end
40+
41+
def delete_files(blob_ids)
42+
keys = ActiveStorage::Blob.where(id: blob_ids).pluck(:key)
43+
44+
service = ActiveStorage::Blob.service
45+
client = service.send(:client)
46+
47+
keys.each_slice(BULK_DELETE_LIMIT) do |slice|
48+
response = client.delete_multiple_objects(service.container, slice)
49+
errors = response.body['Errors']
50+
Sentry.capture_message("Bulk delete errors", extra: { errors: }) if errors.present?
51+
end
52+
end
53+
end

app/jobs/cron/purge_unattached_blobs_job.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def perform
1616
# caveats: the job needs to be run at least once a week to avoid missing blobs
1717
ActiveStorage::Blob
1818
.where(created_at: 1.week.ago..1.day.ago)
19+
.where(soft_delete_at: nil)
1920
.unattached
2021
.select(:id, :service_name)
2122
.in_batches do |relation|

app/jobs/delayed_purge_job.rb

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,8 @@ class DelayedPurgeJob < ApplicationJob
2424
discard_on ActiveJob::DeserializationError
2525
discard_on ActiveRecord::RecordNotFound
2626

27-
def self.openstack?
28-
Rails.application.config.active_storage.service == :openstack
29-
end
30-
31-
if openstack?
32-
require 'fog/openstack'
33-
discard_on Fog::OpenStack::Storage::NotFound
34-
end
27+
require 'fog/openstack'
28+
discard_on Fog::OpenStack::Storage::NotFound
3529

3630
delegate :service, :key, to: :blob
3731
delegate :container, to: :service
@@ -58,21 +52,40 @@ def soft_delete
5852
# We should replicate the same behavior here.
5953
# https://github.com/rails/rails/blob/ef88965e8a0c72496c210a5a0a48b85ec9a2ed17/activestorage/app/models/active_storage/blob.rb#L53-L55
6054
return if blob.attachments.exists?
61-
excon_response = client.copy_object(container, key, container, key, { "Content-Type" => blob.content_type, 'X-Delete-At' => delay.to_s })
62-
if excon_response.status != 201
63-
Sentry.capture_message("Can't expire blob", extra: { key:, headers: })
64-
else
65-
service.delete_prefixed("variants/#{key}/") if blob.image?
66-
end
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)
73+
headers = { "Content-Type" => blob.content_type, 'X-Delete-At' => delay.to_s }
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
6778
end
6879

6980
def soft_delete_enabled?
70-
DelayedPurgeJob.openstack? && delay.positive?
81+
openstack? && delay.positive?
7182
rescue
7283
false
7384
end
7485

86+
def openstack? = service.name == :openstack
87+
7588
def client
76-
ActiveStorage::Blob.service.send(:client)
89+
service.send(:client)
7790
end
7891
end

config/initializers/active_storage.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,31 @@ def publicize(url)
154154
end
155155
end
156156
end
157+
158+
# fog-openstack 1.1.x builds the bulk-delete request body with `URI.encode`, which
159+
# Ruby removed in 3.0, so `delete_multiple_objects` raises NoMethodError on any
160+
# non-empty object list. `URI::DEFAULT_PARSER.escape` is the drop-in replacement:
161+
# same escaping rules, and it keeps the `container/object` slash literal (unlike
162+
# `ERB::Util.url_encode`, which would encode it and break the path).
163+
#
164+
# https://github.com/fog/fog-openstack/blob/v1.1.5/lib/fog/openstack/storage/requests/delete_multiple_objects.rb
165+
require 'fog/openstack'
166+
167+
class Fog::OpenStack::Storage::Real
168+
def delete_multiple_objects(container, object_names, options = {})
169+
body = object_names.map do |name|
170+
object_name = container ? "#{container}/#{name}" : name
171+
URI::DEFAULT_PARSER.escape(object_name)
172+
end.join("\n")
173+
174+
response = request({
175+
expects: 200,
176+
method: 'DELETE',
177+
headers: options.merge('Content-Type' => 'text/plain', 'Accept' => 'application/json'),
178+
body:,
179+
query: { 'bulk-delete' => true },
180+
}, false)
181+
response.body = Fog::JSON.decode(response.body)
182+
response
183+
end
184+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
class AddSoftDeleteAtToActiveStorageBlobs < ActiveRecord::Migration[7.2]
4+
disable_ddl_transaction!
5+
6+
def change
7+
add_column :active_storage_blobs, :soft_delete_at, :datetime, null: true, if_not_exists: true
8+
9+
add_index :active_storage_blobs, :soft_delete_at,
10+
where: "soft_delete_at IS NOT NULL",
11+
algorithm: :concurrently,
12+
if_not_exists: true
13+
end
14+
end

db/schema.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
#
1212
# It's strongly recommended that you check this file into your version control system.
1313

14-
ActiveRecord::Schema[8.0].define(version: 2026_06_25_153641) do
14+
ActiveRecord::Schema[8.0].define(version: 2026_07_03_000000) do
1515
# These are extensions that must be enabled in order to support this database
1616
enable_extension "pg_buffercache"
1717
enable_extension "pg_stat_statements"
@@ -52,10 +52,12 @@
5252
t.text "metadata"
5353
t.jsonb "ocr"
5454
t.string "service_name", null: false
55+
t.datetime "soft_delete_at"
5556
t.string "virus_scan_result"
5657
t.datetime "virus_scanned_at", precision: nil
5758
t.datetime "watermarked_at", precision: nil
5859
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
60+
t.index ["soft_delete_at"], name: "index_active_storage_blobs_on_soft_delete_at", where: "(soft_delete_at IS NOT NULL)"
5961
t.index ["virus_scan_result", "id"], name: "index_active_storage_blobs_on_pending_virus_scan", order: { id: :desc }
6062
end
6163

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Cron::PurgeSoftDeletedBlobsJob, type: :job do
4+
describe 'perform' do
5+
# Blobs are created against the real (disk) service, then the job runs against
6+
# a mocked OpenStack service — hence the helper, called from each example once
7+
# every blob exists.
8+
subject(:perform) do
9+
service = double('openstack service', container: 'bucket', name: :openstack)
10+
allow(service).to receive(:client).and_return(client) # reached via service.send(:client)
11+
allow(ActiveStorage::Blob).to receive(:service).and_return(service)
12+
described_class.perform_now
13+
end
14+
15+
let(:client) { double('openstack client') }
16+
17+
before do
18+
stub_const('ENV', ENV.to_hash.merge('PURGE_LATER_DELAY_IN_DAY' => '1'))
19+
allow(client).to receive(:delete_multiple_objects).and_return(double(body: { 'Errors' => [] }))
20+
end
21+
22+
# retention = PURGE_LATER_DELAY_IN_DAY = 1.day
23+
let(:blob_old) do
24+
ActiveStorage::Blob.create_and_upload!(io: StringIO.new("old"), filename: "old.txt", content_type: "text/plain").tap do |blob|
25+
blob.update_columns(soft_delete_at: 2.days.ago, service_name: 'openstack')
26+
end
27+
end
28+
29+
let(:blob_recent) do
30+
ActiveStorage::Blob.create_and_upload!(io: StringIO.new("recent"), filename: "recent.txt", content_type: "text/plain").tap do |blob|
31+
blob.update_column(:soft_delete_at, 1.hour.ago)
32+
end
33+
end
34+
35+
let(:blob_never_soft_deleted) do
36+
ActiveStorage::Blob.create_and_upload!(io: StringIO.new("kept"), filename: "kept.txt", content_type: "text/plain")
37+
end
38+
39+
before do
40+
blob_old
41+
blob_recent
42+
blob_never_soft_deleted
43+
end
44+
45+
it 'destroys blobs soft-deleted long enough ago' do
46+
expect { perform }.to change { ActiveStorage::Blob.exists?(id: blob_old.id) }.from(true).to(false)
47+
end
48+
49+
it 'keeps recently soft-deleted and never-soft-deleted blobs' do
50+
perform
51+
expect(ActiveStorage::Blob.exists?(id: blob_recent.id)).to be(true)
52+
expect(ActiveStorage::Blob.exists?(id: blob_never_soft_deleted.id)).to be(true)
53+
end
54+
55+
it 'keeps blobs stored on another service' do
56+
blob_other_service = ActiveStorage::Blob
57+
.create_and_upload!(io: StringIO.new("other"), filename: "other.txt", content_type: "text/plain")
58+
.tap { it.update_columns(soft_delete_at: 2.days.ago, service_name: 'test') }
59+
60+
perform
61+
62+
expect(ActiveStorage::Blob.exists?(id: blob_other_service.id)).to be(true)
63+
end
64+
65+
it 'does nothing when the storage service is not OpenStack' do
66+
allow(ActiveStorage::Blob).to receive(:service).and_return(double('disk service', name: :test))
67+
68+
expect { described_class.perform_now }.not_to change(ActiveStorage::Blob, :count)
69+
end
70+
71+
it 'bulk-deletes the parent file (safety net against a missed X-Delete-At)' do
72+
expect(client).to receive(:delete_multiple_objects)
73+
.with('bucket', [blob_old.key])
74+
.and_return(double(body: { 'Errors' => [] }))
75+
76+
perform
77+
end
78+
79+
context 'when a soft-deleted image has variants' do
80+
let!(:variant_blob) do
81+
ActiveStorage::Blob.create_and_upload!(io: StringIO.new("variant"), filename: "v.png", content_type: "image/png")
82+
end
83+
let!(:variant_record) { ActiveStorage::VariantRecord.create!(blob: blob_old, variation_digest: "digest") }
84+
let!(:image_attachment) { ActiveStorage::Attachment.create!(name: "image", record: variant_record, blob: variant_blob) }
85+
86+
it 'bulk-deletes both variant and parent files, then drops every row' do
87+
expect(client).to receive(:delete_multiple_objects)
88+
.with('bucket', match_array([variant_blob.key, blob_old.key]))
89+
.and_return(double(body: { 'Errors' => [] }))
90+
91+
perform
92+
93+
expect(ActiveStorage::Blob.where(id: [blob_old.id, variant_blob.id])).not_to exist
94+
expect(ActiveStorage::VariantRecord.where(id: variant_record.id)).not_to exist
95+
expect(ActiveStorage::Attachment.where(id: image_attachment.id)).not_to exist
96+
end
97+
98+
it 'reports per-object bulk delete errors to Sentry' do
99+
errors = [["bucket/#{variant_blob.key}", "409 Conflict"]]
100+
allow(client).to receive(:delete_multiple_objects).and_return(double(body: { 'Errors' => errors }))
101+
102+
expect(Sentry).to receive(:capture_message).with("Bulk delete errors", extra: { errors: })
103+
104+
perform
105+
end
106+
end
107+
end
108+
end
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Cron::PurgeUnattachedBlobsJob, type: :job do
4+
describe 'perform' do
5+
subject { described_class.perform_now }
6+
7+
# unattached, within the created_at window
8+
let(:blob) do
9+
ActiveStorage::Blob.create_and_upload!(io: StringIO.new("data"), filename: "data.txt", content_type: "text/plain").tap do |b|
10+
b.update_column(:created_at, 3.days.ago)
11+
end
12+
end
13+
14+
before { blob }
15+
16+
context 'when the blob has not been soft-deleted' do
17+
it 'enqueues a purge' do
18+
expect { subject }.to have_enqueued_job(DelayedPurgeJob)
19+
end
20+
end
21+
22+
context 'when the blob has already been soft-deleted' do
23+
before { blob.update_column(:soft_delete_at, 1.hour.ago) }
24+
25+
it 'does not enqueue a purge' do
26+
expect { subject }.not_to have_enqueued_job(DelayedPurgeJob)
27+
end
28+
end
29+
end
30+
end

0 commit comments

Comments
 (0)