identity_cache 0.0.2 → 0.0.3
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.
- checksums.yaml +7 -0
- data/.travis.yml +1 -0
- data/CHANGELOG +7 -0
- data/README.md +8 -0
- data/Rakefile +19 -0
- data/identity_cache.gemspec +2 -1
- data/lib/{belongs_to_caching.rb → identity_cache/belongs_to_caching.rb} +12 -8
- data/lib/identity_cache/cache_key_generation.rb +58 -0
- data/lib/identity_cache/configuration_dsl.rb +301 -0
- data/lib/identity_cache/memoized_cache_proxy.rb +118 -0
- data/lib/identity_cache/parent_model_expiration.rb +34 -0
- data/lib/identity_cache/query_api.rb +312 -0
- data/lib/identity_cache/version.rb +1 -1
- data/lib/identity_cache.rb +35 -631
- data/performance/cache_runner.rb +123 -0
- data/performance/cpu.rb +28 -0
- data/performance/externals.rb +45 -0
- data/performance/profile.rb +26 -0
- data/test/attribute_cache_test.rb +3 -3
- data/test/fetch_multi_test.rb +13 -39
- data/test/fetch_multi_with_batched_associations_test.rb +236 -0
- data/test/fetch_test.rb +1 -1
- data/test/helpers/active_record_objects.rb +43 -0
- data/test/helpers/cache.rb +3 -12
- data/test/helpers/database_connection.rb +2 -1
- data/test/index_cache_test.rb +7 -0
- data/test/memoized_cache_proxy_test.rb +46 -1
- data/test/normalized_has_many_test.rb +13 -0
- data/test/recursive_denormalized_has_many_test.rb +17 -2
- data/test/save_test.rb +2 -2
- data/test/schema_change_test.rb +8 -28
- data/test/test_helper.rb +49 -43
- metadata +76 -76
- data/lib/memoized_cache_proxy.rb +0 -71
@@ -0,0 +1,312 @@
|
|
1
|
+
module IdentityCache
|
2
|
+
module QueryAPI
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do |base|
|
6
|
+
base.private_class_method :require_if_necessary
|
7
|
+
base.instance_eval(ruby = <<-CODE, __FILE__, __LINE__)
|
8
|
+
private :expire_cache, :was_new_record?, :fetch_denormalized_cached_association,
|
9
|
+
:populate_denormalized_cached_association
|
10
|
+
CODE
|
11
|
+
base.after_commit :expire_cache
|
12
|
+
base.after_touch :expire_cache
|
13
|
+
end
|
14
|
+
|
15
|
+
module ClassMethods
|
16
|
+
# Similar to ActiveRecord::Base#exists? will return true if the id can be
|
17
|
+
# found in the cache or in the DB.
|
18
|
+
def exists_with_identity_cache?(id)
|
19
|
+
raise NotImplementedError, "exists_with_identity_cache? needs the primary index enabled" unless primary_cache_index_enabled
|
20
|
+
!!fetch_by_id(id)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Default fetcher added to the model on inclusion, it behaves like
|
24
|
+
# ActiveRecord::Base.find_by_id
|
25
|
+
def fetch_by_id(id)
|
26
|
+
raise NotImplementedError, "fetching needs the primary index enabled" unless primary_cache_index_enabled
|
27
|
+
if IdentityCache.should_cache?
|
28
|
+
|
29
|
+
require_if_necessary do
|
30
|
+
object = IdentityCache.fetch(rails_cache_key(id)){ resolve_cache_miss(id) }
|
31
|
+
IdentityCache.logger.error "[IDC id mismatch] fetch_by_id_requested=#{id} fetch_by_id_got=#{object.id} for #{object.inspect[(0..100)]} " if object && object.id != id.to_i
|
32
|
+
object
|
33
|
+
end
|
34
|
+
|
35
|
+
else
|
36
|
+
self.find_by_id(id)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Default fetcher added to the model on inclusion, it behaves like
|
41
|
+
# ActiveRecord::Base.find, will raise ActiveRecord::RecordNotFound exception
|
42
|
+
# if id is not in the cache or the db.
|
43
|
+
def fetch(id)
|
44
|
+
fetch_by_id(id) or raise(ActiveRecord::RecordNotFound, "Couldn't find #{self.class.name} with ID=#{id}")
|
45
|
+
end
|
46
|
+
|
47
|
+
# Default fetcher added to the model on inclusion, if behaves like
|
48
|
+
# ActiveRecord::Base.find_all_by_id
|
49
|
+
def fetch_multi(*ids)
|
50
|
+
raise NotImplementedError, "fetching needs the primary index enabled" unless primary_cache_index_enabled
|
51
|
+
options = ids.extract_options!
|
52
|
+
if IdentityCache.should_cache?
|
53
|
+
|
54
|
+
require_if_necessary do
|
55
|
+
cache_keys = ids.map {|id| rails_cache_key(id) }
|
56
|
+
key_to_id_map = Hash[ cache_keys.zip(ids) ]
|
57
|
+
|
58
|
+
objects_by_key = IdentityCache.fetch_multi(*cache_keys) do |unresolved_keys|
|
59
|
+
ids = unresolved_keys.map {|key| key_to_id_map[key] }
|
60
|
+
records = find_batch(ids, options)
|
61
|
+
records.compact.each(&:populate_association_caches)
|
62
|
+
records
|
63
|
+
end
|
64
|
+
|
65
|
+
records = cache_keys.map {|key| objects_by_key[key] }.compact
|
66
|
+
prefetch_associations(options[:includes], records) if options[:includes]
|
67
|
+
|
68
|
+
records
|
69
|
+
end
|
70
|
+
|
71
|
+
else
|
72
|
+
find_batch(ids, options)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def require_if_necessary #:nodoc:
|
77
|
+
# mem_cache_store returns raw value if unmarshal fails
|
78
|
+
rval = yield
|
79
|
+
case rval
|
80
|
+
when String
|
81
|
+
rval = Marshal.load(rval)
|
82
|
+
when Array
|
83
|
+
rval.map!{ |v| v.kind_of?(String) ? Marshal.load(v) : v }
|
84
|
+
end
|
85
|
+
rval
|
86
|
+
rescue ArgumentError => e
|
87
|
+
if e.message =~ /undefined [\w\/]+ (\w+)/
|
88
|
+
ok = Kernel.const_get($1) rescue nil
|
89
|
+
retry if ok
|
90
|
+
end
|
91
|
+
raise
|
92
|
+
end
|
93
|
+
|
94
|
+
def resolve_cache_miss(id)
|
95
|
+
self.find_by_id(id, :include => cache_fetch_includes).tap do |object|
|
96
|
+
object.try(:populate_association_caches)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def all_cached_associations
|
101
|
+
(cached_has_manys || {}).merge(cached_has_ones || {}).merge(cached_belongs_tos || {})
|
102
|
+
end
|
103
|
+
|
104
|
+
def all_cached_associations_needing_population
|
105
|
+
all_cached_associations.select do |cached_association, options|
|
106
|
+
options[:population_method_name].present? # non-embedded belongs_to associations don't need population
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def cache_fetch_includes(additions = {})
|
111
|
+
additions = hashify_includes_structure(additions)
|
112
|
+
embedded_associations = all_cached_associations.select { |name, options| options[:embed] }
|
113
|
+
|
114
|
+
associations_for_identity_cache = embedded_associations.map do |child_association, options|
|
115
|
+
child_class = reflect_on_association(child_association).try(:klass)
|
116
|
+
|
117
|
+
child_includes = additions.delete(child_association)
|
118
|
+
|
119
|
+
if child_class.respond_to?(:cache_fetch_includes)
|
120
|
+
child_includes = child_class.cache_fetch_includes(child_includes)
|
121
|
+
end
|
122
|
+
|
123
|
+
if child_includes.blank?
|
124
|
+
child_association
|
125
|
+
else
|
126
|
+
{ child_association => child_includes }
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
associations_for_identity_cache.push(additions) if additions.keys.size > 0
|
131
|
+
associations_for_identity_cache.compact
|
132
|
+
end
|
133
|
+
|
134
|
+
def find_batch(ids, options = {})
|
135
|
+
@id_column ||= columns.detect {|c| c.name == "id"}
|
136
|
+
ids = ids.map{ |id| @id_column.type_cast(id) }
|
137
|
+
records = where('id IN (?)', ids).includes(cache_fetch_includes(options[:includes])).all
|
138
|
+
records_by_id = records.index_by(&:id)
|
139
|
+
records = ids.map{ |id| records_by_id[id] }
|
140
|
+
mismatching_ids = records.compact.map(&:id) - ids
|
141
|
+
IdentityCache.logger.error "[IDC id mismatch] fetch_batch_requested=#{ids.inspect} fetch_batch_got=#{mismatchig_ids.inspect} mismatching ids " unless mismatching_ids.empty?
|
142
|
+
records
|
143
|
+
end
|
144
|
+
|
145
|
+
def prefetch_associations(associations, records)
|
146
|
+
associations = hashify_includes_structure(associations)
|
147
|
+
|
148
|
+
associations.each do |association, sub_associations|
|
149
|
+
case
|
150
|
+
when details = cached_has_manys[association]
|
151
|
+
|
152
|
+
if details[:embed]
|
153
|
+
child_records = records.map(&details[:cached_accessor_name].to_sym).flatten
|
154
|
+
else
|
155
|
+
ids_to_parent_record = records.each_with_object({}) do |record, hash|
|
156
|
+
child_ids = record.send(details[:cached_ids_name])
|
157
|
+
child_ids.each do |child_id|
|
158
|
+
hash[child_id] = record
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
parent_record_to_child_records = Hash.new { |h, k| h[k] = [] }
|
163
|
+
child_records = details[:association_class].fetch_multi(*ids_to_parent_record.keys)
|
164
|
+
child_records.each do |child_record|
|
165
|
+
parent_record = ids_to_parent_record[child_record.id]
|
166
|
+
parent_record_to_child_records[parent_record] << child_record
|
167
|
+
end
|
168
|
+
|
169
|
+
parent_record_to_child_records.each do |parent_record, child_records|
|
170
|
+
parent_record.send(details[:prepopulate_method_name], child_records)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
next_level_records = child_records
|
175
|
+
|
176
|
+
when details = cached_belongs_tos[association]
|
177
|
+
if details[:embed]
|
178
|
+
raise ArgumentError.new("Embedded belongs_to associations do not support prefetching yet.")
|
179
|
+
else
|
180
|
+
ids_to_child_record = records.each_with_object({}) do |child_record, hash|
|
181
|
+
parent_id = child_record.send(details[:foreign_key])
|
182
|
+
hash[parent_id] = child_record if parent_id.present?
|
183
|
+
end
|
184
|
+
parent_records = details[:association_class].fetch_multi(*ids_to_child_record.keys)
|
185
|
+
parent_records.each do |parent_record|
|
186
|
+
child_record = ids_to_child_record[parent_record.id]
|
187
|
+
child_record.send(details[:prepopulate_method_name], parent_record)
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
next_level_records = parent_records
|
192
|
+
|
193
|
+
when details = cached_has_ones[association]
|
194
|
+
if details[:embed]
|
195
|
+
parent_records = records.map(&details[:cached_accessor_name].to_sym)
|
196
|
+
else
|
197
|
+
raise ArgumentError.new("Non-embedded has_one associations do not support prefetching yet.")
|
198
|
+
end
|
199
|
+
|
200
|
+
next_level_records = parent_records
|
201
|
+
|
202
|
+
else
|
203
|
+
raise ArgumentError.new("Unknown cached association #{association} listed for prefetching")
|
204
|
+
end
|
205
|
+
|
206
|
+
if details && details[:association_class].respond_to?(:prefetch_associations)
|
207
|
+
details[:association_class].prefetch_associations(sub_associations, next_level_records)
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
def hashify_includes_structure(structure)
|
213
|
+
case structure
|
214
|
+
when nil
|
215
|
+
{}
|
216
|
+
when Symbol
|
217
|
+
{structure => []}
|
218
|
+
when Hash
|
219
|
+
structure.clone
|
220
|
+
when Array
|
221
|
+
structure.each_with_object({}) do |member, hash|
|
222
|
+
case member
|
223
|
+
when Hash
|
224
|
+
hash.merge!(member)
|
225
|
+
when Symbol
|
226
|
+
hash[member] = []
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
def populate_association_caches # :nodoc:
|
234
|
+
self.class.all_cached_associations_needing_population.each do |cached_association, options|
|
235
|
+
send(options[:population_method_name])
|
236
|
+
reflection = options[:embed] && self.class.reflect_on_association(cached_association)
|
237
|
+
if reflection && reflection.klass.respond_to?(:cached_has_manys)
|
238
|
+
child_objects = Array.wrap(send(options[:cached_accessor_name]))
|
239
|
+
child_objects.each(&:populate_association_caches)
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
def fetch_denormalized_cached_association(ivar_name, association_name) # :nodoc:
|
245
|
+
ivar_full_name = :"@#{ivar_name}"
|
246
|
+
if IdentityCache.should_cache?
|
247
|
+
populate_denormalized_cached_association(ivar_name, association_name)
|
248
|
+
IdentityCache.unmap_cached_nil_for(instance_variable_get(ivar_full_name))
|
249
|
+
else
|
250
|
+
send(association_name.to_sym)
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
def populate_denormalized_cached_association(ivar_name, association_name) # :nodoc:
|
255
|
+
ivar_full_name = :"@#{ivar_name}"
|
256
|
+
|
257
|
+
value = instance_variable_get(ivar_full_name)
|
258
|
+
return value unless value.nil?
|
259
|
+
|
260
|
+
loaded_association = send(association_name)
|
261
|
+
|
262
|
+
instance_variable_set(ivar_full_name, IdentityCache.map_cached_nil_for(loaded_association))
|
263
|
+
end
|
264
|
+
|
265
|
+
def expire_primary_index # :nodoc:
|
266
|
+
return unless self.class.primary_cache_index_enabled
|
267
|
+
extra_keys = if respond_to? :updated_at
|
268
|
+
old_updated_at = old_values_for_fields([:updated_at]).first
|
269
|
+
"expiring_last_updated_at=#{old_updated_at}"
|
270
|
+
else
|
271
|
+
""
|
272
|
+
end
|
273
|
+
IdentityCache.logger.debug { "[IdentityCache] expiring=#{self.class.name} expiring_id=#{id} #{extra_keys}" }
|
274
|
+
|
275
|
+
IdentityCache.cache.delete(primary_cache_index_key)
|
276
|
+
end
|
277
|
+
|
278
|
+
def expire_secondary_indexes # :nodoc:
|
279
|
+
return unless self.class.primary_cache_index_enabled
|
280
|
+
cache_indexes.try(:each) do |fields|
|
281
|
+
if self.destroyed?
|
282
|
+
IdentityCache.cache.delete(secondary_cache_index_key_for_previous_values(fields))
|
283
|
+
else
|
284
|
+
new_cache_index_key = secondary_cache_index_key_for_current_values(fields)
|
285
|
+
IdentityCache.cache.delete(new_cache_index_key)
|
286
|
+
|
287
|
+
if !was_new_record?
|
288
|
+
old_cache_index_key = secondary_cache_index_key_for_previous_values(fields)
|
289
|
+
IdentityCache.cache.delete(old_cache_index_key) unless old_cache_index_key == new_cache_index_key
|
290
|
+
end
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
def expire_attribute_indexes # :nodoc:
|
296
|
+
cache_attributes.try(:each) do |(attribute, fields)|
|
297
|
+
IdentityCache.cache.delete(attribute_cache_key_for_attribute_and_previous_values(attribute, fields)) unless was_new_record?
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
def expire_cache # :nodoc:
|
302
|
+
expire_primary_index
|
303
|
+
expire_secondary_indexes
|
304
|
+
expire_attribute_indexes
|
305
|
+
true
|
306
|
+
end
|
307
|
+
|
308
|
+
def was_new_record? # :nodoc:
|
309
|
+
!destroyed? && transaction_changed_attributes.has_key?('id') && transaction_changed_attributes['id'].nil?
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|