record-cache 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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