DEV Community

loading...
Cover image for Prevent ActiveStorage to kill your Rails app

Prevent ActiveStorage to kill your Rails app

CHADDA Chakib
Slapping the keyboard with love and passion until something valuable to people happen !
・4 min read

ActiveStoage is a superb piece of code that makes uploads and file manipulation, resizing, and previews like a breeze on your ruby on rails application.

When you're, like me, hosting your app on Heroku, using s3 or other external storage to persist your file uploads, ActiveStorage makes it as easy as adding some lines on a config file.

Using ActiveStorage was for me a benediction, small efforts to maintain and it works reliably most of the time.

One of the main features of the ActiveStorage is the ability to resize pictures and create variants.
It uses ImagicMagic ou LibVibs libraries on the background to make this happen.

To create a variant we need to write:

# resize the picture to fit 200px by 200px by respecting the ratio. 
<%= image_tag picture.variant(resize: "200x200", auto_orient: true) %> 
Enter fullscreen mode Exit fullscreen mode

When the browser fetches the image for the first time, ActiveStorage will check if it has the asked variant of this picture ready, if not it will call in sync the ImagicMagic process to do the resizing and send it back to the browser.

If the picture is stored to S3 from aws for example, the process will follow:

  1. Download the image from S3 into the server
  2. Call the process to resize the picture
  3. Upload the variant to S3 and update the database
  4. Send an HTTP redirect to the new location of the image on S3

This a lot of work and it takes time (some seconds on small dynos).

Meanwhile, the process/thread is too busy and cannot work on other requests.

Imagine, one page contains 5 or 6 pictures, this will make your server busy for resizing all the pictures, may cause a significant slow down for the following requests.

You can tell me, it's not an issue, it's done only once, but when you have an app with uploads occurring all the day, running on a small dyno, this will be a serious issue.

If you care about the user experience and the confidence on your product, you should fix it.

You can throw more money on you Heroku, faster CPU the mitigate the issue, bare with me, I have a solution for you and will cost you only some lines of codes πŸ˜‡

1. Picture resizing done on a background Job

To make your web server happy, overload all the heavy works with your background workers.

When an upload is done, start a background job to process the variants

class ResizePhotoJob < ApplicationJob
  queue_as :default

  def perform(file, resize_cmd:nil)
    if resize_cmd.nil?
      file.variant(auto_orient: true).processed
    else
      # the `.processed` will force the resizing to be done in sync
      file.variant(resize: resize_cmd, auto_orient: true).processed 
    end
  end
end

# and call it for your different needed variants
ResizePhotoJob.perform_later(file)
ResizePhotoJob.perform_later(file, resize_cmd: "250x250")

Enter fullscreen mode Exit fullscreen mode

Of course, you need to write some tests for this! I will help you

require 'test_helper'

class ProcessPhotosFromAttachmentJobTest < ActiveJob::TestCase
  test 'resize photos' do

    profile = profiles(:jamal)
    profile.avatar.attach(io: File.open(file_fixture("photo_1.jpeg")), filename: 'image')

    file = profile.avatar

    key = file.variant(resize: '200x200', auto_orient: true).key
    refute file.service.exist?(key)

    ResizePhotoJob.perform_now(profile.avatar, resize_cmd: "200x200")

    key = file.variant(resize: '200x200', auto_orient: true).key
    assert file.service.exist?(key)
  end
end

Enter fullscreen mode Exit fullscreen mode

Each variant on the ActiveStorage has a key ( a simple hash on the ImageMagic params )
The variant is stored and retrieved using this key.

# ==> retrieve the variant key 
file.variant(resize: '200x200', auto_orient: true).key
Enter fullscreen mode Exit fullscreen mode

This test will see if the variant exists before the job executions and its existence after.

That's good, now we process the variants on the background, and hopefully when the user tries to access the picture, he will find the variant processed and ready to be served.

Unfortunately, it's not always the case πŸ˜”

Even worse, some pictures will fail to resize, for example on a small Heroku dyno, there is not enough memory allowed to ImageMagic to process some big pictures > 15Mb.

I got this error when resizing an image of 15mb (12000x9000) on a Hobby Dyno on Heroku

convert /tmp/ActiveStorage-36637-20210128-4-18i0017.jpg[0] -auto-orient -resize 250x250 -auto-orient
'/tmp/image_processing20210128-4-gnzbsa.jpg' failed with error:
convert-im6.q16: DistributedPixelCache '127.0.0.1' 
@error/distribute-cache.c/ConnectPixelCacheServer/244.
convert-im6.q16: cache resources exhausted
'/tmp/ActiveStorage-36637-20210128-4-18i0017.jpg' 
@error/cache.c/OpenPixelCache/3984.
Enter fullscreen mode Exit fullscreen mode

Hopefully, in my case, this is not the regular size of pictures uploaded on my app.

For those cases, our job is useless, each request will start all the optimization task and it will take too much time.

These requests will end up with a 503 timeout and make worse the process/thread availability to process our regular requests.

2. Prevent resizing on the fly

My solution is dead simple:
When the variant is not available, do not resize and redirect to the original picture.

Okay, the user will download a big picture, that's good enough, the user will get his picture, okay it will take much longer to download but will use the S3 server resources, not mine πŸ˜†.

My app will only redirect the browser to the S3 servers when the actual download will occur.

We will use the same code used on the tests to check if the variant is present.

      <% variant = picture.variant(resize: "200x200" , auto_orient: true) %>
      <% if picture.service.exist?(variant.key) %>
          <%= image_tag variant %>   
      <% else %>
          <%= image_tag picture %>
      <% end %>

Enter fullscreen mode Exit fullscreen mode

This small line of codes will make stay a little longer on small cheap Heroku dynos, make your app more resilient, and make you save money.

Glad to hear if you have done this kind of Budget optimization in another way.

Discussion (3)

Collapse
alecrust profile image
Alec Rust

Nice!

Passing file directly to ResizePhotoJob doesn't work for me as jobs don't seem to be able to accept ActiveStorage::Attached::One as a param. file.attachment works though (passing a ActiveStorage::Attachment instead).

Do you know if calling processed on the variant will also queue up an AnalyzeJob for the variant blob? Or should we also call variant.blob.analyze in ResizePhotoJob?

As well as the variant being created in a background job before the user requests it, I want that variant to be analyzed too so that the dimensions metadata is available before first request.

Collapse
zimski profile image
CHADDA Chakib Author

Hey !
I think the AnalyzeJob is called when the upload is done. I should dive on the rails code to be sure.

Meanwhile, The meta data is needed to know if it’s an image, pdf or a regular file.
This data is also needed to choose the right processor to compress or resize.
So this should be done as soon as possible ( technically just after the upload is done )

Collapse
alecrust profile image
Alec Rust

I believe AnalyzeJob for the variant does get queued when processed is called, not sure how quickly it occurs though. Maybe calling analyze after processed in the job would be wise.