-
Notifications
You must be signed in to change notification settings - Fork 106
Tech: corrige des problèmes avec le soft delete des blobs openstack #13421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+390
−19
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
854e451
fix(s3): delayed_purge uses custom soft delete if the current blob is…
LeSim ee70eb1
fix(sentry): avoid NameError in delayed purge failure path
LeSim b598fc6
feat(soft_delete): add soft_delete_at column to active_storage_blobs
LeSim 6e1086d
feat(soft_delete): record soft_delete_at when soft-deleting a blob
LeSim 6433b7b
fix(soft_delete): skip already soft-deleted blobs in purge unattached…
LeSim 8ee37e9
fix(openstack): repair fog bulk delete on ruby 3.4
LeSim e225281
feat(soft_delete): add cron to purge soft-deleted blobs and erase rem…
LeSim 1107820
feat(soft_delete): soft-delete variants alongside the parent blob
LeSim File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class Cron::PurgeSoftDeletedBlobsJob < Cron::CronJob | ||
| self.schedule_expression = "every day at 01:00" # after PurgeUnattachedBlobsJob (00:30) | ||
|
|
||
| # Swift bulk delete accepts up to 10_000 objects per request; stay well under. | ||
| BULK_DELETE_LIMIT = 1000 | ||
|
|
||
| def perform | ||
| return if ENV['PURGE_LATER_DELAY_IN_DAY'].blank? | ||
| return if ActiveStorage::Blob.service.name != :openstack | ||
|
|
||
| retention = Integer(ENV['PURGE_LATER_DELAY_IN_DAY']).days | ||
|
|
||
| ActiveStorage::Blob | ||
| .where(service_name: :openstack, soft_delete_at: ..retention.ago) | ||
| .in_batches(of: BULK_DELETE_LIMIT) { purge_batch(it.ids) } | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # For a batch of expired blobs | ||
| # - add all the related variant blobs | ||
| # - delete all the corresponding files on the bucket | ||
| # - delete in db attachment / variant / blob | ||
| def purge_batch(parent_ids) | ||
| variant_record_ids = ActiveStorage::VariantRecord.where(blob_id: parent_ids).ids | ||
| image_attachments = ActiveStorage::Attachment | ||
| .where(record_type: 'ActiveStorage::VariantRecord', record_id: variant_record_ids) | ||
| blob_ids = image_attachments.pluck(:blob_id) + parent_ids | ||
|
|
||
| delete_files(blob_ids) | ||
|
|
||
| # delete_all bypasses callbacks, so we drop the rows ourselves, in FK order: | ||
| # image attachments -> variant records -> blobs (variants + parents). | ||
| image_attachments.delete_all | ||
| ActiveStorage::VariantRecord.where(id: variant_record_ids).delete_all | ||
| ActiveStorage::Blob.where(id: blob_ids).delete_all | ||
| end | ||
|
|
||
| def delete_files(blob_ids) | ||
| keys = ActiveStorage::Blob.where(id: blob_ids).pluck(:key) | ||
|
|
||
| service = ActiveStorage::Blob.service | ||
| client = service.send(:client) | ||
|
|
||
| keys.each_slice(BULK_DELETE_LIMIT) do |slice| | ||
| response = client.delete_multiple_objects(service.container, slice) | ||
| errors = response.body['Errors'] | ||
| Sentry.capture_message("Bulk delete errors", extra: { errors: }) if errors.present? | ||
| end | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
14 changes: 14 additions & 0 deletions
14
db/migrate/20260703000000_add_soft_delete_at_to_active_storage_blobs.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class AddSoftDeleteAtToActiveStorageBlobs < ActiveRecord::Migration[7.2] | ||
| disable_ddl_transaction! | ||
|
|
||
| def change | ||
| add_column :active_storage_blobs, :soft_delete_at, :datetime, null: true, if_not_exists: true | ||
|
|
||
| add_index :active_storage_blobs, :soft_delete_at, | ||
| where: "soft_delete_at IS NOT NULL", | ||
| algorithm: :concurrently, | ||
| if_not_exists: true | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| RSpec.describe Cron::PurgeSoftDeletedBlobsJob, type: :job do | ||
| describe 'perform' do | ||
| # Blobs are created against the real (disk) service, then the job runs against | ||
| # a mocked OpenStack service — hence the helper, called from each example once | ||
| # every blob exists. | ||
| subject(:perform) do | ||
| service = double('openstack service', container: 'bucket', name: :openstack) | ||
| allow(service).to receive(:client).and_return(client) # reached via service.send(:client) | ||
| allow(ActiveStorage::Blob).to receive(:service).and_return(service) | ||
| described_class.perform_now | ||
| end | ||
|
|
||
| let(:client) { double('openstack client') } | ||
|
|
||
| before do | ||
| stub_const('ENV', ENV.to_hash.merge('PURGE_LATER_DELAY_IN_DAY' => '1')) | ||
| allow(client).to receive(:delete_multiple_objects).and_return(double(body: { 'Errors' => [] })) | ||
| end | ||
|
|
||
| # retention = PURGE_LATER_DELAY_IN_DAY = 1.day | ||
| let(:blob_old) do | ||
| ActiveStorage::Blob.create_and_upload!(io: StringIO.new("old"), filename: "old.txt", content_type: "text/plain").tap do |blob| | ||
| blob.update_columns(soft_delete_at: 2.days.ago, service_name: 'openstack') | ||
| end | ||
| end | ||
|
|
||
| let(:blob_recent) do | ||
| ActiveStorage::Blob.create_and_upload!(io: StringIO.new("recent"), filename: "recent.txt", content_type: "text/plain").tap do |blob| | ||
| blob.update_column(:soft_delete_at, 1.hour.ago) | ||
| end | ||
| end | ||
|
|
||
| let(:blob_never_soft_deleted) do | ||
| ActiveStorage::Blob.create_and_upload!(io: StringIO.new("kept"), filename: "kept.txt", content_type: "text/plain") | ||
| end | ||
|
|
||
| before do | ||
| blob_old | ||
| blob_recent | ||
| blob_never_soft_deleted | ||
| end | ||
|
|
||
| it 'destroys blobs soft-deleted long enough ago' do | ||
| expect { perform }.to change { ActiveStorage::Blob.exists?(id: blob_old.id) }.from(true).to(false) | ||
| end | ||
|
|
||
| it 'keeps recently soft-deleted and never-soft-deleted blobs' do | ||
| perform | ||
| expect(ActiveStorage::Blob.exists?(id: blob_recent.id)).to be(true) | ||
| expect(ActiveStorage::Blob.exists?(id: blob_never_soft_deleted.id)).to be(true) | ||
| end | ||
|
|
||
| it 'keeps blobs stored on another service' do | ||
| blob_other_service = ActiveStorage::Blob | ||
| .create_and_upload!(io: StringIO.new("other"), filename: "other.txt", content_type: "text/plain") | ||
| .tap { it.update_columns(soft_delete_at: 2.days.ago, service_name: 'test') } | ||
|
|
||
| perform | ||
|
|
||
| expect(ActiveStorage::Blob.exists?(id: blob_other_service.id)).to be(true) | ||
| end | ||
|
|
||
| it 'does nothing when the storage service is not OpenStack' do | ||
| allow(ActiveStorage::Blob).to receive(:service).and_return(double('disk service', name: :test)) | ||
|
|
||
| expect { described_class.perform_now }.not_to change(ActiveStorage::Blob, :count) | ||
| end | ||
|
|
||
| it 'bulk-deletes the parent file (safety net against a missed X-Delete-At)' do | ||
| expect(client).to receive(:delete_multiple_objects) | ||
| .with('bucket', [blob_old.key]) | ||
| .and_return(double(body: { 'Errors' => [] })) | ||
|
|
||
| perform | ||
| end | ||
|
|
||
| context 'when a soft-deleted image has variants' do | ||
| let!(:variant_blob) do | ||
| ActiveStorage::Blob.create_and_upload!(io: StringIO.new("variant"), filename: "v.png", content_type: "image/png") | ||
| end | ||
| let!(:variant_record) { ActiveStorage::VariantRecord.create!(blob: blob_old, variation_digest: "digest") } | ||
| let!(:image_attachment) { ActiveStorage::Attachment.create!(name: "image", record: variant_record, blob: variant_blob) } | ||
|
|
||
| it 'bulk-deletes both variant and parent files, then drops every row' do | ||
| expect(client).to receive(:delete_multiple_objects) | ||
| .with('bucket', match_array([variant_blob.key, blob_old.key])) | ||
| .and_return(double(body: { 'Errors' => [] })) | ||
|
|
||
| perform | ||
|
|
||
| expect(ActiveStorage::Blob.where(id: [blob_old.id, variant_blob.id])).not_to exist | ||
| expect(ActiveStorage::VariantRecord.where(id: variant_record.id)).not_to exist | ||
| expect(ActiveStorage::Attachment.where(id: image_attachment.id)).not_to exist | ||
| end | ||
|
|
||
| it 'reports per-object bulk delete errors to Sentry' do | ||
| errors = [["bucket/#{variant_blob.key}", "409 Conflict"]] | ||
| allow(client).to receive(:delete_multiple_objects).and_return(double(body: { 'Errors' => errors })) | ||
|
|
||
| expect(Sentry).to receive(:capture_message).with("Bulk delete errors", extra: { errors: }) | ||
|
|
||
| perform | ||
| end | ||
| end | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| RSpec.describe Cron::PurgeUnattachedBlobsJob, type: :job do | ||
| describe 'perform' do | ||
| subject { described_class.perform_now } | ||
|
|
||
| # unattached, within the created_at window | ||
| let(:blob) do | ||
| ActiveStorage::Blob.create_and_upload!(io: StringIO.new("data"), filename: "data.txt", content_type: "text/plain").tap do |b| | ||
| b.update_column(:created_at, 3.days.ago) | ||
| end | ||
| end | ||
|
|
||
| before { blob } | ||
|
|
||
| context 'when the blob has not been soft-deleted' do | ||
| it 'enqueues a purge' do | ||
| expect { subject }.to have_enqueued_job(DelayedPurgeJob) | ||
| end | ||
| end | ||
|
|
||
| context 'when the blob has already been soft-deleted' do | ||
| before { blob.update_column(:soft_delete_at, 1.hour.ago) } | ||
|
|
||
| it 'does not enqueue a purge' do | ||
| expect { subject }.not_to have_enqueued_job(DelayedPurgeJob) | ||
| end | ||
| end | ||
| end | ||
| end |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.