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.
- data/lib/record-cache.rb +1 -0
- data/lib/record_cache/active_record.rb +318 -0
- data/lib/record_cache/base.rb +136 -0
- data/lib/record_cache/dispatcher.rb +90 -0
- data/lib/record_cache/multi_read.rb +51 -0
- data/lib/record_cache/query.rb +85 -0
- data/lib/record_cache/statistics.rb +82 -0
- data/lib/record_cache/strategy/base.rb +154 -0
- data/lib/record_cache/strategy/id_cache.rb +93 -0
- data/lib/record_cache/strategy/index_cache.rb +122 -0
- data/lib/record_cache/strategy/request_cache.rb +49 -0
- data/lib/record_cache/test/resettable_version_store.rb +49 -0
- data/lib/record_cache/version.rb +5 -0
- data/lib/record_cache/version_store.rb +54 -0
- data/lib/record_cache.rb +11 -0
- data/spec/db/database.yml +6 -0
- data/spec/db/schema.rb +42 -0
- data/spec/db/seeds.rb +40 -0
- data/spec/initializers/record_cache.rb +14 -0
- data/spec/lib/dispatcher_spec.rb +86 -0
- data/spec/lib/multi_read_spec.rb +51 -0
- data/spec/lib/query_spec.rb +148 -0
- data/spec/lib/statistics_spec.rb +140 -0
- data/spec/lib/strategy/base_spec.rb +241 -0
- data/spec/lib/strategy/id_cache_spec.rb +168 -0
- data/spec/lib/strategy/index_cache_spec.rb +223 -0
- data/spec/lib/strategy/request_cache_spec.rb +85 -0
- data/spec/lib/version_store_spec.rb +104 -0
- data/spec/models/apple.rb +8 -0
- data/spec/models/banana.rb +8 -0
- data/spec/models/pear.rb +6 -0
- data/spec/models/person.rb +11 -0
- data/spec/models/store.rb +13 -0
- data/spec/spec_helper.rb +44 -0
- data/spec/support/after_commit.rb +71 -0
- data/spec/support/matchers/hit_cache_matcher.rb +53 -0
- data/spec/support/matchers/miss_cache_matcher.rb +53 -0
- data/spec/support/matchers/use_cache_matcher.rb +53 -0
- metadata +253 -0
data/lib/record-cache.rb
ADDED
@@ -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
|