Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Initial version of Record Cache gem

commit 41244319a93182579a2f42a32e92f55d73efedaa 0 parents
@orslumen authored
Showing with 3,405 additions and 0 deletions.
  1. +13 −0 .gitignore
  2. +3 −0  Gemfile
  3. +101 −0 Gemfile.lock
  4. +20 −0 MIT-LICENSE
  5. +239 −0 README.markdown
  6. +30 −0 Rakefile
  7. +1 −0  init.rb
  8. +1 −0  lib/record-cache.rb
  9. +11 −0 lib/record_cache.rb
  10. +318 −0 lib/record_cache/active_record.rb
  11. +136 −0 lib/record_cache/base.rb
  12. +90 −0 lib/record_cache/dispatcher.rb
  13. +51 −0 lib/record_cache/multi_read.rb
  14. +85 −0 lib/record_cache/query.rb
  15. +82 −0 lib/record_cache/statistics.rb
  16. +154 −0 lib/record_cache/strategy/base.rb
  17. +93 −0 lib/record_cache/strategy/id_cache.rb
  18. +122 −0 lib/record_cache/strategy/index_cache.rb
  19. +49 −0 lib/record_cache/strategy/request_cache.rb
  20. +49 −0 lib/record_cache/test/resettable_version_store.rb
  21. +5 −0 lib/record_cache/version.rb
  22. +54 −0 lib/record_cache/version_store.rb
  23. +30 −0 record-cache.gemspec
  24. +6 −0 spec/db/database.yml
  25. +42 −0 spec/db/schema.rb
  26. +40 −0 spec/db/seeds.rb
  27. +14 −0 spec/initializers/record_cache.rb
  28. +86 −0 spec/lib/dispatcher_spec.rb
  29. +51 −0 spec/lib/multi_read_spec.rb
  30. +148 −0 spec/lib/query_spec.rb
  31. +140 −0 spec/lib/statistics_spec.rb
  32. +241 −0 spec/lib/strategy/base_spec.rb
  33. +168 −0 spec/lib/strategy/id_cache_spec.rb
  34. +223 −0 spec/lib/strategy/index_cache_spec.rb
  35. +85 −0 spec/lib/strategy/request_cache_spec.rb
  36. +104 −0 spec/lib/version_store_spec.rb
  37. +8 −0 spec/models/apple.rb
  38. +8 −0 spec/models/banana.rb
  39. +6 −0 spec/models/pear.rb
  40. +11 −0 spec/models/person.rb
  41. +13 −0 spec/models/store.rb
  42. +44 −0 spec/spec_helper.rb
  43. +71 −0 spec/support/after_commit.rb
  44. +53 −0 spec/support/matchers/hit_cache_matcher.rb
  45. +53 −0 spec/support/matchers/miss_cache_matcher.rb
  46. +53 −0 spec/support/matchers/use_cache_matcher.rb
