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!