Skip to content

Commit e70f504

Browse files
committed
Prevent Active Storage Blob from autosaving Attachments:
- This commit is meant to fix a behaviour change introduced in rails#50800, which causes issues reported in rails#53451 and rails#53452. ### Context There are a lot of codepaths and callbacks happening and the issue is subtle which makes things a bit tricky to explain, but in short, adding the `inverse_of` on the `ActiveStorage::Attachment <-> Blob` relation added behavioural difference on how the Attachement get saved. ### Problem By adding the `ActiveStorage::Attachment <-> Blob` inverse_of in rails#50800, a Blob is now aware of its associated Attachment and this creates a problem: Order of association save callbacks ```ruby class User < ApplicationRecord has_one_attached :avatar end blob = create_active_storage_blob("avatar.png") User.create!(avatar: blob) ``` Before rails#50800, this was the order of how the records were persisted: ``` 1. Blob is created 2. User is created 3. Blob is updated through the User<->Blob autosave association (Active storage [modifies some metadata](https://github.com/rails/rails/blob/ad9bc2a51d5980d1c0990db97e552015410778f0/activestorage/lib/active_storage/attached/changes/create_one.rb#L12) on the Blob when creating an attachment) 4. Attachment is created through the User<->Attachment autosave association ``` After rails#50800, this is the order: ``` 1-3. Similar as above 4. Attachment is created through the ~User~Blob<->Attachment autosave association ``` The reasons this creates the issues in rails#53451 and rails#53452 are different so I'll explain them separately. #### Problem in rails#53451 Because an Attachment now gets created through the Blob association in a [after_update](https://github.com/rails/rails/blob/ad9bc2a51d5980d1c0990db97e552015410778f0/activerecord/lib/active_record/autosave_association.rb#L198) callback that **runs prior** to the [`Blob#touch_attachments`](https://github.com/rails/rails/blob/ad9bc2a51d5980d1c0990db97e552015410778f0/activestorage/app/models/active_storage/blob.rb#L44) after_update callback, the Blob will now `touch` associated attachments which by repercusion ends up loading the record (e.g the User in the example above) and touching it. By calling `user.touch_later`, this adds the user (that was loaded from the db, not the one in memory), to the transaction which ultimately gets used and replace the one in memory to run the commit callbacks ([here](https://github.com/rails/rails/blob/ad9bc2a51d5980d1c0990db97e552015410778f0/activerecord/lib/active_record/touch_later.rb#L23) and [here](https://github.com/rails/rails/blob/ad9bc2a51d5980d1c0990db97e552015410778f0/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb#L301-L303)) A simple example is the following: ```ruby class Car < ApplicationRecord attr_accessor :engine_sound after_save :touch_car after_commit :rev_engine def touch_car Car.find(1).touch end def rev_engine puts engine_sound # => nil end end Car.new(id: 1, engine_sound: "vroom") ``` #### Problem in rails#53452 Previously, the User could see the `Attachment#previously_new_record?` being true. This is again because the Attachment used to be created thanks to the autosave association on their relation. Now that the Attachment is saved through the Blob autosave association, when the User<->Attachment autosave association is called, the attachment is already persisted so it's considered an updat and we end up in a different codepath which as a result ends up resetting the `@previously_new_record` flag. ([here](https://github.com/rails/rails/blob/67c6ef2e5957152f00050224281ed4eabaaebd92/activerecord/lib/active_record/associations/collection_association.rb#L377) and [here]https://github.com/rails/rails/blob/67c6ef2e5957152f00050224281ed4eabaaebd92/activerecord/lib/active_record/persistence.rb#L263()) ### Solution I couldn't find a better way but to modify the Blob<->Attachment relation and sets the autosave to false. This way, the Blob is not responsible to save the attachment, but the record (User) is. I also don't think it made sense anyway to have the Blob be responsible to save the Attachment. I tried to think of reasons that this would create issues where we instantiate a Blob and an Attachment, saves the Blob and leave the Attachment unsaved but I couldn't think of any. The reason is because an Attachment needs a record (e.g. User) to be valid and that same record will be saving the attachment. > [!IMPORTANT] > If for reasons an application changes the metadata of an **existing** Blob > and saves it, its existing Attachment > and dirty attributes would not be saved with this commit. > > Is this a common scenario? I don't know. ### Other solution We could revert rails#50800 and set the inverse_of explicitly to `nil` on the Blob<->Attachment.
1 parent db40022 commit e70f504

File tree

4 files changed

+50
-1
lines changed

4 files changed

+50
-1
lines changed

activestorage/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,13 @@
1+
* A Blob will no longer autosave associated Attachment.
2+
3+
This fixes an issue where a record with an attachment would have
4+
its dirty attributes reset, preventing your `after commit` callbacks
5+
on that record to behave as expected.
6+
7+
Note that this change doesn't require any changes on your application
8+
and is supposed to be internal. Active Storage Attachment will continue
9+
to be autosaved (through a different relation).
10+
11+
*Edouard-chin*
112

213
Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/activestorage/CHANGELOG.md) for previous changes.

activestorage/app/models/active_storage/blob.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class ActiveStorage::Blob < ActiveStorage::Record
2929
# :method:
3030
#
3131
# Returns the associated ActiveStorage::Attachment instances.
32-
has_many :attachments
32+
has_many :attachments, autosave: false
3333

3434
##
3535
# :singleton-method:

activestorage/test/models/attachment_test.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,27 @@ class ActiveStorage::AttachmentTest < ActiveSupport::TestCase
3838
assert_predicate blob.reload, :analyzed?
3939
end
4040

41+
test "attaching a blob doesn't touch the record" do
42+
data = "Something else entirely!"
43+
io = StringIO.new(data)
44+
blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data)
45+
blob.upload(io)
46+
47+
user = User.create!(
48+
name: "Roger",
49+
avatar: blob.signed_id,
50+
record_callbacks: true,
51+
)
52+
53+
assert_equal(1, user.callback_counter)
54+
end
55+
56+
test "attaching a record doesn't reset the previously_new_record flag" do
57+
@user.highlights.attach(io: ::StringIO.new("dummy"), filename: "dummy.txt")
58+
59+
assert(@user.notification_sent)
60+
end
61+
4162
test "mirroring a directly-uploaded blob after attaching it" do
4263
with_service("mirror") do
4364
blob = directly_upload_file_blob

activestorage/test/test_helper.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ def with_raise_on_open_redirects(service)
121121
ActiveRecord::Base.include GlobalID::Identification
122122

123123
class User < ActiveRecord::Base
124+
attr_accessor :record_callbacks, :callback_counter
125+
attr_reader :notification_sent
126+
124127
validates :name, presence: true
125128

126129
has_one_attached :avatar
@@ -161,11 +164,25 @@ class User < ActiveRecord::Base
161164
attachable.variant :preview, resize_to_fill: [400, 400], preprocessed: true
162165
end
163166

167+
after_commit :increment_callback_counter
168+
after_update_commit :notify
169+
164170
accepts_nested_attributes_for :highlights_attachments, allow_destroy: true
165171

166172
def should_preprocessed?
167173
name == "transform via method"
168174
end
175+
176+
def increment_callback_counter
177+
if record_callbacks
178+
@callback_counter ||= 0
179+
@callback_counter += 1
180+
end
181+
end
182+
183+
def notify
184+
@notification_sent = true if highlights_attachments.any?(&:previously_new_record?)
185+
end
169186
end
170187

171188
class Group < ActiveRecord::Base

0 commit comments

Comments
 (0)