record-cache 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. data/lib/record-cache.rb +1 -0
  2. data/lib/record_cache/active_record.rb +318 -0
  3. data/lib/record_cache/base.rb +136 -0
  4. data/lib/record_cache/dispatcher.rb +90 -0
  5. data/lib/record_cache/multi_read.rb +51 -0
  6. data/lib/record_cache/query.rb +85 -0
  7. data/lib/record_cache/statistics.rb +82 -0
  8. data/lib/record_cache/strategy/base.rb +154 -0
  9. data/lib/record_cache/strategy/id_cache.rb +93 -0
  10. data/lib/record_cache/strategy/index_cache.rb +122 -0
  11. data/lib/record_cache/strategy/request_cache.rb +49 -0
  12. data/lib/record_cache/test/resettable_version_store.rb +49 -0
  13. data/lib/record_cache/version.rb +5 -0
  14. data/lib/record_cache/version_store.rb +54 -0
  15. data/lib/record_cache.rb +11 -0
  16. data/spec/db/database.yml +6 -0
  17. data/spec/db/schema.rb +42 -0
  18. data/spec/db/seeds.rb +40 -0
  19. data/spec/initializers/record_cache.rb +14 -0
  20. data/spec/lib/dispatcher_spec.rb +86 -0
  21. data/spec/lib/multi_read_spec.rb +51 -0
  22. data/spec/lib/query_spec.rb +148 -0
  23. data/spec/lib/statistics_spec.rb +140 -0
  24. data/spec/lib/strategy/base_spec.rb +241 -0
  25. data/spec/lib/strategy/id_cache_spec.rb +168 -0
  26. data/spec/lib/strategy/index_cache_spec.rb +223 -0
  27. data/spec/lib/strategy/request_cache_spec.rb +85 -0
  28. data/spec/lib/version_store_spec.rb +104 -0
  29. data/spec/models/apple.rb +8 -0
  30. data/spec/models/banana.rb +8 -0
  31. data/spec/models/pear.rb +6 -0
  32. data/spec/models/person.rb +11 -0
  33. data/spec/models/store.rb +13 -0
  34. data/spec/spec_helper.rb +44 -0
  35. data/spec/support/after_commit.rb +71 -0
  36. data/spec/support/matchers/hit_cache_matcher.rb +53 -0
  37. data/spec/support/matchers/miss_cache_matcher.rb +53 -0
  38. data/spec/support/matchers/use_cache_matcher.rb +53 -0
  39. metadata +253 -0
