ForgetMeNot
Provides memoization and caching mixins
Installation
Add this line to your application's Gemfile:
gem 'forget-me-not'
And then execute:
$ bundle
Or install it yourself as:
$ gem install forget-me-not
Background
Although the code is quite similar between the two mixins they solve very different problems.
Memoization
Memoization is intended to allow a single object instance to remember the result of a method call. If you have ever written something like:
def full_name
@full_name ||= "#{last_name}, #{first_name}"
end
then you have done memoization. The result is calculated the first time full_name is called. Subsequent calls return the same value and do not incur the overhead of calculating the result. Trivial in this case, but the work could be quite substantial.
Memoization is scoped to an object. If you ask two different User instances what the memoized full_name is, then you will get a different answer from each of them.
Caching
Caching is similar, but broader in scope. For us, the initial use case was a dashboard in one of our products that aggregated the numerical values of several hundred thousand medical claims. Rather than performing this query every time we generated the dashboard, we cached the results of the query allowing us to display the web page in a snap.
Our caching mixin is intended to be used with a cache like memcached that is available across your servers. It includes convenience methods to allow cache warming.
Caching is system wide. If the same cached method for two separate instances is called, the actual method should only be called once.
Usage
Memoizable Mixin
class MyClass
include ForgetMeNot::Memoizable
def some_method
'result'
end
def method_with_args(with_an_arg)
"result2-#{with_an_arg}"
end
memoize :some_method
memoize_with_args :method_with_args
end
Calls to both some_method and method_with_args are memoized.
Caution!
Use caution when memoizing results on long-lived objects (e.g., objects that live for the life-time of the process). Memoized results never expire and the initially calculated result will always be the one that is returned. This can lead to incorrect application logic if the memoized results would differ if calculated at a later time.
Consider the case of the application that memoizes a user's privileges the first time they are needed. If the privilege container were long lived, it would never reflect a change in the user's privileges. (There are more subtle ways to lose, this is just an obvious example).
An additional caution is warranted when memoizing methods that take arguments. Because different argument values result in different results being memoized, if the set of arguments is unbounded, a great deal of memory can be consumed to hold on to the results.
Memoization uses Ruby's hash() to differentiate arguments. If you are using a custom class as an argument to a memoized method, you must implement a valid hash method that is computed based upon the properties of the class.
The easiest way to prevent stale results and excessive consumption of memory when memoizing is to ensure that two things are true:
- The memoizing object has a finite, well-known life span (e.g., a request)
- If arguments are used, the set of possible arguments is bounded
- If arguments are used, every class must have a meaningful hash() method (the built in string, number, array, and hash all meet this criteria)
In order to draw attention to the potential issues with argument driven memoization, you must call the memoize_with_args method if the method being memoized has arity > 0.
Memoization is a powerful tool, but like all powerful tools, needs to be used with knowledge and respect.
Blocks
You may not pass a block to a memoized method. Attempting to do so results in an error being raised.
Storage
By default, the memoization code stores results in a simple Hash based cache. If you have other requirements, perhaps a thread-safe storage, then set the ForgetMeNot::Memoization.storage_builder property to a proc that will create a new instance of whatever storage you desire.
Cacheable Mixin
The basics are unsurprising:
class MyClass
include ForgetMeNot::Cacheable
def some_method
'result'
end
cache :some_method
end
Like memoization, arguments are fully supported and will result in distinct storage to the cache. Unlike memoization, you do not need to call a with_args variant of the cache method. Caching uses Ruby's to_s() to create a string representation of the argument (we can't use Ruby's hash, because the hash value depends upon the runtime).
If you are using a custom class as an argument to a cached method, you must implement a valid to_s method that is computed based upon the properties of the class.
To control warming the cache, implement the cache_warm method
class MyClass
include ForgetMeNot::Cacheable
def some_method
'result'
end
cache :some_method
# Warm the cache for this object
def self.cache_warm(*args)
instance = new
instance.some_method
end
end
Then somewhere (a rake task perhaps), call Cacheable.warm. Whatever args you pass to warm are passed to every class that included Cacheable.
In addition to the arguments passed to the cached method, Cacheable allows instance properties to also be used as cache key members.
class MyClass
include ForgetMeNot::Cacheable
attr_reader :important_property
def initialize(important_property)
@important_property = important_property
end
def some_method
'result'
end
cache :some_method, :include => :important_property
end
Caution!
Of course, every argument variation and every included instance property amplifies the potential amount of cache memory that is consumed.
Blocks
You may not pass a block to a cached method. Attempting to do so results in an error being raised.
Storage
By default, the cache will attempt to use the Rails cache. If that isn't found, but ActiveSupport is available, a new instance of MemoryStore will be used. Failing that, cache will raise an error.
This is intended to provide a reasonably sane default, but really, set the ForgetMeNot::Cacheable.cache with something shaped like an ActiveSupport::Cacheable::Store
Troubleshooting
Both Memoizable and Cacheable have logging of their cache activity (cacheable more so). By default, this is disabled. To enable logging, change the module property log_activity to true. Both the cache and memoizable specs do this.
Origins
This is an extension of the ideas and approach found here: https://github.com/sferik/twitter/blob/master/lib/twitter/memoizable.rb. You guys rock.
Contributing
- Fork it
- Create your feature branch (
git checkout -b my-new-feature
) - Write tests for your code.
- Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create new Pull Request
Copyright
(c) 2013 Koan Health. See LICENSE.txt for further details.