LazyGlobalRecord

Gem Version

Lazy loading of 'interesting' ActiveRecord model id's, thread-safely and with easy cache reset and lazy creation in testing. Uses ruby-concurrent as a dependency.

You might find yourself doing this in Rails:

class Department < ActiveRecord::Base
   # Bad idea, don't do this.
   def self.master_department_id
     @@master_department_id ||= where(name: "master").first.id
   end
   # ...
end

A class acessor that looks up a particular record of concern in the db, and caches it's id.

First of all, if you can find any way to not do this in your architecture, you'll be happier. But maybe you can't get out of it.

If you take that naive approach, it ends up raising heck on your test environment. DatabaseCleaner is cleaning out your db after every test, so that record that you always expect to be there isn't; if you switch to first_or_create, you still have a problem because you don't really want to be silently creating the record in production, and even in test when you silently create it, it ends up getting cached, but then DatabaseCleaner cleans it out and the cached value is wrong. And it's none of it thread-safe, and this is 2016, get with the concurrency program already.

So this gem provides an answer, with a pattern to fetch and cache an ActiveRecord model id (or other values), lazily, thread-safely, with auto-creation and easy cache reset in test env.

class Department < ActiveRecord::Base
    @lazy_master_department_id = LazyGlobalRecord.new(
      relation: -> { where(name: "master") }
    )
    def self.master_department_id
      @lazy_master_departent_id.value
    end
end

Note the relation argument is a proc.

It won't look up the database until you ask for value. It'll take your relation, call .first.id on it, and cache the result. By default in production, it'll raise an ActiveRecord::RecordNotFound if it can't be found.

In Test/Dev: Auto-creation, and reset

In development/test, it'll automatically create the record if it's not found, adding create! onto your relation.

You can customize the creation routine:

class Department < ActiveRecord::Base
    @lazy_master_department_id = LazyGlobalRecord.new(
      relation: -> { where(name: "master") }
      creatable: true # default true unless production

      # Use whatever you want to create!
      create_with: -> { FactoryGirl.create(:master_department) }
    )
end

Also, in your test setup, you can call LazyGlobalRecord.reset_all to reset all LazyGlobalRecord objects to fetch again next time they are called. You want to do this after any DatabaseCleaner.clean in your test setup. You likely have one in a before(:suite) and another in a before(:each) in your spec_helper.rb. Put a LazyGlobalRecord.reset_all after each and any DatabaseCleaner.clean or clean_with calls, to reset cached values when the db is cleaned out.

You can also use LazyGlobalRecord.reload_all, which rests and then forces immediate re-fetch. Defeats the purpose of lazy loading to make tests faster by only loading what's needed for the test -- but I was getting odd segfaults in some cases that this is a potential workaround for.

Custom transformations

What if you need more than just the id? You can supply a custom filter proc.

We really recommend against cacheing actual ActiveRecord objects, instead use an OpenStruct to cache whatever values you need.

class Department < ActiveRecord::Base
    @lazy_master_department_id = LazyGlobalRecord.new(
      relation: -> { where(name: "master") }
      filter: ->(obj) { OpenStruct.new(:id => obj.id, :city => obj.city, :boss_ids => obj.bosses.map(&:id))}
    )
    def self.master_department_id
      @lazy_master_department_id.value.id
    end
    def self.master_department_city
      @lazy_master_department_id.value.city
    end
    def self.master_boss_ids
      @lazy_master_department_id.value.boss_ids
    end
end

The object you return from a custom filter proc will be frozen for you.

Keep in mind anything you do here will ordinarily be cached for the life of the process, you need to only cache things that won't change, or deal with cache invalidation by calling reset on the LazyGlobalRecord where appropriate.

What if I want the actual AR model?

In general, it's not safe to share an AR model object between threads, so this can be dangerous and is not hte default.

Is it okay if the model is frozen and readonly? Not sure, but it might be. If you want to try, at your own risk, there's a built-in filter proc that returns the model object itself, but frozen and marked readonly.

lazy = LazyGlobalRecord.new(
  relation: -> {where(name: "master"),
  filter: LazyGlobalRecord::FROZEN_MODEL