I'm currently building an app that automatically generates and attaches an open graph image to a BlogPost
model when it is updated via its after_save
callback. Unfortunately, my first attempt caused an endless loop.
This post will demonstrate how setting attachments in an after_save
callback can cause an endless loop, then it will demonstrate how to set an attachment on a model via its after_save
callback without triggering an endless loop.
The problem: creating an endless loop when setting an attachment within after_save
Here was the code I originally wrote:
# blog_post.rb
class BlogPost < ApplicationRecord
after_save :generate_open_graph_image
has_one_attached :open_graph_image
private
def generate_open_graph_image
GenerateBlogPostImageJob.perform_async(id)
end
end
# generate_blog_post_image_job.rb
class GenerateBlogPostImageJob
include Sidekiq::Job
def perform(blog_post_id)
blog_post = BlogPost.find(blog_post_id)
return if blog_post.nil?
image = BlogPostImageGenerator(blog_post)
blog_post.open_graph_image.attach(
io: image,
filename: "blog_post_#{blog_post.id}.png"
)
end
end
This code will trigger an endless loop when the model saves. Running blog_post.open_graph_image.attach
triggers the BlogPost
instance to update its updated_at
field, which causes the after_save
callback to execute again. This loop will continue until Rails shuts down.
The solution: creating the attachment manually
Under the hood, Active Storage uses the ActiveStorage::Blob
and ActiveStorage::Attachment
models to store attachments, where Blob
is a reference to the underlying file, and Attachment
associates the Blob
with an application's Active Record model.
The open_graph_image.attach
method used above will delete the existing blobs/attachments/files, create new ones, and update the post's updated_at
column (causing the infinite loop).
Rather than relying on this attach
method, we could manually delete the existing blobs/attachments/files, then create new ones without updating the BlogPost
instance.
Here is a complete working example:
# blog_post.rb
class BlogPost < ApplicationRecord
after_save :generate_open_graph_image
has_one_attached :open_graph_image
private
def generate_open_graph_image
GenerateBlogPostImageJob.perform_async(id)
end
end
# generate_blog_post_image_job.rb
class GenerateBlogPostImageJob
include Sidekiq::Job
def perform(blog_post_id)
blog_post = BlogPost.find(blog_post_id)
return if blog_post.nil?
image = BlogPostImageGenerator(blog_post)
current_attachments = ActiveStorage::Attachment.where(
name: "open_graph_image",
record_type: "BlogPost",
record_id: blog_post.id,
)
current_attachments.each(&:purge)
blob = ActiveStorage::Blob.create_and_upload!(
io: image,
filename: "blog_post_#{blog_post.id}.png"
)
ActiveStorage::Attachment.create(
name: "open_graph_image",
record: blog_post,
blob: blob,
)
end
end
How it works
After generating the open graph image, the job queries any attachments associated with the current blog post's open_graph_image
attribute (there should typically only be one).
After that, purge
is called on any matching attachment, which deletes the Attachment
record, the associated Blob
and variations, and the underlying files.
Once the existing attachments have been deleted, the image generated by BlogPostImageGenerator
is used to create a new Blob
, which is then associated with the BlogPost
instance by creating an Attachment
that references the post and the blob.
Because the BlogPost
instance was never updated, the code does not cause after_save
to run again, preventing the endless loop.
What's next
The code shown here isn't perfect. It's possible for the job to fail before the new image is attached, leaving the BlogPost
instance with no attached open_graph_image
. This is fine for my app's use case, but your project may have different requirements.
Let me know if you enjoyed this post by leaving a like, and please drop a comment if you know a better way of doing this!
Top comments (2)
Thank u <3
I'm glad you found this helpful!