ActiveStorage and Turbo: Order Matters
Background
Starting with Rails 7, the turbo-rails
gem
is automatically configured in a new Rails application. Among other things,
Turbo excels at simplifying live updates to web pages. As an example,
consider a chat application. Turbo reduces the amount of code needed to
build real-time chat messages that are visible to each chat participant without
refreshing the page.
To learn more about Turbo, I set out to create a simple timeline-based
application. I wanted users to be able to submit messages and optionally upload
images to a Timeline. Any submitted updates should appear live for other users
viewing the Timeline. I chose ActiveStorage
to store the image files, and
turbo-rails
to broadcast updates to the UI. As I wrote the application, I
encountered some surprising behavior that sent me into the Rails and Turbo
source code to learn how Rails callbacks work. To share my experience, I will
walk through my initial, buggy implementation before arriving at a subtle fix.
The Buggy Implementation
First I had models to represent a Timeline
and TimelineUpdate
.
# app/models/timeline.rb
class Timeline < ApplicationRecord
has_many :timeline_updates
end
# app/models/timeline_update.rb
class TimelineUpdate < ApplicationRecord
belongs_to :timeline
has_one_attached :image
after_create_commit -> { broadcast_append_to(timeline, target: :timeline_updates) }
end
For those unfamiliar with Turbo and broadcasting changes,
the broadcast_append_to
method will publish a message using an ActionCable
WebSocket. That message includes the HTML of the TimelineUpdate
Rails view
partial. The Turbo JavaScript framework automatically listens for messages like
these and will append the published partial to the HTML element with the id
specified as the target
.
The view to show a Timeline
and its TimelineUpdate
s included some header content,
a list of the TimelineUpdate
s, and a form to create new TimelineUpdate
s.
I used a partial to render each TimelineUpdate
, the same partial that Turbo
would broadcast. In erb
the views looked something like this:
<!-- app/views/timelines/show.html.erb -->
<h1>
Timeline <%= @timeline.id %>
</h1>
<div id="timeline_updates">
<% timeline_updates.each do |timeline_update| %>
<%= render timeline_update %>
<% end %>
</div>
<%= turbo_frame_tag @timeline.timeline_updates.new do %>
<%= form_with model: @timeline.timeline_updates.new do |form| %>
<%= form.label :message %>
<%= form.text_field :message %>
<%= form.file_field :image %>
<%= form.submit
<% end %>
<% end %>
<!-- app/views/timeline_updates/_timeline_update.html.erb -->
<p>
<%= timeline_update.message %>
</p>
<% if timeline_update.image.attached? %>
<%= image_tag timeline_update.image.representation(resize_to_limit: [100, 100]).processed.url %>
<% end %>
I used Rails to generate the controllers and made no significant alterations.
Given those models and views, I expected a new TimelineUpdate
to show up in the
timeline_updates
div
whenever any user created one. When I started creating
new TimelineUpdates
without uploading any images, the application worked
as expected. I could open up two browsers and see messages showing up in both
users’ timelines. Although, whenever I would upload an image, I would encounter
an ActiveStorage::FileNotFound
error. The errors were not visible within the
UI, but I could see them in the Rails development logs. The stack trace from the
errors revealed that the error was occurring when Turbo rendered the partial for
the newly created TimelineUpdate
and attempted to generate the image_tag
.
Where is the Bug?
At first, I thought maybe resizing the image in the TimelineUpdate
partial was
my issue. It made intuitive sense to me that any image processing may happen in
a background process which may not finish before Turbo would broadcast the created
TimelineUpdate
; however, the call to processed.url
ensures that the image
variant is available. Furthermore, I tried removing the resizing from the partial,
and the error persisted. The image processing was not causing the error.
The ActiveStorage::FileNotFound
error included a key for the
ActiveStorage::Blob
that Rails failed to find. Locally, I was using the
ActiveStorage
Disk
service, which stores files on the local filesystem.
Sure enough, the file that Rails was looking for to generate a URL was
not saved anywhere. My debugging was telling me that ActiveStorage
had attached the image
to the TimelineUpdate
but had not uploaded the file
itself. How could that happen?
I began to wonder, “When and how does ActiveStorage
upload files?” Does it
happen before_save
, after_save
, somewhere in-between somehow? It turns
out that the file for an attachment gets uploaded in an after_commit
callback.
The callback gets defined when has_one_attached
gets called in a model. For
the Disk service, ActiveStorage
will not save the file to the filesystem until
that after_commit
callback executes.
Recall that I was also calling broadcast_append_to
in the TimelineUpdate
model in an after_commit
callback. The bug was starting to take shape. I
needed ActiveStorage
to upload the file before trying to generate a src
for
the image_tag
, so I needed the ActiveStorage
callback to run before the
Turbo broadcast. Well, what determines the order of ActiveRecord
callbacks
when the callbacks are both defined as after_commit
?
Callback Order
For before
and around
callbacks, the callbacks run in the same order that
you define them. These callbacks:
class Model < ApplicationRecord
before_create -> { puts "Callback 1" }
before_create -> { puts "Callback 2" }
end
would print statements in this order:
Callback 1
Callback 2
According to the ActiveSupport
source code, after
callbacks run in reverse order.
Meaning that for the following after
callbacks:
class Model < ApplicationRecord
after_commit -> { puts "Callback 1" }
after_commit -> { puts "Callback 2" }
end
You would see the puts
statements print in this order:
Callback 2
Callback 1
What is the Fix?
In the end, this was a long journey to a trivial fix. All that I had to do was
switch the order of the has_one_attached
call and the after_create_commit
callback in the TimelineUpdate
model. After that switch, everything
“just works”. The broadcast_append_to
callback gets defined first.
Then the ActiveStorage
callback gets defined by has_one_attached
. The
callbacks run in reverse order, so ActiveStorage
uploads the file before
Turbo broadcasts the UI update.
# Buggy
class TimelineUpdate < ApplicationRecord
has_one_attached :file
after_create_commit -> { broadcast_append_to(...) }
end
# Fixed
class TimelineUpdate < ApplicationRecord
after_create_commit -> { broadcast_append_to(...) }
has_one_attached :file
end
A Note on Direct Uploads
When using direct uploads
with ActiveStorage
, either order works. The direct upload stores the file
before the form even gets submitted.
Conclusion
I rarely consider has_one_attached
or other Rails DSLs as methods that do
anything at runtime in an application. If the model raises no exceptions when
it gets loaded, I tend to think that all the pieces will “just work”. That is
empirically an unsafe assumption. Investigating this bug and diving into the
ActiveStorage
and ActiveSupport
source code gave me a much better grasp of
some tools that I use often. It’s important to understand the tools that we
use; misunderstood tools tend to surprise you.