Skip to content

Background PDF creation via delayed_job gem

Brendon Muir edited this page Nov 9, 2021 · 7 revisions

Generating PDFs in your Rails app can be resource-intensive, especially if multiple people are generating PDFs at the same time. This can be mitigated by using a background task queue via delayed_job or resque. I went with delayed_job to avoid the extra overhead of setting up Redis. Here's the working code:

Controller

class DocsController < ApplicationController
  def generate_pdf
    @doc = Doc.find(params[:id])

    # enqueue our custom job object that uses delayed_job methods
    Delayed::Job.enqueue GeneratePdfJob.new(@doc.id)

    # update the status so nobody generates a PDF twice
    doc.update_attribute(:status, 'queued')
  end
end

Delayed Job

I put this code in /lib/generate_pdf_job in my Rails app, but you can put it wherever it makes sense. Note that I'm not including any layouts when rendering my PDF view, because it's a standalone view without headers or footers. Also note that I'm passing a local 'doc' variable to the view, rather than an instance @doc variable.

NB! In some cases it's better to use cells gem.

class GeneratePdfJob < Struct.new(:doc_id)

  # delayed_job automatically looks for a "perform" method
  def perform
    # create an instance of ActionView, so we can use the render method outside of a controller
    av = ActionView::Base.new()
    av.view_paths = ActionController::Base.view_paths

    # need these in case your view constructs any links or references any helper methods.
    av.class_eval do
      include Rails.application.routes.url_helpers
      include ApplicationHelper
    end

    pdf_html = av.render :template => "docs/pdf.html.erb", :layout => nil, :locals => {:doc => doc}

    # use wicked_pdf gem to create PDF from the doc HTML
    doc_pdf = WickedPdf.new.pdf_from_string(pdf_html, :page_size => 'Letter')

    # save PDF to disk
    pdf_path = Rails.root.join('tmp', "#{doc.id}.pdf")
    File.open(pdf_path, 'wb') do |file|
      file << doc_pdf
    end

  end

  # delayed_job's built-in success callback method
  def success(job)
    doc.update_attribute(:status, 'complete')
  end

  private

    # get the Doc object when the job is run
    def doc
      @doc = Doc.find(doc_id)
    end
end

To get this to work in Rails 6, change

av = ActionView::Base.new()
av.view_paths = ActionController::Base.view_paths

to

av = ActionView::Base.new(ActionView::LookupContext.new(ActionView::ViewPaths.all_view_paths))

But if you get an error like :

ActionView::Template::Error: undefined method `protect_against_forgery?' for #<ActionView::Base:0xc421704>

you can define a protect_against_forgery method before the variable declaration av :

ActionView::Base.send(:define_method, :protect_against_forgery?) { false }

Rails 6.1

You no longer need to create a view to render from. Simply use ApplicationController.render instead of av.render in the examples above and don't bother about creating av in the first place.

View

This lives in /app/views/docs/pdf.html.erb:

<!DOCTYPE html>

<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Language" content="en-us" />
  <title>PDF Doc</title>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
  <%= wicked_pdf_stylesheet_link_tag 'pdf' %>
</head>
<body>

  <% @doc = doc %>    
  <div class="page">
    <p>
    The doc ID is: <%= @doc.id %>.
    </p>
  </div>
  
</body>
</html>

This is a very simple example. There's obviously lots of other stuff you could do in the "perform" method, like send an email with the PDF attached, and a number of other callbacks supported by delayed_job. Switching to background PDF generation made a huge difference in the CPU activity on my production server, simply because I no longer had multiple instances of wkhtmltopdf running at the same time.