Immediate avatar and embed variants

Process variants synchronously on attachment to close the window between
image upload and variant availability, guaranteeing that we won't have
lazy variant processing attempts in GET requests.

Tradeoff is that we do variant processing in upload requests, which is
actually desirable. We're working with images that should take
milliseconds to resize given that we'll already have the file on hand.

References https://github.com/rails/rails/pull/51951
This commit is contained in:
Jeremy Daer
2025-12-04 23:17:21 -08:00
parent d729cf59f9
commit 21f3f72647
9 changed files with 59 additions and 17 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 3c3f6c8a253c8a0f346695374f927c9ab32fbefb
revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0
branch: main
specs:
actioncable (8.2.0.alpha)
+3 -3
View File
@@ -75,7 +75,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 690ec8898318b8f50714e86676353ebe1551261e
revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0
branch: main
specs:
actioncable (8.2.0.alpha)
@@ -292,7 +292,7 @@ GEM
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
jmespath (1.6.2)
json (2.16.0)
json (2.17.1)
jwt (3.1.2)
base64
kamal (2.9.0)
@@ -552,7 +552,7 @@ GEM
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.8)
stringio (3.1.9)
thor (1.4.0)
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
+3 -6
View File
@@ -1,13 +1,10 @@
module Attachments
extend ActiveSupport::Concern
# The variants we use must all declared here so that we can preprocess them.
# Variants used by ActionText embeds. Processed immediately on attachment to avoid
# read replica issues (lazy variants would attempt writes on read replicas).
#
# If they are not preprocessed, then Rails will attempt to transform the image on-the-fly when
# they are first viewed, which may be on the read replica where writing to the database is not
# allowed. Chaos will ensue if that happens.
#
# These variants are patched into ActionText::RichText in config/initializers/action_text.rb
# Patched into ActionText::RichText in config/initializers/action_text.rb
VARIANTS = {
# vipsthumbnail used to create sized image variants has a intent setting to manage colors during
# resize. By setting an invalid intent value the gif-incompatible intent filtering is skipped and
+1 -1
View File
@@ -5,7 +5,7 @@ module User::Avatar
included do
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_fill: [ 256, 256 ]
attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately
end
validate :avatar_content_type_allowed
+1 -1
View File
@@ -7,7 +7,7 @@ module ActionText
# This overrides the default :embeds association!
has_many_attached :embeds do |attachable|
::Attachments::VARIANTS.each do |variant_name, variant_options|
attachable.variant variant_name, variant_options.merge(preprocessed: true)
attachable.variant variant_name, **variant_options, process: :immediately
end
end
end
@@ -27,7 +27,9 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest
end
test "show own image redirects to the blob url" do
users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "moon.jpg", content_type: "image/jpeg")
# Create blob separately to ensure file is uploaded before variant processing
blob = ActiveStorage::Blob.create_and_upload!(io: File.open(file_fixture("moon.jpg")), filename: "moon.jpg", content_type: "image/jpeg")
users(:david).avatar.attach(blob)
assert users(:david).avatar.attached?
get user_avatar_path(users(:david))
@@ -36,7 +38,9 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest
end
test "show other image redirects to the blob url" do
users(:kevin).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "moon.jpg", content_type: "image/jpeg")
# Create blob separately to ensure file is uploaded before variant processing
blob = ActiveStorage::Blob.create_and_upload!(io: File.open(file_fixture("moon.jpg")), filename: "moon.jpg", content_type: "image/jpeg")
users(:kevin).avatar.attach(blob)
assert users(:kevin).avatar.attached?
get user_avatar_path(users(:kevin))
@@ -22,11 +22,12 @@ class Notification::BundleMailerTest < ActionMailer::TestCase
end
test "renders avatar with external image URL when avatar is attached" do
@user.avatar.attach(
blob = ActiveStorage::Blob.create_and_upload!(
io: File.open(Rails.root.join("test", "fixtures", "files", "avatar.png")),
filename: "avatar.png",
content_type: "image/png"
)
@user.avatar.attach(blob)
create_notification(@user)
+20
View File
@@ -4,4 +4,24 @@ class CommentTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
end
test "rich text embed variants are processed immediately on attachment" do
comment = cards(:logo).comments.create!(body: "Check this out")
comment.body.body.attachables # force load
blob = ActiveStorage::Blob.create_and_upload! \
io: File.open(file_fixture("moon.jpg")),
filename: "moon.jpg",
content_type: "image/jpeg"
comment.body.body = ActionText::Content.new(comment.body.body.to_html).append_attachables(blob)
comment.save!
embed = comment.body.embeds.sole
Attachments::VARIANTS.each_key do |variant_name|
variant = embed.variant(variant_name)
assert variant.processed?, "Expected #{variant_name} variant to be processed immediately"
end
end
end
+22 -2
View File
@@ -2,7 +2,8 @@ require "test_helper"
class User::AvatarTest < ActiveSupport::TestCase
test "avatar_thumbnail returns variant for variable images" do
users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "moon.jpg", content_type: "image/jpeg")
blob = ActiveStorage::Blob.create_and_upload!(io: File.open(file_fixture("moon.jpg")), filename: "moon.jpg", content_type: "image/jpeg")
users(:david).avatar.attach(blob)
assert users(:david).avatar.variable?
assert_equal users(:david).avatar.variant(:thumb).blob, users(:david).avatar_thumbnail.blob
@@ -16,7 +17,8 @@ class User::AvatarTest < ActiveSupport::TestCase
end
test "allows valid image content types" do
users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "test.jpg")
blob = ActiveStorage::Blob.create_and_upload!(io: File.open(file_fixture("moon.jpg")), filename: "test.jpg", content_type: "image/jpeg")
users(:david).avatar.attach(blob)
assert users(:david).valid?
end
@@ -27,4 +29,22 @@ class User::AvatarTest < ActiveSupport::TestCase
assert_not users(:david).valid?
assert_includes users(:david).errors[:avatar], "must be a JPEG, PNG, GIF, or WebP image"
end
test "thumb variant is processed immediately on attachment" do
# Create blob separately to ensure file is uploaded before variant processing.
#
# Root cause: When ActiveStorage::Record uses `connects_to` for read replica support
# (as in SAAS mode), it creates a separate connection pool from application models.
# Since after_commit callbacks are tracked per connection pool, the callback order
# between User's pool (upload) and Attachment's pool (create_variants) isn't guaranteed.
# In MySQL/SAAS mode, the Attachment callback fires before the file is uploaded.
blob = ActiveStorage::Blob.create_and_upload!(
io: File.open(file_fixture("avatar.png")),
filename: "avatar.png",
content_type: "image/png"
)
users(:david).avatar.attach(blob)
assert users(:david).avatar.variant(:thumb).processed?
end
end