rails: ActiveStorage::FileNotFoundError due to upload after commit
We were getting ActiveStorage::FileNotFoundError
s spuriously and are fairly confident that this is a race condition in the way ActiveStorage handles uploads and attachment.
Steps to reproduce
In the rails guides, the recommended set up for a controller that attaches a file to a model is as follows:
class SignupController < ApplicationController
def create
user = User.create!(user_params)
redirect_to root_path
end
private
def user_params
params.require(:user).permit(:email_address, :password, :avatar)
end
end
When using this recommended set up Rails will upload the file after committing the creation and attachment of the Blob
to the model.
Within the same thread this is not a problem because the hook will obviously wait until the upload is finished and other code runs that might depend on that file.
However since the Blob
and Attachment
are already committed in the database, parallel processes like integrations polling for updates, or even just users clicking on the entity at just the wrong moment, will already see the attachment and try to download it causing an ActiveStorage::FileNotFoundError
.
Expected behavior
Before the file is actually uploaded, the User
should not be committed to the database.
For updates, the Attachment
should not be committed to the database before upload is finished.
Actual behavior
The new user is committed with the Blob
attached before the file is uploaded. Other requests or background tasks can find that User
causing an ActiveStorage::FileNotFoundError
when they try to download the avatar.
Our workaround
Rewriting the controller like this solves the problem as the file is uploaded before the change is committed to the database:
Edit: The original version of the workaround that I posted broke presence-validation. With this updated variant, we still just assign nil
to the avatar
, if the param is not sent, so displaying form errors still works normally.
class SignupController < ApplicationController
def create
user = User.new(user_params.except(:avatar)) # Note this change, to avoid creating the attachment twice.
user.avatar = try_create_and_upload_blob!(user_params.fetch(:avatar, nil))
user.save!
redirect_to root_path
end
private
def user_params
params.require(:user).permit(:email_address, :password, :avatar)
end
# This can also be put somewhere as a static method for reuse in different controllers.
def try_create_and_upload_blob!(uploaded_file)
return nil if uploaded_file.blank?
ActiveStorage::Blob.create_and_upload!(
io: uploaded_file.to_io,
filename: uploaded_file.original_filename,
)
end
end
This makes sure, that the file is completely uploaded before the Blob
is attached to the User
. Of course this is quite a bit more cumbersome.
Other considerations
I could also see other problems arising from doing a long upload within a transaction, so I was wondering if this was intentional.
If upload it is really supposed to be happening in the after_commit
, at least the guide should point out these potential issues and maybe we can even find a way to make uploading before committing less verbose and cumbersome.
Potentially related issues
These issues might be related:
System configuration
Rails version: 6.1.6
Ruby version: 3.1.3
About this issue
- Original URL
- State: open
- Created a year ago
- Reactions: 7
- Comments: 16 (5 by maintainers)
We’re seeing the same issue in an ActiveJob/Sidekiq job which results in the file being lost due to the job exiting before the
after_commit
fires.Adding a flag on
Blob
would be useful and allow us to ensure the job doesn’t exit early.This seems like the expected behavior. Maybe we should have a flag in the
Blob
record to say the image isn’t yet uploaded to the service so users can check that flag, but I don’t see how to change this behavior so the record is only committed when it is uploaded to the service without decreasing considerably the resilience of applications.