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 TimelineUpdates included some header content,
a list of the TimelineUpdates, and a form to create new TimelineUpdates.
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.