13 .gitignore
@@ -0,0 +1,13 @@
+.DS_Store
+/.bundle
+/.project
+/.loadpath
+/.rvmrc
+/coverage
+/doc
+/pkg
+/tags
+/spec/log
+*.log
+*.sqlite3
+
3  Gemfile
@@ -0,0 +1,3 @@
+source :rubygems
+
+gemspec
101 Gemfile.lock
@@ -0,0 +1,101 @@
+PATH
+ remote: .
+ specs:
+ record-cache (0.1.0)
+ rails (= 3.0.10)
+
+GEM
+ remote: http://rubygems.org/
+ specs:
+ abstract (1.0.0)
+ actionmailer (3.0.10)
+ actionpack (= 3.0.10)
+ mail (~> 2.2.19)
+ actionpack (3.0.10)
+ activemodel (= 3.0.10)
+ activesupport (= 3.0.10)
+ builder (~> 2.1.2)
+ erubis (~> 2.6.6)
+ i18n (~> 0.5.0)
+ rack (~> 1.2.1)
+ rack-mount (~> 0.6.14)
+ rack-test (~> 0.5.7)
+ tzinfo (~> 0.3.23)
+ activemodel (3.0.10)
+ activesupport (= 3.0.10)
+ builder (~> 2.1.2)
+ i18n (~> 0.5.0)
+ activerecord (3.0.10)
+ activemodel (= 3.0.10)
+ activesupport (= 3.0.10)
+ arel (~> 2.0.10)
+ tzinfo (~> 0.3.23)
+ activeresource (3.0.10)
+ activemodel (= 3.0.10)
+ activesupport (= 3.0.10)
+ activesupport (3.0.10)
+ arel (2.0.10)
+ builder (2.1.2)
+ database_cleaner (0.6.7)
+ diff-lcs (1.1.3)
+ erubis (2.6.6)
+ abstract (>= 1.0.0)
+ i18n (0.5.0)
+ mail (2.2.19)
+ activesupport (>= 2.3.6)
+ i18n (>= 0.4.0)
+ mime-types (~> 1.16)
+ treetop (~> 1.4.8)
+ mime-types (1.16)
+ polyglot (0.3.2)
+ rack (1.2.4)
+ rack-mount (0.6.14)
+ rack (>= 1.0.0)
+ rack-test (0.5.7)
+ rack (>= 1.0)
+ rails (3.0.10)
+ actionmailer (= 3.0.10)
+ actionpack (= 3.0.10)
+ activerecord (= 3.0.10)
+ activeresource (= 3.0.10)
+ activesupport (= 3.0.10)
+ bundler (~> 1.0)
+ railties (= 3.0.10)
+ railties (3.0.10)
+ actionpack (= 3.0.10)
+ activesupport (= 3.0.10)
+ rake (>= 0.8.7)
+ rdoc (~> 3.4)
+ thor (~> 0.14.4)
+ rake (0.9.2)
+ rcov (0.9.10)
+ rdoc (3.9.4)
+ rr (1.0.4)
+ rspec (2.6.0)
+ rspec-core (~> 2.6.0)
+ rspec-expectations (~> 2.6.0)
+ rspec-mocks (~> 2.6.0)
+ rspec-core (2.6.4)
+ rspec-expectations (2.6.0)
+ diff-lcs (~> 1.1.2)
+ rspec-mocks (2.6.0)
+ sqlite3 (1.3.4)
+ thor (0.14.6)
+ treetop (1.4.10)
+ polyglot
+ polyglot (>= 0.3.1)
+ tzinfo (0.3.29)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ activerecord (= 3.0.10)
+ database_cleaner
+ rake
+ rcov
+ rdoc
+ record-cache!
+ rr
+ rspec
+ sqlite3
20 MIT-LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2009-2011 Lawrence Pit
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
239 README.markdown
@@ -0,0 +1,239 @@
+Record Cache
+============
+
+*Cache Active Model Records in Rails 3*
+
+Record Cache transparantly stores Records in a Cache Store and retrieve those Records from the store when queried (by ID) using Active Model.
+Cache invalidation is performed automatically when Records are created, updated or destroyed. Currently only Active Record is supported, but more
+data stores may be added in the future.
+
+
+Usage
+-----
+
+#### Initializer
+
+In /config/initializers/record_cache.rb:
+
+ # --- Version Store
+ # All Workers that use the Record Cache should point to the same Version Store
+ # E.g. a MemCached cluster or a Redis Store (defaults to Rails.cache)
+ RecordCache::Base.version_store = Rails.cache
+
+ # --- Record Stores
+ # Register Cache Stores for the Records themselves
+ # Note: A different Cache Store could be used per Model, but in most configurations the following 2 stores will suffice:
+
+ # The :local store is used to keep records in Worker memory
+ RecordCache::Base.register_store(:local, ActiveSupport::Cache.lookup_store(:memory_store))
+
+ # The :shared store is used to share Records between multiple Workers
+ RecordCache::Base.register_store(:shared, Rails.cache)
+
+
+#### Models
+
+Define the Caching Strategy in your models.
+
+Typical Example: /app/models/person.rb:
+
+ cache_records :store => :shared, :key => "pers"
+
+Example with Index Cache: /app/models/permission.rb:
+
+ cache_records :store => :shared, :key => "perm", :index => [:person_id]
+
+Example with Request Cache: /app/models/account.rb:
+
+ cache_records :store => :local, :key => "acc", :request_cache => true
+
+The following options are available:
+
+- <a name="store" />`:store`: The name of the Cache Store for the Records (default: `Rails.cache`)
+
+ _@see Initializer section above how to define named Cache Stores_
+
+- <a name="key" />`:key`: Provide a short (unique) name to be used in the cache keys (default: `<model>.name`)
+
+ _Using shorter cache keys will improve performance as less data is sent to the Cache Stores_
+
+- <a name="request_cache" />`:request_cache`: Set to true to switch on Request Caching (default: `false`)
+
+ _In case the same Record is (always) queried multiple times during a single request from different locations,
+ e.g. from a helper and from a model, the Record can be cached in the Request Scope by setting this option to +true+.
+ **Important**: Add to application_controller.rb: `before_filter { |c| RecordCache::Strategy::RequestCache.clear }`
+ Note: In most cases you should be able to use an instance variable in the controller (or helper) instead._
+
+- <a name="index" />`:index`: An array of `:belongs_to` attributes to cache `:has_many` relations (default: `[]`)
+
+ _`has_many` relations will lead to queries like: `SELECT * FROM permissions WHERE permission.person_id = 10`
+ As Record Cache only caches records by ID, this query would always hit the DB. If an index is set
+ on person_id (like in the example above), Record Cache will keep track of the Permission IDs per
+ Person ID.
+ Using that information the query will be translated to: `SELECT * FROM permissions WHERE permission.id IN (14,15,...)`
+ and the permissions can be retrieved from cache.
+ Note: The administration overhead for the Permission IDs per Person ID leads to more calls to the Version Store and the Record
+ Store. Whether or not it is profitable to add specific indexes for has_many relations will differ per use-case._
+
+
+#### Tests
+
+To switch off Record Cache during the tests, add the following line to /config/environments/test.rb:
+
+ RecordCache::Base.disable!
+
+But it is also possible (and preferable during Integration Tests) to keep the Record Cache switched on.
+To make sure the cache is invalidated for all updated Records after each test/scenario, require the
+resettable_version_store and reset the Version Store after each test/scenario.
+
+RSpec 2 example, in spec/spec_helper.rb:
+
+ require 'record_cache/test/resettable_version_store'
+
+ RSpec.configure do |config|
+ config.after(:each) do
+ RecordCache::Base.version_store.reset!
+ end
+ end
+
+Cucumber example, in features/support/env.rb:
+
+ require 'record_cache/test/resettable_version_store'
+
+ After do |scenario|
+ RecordCache::Base.version_store.reset!
+ end
+
+
+Restrictions
+------------
+
+1. This gem is dependent on Rails 3.0 (3.1 support will follow).
+
+2. Only Active Record is supported as a data store.
+
+3. Models that do not have an `id` attribute cannot be cached.
+
+4. All servers that host Workers should be time-synchronized (otherwise the Version Store may return stale results).
+
+#### Caveats
+
+1. Record Cache sorting mimics the MySQL sort order being case-insensitive and using collation.
+ _If you need a different sort order, check out the code in `<gem>/lib/record_cache/strategy/base.rb`._
+
+2. Using `update_all` to modify attributes used in the [:index option](#index) will lead to stale results.
+
+3. When using `<model>.transaction do ... end`, make sure wrap it in `RecordCache::Base.without_record_cache do ... end`.
+ During the transaction the after_commit callbacks are delayed until the whole transaction completed successfullt. As
+ a result the records fetched from the Record Cache within that transaction will not contain the uncommitted changes yet.
+
+4. (Uncommon) If you have a model (A) with a `has_many :autosave => true` relation to another model (B) that defines a
+ `:counter_cache` back to model A, the `<model B>_count` attribute will contain stale results. To solve this, add an
+ after_save hook to model A and update the `<model B>_count` attribute there in case the `has_many` relation was loaded.
+
+5. When using Dalli as a MemCache client, multi_read actions may be 50x slower than normal reads,
+ @see https://github.com/mperham/dalli/issues/106
+ If the same applies to your environment, add the following at the top of /config/initializers/record_cache.rb:
+ `RecordCache::MultiRead.disable(ActiveSupport::Cache::DalliStore)`
+
+
+Explain
+-------
+
+#### Retrieval
+
+Each query is parsed and sent to record_cache before it is executed to check if the query is cacheable.
+A query is cacheable if:
+
+- it contains at least one `where(:id => ...)` or `where(<indexed attribute> => ...)` clause, and
+
+- it contains zero or more `where(<attribute> => <single value>)` clauses on attributes in the same model, and
+
+- it has no `limit(...)` defined, or is limited to 1 record and has exactly one id in the `where(:id => ...)` clause, and
+
+- it has no `order(...)` clause, or it is sorted on single attributes using ASC and DESC only
+
+- it has no joins, calculations, group by, etc. clauses
+
+When the query is accepted by Record Cache, all requested records will be retrieved and cached as follows:
+
+ID queries:
+
+1. The Version Store is called to retrieve the current version for each ID using a `multi_read` (keys `rc/<model-name>/<id>`).
+
+2. A new version will be generated (using the current timestamp) for each ID unknown to the Version Store.
+
+3. The Record Store is called to retrieve the latest data for each ID using a `multi_read` (keys `rc/<model-name>/<id>v<current-version>`).
+
+4. The data of the missing records is retrieved directly from the Data Store (single query) and are subsequently cached in the Record Store.
+
+5. The data of all records is deserialized to Active Model records.
+
+6. The other (simple) `where(<attribute> => <single value>)` clauses are applied, if applicable.
+
+7. The (simple) `order(...)` clause is applied, if applicable.
+
+Index queries:
+
+1. The Version Store is called to retrieve the current version for the group (key `rc/<model-name>/<index>/<id>`).
+
+2. A new version will be generated (using the current timestamp) in case the current version is unknown to the Version Store.
+
+3. The Record Store is called to retrieve the latest set of IDs in this group (key `rc/<model-name>/<index>/<id>v<current-version>`).
+
+4. In case the IDs are missing, the IDs (only) will be retrieved from the Data Store (single query) and subsequently cached in the Record Store.
+
+5. The IDs are passed as an ID query to the id-based-cache (see above).
+
+
+#### Invalidation
+
+The `after_commit, :on => :create/:update/:destroy` hooks are used to inform the Record Cache of changes to the cached records.
+
+ID cache:
+
+- `:create`: add a new version to the Version Store and cache the record in the Records Store
+
+- `:update`: similar to :create
+
+- `:destroy`: remove the record from the Version Store
+
+Index cache:
+
+- `:create`: increment Version Store for each index that contains the indexed attribute value of this record.
+ In case the IDs in this group are cached and fresh, add the ID of the new record to the group and store
+ the updated list of IDs in the Records Store.
+
+- `:update`: For each index that is included in the changed attribute, apply the :destoy logic to the old value
+ and the :create logic to the new value.
+
+- `:destroy`: increment Version Store for each index that contains the indexed attribute value of this record.
+ In case the IDs in this group are current cached and fresh, remove the ID of the record from the group and store
+ the updated list of IDs in the Records Store.
+
+The `update_all` method of Active Record Relation is also overridden to make sure than mass-updates are processed correctly, e.g. used by the
+:counter_cache. As the details of the change are not known, all records that match the IDs mentioned in the update_all statement are invalidated by
+removing them from the Version Store.
+
+Finally for `has_many` relations, the `after_commit` hooks are not triggered on add and remove. Whether this is a bug or feature I do not know, but
+for Active Record the Has Many Association is patched to invalidate the Index Cache of the referenced (reflection) Record in case it has
+an [:index](#index) on the reverse `belongs_to` relation.
+
+
+Installation
+------------
+
+Add the following line to your Gemfile:
+
+gem 'record_cache'
+
+
+Development
+-----------
+
+ $ bundle install
+
+ $ rake
+
+----
+Copyright (c) 2011 Orslumen, released under the MIT license
30 Rakefile
@@ -0,0 +1,30 @@
+require 'rubygems'
+require 'bundler'
+Bundler::GemHelper.install_tasks
+
+require 'rake'
+require 'rspec/core/rake_task'
+require 'rdoc/task'
+
+
+RSpec::Core::RakeTask.new
+
+RSpec::Core::RakeTask.new(:rcov) do |spec|
+ spec.rcov = true
+ spec.rcov_opts = ['--exclude', 'spec', '--exclude', '.rvm']
+end
+
+desc 'Run the specs.'
+task :default => :rcov
+
+
+RDoc::Task.new(:rdoc) do |rdoc|
+ rdoc.rdoc_dir = 'doc'
+ rdoc.title = 'RecordCache'
+ rdoc.options << '--line-numbers'
+ rdoc.rdoc_files.include('lib')
+end
+
+task :notes do
+ system "grep -n -r 'FIXME\\|TODO' lib spec"
+end
1  init.rb
@@ -0,0 +1 @@
+require 'record_cache'
1  lib/record-cache.rb
@@ -0,0 +1 @@
+require 'record_cache'
11 lib/record_cache.rb
@@ -0,0 +1,11 @@
+# Record Cache shared files
+["query", "version_store", "multi_read",
+ "strategy/base", "strategy/id_cache", "strategy/index_cache", "strategy/request_cache",
+ "statistics", "dispatcher", "base"].each do |file|
+ require File.dirname(__FILE__) + "/record_cache/#{file}.rb"
+end
+
+# Support for Active Record
+require 'active_record'
+ActiveRecord::Base.send(:include, RecordCache::Base)
+require File.dirname(__FILE__) + "/record_cache/active_record.rb"
318 lib/record_cache/active_record.rb
@@ -0,0 +1,318 @@
+module RecordCache
+ module ActiveRecord
+
+ module Base
+ class << self
+ def included(klass)
+ klass.extend ClassMethods
+ klass.class_eval do
+ class << self
+ alias_method_chain :find_by_sql, :record_cache
+ end
+ end
+ include InstanceMethods
+ end
+ end
+
+ module ClassMethods
+
+ # add cache invalidation hooks on initialization
+ def record_cache_init
+ after_commit :record_cache_create, :on => :create
+ after_commit :record_cache_update, :on => :update
+ after_commit :record_cache_destroy, :on => :destroy
+ end
+
+ # Retrieve the records, possibly from cache
+ def find_by_sql_with_record_cache(*args)
+ # no caching please
+ return find_by_sql_without_record_cache(*args) unless record_cache?
+
+ # check the piggy-back'd ActiveRelation record to see if the query can be retrieved from cache
+ sql = args[0]
+ arel = sql.instance_variable_get(:@arel)
+ query = arel ? RecordCache::Arel::QueryVisitor.new.accept(arel.ast) : nil
+ cacheable = query && record_cache.cacheable?(query)
+ # log only in debug mode!
+ RecordCache::Base.logger.debug("#{cacheable ? 'Fetch from cache' : 'Not cacheable'} (#{query}): SQL = #{sql}") if RecordCache::Base.logger.debug?
+ # retrieve the records from cache if the query is cacheable otherwise go straight to the DB
+ cacheable ? record_cache.fetch(query) : find_by_sql_without_record_cache(*args)
+ end
+ end
+
+ module InstanceMethods
+ end
+ end
+ end
+
+ module Arel
+
+ # The method <ActiveRecord::Base>.find_by_sql is used to actually
+ # retrieve the data from the DB.
+ # Unfortunately the ActiveRelation record is not accessible from
+ # there, so it is piggy-back'd in the SQL string.
+ module TreeManager
+ def self.included(klass)
+ klass.extend ClassMethods
+ klass.send(:include, InstanceMethods)
+ klass.class_eval do
+ alias_method_chain :to_sql, :record_cache
+ end
+ end
+
+ module ClassMethods
+ end
+
+ module InstanceMethods
+ def to_sql_with_record_cache
+ sql = to_sql_without_record_cache
+ sql.instance_variable_set(:@arel, self)
+ sql
+ end
+ end
+ end
+
+ # Visitor for the ActiveRelation to extract a simple cache query
+ # Only accepts single select queries with equality where statements
+ # Rejects queries with grouping / having / offset / etc.
+ class QueryVisitor < ::Arel::Visitors::Visitor
+ def initialize
+ super()
+ @cacheable = true
+ @query = ::RecordCache::Query.new
+ end
+
+ def accept object
+ super
+ @cacheable ? @query : nil
+ end
+
+ private
+
+ def not_cacheable o
+ @cacheable = false
+ end
+
+ alias :visit_Arel_Nodes_Ordering :not_cacheable
+
+ alias :visit_Arel_Nodes_TableAlias :not_cacheable
+
+ alias :visit_Arel_Nodes_Sum :not_cacheable
+ alias :visit_Arel_Nodes_Max :not_cacheable
+ alias :visit_Arel_Nodes_Avg :not_cacheable
+ alias :visit_Arel_Nodes_Count :not_cacheable
+
+ alias :visit_Arel_Nodes_StringJoin :not_cacheable
+ alias :visit_Arel_Nodes_InnerJoin :not_cacheable
+ alias :visit_Arel_Nodes_OuterJoin :not_cacheable
+
+ alias :visit_Arel_Nodes_DeleteStatement :not_cacheable
+ alias :visit_Arel_Nodes_InsertStatement :not_cacheable
+ alias :visit_Arel_Nodes_UpdateStatement :not_cacheable
+
+
+ alias :unary :not_cacheable
+ alias :visit_Arel_Nodes_Group :unary
+ alias :visit_Arel_Nodes_Having :unary
+ alias :visit_Arel_Nodes_Not :unary
+ alias :visit_Arel_Nodes_On :unary
+ alias :visit_Arel_Nodes_UnqualifiedColumn :unary
+
+ def visit_Arel_Nodes_Offset o
+ @cacheable = false unless o.expr == 0
+ end
+
+ def visit_Arel_Nodes_Values o
+ visit o.expressions if @cacheable
+ end
+
+ def visit_Arel_Nodes_Limit o
+ @query.limit = o.expr
+ end
+ alias :visit_Arel_Nodes_Top :visit_Arel_Nodes_Limit
+
+ def visit_Arel_Nodes_Grouping o
+ return unless @cacheable
+ # "`calendars`.account_id = 5"
+ if @table_name && o.expr =~ /^`#{@table_name}`\.`?(\w*)`?\s*=\s*(\d+)$/
+ @cacheable = @query.where($1, $2.to_i)
+ # "`service_instances`.`id` IN (118,80,120,82)"
+ elsif o.expr =~ /^`#{@table_name}`\.`?(\w*)`?\s*IN\s*\(([\d\s,]+)\)$/
+ @cacheable = @query.where($1, $2.split(',').map(&:to_i))
+ else
+ @cacheable = false
+ end
+ end
+
+ def visit_Arel_Nodes_SelectCore o
+ @cacheable = false unless o.groups.empty?
+ visit o.froms if @cacheable
+ visit o.wheres if @cacheable
+ # skip o.projections
+ end
+
+ def visit_Arel_Nodes_SelectStatement o
+ @cacheable = false if o.cores.size > 1
+ if @cacheable
+ visit o.offset
+ o.orders.map { |x| handle_order_by(visit x) } if @cacheable && o.orders.size > 0
+ visit o.limit
+ visit o.cores
+ end
+ end
+
+ def handle_order_by(order)
+ order.to_s.split(",").each do |o|
+ # simple sort order (+peope.id+ can be replaced by +id+, as joins are not allowed anyways)
+ if o.match(/^\s*([\w\.]*)\s*(|ASC|DESC|)\s*$/)
+ asc = $2 == "DESC" ? false : true
+ @query.order_by($1.split('.').last, asc)
+ else
+ @cacheable = false
+ end
+ end
+ end
+
+ def visit_Arel_Table o
+ @table_name = o.name
+ end
+
+ def visit_Arel_Nodes_Ordering o
+ [visit(o.expr), o.descending]
+ end
+
+ def visit_Arel_Attributes_Attribute o
+ o.name.to_sym
+ end
+ alias :visit_Arel_Attributes_Integer :visit_Arel_Attributes_Attribute
+ alias :visit_Arel_Attributes_Float :visit_Arel_Attributes_Attribute
+ alias :visit_Arel_Attributes_String :visit_Arel_Attributes_Attribute
+ alias :visit_Arel_Attributes_Time :visit_Arel_Attributes_Attribute
+ alias :visit_Arel_Attributes_Boolean :visit_Arel_Attributes_Attribute
+
+ def visit_Arel_Nodes_Equality o
+ equality = [visit(o.left), visit(o.right)]
+# equality.reverse! if equality.last.is_a?(Symbol) || equality.first.is_a?(Fixnum)
+# p " =====> equality found: #{equality.first.inspect}@#{equality.first.class.name} => #{equality.last.inspect}@#{equality.last.class.name}"
+ @query.where(equality.first, equality.last)
+ end
+ alias :visit_Arel_Nodes_In :visit_Arel_Nodes_Equality
+
+ def visit_Arel_Nodes_And o
+ visit(o.left)
+ visit(o.right)
+ end
+
+ alias :visit_Arel_Nodes_Or :not_cacheable
+ alias :visit_Arel_Nodes_NotEqual :not_cacheable
+ alias :visit_Arel_Nodes_GreaterThan :not_cacheable
+ alias :visit_Arel_Nodes_GreaterThanOrEqual :not_cacheable
+ alias :visit_Arel_Nodes_Assignment :not_cacheable
+ alias :visit_Arel_Nodes_LessThan :not_cacheable
+ alias :visit_Arel_Nodes_LessThanOrEqual :not_cacheable
+ alias :visit_Arel_Nodes_Between :not_cacheable
+ alias :visit_Arel_Nodes_NotIn :not_cacheable
+ alias :visit_Arel_Nodes_DoesNotMatch :not_cacheable
+ alias :visit_Arel_Nodes_Matches :not_cacheable
+
+ def visit_Fixnum o
+ o.to_i
+ end
+ alias :visit_Bignum :visit_Fixnum
+
+ def visit_Symbol o
+ o.to_sym
+ end
+
+ def visit_Object o
+ o
+ end
+ alias :visit_Arel_Nodes_SqlLiteral :visit_Object
+ alias :visit_Arel_SqlLiteral :visit_Object # This is deprecated
+ alias :visit_String :visit_Object
+ alias :visit_NilClass :visit_Object
+ alias :visit_TrueClass :visit_Object
+ alias :visit_FalseClass :visit_Object
+ alias :visit_Arel_SqlLiteral :visit_Object
+ alias :visit_BigDecimal :visit_Object
+ alias :visit_Float :visit_Object
+ alias :visit_Time :visit_Object
+ alias :visit_Date :visit_Object
+ alias :visit_DateTime :visit_Object
+ alias :visit_Hash :visit_Object
+
+ def visit_Array o
+ o.map{ |x| visit x }
+ end
+ end
+ end
+
+ # Patch ActiveRecord::Relation to make sure update_all will invalidate all referenced records
+ module ActiveRecord
+ module UpdateAll
+ class << self
+ def included(klass)
+ klass.extend ClassMethods
+ klass.send(:include, InstanceMethods)
+ klass.class_eval do
+ alias_method_chain :update_all, :record_cache
+ end
+ end
+ end
+
+ module ClassMethods
+ end
+
+ module InstanceMethods
+ def update_all_with_record_cache(updates, conditions = nil, options = {})
+ result = update_all_without_record_cache(updates, conditions, options)
+
+ if record_cache?
+ # when this condition is met, the arel.update method will be called on the current scope, see ActiveRecord::Relation#update_all
+ unless conditions || options.present? || @limit_value.present? != @order_values.present?
+ # go straight to SQL result (without instantiating records) for optimal performance
+ connection.execute(select('id').to_sql).each{ |row| record_cache.invalidate(:id, (row.is_a?(Hash) ? row['id'] : row.first).to_i ) }
+ end
+ end
+
+ result
+ end
+ end
+ end
+ end
+
+ # Patch ActiveRecord::Associations::HasManyAssociation to make sure the index_cache is updated when records are
+ # deleted from the collection
+ module ActiveRecord
+ module HasMany
+ class << self
+ def included(klass)
+ klass.extend ClassMethods
+ klass.send(:include, InstanceMethods)
+ klass.class_eval do
+ alias_method_chain :delete_records, :record_cache
+ end
+ end
+ end
+
+ module ClassMethods
+ end
+
+ module InstanceMethods
+ def delete_records_with_record_cache(records)
+ # invalidate :id cache for all records
+ records.each{ |record| record.class.record_cache.invalidate(record.id) if record.class.record_cache? unless record.new_record? }
+ # invalidate the referenced class for the attribute/value pair on the index cache
+ @reflection.klass.record_cache.invalidate(@reflection.primary_key_name.to_sym, @owner.id) if @reflection.klass.record_cache?
+ delete_records_without_record_cache(records)
+ end
+ end
+ end
+ end
+
+end
+
+ActiveRecord::Base.send(:include, RecordCache::ActiveRecord::Base)
+Arel::TreeManager.send(:include, RecordCache::Arel::TreeManager)
+ActiveRecord::Relation.send(:include, RecordCache::ActiveRecord::UpdateAll)
+ActiveRecord::Associations::HasManyAssociation.send(:include, RecordCache::ActiveRecord::HasMany)
136 lib/record_cache/base.rb
@@ -0,0 +1,136 @@
+module RecordCache
+ # Normal mode
+ ENABLED = 1
+ # Do not fetch queries through the cache (but still update the cache after commit)
+ NO_FETCH = 2
+ # Completely disable the cache (may lead to stale results in case caching for other workers is not DISABLED)
+ DISABLED = 3
+
+ module Base
+ class << self
+ def included(klass)
+ klass.class_eval do
+ extend ClassMethods
+ include InstanceMethods
+ end
+ end
+
+ # The logger instance (Rails.logger if present)
+ def logger
+ @logger ||= defined?(::Rails) ? ::Rails.logger : ::ActiveRecord::Base.logger
+ end
+
+ # Set the ActiveSupport::Cache::Store instance that contains the current record(group) versions.
+ # Note that it must point to a single Store shared by all webservers (defaults to Rails.cache)
+ def version_store=(store)
+ @version_store = RecordCache::VersionStore.new(RecordCache::MultiRead.test(store))
+ end
+
+ # The ActiveSupport::Cache::Store instance that contains the current record(group) versions.
+ # Note that it must point to a single Store shared by all webservers (defaults to Rails.cache)
+ def version_store
+ @version_store ||= RecordCache::VersionStore.new(RecordCache::MultiRead.test(Rails.cache))
+ end
+
+ # Register a store with a specific id for reference with :store in +cache_records+
+ # e.g. RecordCache::Base.register_store(:server, ActiveSupport::Cache.lookup_store(:memory_store))
+ def register_store(id, store)
+ stores[id] = RecordCache::MultiRead.test(store)
+ end
+
+ # The hash of stores (store_id => store)
+ def stores
+ @stores ||= {}
+ end
+
+ # To disable the record cache for all models:
+ # RecordCache::Base.disabled!
+ # Enable again with:
+ # RecordCache::Base.enable
+ def disable!
+ @status = RecordCache::DISABLED
+ end
+
+ # Enable record cache
+ def enable
+ @status = RecordCache::ENABLED
+ end
+
+ # Retrieve the current status
+ def status
+ @status ||= RecordCache::ENABLED
+ end
+
+ # execute block of code without using the records cache to fetch records
+ # note that updates are still written to the cache, as otherwise other
+ # workers may receive stale results.
+ # To fully disable caching use +disable!+
+ def without_record_cache(&block)
+ old_status = status
+ begin
+ @status = RecordCache::NO_FETCH
+ yield
+ ensure
+ @status = old_status
+ end
+ end
+ end
+
+ module ClassMethods
+ # Cache the instances of this model
+ # options:
+ # :store => the cache store for the instances, e.g. :memory_store, :dalli_store* (default: Rails.cache)
+ # or one of the store ids defined using +RecordCache::Base.register_store+
+ # :key => provide a unique shorter key to limit the cache key length (default: model.name)
+ # :index => one or more attributes (Symbols) for which the ids are cached for the value of the attribute
+ # :request_cache => Set to true in case the exact same query is executed more than once during a single request
+ # If set to true somewhere, make sure to add the following to your application controller:
+ # before_filter { |c| RecordCache::Strategy::RequestCache.clear }
+ #
+ # Hints:
+ # - Dalli is a high performance pure Ruby client for accessing memcached servers, see https://github.com/mperham/dalli
+ # - use :store => :memory_store in case all records can easily fit in server memory
+ # - use :index => :account_id in case the records are (almost) always queried as a full set per account
+ # - use :index => :person_id for aggregated has_many associations
+ def cache_records(options)
+ @rc_dispatcher = RecordCache::Dispatcher.new(self) unless defined?(@rc_dispatcher)
+ store = RecordCache::MultiRead.test(options[:store] ? RecordCache::Base.stores[options[:store]] || ActiveSupport::Cache.lookup_store(options[:store]) : (defined?(::Rails) ? Rails.cache : ActiveSupport::Cache.lookup_store(:memory_store)))
+ # always register an ID Cache
+ record_cache.register(:id, ::RecordCache::Strategy::IdCache, store, options)
+ # parse :index option
+ [options[:index]].flatten.compact.map(&:to_sym).each do |index|
+ record_cache.register(index, ::RecordCache::Strategy::IndexCache, store, options.merge({:index => index}))
+ end
+ # parse :request_cache option
+ record_cache.register(:request_cache, ::RecordCache::Strategy::RequestCache, store, options) if options[:request_cache]
+ # Callback for Data Store specific initialization
+ record_cache_init
+ end
+
+ # Returns true if record cache is defined and active for this class
+ def record_cache?
+ record_cache && RecordCache::Base.status == RecordCache::ENABLED
+ end
+
+ # Returns the RecordCache (class) instance
+ def record_cache
+ @rc_dispatcher
+ end
+ end
+
+ module InstanceMethods
+ def record_cache_create
+ self.class.record_cache.record_change(self, :create) unless RecordCache::Base.status == RecordCache::DISABLED
+ end
+
+ def record_cache_update
+ self.class.record_cache.record_change(self, :update) unless RecordCache::Base.status == RecordCache::DISABLED
+ end
+
+ def record_cache_destroy
+ self.class.record_cache.record_change(self, :destroy) unless RecordCache::Base.status == RecordCache::DISABLED
+ end
+ end
+
+ end
+end
90 lib/record_cache/dispatcher.rb
@@ -0,0 +1,90 @@
+module RecordCache
+
+ # Every model that calls cache_records will receive an instance of this class
+ # accessible through +<model>.record_cache+
+ #
+ # The dispatcher is responsible for dispatching queries, record_changes and invalidation calls
+ # to the appropriate cache strategies.
+ class Dispatcher
+ def initialize(base)
+ @base = base
+ @strategy_by_id = {}
+ # all strategies except :request_cache, with the :id stategy first (most used and best performing)
+ @ordered_strategies = []
+ end
+
+ # Register a cache strategy for this model
+ def register(strategy_id, strategy_klass, record_store, options)
+ if @strategy_by_id.key?(strategy_id)
+ return if strategy_id == :id
+ raise "Multiple record cache definitions found for '#{strategy_id}' on #{@base.name}"
+ end
+ # Instantiate the cache strategy
+ strategy = strategy_klass.new(@base, strategy_id, record_store, options)
+ # Keep track of all strategies for this model
+ @strategy_by_id[strategy_id] = strategy
+ # Note that the :id strategy is always registered first
+ @ordered_strategies << strategy unless strategy_id == :request_cache
+ end
+
+ # Retrieve the caching strategy for the given attribute
+ def [](strategy_id)
+ @strategy_by_id[strategy_id]
+ end
+
+ # Can the cache retrieve the records based on this query?
+ def cacheable?(query)
+ !!first_cacheable_strategy(query)
+ end
+
+ # retrieve the record(s) with the given id(s) as an array
+ def fetch(query)
+ if request_cache
+ # cache the query in the request
+ request_cache.fetch(query) { fetch_from_first_cacheable_strategy(query) }
+ else
+ # fetch the results using the first strategy that accepts this query
+ fetch_from_first_cacheable_strategy(query)
+ end
+ end
+
+ # Update the version store and the record store (used by callbacks)
+ # @param record the updated record (possibly with
+ # @param action one of :create, :update or :destroy
+ def record_change(record, action)
+ # skip unless something has actually changed
+ return if action == :update && record.previous_changes.empty?
+ # dispatch the record change to all known strategies
+ @strategy_by_id.values.each { |strategy| strategy.record_change(record, action) }
+ end
+
+ # Explicitly invalidate one or more records
+ # @param: strategy: the strategy to invalidate
+ # @param: value: the value to send to the invalidate method of the chosen strategy
+ def invalidate(strategy, value = nil)
+ (value = strategy; strategy = :id) unless strategy.is_a?(Symbol)
+ # call the invalidate method of the chosen strategy
+ @strategy_by_id[strategy].invalidate(value) if @strategy_by_id[strategy]
+ # always clear the request cache if invalidate is explicitly called for this class
+ request_cache.try(:invalidate, value)
+ end
+
+ private
+
+ # retrieve the data from the first strategy that handle the query
+ def fetch_from_first_cacheable_strategy(query)
+ first_cacheable_strategy(query).fetch(query)
+ end
+
+ # find the first strategy that can handle this query
+ def first_cacheable_strategy(query)
+ @ordered_strategies.detect { |strategy| strategy.cacheable?(query) }
+ end
+
+ # retrieve the request cache strategy, if defined for this model
+ def request_cache
+ @strategy_by_id[:request_cache]
+ end
+
+ end
+end
51 lib/record_cache/multi_read.rb
@@ -0,0 +1,51 @@
+# This class will delegate read_multi to sequential read calls in case read_multi is not supported.
+#
+# If a particular Store Class does support read_multi, but is somehow slower because of a bug,
+# you can disable read_multi by calling:
+# RecordCache::MultiRead.disable(ActiveSupport::Cache::DalliStore)
+#
+# Important: Because of a bug in Dalli, read_multi is quite slow on some machines.
+# @see https://github.com/mperham/dalli/issues/106
+module RecordCache
+ module MultiRead
+ @tested = Set.new
+ @disabled_klass_names = Set.new
+
+ class << self
+
+ # Disable multi_read for a particular Store, e.g.
+ # RecordCache::MultiRead.disable(ActiveSupport::Cache::DalliStore)
+ def disable(klass)
+ @disabled_klass_names << klass.name
+ end
+
+ # Test the store if it supports read_multi calls
+ # If not, delegate multi_read calls to normal read calls
+ def test(store)
+ return store if @tested.include?(store)
+ @tested << store
+ override_read_multi(store) unless read_multi_supported?(store)
+ store
+ end
+
+ private
+
+ def read_multi_supported?(store)
+ return false if @disabled_klass_names.include?(store.class.name)
+ begin
+ store.read_multi('a', 'b')
+ true
+ rescue Exception => ignore
+ false
+ end
+ end
+
+ # delegate read_multi to normal read calls
+ def override_read_multi(store)
+ def store.read_multi(*keys)
+ keys.inject({}){ |h,key| h[key] = self.read(key); h}
+ end
+ end
+ end
+ end
+end
85 lib/record_cache/query.rb
@@ -0,0 +1,85 @@
+module RecordCache
+
+ # Container for the Query parameters
+ class Query
+ attr_reader :wheres, :sort_orders, :limit
+
+ def initialize(equality = nil)
+ @wheres = equality || {}
+ @sort_orders = []
+ @limit = nil
+ @where_ids = {}
+ end
+
+ # Set equality of an attribute (usually found in where clause)
+ # Returns false if another attribute values was already set (making this query uncachable)
+ def where(attribute, values)
+ @wheres[attribute.to_sym] = values if attribute
+ end
+
+ # Retrieve the ids (array of positive integers) for the given attribute from the where statements
+ # Returns nil if no the attribute is not present
+ def where_ids(attribute)
+ return @where_ids[attribute] if @where_ids.key?(attribute)
+ @where_ids[attribute] ||= array_of_positive_integers(@wheres[attribute])
+ end
+
+ # Retrieve the single id (positive integer) for the given attribute from the where statements
+ # Returns nil if no the attribute is not present, or if it contains an array
+ def where_id(attribute)
+ ids = where_ids(attribute)
+ return nil unless ids && ids.size == 1
+ ids.first
+ end
+
+ # Add a sort order to the query
+ def order_by(attribute, ascending = true)
+ @sort_orders << [attribute.to_s, ascending]
+ end
+
+ def sorted?
+ @sort_orders.size > 0
+ end
+
+ def limit=(limit)
+ @limit = limit.to_i
+ end
+
+ # retrieve a unique key for this Query (used in RequestCache)
+ def cache_key
+ @cache_key ||= generate_key
+ end
+
+ def to_s
+ s = "SELECT "
+ s << @wheres.map{|k,v| "#{k} = #{v.inspect}"}.join(" AND ")
+ if @sort_orders.size > 0
+ order_by = @sort_orders.map{|attr,asc| "#{attr} #{asc ? 'ASC' : 'DESC'}"}.join(', ')
+ s << " ORDER_BY #{order_by}"
+ end
+ s << " LIMIT #{@limit}" if @limit
+ s
+ end
+
+ private
+
+ def generate_key
+ key = @wheres.map{|k,v| "#{k}=#{v.inspect}"}.join("&")
+ if @sort_orders
+ order_by = @sort_orders.map{|attr,asc| "#{attr}=#{asc ? 'A' : 'D'}"}.join('-')
+ key << ".#{order_by}"
+ end
+ key << "L#{@limit}" if @limit
+ key
+ end
+
+ def array_of_positive_integers(values)
+ return nil unless values
+ values = [values] unless values.is_a?(Array)
+ values = values.map{|value| value.to_i} unless values.first.is_a?(Fixnum)
+ return nil unless values.all?{ |value| value > 0 } # all values must be positive integers
+ values
+ end
+
+ end
+end
82 lib/record_cache/statistics.rb
@@ -0,0 +1,82 @@
+module RecordCache
+
+ # Collect cache hit/miss statistics for each cache strategy
+ module Statistics
+
+ class << self
+
+ # returns +true+ if statistics need to be collected
+ def active?
+ !!@active
+ end
+
+ # start statistics collection
+ def start
+ @active = true
+ end
+
+ # stop statistics collection
+ def stop
+ @active = false
+ end
+
+ # toggle statistics collection
+ def toggle
+ @active = !@active
+ end
+
+ # reset all statistics
+ def reset!(base = nil)
+ stats = find(base).values
+ stats = stats.map(&:values).flatten unless base # flatten hash of hashes in case base was nil
+ stats.each{ |s| s.reset! }
+ end
+
+ # Retrieve the statistics for the given base and strategy_id
+ # Returns a hash {<stategy_id> => <statistics} for a model if no strategy is provided
+ # Returns a hash of hashes { <model_name> => {<stategy_id> => <statistics} } if no parameter is provided
+ def find(base = nil, strategy_id = nil)
+ stats = (@stats ||= {})
+ stats = (stats[base.name] ||= {}) if base
+ stats = (stats[strategy_id] ||= Counter.new) if strategy_id
+ stats
+ end
+ end
+
+ class Counter
+ attr_accessor :calls, :hits, :misses
+
+ def initialize
+ reset!
+ end
+
+ # add hit statatistics for the given cache strategy
+ # @param queried: nr of ids queried
+ # @param found: nr of records found in the cache
+ def add(queried, found)
+ @calls += 1
+ @hits += found
+ @misses += (queried - found)
+ end
+
+ def reset!
+ @hits = 0
+ @misses = 0
+ @calls = 0
+ end
+
+ def active?
+ RecordCache::Statistics.active?
+ end
+
+ def percentage
+ return 0.0 if @hits == 0
+ (@hits.to_f / (@hits + @misses)) * 100
+ end
+
+ def inspect
+ "#{percentage}% (#{@hits}/#{@hits + @misses})"
+ end
+ end
+ end
+end
154 lib/record_cache/strategy/base.rb
@@ -0,0 +1,154 @@
+module RecordCache
+ module Strategy
+ class Base
+ CLASS_KEY = :c
+ ATTRIBUTES_KEY = :a
+
+ def initialize(base, strategy_id, record_store, options)
+ @base = base
+ @strategy_id = strategy_id
+ @record_store = record_store
+ @cache_key_prefix = "rc/#{options[:key] || @base.name}/".freeze
+ end
+
+ # Fetch all records and sort and filter locally
+ def fetch(query)
+ records = fetch_records(query)
+ filter!(records, query.wheres) if query.wheres.size > 0
+ sort!(records, query.sort_orders) if query.sorted?
+ records
+ end
+
+ # Handle create/update/destroy (use record.previous_changes to find the old values in case of an update)
+ def record_change(record, action)
+ raise NotImplementedError
+ end
+
+ # Can the cache retrieve the records based on this query?
+ def cacheable?(query)
+ raise NotImplementedError
+ end
+
+ # Handle invalidation call
+ def invalidate(id)
+ raise NotImplementedError
+ end
+
+ protected
+
+ def fetch_records(query)
+ raise NotImplementedError
+ end
+
+ # ------------------------- Utility methods ----------------------------
+
+ # retrieve the version store (unique store for the whole application)
+ def version_store
+ RecordCache::Base.version_store
+ end
+
+ # retrieve the record store (store for records for this cache strategy)
+ def record_store
+ @record_store
+ end
+
+ # find the statistics for this cache strategy
+ def statistics
+ @statistics ||= RecordCache::Statistics.find(@base, @strategy_id)
+ end
+
+ # retrieve the cache key for the given id, e.g. rc/person/14
+ def cache_key(id)
+ "#{@cache_key_prefix}#{id}".freeze
+ end
+
+ # retrieve the versioned record key, e.g. rc/person/14v1
+ def versioned_key(cache_key, version)
+ "#{cache_key}v#{version.to_s}".freeze
+ end
+
+ # serialize one record before adding it to the cache
+ # creates a shallow clone with a version and without associations
+ def serialize(record)
+ {CLASS_KEY => record.class.name,
+ ATTRIBUTES_KEY => record.instance_variable_get(:@attributes)}.freeze
+ end
+
+ # deserialize a cached record
+ def deserialize(serialized)
+ record = serialized[CLASS_KEY].constantize.new
+ attributes = serialized[ATTRIBUTES_KEY]
+ record.instance_variable_set(:@attributes, Hash[attributes])
+ record.instance_variable_set(:@new_record, false)
+ record.instance_variable_set(:@changed_attributes, {})
+ record.instance_variable_set(:@previously_changed, {})
+ record
+ end
+
+ private
+
+ # Filter the cached records in memory
+ # only simple x = y or x IN (a,b,c) can be handled
+ def filter!(records, wheres)
+ wheres.each_pair do |attr, value|
+ if value.is_a?(Array)
+ records.reject! { |record| !value.include?(record.send(attr)) }
+ else
+ records.reject! { |record| record.send(attr) != value }
+ end
+ end
+ end
+
+ # Sort the cached records in memory
+ def sort!(records, sort_orders)
+ records.sort!(&sort_proc(sort_orders))
+ Collator.clear
+ records
+ end
+
+ # Retrieve the Proc based on the order by attributes
+ # Note: Case insensitive sorting with collation is used for Strings
+ def sort_proc(sort_orders)
+ # [['(COLLATER.collate(x.name) || NIL_COMES_FIRST)', 'COLLATER.collate(y.name)'], ['(y.updated_at || NIL_COMES_FIRST)', 'x.updated_at']]
+ sort = sort_orders.map do |attr, asc|
+ lr = ["x.", "y."]
+ lr.reverse! unless asc
+ lr.each{ |s| s << attr }
+ lr.each{ |s| s.replace("Collator.collate(#{s})") } if @base.columns_hash[attr].type == :string
+ lr[0].replace("(#{lr[0]} || NIL_COMES_FIRST)")
+ lr
+ end
+ # ['[(COLLATER.collate(x.name) || NIL_COMES_FIRST), (y.updated_at || NIL_COMES_FIRST)]', '[COLLATER.collate(y.name), x.updated_at]']
+ sort = sort.transpose.map{|s| s.size == 1 ? s.first : "[#{s.join(',')}]"}
+ # Proc.new{ |x,y| { ([(COLLATER.collate(x.name) || NIL_COMES_FIRST), (y.updated_at || NIL_COMES_FIRST)] <=> [COLLATER.collate(y.name), x.updated_at]) || 1 }
+ eval("Proc.new{ |x,y| (#{sort[0]} <=> #{sort[1]}) || 1 }")
+ end
+
+ # If +x.nil?+ this class will return -1 for +x <=> y+
+ NIL_COMES_FIRST = ((class NilComesFirst; def <=>(y); -1; end; end); NilComesFirst.new)
+
+ # StringCollator uses the Rails transliterate method for collation
+ module Collator
+ @collated = []
+
+ def self.clear
+ @collated.each { |string| string.send(:remove_instance_variable, :@rc_collated) }
+ @collated.clear
+ end
+
+ def self.collate(string)
+ collated = string.instance_variable_get(:@rc_collated)
+ return collated if collated
+ normalized = ActiveSupport::Multibyte::Unicode.normalize(ActiveSupport::Multibyte::Unicode.tidy_bytes(string), :c).mb_chars
+ collated = I18n.transliterate(normalized).downcase.mb_chars
+ # transliterate will replace ignored/unknown chars with ? the following line replaces ? with the original character
+ collated.chars.each_with_index{ |c, i| collated[i] = normalized[i] if c == '?' } if collated.index('?')
+ # puts "collation: #{string} => #{collated.to_s}"
+ string.instance_variable_set(:@rc_collated, collated)
+ @collated << string
+ collated
+ end
+ end
+ end
+ end
+end
93 lib/record_cache/strategy/id_cache.rb
@@ -0,0 +1,93 @@
+module RecordCache
+ module Strategy
+ class IdCache < Base
+
+ # Can the cache retrieve the records based on this query?
+ def cacheable?(query)
+ ids = query.where_ids(:id)
+ ids && (query.limit.nil? || (query.limit == 1 && ids.size == 1))
+ end
+
+ # Update the version store and the record store
+ def record_change(record, action)
+ key = cache_key(record.id)
+ if action == :destroy
+ version_store.delete(key)
+ else
+ # update the version store and add the record to the cache
+ new_version = version_store.increment(key)
+ record_store.write(versioned_key(key, new_version), serialize(record))
+ end
+ end
+
+ # Handle invalidation call
+ def invalidate(id)
+ version_store.delete(cache_key(id))
+ end
+
+ protected
+
+ # retrieve the record(s) with the given id(s) as an array
+ def fetch_records(query)
+ ids = query.where_ids(:id)
+ query.wheres.delete(:id) # make sure CacheCase.filter! does not see this where anymore
+ id_to_key_map = ids.inject({}){|h,id| h[id] = cache_key(id); h }
+ # retrieve the current version of the records
+ current_versions = version_store.current_multi(id_to_key_map)
+ # get the keys for the records for which a current version was found
+ id_to_version_key_map = Hash[id_to_key_map.map{ |id, key| current_versions[id] ? [id, versioned_key(key, current_versions[id])] : nil }]
+ # retrieve the records from the cache
+ records = id_to_version_key_map.size > 0 ? from_cache(id_to_version_key_map) : []
+ # query the records with missing ids
+ id_to_key_map.except!(*records.map(&:id))
+ # logging (only in debug mode!) and statistics
+ log_id_cache_hit(ids, id_to_key_map.keys) if RecordCache::Base.logger.debug?
+ statistics.add(ids.size, records.size) if statistics.active?
+ # retrieve records from DB in case there are some missing ids
+ records += from_db(id_to_key_map, id_to_version_key_map) if id_to_key_map.size > 0
+ # return the array
+ records
+ end
+
+ private
+
+ # ---------------------------- Querying ------------------------------------
+
+ # retrieve the records from the cache with the given keys
+ def from_cache(id_to_versioned_key_map)
+ records = record_store.read_multi(*(id_to_versioned_key_map.values)).values.compact
+ records.map{ |record| deserialize(record) }
+ end
+
+ # retrieve the records with the given ids from the database
+ def from_db(id_to_key_map, id_to_version_key_map)
+ RecordCache::Base.without_record_cache do
+ # retrieve the records from the database
+ records = @base.where(:id => id_to_key_map.keys).to_a
+ records.each do |record|
+ versioned_key = id_to_version_key_map[record.id]
+ unless versioned_key
+ # renew the key in the version store in case it was missing
+ key = id_to_key_map[record.id]
+ versioned_key = versioned_key(key, version_store.renew(key))
+ end
+ # store the record based on the versioned key
+ record_store.write(versioned_key, serialize(record))
+ end
+ records
+ end
+ end
+
+ # ------------------------- Utility methods ----------------------------
+
+ # log cache hit/miss to debug log
+ def log_id_cache_hit(ids, missing_ids)
+ hit = missing_ids.empty? ? "hit" : ids.size == missing_ids.size ? "miss" : "partial hit"
+ missing = missing_ids.empty? || ids.size == missing_ids.size ? "" : ": missing #{missing_ids.inspect}"
+ msg = "IdCache #{hit} for ids #{ids.size == 1 ? ids.first : ids.inspect}#{missing}"
+ RecordCache::Base.logger.debug(msg)
+ end
+
+ end
+ end
+end
122 lib/record_cache/strategy/index_cache.rb
@@ -0,0 +1,122 @@
+module RecordCache
+ module Strategy
+ class IndexCache < Base
+
+ def initialize(base, strategy_id, record_store, options)
+ super
+ @index = options[:index]
+ # check the index
+ type = @base.columns_hash[@index.to_s].try(:type)
+ raise "No column found for index '#{@index}' on #{@base.name}." unless type
+ raise "Incorrect type (expected integer, found #{type}) for index '#{@index}' on #{@base.name}." unless type == :integer
+ @index_cache_key_prefix = cache_key(@index) # "/rc/<model>/<index>"
+ end
+
+ # Can the cache retrieve the records based on this query?
+ def cacheable?(query)
+ query.where_id(@index) && query.limit.nil?
+ end
+
+ # Handle create/update/destroy (use record.previous_changes to find the old values in case of an update)
+ def record_change(record, action)
+ if action == :destroy
+ remove_from_index(record.send(@index), record.id)
+ elsif action == :create
+ add_to_index(record.send(@index), record.id)
+ else
+ index_change = record.previous_changes[@index.to_s]
+ return unless index_change
+ remove_from_index(index_change[0], record.id)
+ add_to_index(index_change[1], record.id)
+ end
+ end
+
+ # Explicitly invalidate the record cache for the given value
+ def invalidate(value)
+ version_store.increment(index_cache_key(value))
+ end
+
+ protected
+
+ # retrieve the record(s) based on the given query
+ def fetch_records(query)
+ value = query.where_id(@index)
+ # make sure CacheCase.filter! does not see this where clause anymore
+ query.wheres.delete(@index)
+ # retrieve the cache key for this index and value
+ key = index_cache_key(value)
+ # retrieve the current version of the ids list
+ current_version = version_store.current(key)
+ # create the versioned key, renew the version in case it was missing in the version store
+ versioned_key = versioned_key(key, current_version || version_store.renew(key))
+ # retrieve the ids from the local cache based on the current version from the version store
+ ids = current_version ? fetch_ids_from_cache(versioned_key) : nil
+ # logging (only in debug mode!) and statistics
+ log_cache_hit(versioned_key, ids) if RecordCache::Base.logger.debug?
+ statistics.add(1, ids ? 1 : 0) if statistics.active?
+ # retrieve the ids from the DB if the result was not fresh
+ ids = fetch_ids_from_db(versioned_key, value) unless ids
+ # use the IdCache to retrieve the records based on the ids
+ @base.record_cache[:id].send(:fetch_records, ::RecordCache::Query.new({:id => ids}))
+ end
+
+ private
+
+ # ---------------------------- Querying ------------------------------------
+
+ # key to retrieve the ids for a given value
+ def index_cache_key(value)
+ "#{@index_cache_key_prefix}=#{value}"
+ end
+
+ # Retrieve the ids from the local cache
+ def fetch_ids_from_cache(versioned_key)
+ record_store.read(versioned_key)
+ end
+
+ # retrieve the ids from the database and update the local cache
+ def fetch_ids_from_db(versioned_key, value)
+ RecordCache::Base.without_record_cache do
+ # go straight to SQL result for optimal performance
+ sql = @base.select('id').where(@index => value).to_sql
+ ids = []; @base.connection.execute(sql).each{ |row| ids << (row.is_a?(Hash) ? row['id'] : row.first).to_i }
+ record_store.write(versioned_key, ids)
+ ids
+ end
+ end
+
+ # ---------------------------- Local Record Changes ---------------------------------
+
+ # add one record(id) to the index with the given value
+ def add_to_index(value, id)
+ increment_version(value.to_i) { |ids| ids << id } if value
+ end
+
+ # remove one record(id) from the index with the given value
+ def remove_from_index(value, id)
+ increment_version(value.to_i) { |ids| ids.delete(id) } if value
+ end
+
+ # increment the version store and update the local store
+ def increment_version(value, &block)
+ # retrieve local version and increment version store
+ key = index_cache_key(value)
+ version = version_store.increment(key)
+ # try to update the ids list based on the last version
+ ids = fetch_ids_from_cache(versioned_key(key, version - 1))
+ if ids
+ ids = Array.new(ids)
+ yield ids
+ record_store.write(versioned_key(key, version), ids)
+ end
+ end
+
+ # ------------------------- Utility methods ----------------------------
+
+ # log cache hit/miss to debug log
+ def log_cache_hit(key, ids)
+ RecordCache::Base.logger.debug("IndexCache #{ids ? 'hit' : 'miss'} for #{key}: found #{ids ? ids.size : 'no'} ids")
+ end
+ end
+ end
+end
49 lib/record_cache/strategy/request_cache.rb
@@ -0,0 +1,49 @@
+# Remembers the queries performed during a single Request.
+# If the same query is requested again the result is provided straight from local memory.
+#
+# Records are invalidated per model-klass, when any record is created, updated or destroyed.
+module RecordCache
+ module Strategy
+
+ class RequestCache < Base
+ @@request_store = {}
+
+ # call before each request: in application_controller.rb
+ # before_filter { |c| RecordCache::Strategy::RequestCache.clear }
+ def self.clear
+ @@request_store.clear
+ end
+
+ # Handle record change
+ def record_change(record, action)
+ @@request_store.delete(@base.name)
+ end
+
+ # Handle invalidation call
+ def invalidate(value)
+ @@request_store.delete(@base.name)
+ end
+
+ # return the records from the request cache, execute block in case
+ # this is the first time this query is performed during this request
+ def fetch(query, &block)
+ klass_store = (@@request_store[@base.name] ||= {})
+ key = query.cache_key
+ # logging (only in debug mode!) and statistics
+ log_cache_hit(key, klass_store.key?(key)) if RecordCache::Base.logger.debug?
+ statistics.add(1, klass_store.key?(key) ? 1 : 0) if statistics.active?
+ klass_store[key] ||= yield
+ end
+
+ private
+
+ # ------------------------- Utility methods ----------------------------
+
+ # log cache hit/miss to debug log
+ def log_cache_hit(key, hit)
+ RecordCache::Base.logger.debug("RequestCache #{hit ? 'hit' : 'miss'} for #{key}")
+ end
+
+ end
+ end
+end
49 lib/record_cache/test/resettable_version_store.rb
@@ -0,0 +1,49 @@
+# Make sure the version store can be reset to it's starting point after each test
+# Usage:
+# require 'record_cache/test/resettable_version_store'
+# after(:each) { RecordCache::Base.version_store.reset! }
+module RecordCache
+ module Test
+
+ module ResettableVersionStore
+
+ def self.included(base)
+ base.extend ClassMethods
+ base.send(:include, InstanceMethods)
+ base.instance_eval do
+ alias_method_chain :increment, :reset
+ alias_method_chain :renew, :reset
+ end
+ end
+
+ module ClassMethods
+ end
+
+ module InstanceMethods
+
+ def increment_with_reset(key)
+ updated_version_keys << key
+ increment_without_reset(key)
+ end
+
+ def renew_with_reset(key)
+ updated_version_keys << key
+ renew_without_reset(key)
+ end
+
+ def reset!
+ RecordCache::Strategy::RequestCache.clear
+ updated_version_keys.each { |key| delete(key) }
+ updated_version_keys.clear
+ end
+
+ def updated_version_keys
+ @updated_version_keys ||= []
+ end
+ end
+ end
+
+ end
+end
+
+RecordCache::VersionStore.send(:include, RecordCache::Test::ResettableVersionStore)
5 lib/record_cache/version.rb
@@ -0,0 +1,5 @@
+module RecordCache # :nodoc:
+ module Version # :nodoc:
+ STRING = '0.1.0'
+ end
+end
54 lib/record_cache/version_store.rb
@@ -0,0 +1,54 @@
+module RecordCache
+ class VersionStore
+ attr_accessor :store
+
+ def initialize(store)
+ raise "Must be an ActiveSupport::Cache::Store" unless store.is_a?(ActiveSupport::Cache::Store)
+ @store = store
+ end
+
+ # Retrieve the current versions for the given key
+ # @return nil in case the key is not known in the version store
+ def current(key)
+ @store.read(key)
+ end
+
+ # Retrieve the current versions for the given keys
+ # @param id_key_map is a map with {id => cache_key}
+ # @return a map with {id => current_version}
+ # version nil for all keys unknown to the version store
+ def current_multi(id_key_map)
+ current_versions = @store.read_multi(*(id_key_map.values))
+ Hash[id_key_map.map{ |id, key| [id, current_versions[key]] }]
+ end
+
+ # In case the version store did not have a key anymore, call this methods
+ # to reset the key with a unique new key
+ def renew(key)
+ new_version = (Time.current.to_f * 10000).to_i
+ @store.write(key, new_version)
+ RecordCache::Base.logger.debug("Version Store: renew #{key}: nil => #{new_version}") if RecordCache::Base.logger.debug?
+ new_version
+ end
+
+ # Increment the current version for the given key, in case of record updates
+ def increment(key)
+ version = @store.increment(key, 1)
+ # renew key in case the version store already purged the key
+ if version.nil? || version == 1
+ version = renew(key)
+ else
+ RecordCache::Base.logger.debug("Version Store: incremented #{key}: #{version - 1} => #{version}") if RecordCache::Base.logger.debug?
+ end
+ version
+ end
+
+ # Delete key from the version store, in case the record(s) are destroyed
+ def delete(key)
+ deleted = @store.delete(key)
+ RecordCache::Base.logger.debug("Version Store: deleted #{key}") if RecordCache::Base.logger.debug?
+ deleted
+ end
+
+ end
+end
30 record-cache.gemspec
@@ -0,0 +1,30 @@
+# -*- encoding: utf-8 -*-
+$LOAD_PATH.unshift File.expand_path("../lib", __FILE__)
+require "record_cache/version"
+
+Gem::Specification.new do |s|
+ s.name = "record-cache"
+ s.version = RecordCache::Version::STRING
+ s.authors = ["Orslumen"]
+ s.email = "orslumen@gmail.com"
+ s.homepage = "https://github.com/orslumen/record-cache"
+ s.summary = "Record Cache v#{RecordCache::Version::STRING} transparantly stores Records in a Cache Store and retrieve those Records from the store when queried (by ID) using Active Model."
+ s.description = "Record Cache for Rails 3"
+
+ s.files = `git ls-files -- lib/*`.split("\n")
+ s.test_files = `git ls-files -- spec/*`.split("\n")
+ s.rdoc_options = ["--charset=UTF-8"]
+ s.require_path = "lib"
+
+ s.add_runtime_dependency "rails", ["3.0.10"]
+
+ s.add_development_dependency "activerecord", ["3.0.10"]
+ s.add_development_dependency "sqlite3"
+ s.add_development_dependency "rake"
+ s.add_development_dependency "rcov"
+ s.add_development_dependency "rspec"
+ s.add_development_dependency "rr"
+ s.add_development_dependency "database_cleaner"
+ s.add_development_dependency "rdoc"
+
+end
6 spec/db/database.yml
@@ -0,0 +1,6 @@
+sqlite3:
+ adapter: sqlite3
+ database: ":memory:"
+ encoding: utf8
+ charset: utf8
+ timeout: 5000
42 spec/db/schema.rb
@@ -0,0 +1,42 @@
+ActiveRecord::Schema.define :version => 0 do
+
+ create_table :people, :force => true do |t|
+ t.integer :id
+ t.string :name
+ t.date :birthday
+ t.float :height
+ end
+
+ create_table :stores, :force => true do |t|
+ t.integer :id
+ t.string :name
+ t.integer :owner_id
+ end
+
+ create_table :people_stores, :id => false, :force => true do |t|
+ t.integer :person_id
+ t.string :store_id
+ end
+
+ create_table :apples, :force => true do |t|
+ t.integer :id
+ t.string :name
+ t.integer :store_id
+ t.integer :person_id
+ end
+
+ create_table :bananas, :force => true do |t|
+ t.integer :id
+ t.string :name
+ t.integer :store_id
+ t.integer :person_id
+ end
+
+ create_table :pears, :force => true do |t|
+ t.integer :id
+ t.string :name
+ t.integer :store_id
+ t.integer :person_id
+ end
+
+end
40 spec/db/seeds.rb
@@ -0,0 +1,40 @@
+ActiveRecord::Schema.define :version => 1 do
+
+ # Make sure that at the beginning of the tests, NOTHING is known to Record Cache
+ RecordCache::Base.disable!
+
+ @adam = Person.create!(:name => "Adam", :birthday => Date.civil(1975,03,20), :height => 1.83)
+ @blue = Person.create!(:name => "Blue", :birthday => Date.civil(1953,11,11), :height => 1.75)
+ @cris = Person.create!(:name => "Cris", :birthday => Date.civil(1975,03,20), :height => 1.75)
+
+ @adam_apples = Store.create!(:name => "Adams Apple Store", :owner => @adam)
+ @blue_fruits = Store.create!(:name => "Blue Fruits", :owner => @blue)
+ @cris_bananas = Store.create!(:name => "Chris Bananas", :owner => @cris)
+
+ @fry = Person.create!(:name => "Fry", :birthday => Date.civil(1985,01,20), :height => 1.69)
+ @chase = Person.create!(:name => "Chase", :birthday => Date.civil(1970,07,03), :height => 1.91)
+ @penny = Person.create!(:name => "Penny", :birthday => Date.civil(1958,04,16), :height => 1.61)
+
+ Apple.create!(:name => "Adams Apple 1", :store => @adam_apples)
+ Apple.create!(:name => "Adams Apple 2", :store => @adam_apples)
+ Apple.create!(:name => "Adams Apple 3", :store => @adam_apples, :person => @fry)
+ Apple.create!(:name => "Adams Apple 4", :store => @adam_apples, :person => @fry)
+ Apple.create!(:name => "Adams Apple 5", :store => @adam_apples, :person => @chase)
+ Apple.create!(:name => "Blue Apple 1", :store => @blue_fruits, :person => @fry)
+ Apple.create!(:name => "Blue Apple 2", :store => @blue_fruits, :person => @fry)
+ Apple.create!(:name => "Blue Apple 3", :store => @blue_fruits, :person => @chase)
+ Apple.create!(:name => "Blue Apple 4", :store => @blue_fruits, :person => @chase)
+
+ Banana.create!(:name => "Blue Banana 1", :store => @blue_fruits, :person => @fry)
+ Banana.create!(:name => "Blue Banana 2", :store => @blue_fruits, :person => @chase)
+ Banana.create!(:name => "Blue Banana 3", :store => @blue_fruits, :person => @chase)
+ Banana.create!(:name => "Cris Banana 1", :store => @cris_bananas, :person => @fry)
+ Banana.create!(:name => "Cris Banana 2", :store => @cris_bananas, :person => @chase)
+
+ Pear.create!(:name => "Blue Pear 1", :store => @blue_fruits)
+ Pear.create!(:name => "Blue Pear 2", :store => @blue_fruits, :person => @fry)
+ Pear.create!(:name => "Blue Pear 3", :store => @blue_fruits, :person => @chase)
+ Pear.create!(:name => "Blue Pear 4", :store => @blue_fruits, :person => @chase)
+
+ RecordCache::Base.enable
+end
14 spec/initializers/record_cache.rb
@@ -0,0 +1,14 @@
+# --- Version Store
+# All Workers that use the Record Cache should point to the same Version Store
+# E.g. a MemCached cluster or a Redis Store (defaults to Rails.cache)
+RecordCache::Base.version_store = ActiveSupport::Cache.lookup_store(:memory_store)
+
+# --- Record Stores
+# Register Cache Stores for the Records themselves
+# Note: A different Cache Store could be used per Model, but in most configurations the following 2 stores will suffice:
+
+# The :local store is used to keep records in Worker memory
+RecordCache::Base.register_store(:local, ActiveSupport::Cache.lookup_store(:memory_store))
+
+# The :shared store is used to share Records between multiple Workers
+RecordCache::Base.register_store(:shared, ActiveSupport::Cache.lookup_store(:memory_store))
86 spec/lib/dispatcher_spec.rb
@@ -0,0 +1,86 @@
+require 'spec_helper'
+
+describe RecordCache::Dispatcher do
+ before(:each) do
+ @apple_dispatcher = Apple.record_cache
+ end
+
+ it "should raise an error when the same index is added twice" do
+ lambda { @apple_dispatcher.register(:store_id, RecordCache::Strategy::IdCache, nil, {}) }.should raise_error("Multiple record cache definitions found for 'store_id' on Apple")
+ end
+
+ it "should return the Cache for the requested strategy" do
+ @apple_dispatcher[:id].class.should == RecordCache::Strategy::IdCache
+ @apple_dispatcher[:store_id].class.should == RecordCache::Strategy::IndexCache
+ end
+
+ it "should return nil for unknown requested strategies" do
+ @apple_dispatcher[:unknown].should == nil
+ end
+
+ it "should return cacheable? true if there is a cacheable strategy that accepts the query" do
+ query = RecordCache::Query.new
+ mock(@apple_dispatcher).first_cacheable_strategy(query) { Object.new }
+ @apple_dispatcher.cacheable?(query).should == true
+ end
+
+ context "fetch" do
+ it "should delegate fetch to the Request Cache if present" do
+ query = RecordCache::Query.new
+ mock(@apple_dispatcher[:request_cache]).fetch(query)
+ @apple_dispatcher.fetch(query)
+ end
+
+ it "should delegate fetch to the first cacheable strategy if Request Cache is not present" do
+ query = RecordCache::Query.new
+ banana_dispatcher = Banana.record_cache
+ banana_dispatcher[:request_cache].should == nil
+ mock(banana_dispatcher).first_cacheable_strategy(query) { mock(Object.new).fetch(query) }
+ banana_dispatcher.fetch(query)
+ end
+ end
+
+ context "record_change" do
+ it "should dispatch record_change to all strategies" do
+ apple = Apple.first
+ [:id, :store_id, :person_id].each do |strategy|
+ mock(@apple_dispatcher[strategy]).record_change(apple, :create)
+ end
+ @apple_dispatcher.record_change(apple, :create)
+ end
+
+ it "should not dispatch record_change for updates without changes" do
+ apple = Apple.first
+ [:request_cache, :id, :store_id, :person_id].each do |strategy|
+ mock(@apple_dispatcher[strategy]).record_change(anything, anything).times(0)
+ end
+ @apple_dispatcher.record_change(apple, :update)
+ end
+ end
+
+ context "invalidate" do
+ it "should default to the :id strategy" do
+ mock(@apple_dispatcher[:id]).invalidate(15)
+ @apple_dispatcher.invalidate(15)
+ end
+
+ it "should delegate to given strategy" do
+ mock(@apple_dispatcher[:id]).invalidate(15)
+ mock(@apple_dispatcher[:store_id]).invalidate(31)
+ @apple_dispatcher.invalidate(:id, 15)
+ @apple_dispatcher.invalidate(:store_id, 31)
+ end
+
+ it "should invalidate the request cache" do
+ store_dispatcher = Store.record_cache
+ mock(store_dispatcher[:request_cache]).invalidate(15)
+ store_dispatcher.invalidate(:id, 15)
+ end
+
+ it "should even invalidate the request cache if the given strategy is not known" do
+ store_dispatcher = Store.record_cache
+ mock(store_dispatcher[:request_cache]).invalidate(31)
+ store_dispatcher.invalidate(:unknown_id, 31)
+ end
+ end
+end
51 spec/lib/multi_read_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe RecordCache::MultiRead do
+
+ it "should not delegate to single reads when multi_read is supported" do
+ class MultiReadSupported
+ def read(key) "single" end
+ def read_multi(*keys) "multi" end
+ end
+ store = RecordCache::MultiRead.test(MultiReadSupported.new)
+ store.read_multi("key1", "key2").should == "multi"
+ end
+
+ it "should delegate to single reads when multi_read is explicitly disabled" do
+ class ExplicitlyDisabled
+ def read(key) "single" end
+ def read_multi(*keys) "multi" end
+ end
+ RecordCache::MultiRead.disable(ExplicitlyDisabled)
+ store = RecordCache::MultiRead.test(ExplicitlyDisabled.new)
+ store.read_multi("key1", "key2").should == {"key1" => "single", "key2" => "single"}
+ end
+
+ it "should delegate to single reads when multi_read throws an error" do
+ class MultiReadNotImplemented
+ def read(key) "single" end
+ def read_multi(*keys) raise NotImplementedError.new("multiread not implemented") end
+ end
+ store = RecordCache::MultiRead.test(MultiReadNotImplemented.new)
+ store.read_multi("key1", "key2").should == {"key1" => "single", "key2" => "single"}
+ end
+
+ it "should delegate to single reads when multi_read is undefined" do
+ class MultiReadNotDefined
+ def read(key) "single" end
+ end
+ store = RecordCache::MultiRead.test(MultiReadNotDefined.new)
+ store.read_multi("key1", "key2").should == {"key1" => "single", "key2" => "single"}
+ end
+
+ it "should have tested the Version Store" do
+ RecordCache::MultiRead.instance_variable_get(:@tested).should include(RecordCache::Base.version_store.instance_variable_get(:@store))
+ end
+
+ it "should have tested all Record Stores" do
+ tested_stores = RecordCache::MultiRead.instance_variable_get(:@tested)
+ RecordCache::Base.stores.values.each do |record_store|
+ tested_stores.should include(record_store)
+ end
+ end
+end
148 spec/lib/query_spec.rb
@@ -0,0 +1,148 @@
+require 'spec_helper'
+
+describe RecordCache::Query do
+ before(:each) do
+ @query = RecordCache::Query.new
+ end
+
+ context "wheres" do
+ it "should be an empty hash by default" do
+ @query.wheres.should == {}
+ end
+
+ it "should fill wheres on instantiation" do
+ @query = RecordCache::Query.new({:id => 1})
+ @query.wheres.should == {:id => 1}
+ end
+
+ it "should keep track of where clauses" do
+ @query.where(:name, "My name")
+ @query.where(:id, [1, 2, 3])
+ @query.where(:height, 1.75)
+ @query.wheres.should == {:name => "My name", :id => [1, 2, 3], :height => 1.75}
+ end
+
+ context "where_ids" do
+ it "should return nil if the attribute is not defined" do
+ @query.where(:idx, 15)
+ @query.where_ids(:id).should == nil
+ end
+
+ it "should return nil if one the value is nil" do
+ @query.where(:id, nil)
+ @query.where_ids(:id).should == nil
+ end
+
+ it "should return nil if one of the values is < 1" do
+ @query.where(:id, [2, 0, 8])
+ @query.where_ids(:id).should == nil
+ end
+
+ it "should return nil if one of the values is nil" do
+ @query.where(:id, ["1", nil, "3"])
+ @query.where_ids(:id).should == nil
+ end
+
+ it "should retrieve an array of integers when a single integer is provided" do
+ @query.where(:id, 15)
+ @query.where_ids(:id).should == [15]
+ end
+
+ it "should retrieve an array of integers when a multiple integers are provided" do
+ @query.where(:id, [2, 4, 8])
+ @query.where_ids(:id).should == [2, 4, 8]
+ end
+
+ it "should retrieve an array of integers when a single string is provided" do
+ @query.where(:id, "15")
+ @query.where_ids(:id).should == [15]
+ end
+
+ it "should retrieve an array of integers when a multiple strings are provided" do
+ @query.where(:id, ["2", "4", "8"])
+ @query.where_ids(:id).should == [2, 4, 8]
+ end
+
+ it "should cache the array of integers" do
+ @query.where(:id, ["2", "4", "8"])
+ ids1 = @query.where_ids(:id)
+ ids2 = @query.where_ids(:id)
+ ids1.object_id.should == ids2.object_id
+ end
+ end
+
+ context "where_id" do
+ it "should return nil when multiple integers are provided" do
+ @query.where(:id, [2, 4, 8])
+ @query.where_id(:id).should == nil
+ end
+
+ it "should return the id when a single integer is provided" do
+ @query.where(:id, 4)
+ @query.where_id(:id).should == 4
+ end
+
+ it "should return the id when a single string is provided" do
+ @query.where(:id, ["4"])
+ @query.where_id(:id).should == 4
+ end
+ end
+ end
+
+ context "sort" do
+ it "should be an empty array by default" do
+ @query.sort_orders.should == []
+ end
+
+ it "should keep track of sort orders" do
+ @query.order_by("name", true)
+ @query.order_by("id", false)
+ @query.sort_orders.should == [ ["name", true], ["id", false] ]
+ end
+
+ it "should convert attribute to string" do
+ @query.order_by(:name, true)
+ @query.sort_orders.should == [ ["name", true] ]
+ end
+
+ it "should define sorted?" do
+ @query.sorted?.should == false
+ @query.order_by("name", true)
+ @query.sorted?.should == true
+ end
+ end
+
+ context "limit" do
+ it "should be +nil+ by default" do
+ @query.limit.should == nil
+ end
+
+ it "should keep track of limit" do
+ @query.limit = 4
+ @query.limit.should == 4
+ end
+
+ it "should convert limit to integer" do
+ @query.limit = "4"
+ @query.limit.should == 4
+ end
+ end
+
+ context "utility" do
+ before(:each) do
+ @query.where(:name, "My name")
+ @query.where(:id, [1, 2, 3])
+ @query.order_by("name", true)
+ @query.limit = "4"
+ end
+
+ it "should generate a unique key for (request) caching purposes" do
+ @query.cache_key.should == 'name="My name"&id=[1, 2, 3].name=AL4'
+ end
+
+ it "should generate a pretty formatted query" do
+ @query.to_s.should == 'SELECT name = "My name" AND id = [1, 2, 3] ORDER_BY name ASC LIMIT 4'
+ end
+ end
+
+end
140 spec/lib/statistics_spec.rb
@@ -0,0 +1,140 @@
+require 'spec_helper'
+
+describe RecordCache::Statistics do
+ before(:each) do
+ # remove active setting from other tests
+ RecordCache::Statistics.send(:remove_instance_variable, :@active) if RecordCache::Statistics.instance_variable_get(:@active)
+ end
+
+ context "active" do
+ it "should default to false" do
+ RecordCache::Statistics.active?.should == false
+ end
+
+ it "should be activated by start" do
+ RecordCache::Statistics.start
+ RecordCache::Statistics.active?.should == true
+ end
+
+ it "should be deactivated by stop" do
+ RecordCache::Statistics.start
+ RecordCache::Statistics.active?.should == true
+ RecordCache::Statistics.stop
+ RecordCache::Statistics.active?.should == false
+ end
+
+ it "should be toggleable" do
+ RecordCache::Statistics.toggle
+ RecordCache::Statistics.active?.should == true
+ RecordCache::Statistics.toggle
+ RecordCache::Statistics.active?.should == false
+ end
+ end
+
+ context "find" do
+ it "should return {} for unknown base classes" do
+ class UnknownBase; end
+ RecordCache::Statistics.find(UnknownBase).should == {}
+ end
+
+ it "should create a new counter for unknown strategies" do
+ class UnknownBase; end
+ counter = RecordCache::Statistics.find(UnknownBase, :strategy)
+ counter.calls.should == 0
+ end
+
+ it "should retrieve all strategies if only the base is provided" do
+ class KnownBase; end
+ counter1 = RecordCache::Statistics.find(KnownBase, :strategy1)
+ counter2 = RecordCache::Statistics.find(KnownBase, :strategy2)
+ counters = RecordCache::Statistics.find(KnownBase)
+ counters.size.should == 2
+ counters[:strategy1].should == counter1
+ counters[:strategy2].should == counter2
+ end
+
+ it "should retrieve the counter for an existing strategy" do
+ class KnownBase; end
+ counter1 = RecordCache::Statistics.find(KnownBase, :strategy1)
+ RecordCache::Statistics.find(KnownBase, :strategy1).should == counter1
+ end
+ end
+
+ context "reset!" do
+ before(:each) do
+ class BaseA; end
+ @counter_a1 = RecordCache::Statistics.find(BaseA, :strategy1)
+ @counter_a2 = RecordCache::Statistics.find(BaseA, :strategy2)
+ class BaseB; end
+ @counter_b1 = RecordCache::Statistics.find(BaseB, :strategy1)
+ end
+
+ it "should reset all counters for a specific base" do
+ mock(@counter_a1).reset!
+ mock(@counter_a2).reset!
+ mock(@counter_b1).reset!.times(0)
+ RecordCache::Statistics.reset!(BaseA)
+ end
+
+ it "should reset all counters" do
+ mock(@counter_a1).reset!
+ mock(@counter_a2).reset!
+ mock(@counter_b1).reset!
+ RecordCache::Statistics.reset!
+ end
+ end
+
+ context "counter" do
+ before(:each) do
+ @counter = RecordCache::Statistics::Counter.new
+ end
+
+ it "should be empty by default" do
+ [@counter.calls, @counter.hits, @counter.misses].should == [0, 0, 0]
+ end
+
+ it "should delegate active? to RecordCache::Statistics" do
+ mock(RecordCache::Statistics).active?
+ @counter.active?
+ end
+
+ it "should add hits and misses" do