@@ -0,0 +1 @@
1
+ require 'record_cache'
@@ -0,0 +1,318 @@
1
+ module RecordCache
2
+ module ActiveRecord
3
+
4
+ module Base
5
+ class << self
6
+ def included(klass)
7
+ klass.extend ClassMethods
8
+ klass.class_eval do
9
+ class << self
10
+ alias_method_chain :find_by_sql, :record_cache
11
+ end
12
+ end
13
+ include InstanceMethods
14
+ end
15
+ end
16
+
17
+ module ClassMethods
18
+
19
+ # add cache invalidation hooks on initialization
20
+ def record_cache_init
21
+ after_commit :record_cache_create, :on => :create
22
+ after_commit :record_cache_update, :on => :update
23
+ after_commit :record_cache_destroy, :on => :destroy
24
+ end
25
+
26
+ # Retrieve the records, possibly from cache
27
+ def find_by_sql_with_record_cache(*args)
28
+ # no caching please
29
+ return find_by_sql_without_record_cache(*args) unless record_cache?
30
+
31
+ # check the piggy-back'd ActiveRelation record to see if the query can be retrieved from cache
32
+ sql = args[0]
33
+ arel = sql.instance_variable_get(:@arel)
34
+ query = arel ? RecordCache::Arel::QueryVisitor.new.accept(arel.ast) : nil
35
+ cacheable = query && record_cache.cacheable?(query)
36
+ # log only in debug mode!
37
+ RecordCache::Base.logger.debug("#{cacheable ? 'Fetch from cache' : 'Not cacheable'} (#{query}): SQL = #{sql}") if RecordCache::Base.logger.debug?
38
+ # retrieve the records from cache if the query is cacheable otherwise go straight to the DB
39
+ cacheable ? record_cache.fetch(query) : find_by_sql_without_record_cache(*args)
40
+ end
41
+ end
42
+
43
+ module InstanceMethods
44
+ end
45
+ end
46
+ end
47
+
48
+ module Arel
49
+
50
+ # The method <ActiveRecord::Base>.find_by_sql is used to actually
51
+ # retrieve the data from the DB.
52
+ # Unfortunately the ActiveRelation record is not accessible from
53
+ # there, so it is piggy-back'd in the SQL string.
54
+ module TreeManager
55
+ def self.included(klass)
56
+ klass.extend ClassMethods
57
+ klass.send(:include, InstanceMethods)
58
+ klass.class_eval do
59
+ alias_method_chain :to_sql, :record_cache
60
+ end
61
+ end
62
+
63
+ module ClassMethods
64
+ end
65
+
66
+ module InstanceMethods
67
+ def to_sql_with_record_cache
68
+ sql = to_sql_without_record_cache
69
+ sql.instance_variable_set(:@arel, self)
70
+ sql
71
+ end
72
+ end
73
+ end
74
+
75
+ # Visitor for the ActiveRelation to extract a simple cache query
76
+ # Only accepts single select queries with equality where statements
77
+ # Rejects queries with grouping / having / offset / etc.
78
+ class QueryVisitor < ::Arel::Visitors::Visitor
79
+ def initialize
80
+ super()
81
+ @cacheable = true
82
+ @query = ::RecordCache::Query.new
83
+ end
84
+
85
+ def accept object
86
+ super
87
+ @cacheable ? @query : nil
88
+ end
89
+
90
+ private
91
+
92
+ def not_cacheable o
93
+ @cacheable = false
94
+ end
95
+
96
+ alias :visit_Arel_Nodes_Ordering :not_cacheable
97
+
98
+ alias :visit_Arel_Nodes_TableAlias :not_cacheable
99
+
100
+ alias :visit_Arel_Nodes_Sum :not_cacheable
101
+ alias :visit_Arel_Nodes_Max :not_cacheable
102
+ alias :visit_Arel_Nodes_Avg :not_cacheable
103
+ alias :visit_Arel_Nodes_Count :not_cacheable
104
+
105
+ alias :visit_Arel_Nodes_StringJoin :not_cacheable
106
+ alias :visit_Arel_Nodes_InnerJoin :not_cacheable
107
+ alias :visit_Arel_Nodes_OuterJoin :not_cacheable
108
+
109
+ alias :visit_Arel_Nodes_DeleteStatement :not_cacheable
110
+ alias :visit_Arel_Nodes_InsertStatement :not_cacheable
111
+ alias :visit_Arel_Nodes_UpdateStatement :not_cacheable
112
+
113
+
114
+ alias :unary :not_cacheable
115
+ alias :visit_Arel_Nodes_Group :unary
116
+ alias :visit_Arel_Nodes_Having :unary
117
+ alias :visit_Arel_Nodes_Not :unary
118
+ alias :visit_Arel_Nodes_On :unary
119
+ alias :visit_Arel_Nodes_UnqualifiedColumn :unary
120
+
121
+ def visit_Arel_Nodes_Offset o
122
+ @cacheable = false unless o.expr == 0
123
+ end
124
+
125
+ def visit_Arel_Nodes_Values o
126
+ visit o.expressions if @cacheable
127
+ end
128
+
129
+ def visit_Arel_Nodes_Limit o
130
+ @query.limit = o.expr
131
+ end
132
+ alias :visit_Arel_Nodes_Top :visit_Arel_Nodes_Limit
133
+
134
+ def visit_Arel_Nodes_Grouping o
135
+ return unless @cacheable
136
+ # "`calendars`.account_id = 5"
137
+ if @table_name && o.expr =~ /^`#{@table_name}`\.`?(\w*)`?\s*=\s*(\d+)$/
138
+ @cacheable = @query.where($1, $2.to_i)
139
+ # "`service_instances`.`id` IN (118,80,120,82)"
140
+ elsif o.expr =~ /^`#{@table_name}`\.`?(\w*)`?\s*IN\s*\(([\d\s,]+)\)$/
141
+ @cacheable = @query.where($1, $2.split(',').map(&:to_i))
142
+ else
143
+ @cacheable = false
144
+ end
145
+ end
146
+
147
+ def visit_Arel_Nodes_SelectCore o
148
+ @cacheable = false unless o.groups.empty?
149
+ visit o.froms if @cacheable
150
+ visit o.wheres if @cacheable
151
+ # skip o.projections
152
+ end
153
+
154
+ def visit_Arel_Nodes_SelectStatement o
155
+ @cacheable = false if o.cores.size > 1
156
+ if @cacheable
157
+ visit o.offset
158
+ o.orders.map { |x| handle_order_by(visit x) } if @cacheable && o.orders.size > 0
159
+ visit o.limit
160
+ visit o.cores
161
+ end
162
+ end
163
+
164
+ def handle_order_by(order)
165
+ order.to_s.split(",").each do |o|
166
+ # simple sort order (+peope.id+ can be replaced by +id+, as joins are not allowed anyways)
167
+ if o.match(/^\s*([\w\.]*)\s*(|ASC|DESC|)\s*$/)
168
+ asc = $2 == "DESC" ? false : true
169
+ @query.order_by($1.split('.').last, asc)
170
+ else
171
+ @cacheable = false
172
+ end
173
+ end
174
+ end
175
+
176
+ def visit_Arel_Table o
177
+ @table_name = o.name
178
+ end
179
+
180
+ def visit_Arel_Nodes_Ordering o
181
+ [visit(o.expr), o.descending]
182
+ end
183
+
184
+ def visit_Arel_Attributes_Attribute o
185
+ o.name.to_sym
186
+ end
187
+ alias :visit_Arel_Attributes_Integer :visit_Arel_Attributes_Attribute
188
+ alias :visit_Arel_Attributes_Float :visit_Arel_Attributes_Attribute
189
+ alias :visit_Arel_Attributes_String :visit_Arel_Attributes_Attribute
190
+ alias :visit_Arel_Attributes_Time :visit_Arel_Attributes_Attribute
191
+ alias :visit_Arel_Attributes_Boolean :visit_Arel_Attributes_Attribute
192
+
193
+ def visit_Arel_Nodes_Equality o
194
+ equality = [visit(o.left), visit(o.right)]
195
+ # equality.reverse! if equality.last.is_a?(Symbol) || equality.first.is_a?(Fixnum)
196
+ # p " =====> equality found: #{equality.first.inspect}@#{equality.first.class.name} => #{equality.last.inspect}@#{equality.last.class.name}"
197
+ @query.where(equality.first, equality.last)
198
+ end
199
+ alias :visit_Arel_Nodes_In :visit_Arel_Nodes_Equality
200
+
201
+ def visit_Arel_Nodes_And o
202
+ visit(o.left)
203
+ visit(o.right)
204
+ end
205
+
206
+ alias :visit_Arel_Nodes_Or :not_cacheable
207
+ alias :visit_Arel_Nodes_NotEqual :not_cacheable
208
+ alias :visit_Arel_Nodes_GreaterThan :not_cacheable
209
+ alias :visit_Arel_Nodes_GreaterThanOrEqual :not_cacheable
210
+ alias :visit_Arel_Nodes_Assignment :not_cacheable
211
+ alias :visit_Arel_Nodes_LessThan :not_cacheable
212
+ alias :visit_Arel_Nodes_LessThanOrEqual :not_cacheable
213
+ alias :visit_Arel_Nodes_Between :not_cacheable
214
+ alias :visit_Arel_Nodes_NotIn :not_cacheable
215
+ alias :visit_Arel_Nodes_DoesNotMatch :not_cacheable
216
+ alias :visit_Arel_Nodes_Matches :not_cacheable
217
+
218
+ def visit_Fixnum o
219
+ o.to_i
220
+ end
221
+ alias :visit_Bignum :visit_Fixnum
222
+
223
+ def visit_Symbol o
224
+ o.to_sym
225
+ end
226
+
227
+ def visit_Object o
228
+ o
229
+ end
230
+ alias :visit_Arel_Nodes_SqlLiteral :visit_Object
231
+ alias :visit_Arel_SqlLiteral :visit_Object # This is deprecated
232
+ alias :visit_String :visit_Object
233
+ alias :visit_NilClass :visit_Object
234
+ alias :visit_TrueClass :visit_Object
235
+ alias :visit_FalseClass :visit_Object
236
+ alias :visit_Arel_SqlLiteral :visit_Object
237
+ alias :visit_BigDecimal :visit_Object
238
+ alias :visit_Float :visit_Object
239
+ alias :visit_Time :visit_Object
240
+ alias :visit_Date :visit_Object
241
+ alias :visit_DateTime :visit_Object
242
+ alias :visit_Hash :visit_Object
243
+
244
+ def visit_Array o
245
+ o.map{ |x| visit x }
246
+ end
247
+ end
248
+ end
249
+
250
+ # Patch ActiveRecord::Relation to make sure update_all will invalidate all referenced records
251
+ module ActiveRecord
252
+ module UpdateAll
253
+ class << self
254
+ def included(klass)
255
+ klass.extend ClassMethods
256
+ klass.send(:include, InstanceMethods)
257
+ klass.class_eval do
258
+ alias_method_chain :update_all, :record_cache
259
+ end
260
+ end
261
+ end
262
+
263
+ module ClassMethods
264
+ end
265
+
266
+ module InstanceMethods
267
+ def update_all_with_record_cache(updates, conditions = nil, options = {})
268
+ result = update_all_without_record_cache(updates, conditions, options)
269
+
270
+ if record_cache?
271
+ # when this condition is met, the arel.update method will be called on the current scope, see ActiveRecord::Relation#update_all
272
+ unless conditions || options.present? || @limit_value.present? != @order_values.present?
273
+ # go straight to SQL result (without instantiating records) for optimal performance
274
+ connection.execute(select('id').to_sql).each{ |row| record_cache.invalidate(:id, (row.is_a?(Hash) ? row['id'] : row.first).to_i ) }
275
+ end
276
+ end
277
+
278
+ result
279
+ end
280
+ end
281
+ end
282
+ end
283
+
284
+ # Patch ActiveRecord::Associations::HasManyAssociation to make sure the index_cache is updated when records are
285
+ # deleted from the collection
286
+ module ActiveRecord
287
+ module HasMany
288
+ class << self
289
+ def included(klass)
290
+ klass.extend ClassMethods
291
+ klass.send(:include, InstanceMethods)
292
+ klass.class_eval do
293
+ alias_method_chain :delete_records, :record_cache
294
+ end
295
+ end
296
+ end
297
+
298
+ module ClassMethods
299
+ end
300
+
301
+ module InstanceMethods
302
+ def delete_records_with_record_cache(records)
303
+ # invalidate :id cache for all records
304
+ records.each{ |record| record.class.record_cache.invalidate(record.id) if record.class.record_cache? unless record.new_record? }
305
+ # invalidate the referenced class for the attribute/value pair on the index cache
306
+ @reflection.klass.record_cache.invalidate(@reflection.primary_key_name.to_sym, @owner.id) if @reflection.klass.record_cache?
307
+ delete_records_without_record_cache(records)
308
+ end
309
+ end
310
+ end
311
+ end
312
+
313
+ end
314
+
315
+ ActiveRecord::Base.send(:include, RecordCache::ActiveRecord::Base)
316
+ Arel::TreeManager.send(:include, RecordCache::Arel::TreeManager)
317
+ ActiveRecord::Relation.send(:include, RecordCache::ActiveRecord::UpdateAll)
318
+ ActiveRecord::Associations::HasManyAssociation.send(:include, RecordCache::ActiveRecord::HasMany)
@@ -0,0 +1,136 @@
1
+ module RecordCache
2
+ # Normal mode
3
+ ENABLED = 1
4
+ # Do not fetch queries through the cache (but still update the cache after commit)
5
+ NO_FETCH = 2
6
+ # Completely disable the cache (may lead to stale results in case caching for other workers is not DISABLED)
7
+ DISABLED = 3
8
+
9
+ module Base
10
+ class << self
11
+ def included(klass)
12
+ klass.class_eval do
13
+ extend ClassMethods
14
+ include InstanceMethods
15
+ end
16
+ end
17
+
18
+ # The logger instance (Rails.logger if present)
19
+ def logger
20
+ @logger ||= defined?(::Rails) ? ::Rails.logger : ::ActiveRecord::Base.logger
21
+ end
22
+
23
+ # Set the ActiveSupport::Cache::Store instance that contains the current record(group) versions.
24
+ # Note that it must point to a single Store shared by all webservers (defaults to Rails.cache)
25
+ def version_store=(store)
26
+ @version_store = RecordCache::VersionStore.new(RecordCache::MultiRead.test(store))
27
+ end
28
+
29
+ # The ActiveSupport::Cache::Store instance that contains the current record(group) versions.
30
+ # Note that it must point to a single Store shared by all webservers (defaults to Rails.cache)
31
+ def version_store
32
+ @version_store ||= RecordCache::VersionStore.new(RecordCache::MultiRead.test(Rails.cache))
33
+ end
34
+
35
+ # Register a store with a specific id for reference with :store in +cache_records+
36
+ # e.g. RecordCache::Base.register_store(:server, ActiveSupport::Cache.lookup_store(:memory_store))
37
+ def register_store(id, store)
38
+ stores[id] = RecordCache::MultiRead.test(store)
39
+ end
40
+
41
+ # The hash of stores (store_id => store)
42
+ def stores
43
+ @stores ||= {}
44
+ end
45
+
46
+ # To disable the record cache for all models:
47
+ # RecordCache::Base.disabled!
48
+ # Enable again with:
49
+ # RecordCache::Base.enable
50
+ def disable!
51
+ @status = RecordCache::DISABLED
52
+ end
53
+
54
+ # Enable record cache
55
+ def enable
56
+ @status = RecordCache::ENABLED
57
+ end
58
+
59
+ # Retrieve the current status
60
+ def status
61
+ @status ||= RecordCache::ENABLED
62
+ end
63
+
64
+ # execute block of code without using the records cache to fetch records
65
+ # note that updates are still written to the cache, as otherwise other
66
+ # workers may receive stale results.
67
+ # To fully disable caching use +disable!+
68
+ def without_record_cache(&block)
69
+ old_status = status
70
+ begin
71
+ @status = RecordCache::NO_FETCH
72
+ yield
73
+ ensure
74
+ @status = old_status
75
+ end
76
+ end
77
+ end
78
+
79
+ module ClassMethods
80
+ # Cache the instances of this model
81
+ # options:
82
+ # :store => the cache store for the instances, e.g. :memory_store, :dalli_store* (default: Rails.cache)
83
+ # or one of the store ids defined using +RecordCache::Base.register_store+
84
+ # :key => provide a unique shorter key to limit the cache key length (default: model.name)
85
+ # :index => one or more attributes (Symbols) for which the ids are cached for the value of the attribute
86
+ # :request_cache => Set to true in case the exact same query is executed more than once during a single request
87
+ # If set to true somewhere, make sure to add the following to your application controller:
88
+ # before_filter { |c| RecordCache::Strategy::RequestCache.clear }
89
+ #
90
+ # Hints:
91
+ # - Dalli is a high performance pure Ruby client for accessing memcached servers, see https://github.com/mperham/dalli
92
+ # - use :store => :memory_store in case all records can easily fit in server memory
93
+ # - use :index => :account_id in case the records are (almost) always queried as a full set per account
94
+ # - use :index => :person_id for aggregated has_many associations
95
+ def cache_records(options)
96
+ @rc_dispatcher = RecordCache::Dispatcher.new(self) unless defined?(@rc_dispatcher)
97
+ 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)))
98
+ # always register an ID Cache
99
+ record_cache.register(:id, ::RecordCache::Strategy::IdCache, store, options)
100
+ # parse :index option
101
+ [options[:index]].flatten.compact.map(&:to_sym).each do |index|
102
+ record_cache.register(index, ::RecordCache::Strategy::IndexCache, store, options.merge({:index => index}))
103
+ end
104
+ # parse :request_cache option
105
+ record_cache.register(:request_cache, ::RecordCache::Strategy::RequestCache, store, options) if options[:request_cache]
106
+ # Callback for Data Store specific initialization
107
+ record_cache_init
108
+ end
109
+
110
+ # Returns true if record cache is defined and active for this class
111
+ def record_cache?
112
+ record_cache && RecordCache::Base.status == RecordCache::ENABLED
113
+ end
114
+
115
+ # Returns the RecordCache (class) instance
116
+ def record_cache
117
+ @rc_dispatcher
118
+ end
119
+ end
120
+
121
+ module InstanceMethods
122
+ def record_cache_create
123
+ self.class.record_cache.record_change(self, :create) unless RecordCache::Base.status == RecordCache::DISABLED
124
+ end
125
+
126
+ def record_cache_update
127
+ self.class.record_cache.record_change(self, :update) unless RecordCache::Base.status == RecordCache::DISABLED
128
+ end
129
+
130
+ def record_cache_destroy
131
+ self.class.record_cache.record_change(self, :destroy) unless RecordCache::Base.status == RecordCache::DISABLED
132
+ end
133
+ end
134
+
135
+ end
136
+ end
@@ -0,0 +1,90 @@
1
+ module RecordCache
2
+
3
+ # Every model that calls cache_records will receive an instance of this class
4
+ # accessible through +<model>.record_cache+
5
+ #
6
+ # The dispatcher is responsible for dispatching queries, record_changes and invalidation calls
7
+ # to the appropriate cache strategies.
8
+ class Dispatcher
9
+ def initialize(base)
10
+ @base = base
11
+ @strategy_by_id = {}
12
+ # all strategies except :request_cache, with the :id stategy first (most used and best performing)
13
+ @ordered_strategies = []
14
+ end
15
+
16
+ # Register a cache strategy for this model
17
+ def register(strategy_id, strategy_klass, record_store, options)
18
+ if @strategy_by_id.key?(strategy_id)
19
+ return if strategy_id == :id
20
+ raise "Multiple record cache definitions found for '#{strategy_id}' on #{@base.name}"
21
+ end
22
+ # Instantiate the cache strategy
23
+ strategy = strategy_klass.new(@base, strategy_id, record_store, options)
24
+ # Keep track of all strategies for this model
25
+ @strategy_by_id[strategy_id] = strategy
26
+ # Note that the :id strategy is always registered first
27
+ @ordered_strategies << strategy unless strategy_id == :request_cache
28
+ end
29
+
30
+ # Retrieve the caching strategy for the given attribute
31
+ def [](strategy_id)
32
+ @strategy_by_id[strategy_id]
33
+ end
34
+
35
+ # Can the cache retrieve the records based on this query?
36
+ def cacheable?(query)
37
+ !!first_cacheable_strategy(query)
38
+ end
39
+
40
+ # retrieve the record(s) with the given id(s) as an array
41
+ def fetch(query)
42
+ if request_cache
43
+ # cache the query in the request
44
+ request_cache.fetch(query) { fetch_from_first_cacheable_strategy(query) }
45
+ else
46
+ # fetch the results using the first strategy that accepts this query
47
+ fetch_from_first_cacheable_strategy(query)
48
+ end
49
+ end
50
+
51
+ # Update the version store and the record store (used by callbacks)
52
+ # @param record the updated record (possibly with
53
+ # @param action one of :create, :update or :destroy
54
+ def record_change(record, action)
55
+ # skip unless something has actually changed
56
+ return if action == :update && record.previous_changes.empty?
57
+ # dispatch the record change to all known strategies
58
+ @strategy_by_id.values.each { |strategy| strategy.record_change(record, action) }
59
+ end
60
+
61
+ # Explicitly invalidate one or more records
62
+ # @param: strategy: the strategy to invalidate
63
+ # @param: value: the value to send to the invalidate method of the chosen strategy
64
+ def invalidate(strategy, value = nil)
65
+ (value = strategy; strategy = :id) unless strategy.is_a?(Symbol)
66
+ # call the invalidate method of the chosen strategy
67
+ @strategy_by_id[strategy].invalidate(value) if @strategy_by_id[strategy]
68
+ # always clear the request cache if invalidate is explicitly called for this class
69
+ request_cache.try(:invalidate, value)
70
+ end
71
+
72
+ private
73
+
74
+ # retrieve the data from the first strategy that handle the query
75
+ def fetch_from_first_cacheable_strategy(query)
76
+ first_cacheable_strategy(query).fetch(query)
77
+ end
78
+
79
+ # find the first strategy that can handle this query
80
+ def first_cacheable_strategy(query)
81
+ @ordered_strategies.detect { |strategy| strategy.cacheable?(query) }
82
+ end
83
+
84
+ # retrieve the request cache strategy, if defined for this model
85
+ def request_cache
86
+ @strategy_by_id[:request_cache]
87
+ end
88
+
89
+ end
90
+ end
@@ -0,0 +1,51 @@
1
+ # This class will delegate read_multi to sequential read calls in case read_multi is not supported.
2
+ #
3
+ # If a particular Store Class does support read_multi, but is somehow slower because of a bug,
4
+ # you can disable read_multi by calling:
5
+ # RecordCache::MultiRead.disable(ActiveSupport::Cache::DalliStore)
6
+ #
7
+ # Important: Because of a bug in Dalli, read_multi is quite slow on some machines.
8
+ # @see https://github.com/mperham/dalli/issues/106
9
+ module RecordCache
10
+ module MultiRead
11
+ @tested = Set.new
12
+ @disabled_klass_names = Set.new
13
+
14
+ class << self
15
+
16
+ # Disable multi_read for a particular Store, e.g.
17
+ # RecordCache::MultiRead.disable(ActiveSupport::Cache::DalliStore)
18
+ def disable(klass)
19
+ @disabled_klass_names << klass.name
20
+ end
21
+
22
+ # Test the store if it supports read_multi calls
23
+ # If not, delegate multi_read calls to normal read calls
24
+ def test(store)
25
+ return store if @tested.include?(store)
26
+ @tested << store
27
+ override_read_multi(store) unless read_multi_supported?(store)
28
+ store
29
+ end
30
+
31
+ private
32
+
33
+ def read_multi_supported?(store)
34
+ return false if @disabled_klass_names.include?(store.class.name)
35
+ begin
36
+ store.read_multi('a', 'b')
37
+ true
38
+ rescue Exception => ignore
39
+ false
40
+ end
41
+ end
42
+
43
+ # delegate read_multi to normal read calls
44
+ def override_read_multi(store)
45
+ def store.read_multi(*keys)
46
+ keys.inject({}){ |h,key| h[key] = self.read(key); h}
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,85 @@
1
+ module RecordCache
2
+
3
+ # Container for the Query parameters
4
+ class Query
5
+ attr_reader :wheres, :sort_orders, :limit
6
+
7
+ def initialize(equality = nil)
8
+ @wheres = equality || {}
9
+ @sort_orders = []
10
+ @limit = nil
11
+ @where_ids = {}
12
+ end
13
+
14
+ # Set equality of an attribute (usually found in where clause)
15
+ # Returns false if another attribute values was already set (making this query uncachable)
16
+ def where(attribute, values)
17
+ @wheres[attribute.to_sym] = values if attribute
18
+ end
19
+
20
+ # Retrieve the ids (array of positive integers) for the given attribute from the where statements
21
+ # Returns nil if no the attribute is not present
22
+ def where_ids(attribute)
23
+ return @where_ids[attribute] if @where_ids.key?(attribute)
24
+ @where_ids[attribute] ||= array_of_positive_integers(@wheres[attribute])
25
+ end
26
+
27
+ # Retrieve the single id (positive integer) for the given attribute from the where statements
28
+ # Returns nil if no the attribute is not present, or if it contains an array
29
+ def where_id(attribute)
30
+ ids = where_ids(attribute)
31
+ return nil unless ids && ids.size == 1
32
+ ids.first
33
+ end
34
+
35
+ # Add a sort order to the query
36
+ def order_by(attribute, ascending = true)
37
+ @sort_orders << [attribute.to_s, ascending]
38
+ end
39
+
40
+ def sorted?
41
+ @sort_orders.size > 0
42
+ end
43
+
44
+ def limit=(limit)
45
+ @limit = limit.to_i
46
+ end
47
+
48
+ # retrieve a unique key for this Query (used in RequestCache)
49
+ def cache_key
50
+ @cache_key ||= generate_key
51
+ end
52
+
53
+ def to_s
54
+ s = "SELECT "
55
+ s << @wheres.map{|k,v| "#{k} = #{v.inspect}"}.join(" AND ")
56
+ if @sort_orders.size > 0
57
+ order_by = @sort_orders.map{|attr,asc| "#{attr} #{asc ? 'ASC' : 'DESC'}"}.join(', ')
58
+ s << " ORDER_BY #{order_by}"
59
+ end
60
+ s << " LIMIT #{@limit}" if @limit
61
+ s
62
+ end
63
+
64
+ private
65
+
66
+ def generate_key
67
+ key = @wheres.map{|k,v| "#{k}=#{v.inspect}"}.join("&")
68
+ if @sort_orders
69
+ order_by = @sort_orders.map{|attr,asc| "#{attr}=#{asc ? 'A' : 'D'}"}.join('-')
70
+ key << ".#{order_by}"
71
+ end
72
+ key << "L#{@limit}" if @limit
73
+ key
74
+ end
75
+
76
+ def array_of_positive_integers(values)
77
+ return nil unless values
78
+ values = [values] unless values.is_a?(Array)
79
+ values = values.map{|value| value.to_i} unless values.first.is_a?(Fixnum)
80
+ return nil unless values.all?{ |value| value > 0 } # all values must be positive integers
81
+ values
82
+ end
83
+
84
+ end
85
+ end