The GREATEST() Way to do Conditional GETs in Rails

Conditional GETs are a great way to speed up a web application. They're a way of telling a web browser, "Hey, this request is identical to how it was the last time you made the same request. I don't need to send you any body back at all, just reuse the one you already have."

Rails's fresh_when method is a solid method for doing this. For any action that's just working with a single record, it provides a pretty easy way to implement conditional GETs in your app, bypassing the need to render views and send them down the line to a client.

For handling conditional GETs on an action with multiple records, though, there's a big pitfall to watch out for. Consider this scenario, which I've seen in reality before:

- Record 3 [updated_at: 2012-02-14]
- Record 2 [updated_at: 2012-02-13]
- Record 1 [updated_at: 2012-02-10]

We have three records, sorted by their updated_at attribute in descending order. The worst way to handle would be to say caching and conditional GETs are too hard and to just throw in the towel. This is the next worst thing to do:

def index
  @records = Record.all
  fresh_when last_modified: @records.map(&:updated_at).max
end

Since the updated_at attribute is changed every time a record is updated, this approach should do a pretty good job of determining request freshness, right? Well, think about what happens if record 2 above is destroyed. The most recent updated_at is still that of record 3, and so our index action is still going to think that requests from before record 2 was destroyed are still fresh.

I've also seen solutions like the following, where a hash of the updated_at for each record is used to generate an ETag:

def index
  @records = Record.all
  fresh_when etag: @records.map(&:updated_at).join
end

I don't particularly like this approach, as it still requires initializing every single record instance needed for the request, which I'd rather not do unless I'm actually going to be using those records. Recently, I've been using the paranoia gem to make it much easier to do conditional GETs. Since paranoia ensures that records are never actually destroyed but just marked as deleted, I simply need to use the most recent updated_at or deleted_at timestamp for the conditional GET:

def index
  if stale? Record.with_deleted.select("GREATEST(MAX(updated_at), MAX(deleted_at))").first.greatest
    @records = Record.all
  end
end

That slightly ugly-looking SQL basically says to find the maximum value of updated_at and the maximum value of deleted_at, and return the greatest of the two. You might clean it up a little by providing a class method on your model:

# app/models/record.rb
class Record < ActiveRecord::Base
  acts_as_paranoid # Use paranoia on this model

  # `reorder(nil)` ensures that ActiveRecord won't attempt to sort the results
  def self.most_recent_timestamp
    with_deleted.select("GREATEST(MAX(updated_at), MAX(deleted_at))").reorder(nil).first.greatest
  end
end

# app/controllers/records_controller.rb
class RecordsController < ApplicationController
  def index
    if stale? Record.most_recent_timestamp
      @records = Record.all
    end
  end
end

Now, you've got a method on your model that makes it really simple to handle conditional GETs (note that the .most_recent_timestamp method will also work with scoped collections). As long as the relevant timestamp columns (updated_at and deleted_at) are each indexed, you're swapping out initializing every single ActiveRecord object for each request for a very quick SQL query getting the most recent updated or deleted timestamp.

Quick side note on busting these headers between deploys. The downside of conditional GETs is that deploying new view or stylesheets code to your app won't bust these headers, and users can get stale views. I've been solving this problem by using the ettu gem, which adds assets and views digests into ETag generation (i.e. it digests the file contents, not the rendered view). It's still much quicker than actually rendering the views and generating ETags based off of that.