Set an Active Storage attachment in an after_save callback
Tags: rails, ruby
Published: 2023-05-18 02:43:29

---
title: Set an Active Storage attachment in an after_save callback
published: true
description: Set an attachment in a Ruby on Rails Active Record after_save callback without triggering an endless loop.
tags: rails, ruby
# cover_image: https://direct_url_to_image.jpg
# Use a ratio of 100:42 for best results.
# published_at: 2023-05-01 21:35 +0000
---

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:

```rb
# 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
```

```rb
# 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:

```rb
# 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
```

```rb
# 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!