DEV Community

Tyler Smith
Tyler Smith

Posted on

Set an Active Storage attachment in an after_save callback

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
jennysol profile image
Jennifer Soliver

Thank u <3

Collapse
 
tylerlwsmith profile image
Tyler Smith

I'm glad you found this helpful!