Extract detached-blob guard into its own file

Separate the attachment existence check from the broadcast suppression
override so each file has a single responsibility. The guard now lives
in ActiveStorageAnalyzeJobSkipDetached with its own documentation
explaining the upload-then-delete race condition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Joseph Hale
2026-03-02 14:38:19 -07:00
parent 143508fc22
commit c61f49cce9
3 changed files with 18 additions and 3 deletions
@@ -0,0 +1,17 @@
# Skip analysis for blobs whose attachments have already been destroyed.
#
# When an upload is deleted before AnalyzeJob runs, PurgeOnLastAttachment has
# already destroyed the attachment row and enqueued PurgeJob for the blob's S3
# object. Analyzing at this point hits a missing key. Same check as
# PurgeOnLastAttachment#purge_blob_if_last.
module ActiveStorageAnalyzeJobSkipDetached
def perform(blob)
return unless blob.attachments.exists?
super
end
end
ActiveSupport.on_load :active_storage_blob do
ActiveStorage::AnalyzeJob.prepend ActiveStorageAnalyzeJobSkipDetached
end
@@ -4,8 +4,6 @@
# there is currently a bug https://github.com/rails/rails/issues/55144 # there is currently a bug https://github.com/rails/rails/issues/55144
module ActiveStorageAnalyzeJobSuppressBroadcasts module ActiveStorageAnalyzeJobSuppressBroadcasts
def perform(blob) def perform(blob)
return unless blob.attachments.exists?
Board.suppressing_turbo_broadcasts do Board.suppressing_turbo_broadcasts do
Card.suppressing_turbo_broadcasts do Card.suppressing_turbo_broadcasts do
super super
@@ -1,6 +1,6 @@
require "test_helper" require "test_helper"
class ActiveStorageAnalyzeJobSuppressBroadcastsTest < ActiveSupport::TestCase class ActiveStorageAnalyzeJobSkipDetachedTest < ActiveSupport::TestCase
test "skips analysis when blob has no attachments" do test "skips analysis when blob has no attachments" do
blob = ActiveStorage::Blob.create_and_upload!( blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new("x" * 1024), filename: "orphan.txt", content_type: "text/plain" io: StringIO.new("x" * 1024), filename: "orphan.txt", content_type: "text/plain"