Turbo + Active Storage: Image rendering fails using after_commit hook but succeeds from view

I’m trying to simplify the use case here as much as possible but I have the following model:

class Product < ApplicationRecord
  belongs_to :company
  has_many_attached :images

  after_create_commit -> do
    broadcast_prepend_to(
      [company, "products_list"],
      partial: "products/summary",
      locals: {
        product: self,
      },
      target: "products_list"
    )
  end
end

Output:

When creating a product this produces an image that can’t be served, S3 returns a 404 not found. If I refresh the page, it works. I’ve added a sleep before calling broadcast_prepend_to thinking it was an eventual consistency issue, it doesn’t change anything. Is it because the sleep needs to be on the image callback?

Interestingly, removing the after_create_commit and moving it into a create.turbo_stream.erb completely removes the issue:

<%= turbo_stream.prepend "products_list",
  partial: "products/summary",
  locals: {
    product: @product,
  }
%>

Both are producing the exact same redirect URIs.

Any other theories/ideas I could try?

Do you have the same issue if you use service: "Disk"? That could help pin point the problem. Also are you using direct uploads or server side uploads?

@nickjanetakis ya, changing it to :local - works fine.

I fetch the images from Amazon and attach it before saving like this:

def fetch_images
  files = []
  external_response["Images"]["Primary"].each do |size, obj|
    retries = 0
    begin
      Rails.logger.debug "Fetching image: #{obj["URL"]}"
      filext = File.extname(obj["URL"])
      filename = "#{external_id}_#{size}#{filext}"
      io = URI.open(obj["URL"])
      files << { filename: filename, file: io }
    rescue Net::OpenTimeout => e
      retry if (retries += 1) < 3
    end
  end

  files.each do |f|
    images.attach(
      io: f[:file],
      filename: f[:filename],
      content_type: "image/jpeg",
    )
  end
end

You probably have a race condition between the rendering of the broadcast and the creation of the attachment between the product and the blob.

If your form creates the product and upload your images at the same time, especially with direct upload, the sequence is:

  1. Create blob
  2. Upload images
  3. Submit form
  4. Create product
  5. Create attachment between product and blobs

So your callback is running immediately after 4 and didn’t leave enough time for 5 to happen, which means that when the HTML is rendered, the “images” relation is still empty.

@brenogazzola thanks for that. Can you share more, i’m obviously doing images.attach so isn’t the product object hydrated correctly? Is there a fix you’d recommend?

Could you share the controller code so I can see the order that things are happening?

Yes, it’s:

def create
  @product = current_company.products.build(product_params)
  @product.fetch_from_amazon
  if @product.save
    respond_to do |format|
      format.html { redirect_to products_path, notice: "Product created " }
      format.turbo_stream { flash.now[:notice] = "Product created" } 
    end
  else
  ...
end

fetch_from_amazon will:

  1. go get product attributes
  2. calls fetch_images (code in previous post)

Ok, it’s not quite what I said. When you call save, the product is saved first, which causes the callback to fire and send the HTML. While this is happening, Rails is creating the blobs and attachments, and only then uploading the file.

Which means that by the time the HTML arrives in your browser and it requests the the image, Rails hasn’t had the time to upload the files yet. The reason putting the code in the controller code works, is because by the time turbo_stream is called, the files have already been uploaded.

Either keep the code in the controller or wrap the call to save in a transaction, which will delay the commit (and therefore the callback) to after the files have been uploaded.