identity_cache 1.0.1 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -44,6 +44,8 @@ require 'identity_cache/with_primary_index'
44
44
  module IdentityCache
45
45
  extend ActiveSupport::Concern
46
46
 
47
+ autoload :MemCacheStoreCAS, 'identity_cache/mem_cache_store_cas'
48
+
47
49
  include WithPrimaryIndex
48
50
 
49
51
  CACHED_NIL = :idc_cached_nil
@@ -52,10 +54,15 @@ module IdentityCache
52
54
  DELETED_TTL = 1000
53
55
 
54
56
  class AlreadyIncludedError < StandardError; end
57
+
55
58
  class AssociationError < StandardError; end
59
+
56
60
  class InverseAssociationError < StandardError; end
61
+
57
62
  class UnsupportedScopeError < StandardError; end
63
+
58
64
  class UnsupportedAssociationError < StandardError; end
65
+
59
66
  class DerivedModelError < StandardError; end
60
67
 
61
68
  mattr_accessor :cache_namespace
@@ -105,8 +112,27 @@ module IdentityCache
105
112
  end
106
113
 
107
114
  def should_use_cache? # :nodoc:
108
- pool = ActiveRecord::Base.connection_pool
109
- !pool.active_connection? || pool.connection.open_transactions == 0
115
+ ActiveRecord::Base.connection_handler.connection_pool_list.none? do |pool|
116
+ pool.active_connection? &&
117
+ # Rails wraps each of your tests in a transaction, so that any changes
118
+ # made to the database during the test can be rolled back afterwards.
119
+ # These transactions are flagged as "unjoinable", which tries to make
120
+ # your application behave as if they weren't there. In particular:
121
+ #
122
+ # - Opening another transaction during the test creates a savepoint,
123
+ # which can be rolled back independently of the main transaction.
124
+ # - When those nested transactions complete, any `after_commit`
125
+ # callbacks for records modified during the transaction will run,
126
+ # even though the changes haven't actually been committed yet.
127
+ #
128
+ # By ignoring unjoinable transactions, IdentityCache's behaviour
129
+ # during your test suite will more closely match production.
130
+ #
131
+ # When there are no open transactions, `current_transaction` returns a
132
+ # special `NullTransaction` object that is unjoinable, meaning we will
133
+ # use the cache.
134
+ pool.connection.current_transaction.joinable?
135
+ end
110
136
  end
111
137
 
112
138
  # Cache retrieval and miss resolver primitive; given a key it will try to
@@ -118,7 +144,7 @@ module IdentityCache
118
144
  #
119
145
  def fetch(key)
120
146
  if should_use_cache?
121
- unmap_cached_nil_for(cache.fetch(key) { map_cached_nil_for yield })
147
+ unmap_cached_nil_for(cache.fetch(key) { map_cached_nil_for(yield) })
122
148
  else
123
149
  yield
124
150
  end
@@ -144,7 +170,7 @@ module IdentityCache
144
170
  result = if should_use_cache?
145
171
  fetch_in_batches(keys.uniq) do |missed_keys|
146
172
  results = yield missed_keys
147
- results.map { |e| map_cached_nil_for e }
173
+ results.map { |e| map_cached_nil_for(e) }
148
174
  end
149
175
  else
150
176
  results = yield keys
@@ -11,7 +11,7 @@ module IdentityCache
11
11
  private
12
12
 
13
13
  def clear_cached_associations
14
- self.class.send(:all_cached_associations).each_value do |association|
14
+ self.class.all_cached_associations.each_value do |association|
15
15
  association.clear(self)
16
16
  end
17
17
  end
@@ -10,7 +10,7 @@ module IdentityCache
10
10
 
11
11
  def self.denormalized_schema_string(klass)
12
12
  schema_to_string(klass.columns).tap do |schema_string|
13
- klass.send(:all_cached_associations).sort.each do |name, association|
13
+ klass.all_cached_associations.sort.each do |name, association|
14
14
  klass.send(:check_association_scope, name)
15
15
  association.validate if association.embedded?
16
16
  case association
@@ -27,34 +27,41 @@ module IdentityCache
27
27
  end
28
28
  end
29
29
 
30
+ def write(owner_record, associated_record)
31
+ owner_record.instance_variable_set(records_variable_name, associated_record)
32
+ end
33
+
30
34
  def fetch(records)
31
35
  fetch_async(LoadStrategy::Eager, records) { |associated_records| associated_records }
32
36
  end
33
37
 
34
38
  def fetch_async(load_strategy, records)
35
39
  if reflection.polymorphic?
36
- cache_keys_to_associated_ids = {}
40
+ type_fetcher_to_db_ids_hash = {}
37
41
 
38
42
  records.each do |owner_record|
39
43
  associated_id = owner_record.send(reflection.foreign_key)
40
44
  next unless associated_id && !owner_record.instance_variable_defined?(records_variable_name)
41
- associated_cache_key = Object.const_get(
45
+ foreign_type_fetcher = Object.const_get(
42
46
  owner_record.send(reflection.foreign_type)
43
47
  ).cached_model.cached_primary_index
44
- unless cache_keys_to_associated_ids[associated_cache_key]
45
- cache_keys_to_associated_ids[associated_cache_key] = {}
46
- end
47
- cache_keys_to_associated_ids[associated_cache_key][associated_id] = owner_record
48
+ db_ids = type_fetcher_to_db_ids_hash[foreign_type_fetcher] ||= []
49
+ db_ids << associated_id
48
50
  end
49
51
 
50
- load_strategy.load_batch(cache_keys_to_associated_ids) do |associated_records_by_cache_key|
52
+ load_strategy.load_batch(type_fetcher_to_db_ids_hash) do |batch_load_result|
51
53
  batch_records = []
52
- associated_records_by_cache_key.each do |cache_key, associated_records|
53
- associated_records.keys.each do |id, associated_record|
54
- owner_record = cache_keys_to_associated_ids.fetch(cache_key).fetch(id)
55
- batch_records << owner_record
56
- owner_record.instance_variable_set(records_variable_name, associated_record)
57
- end
54
+
55
+ records.each do |owner_record|
56
+ associated_id = owner_record.send(reflection.foreign_key)
57
+ next unless associated_id && !owner_record.instance_variable_defined?(records_variable_name)
58
+ foreign_type_fetcher = Object.const_get(
59
+ owner_record.send(reflection.foreign_type)
60
+ ).cached_model.cached_primary_index
61
+
62
+ associated_record = batch_load_result.fetch(foreign_type_fetcher).fetch(associated_id)
63
+ batch_records << owner_record
64
+ write(owner_record, associated_record)
58
65
  end
59
66
 
60
67
  yield batch_records
@@ -73,7 +80,7 @@ module IdentityCache
73
80
  ) do |associated_records_by_id|
74
81
  associated_records_by_id.each do |id, associated_record|
75
82
  owner_record = ids_to_owner_record.fetch(id)
76
- owner_record.instance_variable_set(records_variable_name, associated_record)
83
+ write(owner_record, associated_record)
77
84
  end
78
85
 
79
86
  yield associated_records_by_id.values.compact
@@ -37,14 +37,24 @@ module IdentityCache
37
37
  private
38
38
 
39
39
  def fetch_association(load_strategy, klass, association, records, &block)
40
- unless records.first.class.should_use_cache?
41
- ActiveRecord::Associations::Preloader.new.preload(records, association)
40
+ unless klass.should_use_cache?
41
+ preload_records(records, association)
42
42
  return yield
43
43
  end
44
44
 
45
45
  cached_association = klass.cached_association(association)
46
46
  cached_association.fetch_async(load_strategy, records, &block)
47
47
  end
48
+
49
+ if ActiveRecord.gem_version < Gem::Version.new("6.2.0.alpha")
50
+ def preload_records(records, association)
51
+ ActiveRecord::Associations::Preloader.new.preload(records, association)
52
+ end
53
+ else
54
+ def preload_records(records, association)
55
+ ActiveRecord::Associations::Preloader.new(records: records, associations: association).call
56
+ end
57
+ end
48
58
  end
49
59
  end
50
60
  end
@@ -60,7 +60,6 @@ module IdentityCache
60
60
  def load_multi_from_db(ids)
61
61
  return {} if ids.empty?
62
62
 
63
- ids = ids.map { |id| model.connection.type_cast(id, id_column) }
64
63
  records = build_query(ids).to_a
65
64
  model.send(:setup_embedded_associations_on_miss, records)
66
65
  records.index_by(&:id)
@@ -11,25 +11,42 @@ module IdentityCache
11
11
  attr_reader :dehydrated_variable_name
12
12
 
13
13
  def build
14
- reflection.active_record.class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
15
- def #{cached_accessor_name}
16
- fetch_recursively_cached_association(
17
- :#{records_variable_name},
18
- :#{dehydrated_variable_name},
19
- :#{name}
20
- )
21
- end
22
- RUBY
14
+ cached_association = self
15
+
16
+ model = reflection.active_record
17
+ model.define_method(cached_accessor_name) do
18
+ cached_association.read(self)
19
+ end
23
20
 
24
21
  ParentModelExpiration.add_parent_expiry_hook(self)
25
22
  end
26
23
 
27
24
  def read(record)
28
- record.public_send(cached_accessor_name)
25
+ assoc = record.association(name)
26
+
27
+ if assoc.klass.should_use_cache? && !assoc.loaded? && assoc.target.blank?
28
+ if record.instance_variable_defined?(records_variable_name)
29
+ record.instance_variable_get(records_variable_name)
30
+ elsif record.instance_variable_defined?(dehydrated_variable_name)
31
+ dehydrated_target = record.instance_variable_get(dehydrated_variable_name)
32
+ association_target = hydrate_association_target(assoc.klass, dehydrated_target)
33
+ record.remove_instance_variable(dehydrated_variable_name)
34
+ set_with_inverse(record, association_target)
35
+ else
36
+ assoc.load_target
37
+ end
38
+ else
39
+ assoc.load_target
40
+ end
41
+ end
42
+
43
+ def write(record, association_target)
44
+ record.instance_variable_set(records_variable_name, association_target)
29
45
  end
30
46
 
31
- def write(record, records)
32
- record.instance_variable_set(records_variable_name, records)
47
+ def set_with_inverse(record, association_target)
48
+ set_inverse(record, association_target)
49
+ write(record, association_target)
33
50
  end
34
51
 
35
52
  def clear(record)
@@ -58,6 +75,30 @@ module IdentityCache
58
75
 
59
76
  private
60
77
 
78
+ def set_inverse(record, association_target)
79
+ return if association_target.nil?
80
+ associated_class = reflection.klass
81
+ inverse_cached_association = associated_class.cached_belongs_tos[inverse_name]
82
+ return unless inverse_cached_association
83
+
84
+ if association_target.is_a?(Array)
85
+ association_target.each do |child_record|
86
+ inverse_cached_association.write(child_record, record)
87
+ end
88
+ else
89
+ inverse_cached_association.write(association_target, record)
90
+ end
91
+ end
92
+
93
+ def hydrate_association_target(associated_class, dehydrated_value)
94
+ dehydrated_value = IdentityCache.unmap_cached_nil_for(dehydrated_value)
95
+ if dehydrated_value.is_a?(Array)
96
+ dehydrated_value.map { |coder| Encoder.decode(coder, associated_class) }
97
+ else
98
+ Encoder.decode(dehydrated_value, associated_class)
99
+ end
100
+ end
101
+
61
102
  def embedded_fetched?(records)
62
103
  record = records.first
63
104
  super || record.instance_variable_defined?(dehydrated_variable_name)
@@ -20,8 +20,8 @@ module IdentityCache
20
20
  end
21
21
 
22
22
  def #{cached_accessor_name}
23
- association_klass = association(:#{name}).klass
24
- if association_klass.should_use_cache? && !#{name}.loaded?
23
+ assoc = association(:#{name})
24
+ if assoc.klass.should_use_cache? && !assoc.loaded? && assoc.target.blank?
25
25
  #{records_variable_name} ||= #{reflection.class_name}.fetch_multi(#{cached_ids_name})
26
26
  else
27
27
  #{name}.to_a
@@ -21,8 +21,8 @@ module IdentityCache
21
21
  end
22
22
 
23
23
  def #{cached_accessor_name}
24
- association_klass = association(:#{name}).klass
25
- if association_klass.should_use_cache? && !association(:#{name}).loaded?
24
+ assoc = association(:#{name})
25
+ if assoc.klass.should_use_cache? && !assoc.loaded?
26
26
  #{records_variable_name} ||= #{reflection.class_name}.fetch(#{cached_id_name}) if #{cached_id_name}
27
27
  else
28
28
  #{name}
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ require 'dalli/cas/client'
3
+
4
+ module IdentityCache
5
+ module MemCacheStoreCAS
6
+ def cas(name, options = nil)
7
+ options = merged_options(options)
8
+ key = normalize_key(name, options)
9
+
10
+ rescue_error_with(false) do
11
+ instrument(:cas, key, options) do
12
+ @data.with do |connection|
13
+ connection.cas(key, options[:expires_in].to_i, options) do |raw_value|
14
+ entry = deserialize_entry(raw_value)
15
+ value = yield entry.value
16
+ entry = ActiveSupport::Cache::Entry.new(value, **options)
17
+ options[:raw] ? entry.value.to_s : entry
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ def cas_multi(*names, **options)
25
+ return if names.empty?
26
+
27
+ options = merged_options(options)
28
+ keys_to_names = names.each_with_object({}) { |name, hash| hash[normalize_key(name, options)] = name }
29
+ keys = keys_to_names.keys
30
+ rescue_error_with(false) do
31
+ instrument(:cas_multi, keys, options) do
32
+ raw_values = @data.get_multi_cas(keys)
33
+
34
+ values = {}
35
+ raw_values.each do |key, raw_value|
36
+ entry = deserialize_entry(raw_value.first)
37
+ values[keys_to_names[key]] = entry.value unless entry.expired?
38
+ end
39
+
40
+ updates = yield values
41
+
42
+ updates.each do |name, value|
43
+ key = normalize_key(name, options)
44
+ cas_id = raw_values[key].last
45
+ entry = ActiveSupport::Cache::Entry.new(value, **options)
46
+ payload = options[:raw] ? entry.value.to_s : entry
47
+ @data.replace_cas(key, payload, cas_id, options[:expires_in].to_i, options)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -12,6 +12,16 @@ module IdentityCache
12
12
  end
13
13
 
14
14
  def cache_backend=(cache_adaptor)
15
+ if cache_adaptor.class.name == 'ActiveSupport::Cache::MemCacheStore'
16
+ if cache_adaptor.respond_to?(:cas) || cache_adaptor.respond_to?(:cas_multi)
17
+ unless cache_adaptor.is_a?(MemCacheStoreCAS)
18
+ raise "#{cache_adaptor} respond to :cas or :cas_multi, that's unexpected"
19
+ end
20
+ else
21
+ cache_adaptor.extend(MemCacheStoreCAS)
22
+ end
23
+ end
24
+
15
25
  if cache_adaptor.respond_to?(:cas) && cache_adaptor.respond_to?(:cas_multi)
16
26
  @cache_fetcher = CacheFetcher.new(cache_adaptor)
17
27
  else
@@ -185,15 +195,15 @@ module IdentityCache
185
195
  end
186
196
 
187
197
  def log_multi_result(keys, memo_miss_keys, cache_miss_keys)
188
- IdentityCache.logger.debug do
189
- memoized_keys = keys - memo_miss_keys
190
- cache_hit_keys = memo_miss_keys - cache_miss_keys
191
- missed_keys = cache_miss_keys
192
-
193
- memoized_keys.each { |k| IdentityCache.logger.debug("[IdentityCache] (memoized) cache hit for #{k} (multi)") }
194
- cache_hit_keys.each { |k| IdentityCache.logger.debug("[IdentityCache] (backend) cache hit for #{k} (multi)") }
195
- missed_keys.each { |k| IdentityCache.logger.debug("[IdentityCache] cache miss for #{k} (multi)") }
196
- end
198
+ return unless IdentityCache.logger.debug?
199
+
200
+ memoized_keys = keys - memo_miss_keys
201
+ cache_hit_keys = memo_miss_keys - cache_miss_keys
202
+ missed_keys = cache_miss_keys
203
+
204
+ memoized_keys.each { |k| IdentityCache.logger.debug("[IdentityCache] (memoized) cache hit for #{k} (multi)") }
205
+ cache_hit_keys.each { |k| IdentityCache.logger.debug("[IdentityCache] (backend) cache hit for #{k} (multi)") }
206
+ missed_keys.each { |k| IdentityCache.logger.debug("[IdentityCache] cache miss for #{k} (multi)") }
197
207
  end
198
208
  end
199
209
  end
@@ -2,6 +2,7 @@
2
2
  module IdentityCache
3
3
  module ParentModelExpiration # :nodoc:
4
4
  extend ActiveSupport::Concern
5
+ include ArTransactionChanges
5
6
 
6
7
  class << self
7
8
  def add_parent_expiry_hook(cached_association)
@@ -20,6 +21,7 @@ module IdentityCache
20
21
  end
21
22
 
22
23
  def install_pending_parent_expiry_hooks(model)
24
+ return if lazy_hooks.empty?
23
25
  name = model.name.demodulize
24
26
  if (hooks = lazy_hooks.delete(name))
25
27
  hooks.each(&:install)
@@ -36,11 +38,9 @@ module IdentityCache
36
38
  included do
37
39
  class_attribute(:parent_expiration_entries)
38
40
  self.parent_expiration_entries = Hash.new { |hash, key| hash[key] = [] }
39
- after_commit(:expire_parent_caches)
40
41
  end
41
42
 
42
43
  def expire_parent_caches
43
- ParentModelExpiration.install_pending_parent_expiry_hooks(cached_model)
44
44
  parents_to_expire = Set.new
45
45
  add_parents_to_cache_expiry_set(parents_to_expire)
46
46
  parents_to_expire.each do |parent|
@@ -49,6 +49,7 @@ module IdentityCache
49
49
  end
50
50
 
51
51
  def add_parents_to_cache_expiry_set(parents_to_expire)
52
+ ParentModelExpiration.install_pending_parent_expiry_hooks(cached_model)
52
53
  self.class.parent_expiration_entries.each do |association_name, cached_associations|
53
54
  parents_to_expire_on_changes(parents_to_expire, association_name, cached_associations)
54
55
  end
@@ -3,10 +3,6 @@ module IdentityCache
3
3
  module QueryAPI
4
4
  extend ActiveSupport::Concern
5
5
 
6
- included do |base|
7
- base.after_commit(:expire_cache)
8
- end
9
-
10
6
  module ClassMethods
11
7
  # Prefetches cached associations on a collection of records
12
8
  def prefetch_associations(includes, records)
@@ -18,6 +14,11 @@ module IdentityCache
18
14
  cached_has_manys[name] || cached_has_ones[name] || cached_belongs_tos.fetch(name)
19
15
  end
20
16
 
17
+ # @api private
18
+ def all_cached_associations # :nodoc:
19
+ cached_has_manys.merge(cached_has_ones).merge(cached_belongs_tos)
20
+ end
21
+
21
22
  private
22
23
 
23
24
  def raise_if_scoped
@@ -81,7 +82,7 @@ module IdentityCache
81
82
  association = record.association(association_name)
82
83
  target = association.target
83
84
  target = readonly_copy(target) if readonly
84
- record.send(:set_embedded_association, association_name, target)
85
+ cached_association.set_with_inverse(record, target)
85
86
  association.reset
86
87
  # reset inverse associations
87
88
  next unless target && association_reflection.has_inverse?
@@ -126,10 +127,6 @@ module IdentityCache
126
127
  all_cached_associations.select { |_name, association| association.embedded_recursively? }
127
128
  end
128
129
 
129
- def all_cached_associations
130
- cached_has_manys.merge(cached_has_ones).merge(cached_belongs_tos)
131
- end
132
-
133
130
  def embedded_associations
134
131
  all_cached_associations.select { |_name, association| association.embedded? }
135
132
  end
@@ -151,6 +148,26 @@ module IdentityCache
151
148
  end
152
149
  end
153
150
 
151
+ no_op_callback = proc {}
152
+ included do |base|
153
+ # Make sure there is at least once after_commit callback so that _run_commit_callbacks
154
+ # is called, which is overridden to do an early after_commit callback
155
+ base.after_commit(&no_op_callback)
156
+ end
157
+
158
+ # Override the method that is used to call after_commit callbacks so that we can
159
+ # expire the caches before other after_commit callbacks. This way we can avoid stale
160
+ # cache reads that happen from the ordering of callbacks. For example, if an after_commit
161
+ # callback enqueues a background job, then we don't want it to be possible for the
162
+ # background job to run and load data from the cache before it is invalidated.
163
+ def _run_commit_callbacks
164
+ if destroyed? || transaction_changed_attributes.present?
165
+ expire_cache
166
+ expire_parent_caches
167
+ end
168
+ super
169
+ end
170
+
154
171
  # Invalidate the cache data associated with the record.
155
172
  def expire_cache
156
173
  expire_attribute_indexes
@@ -165,63 +182,6 @@ module IdentityCache
165
182
 
166
183
  private
167
184
 
168
- def fetch_recursively_cached_association(ivar_name, dehydrated_ivar_name, association_name) # :nodoc:
169
- assoc = association(association_name)
170
-
171
- if assoc.klass.should_use_cache? && !assoc.loaded?
172
- if instance_variable_defined?(ivar_name)
173
- instance_variable_get(ivar_name)
174
- elsif instance_variable_defined?(dehydrated_ivar_name)
175
- associated_records = hydrate_association_target(assoc.klass, instance_variable_get(dehydrated_ivar_name))
176
- set_embedded_association(association_name, associated_records)
177
- remove_instance_variable(dehydrated_ivar_name)
178
- instance_variable_set(ivar_name, associated_records)
179
- else
180
- assoc.load_target
181
- end
182
- else
183
- assoc.load_target
184
- end
185
- end
186
-
187
- def hydrate_association_target(associated_class, dehydrated_value) # :nodoc:
188
- dehydrated_value = IdentityCache.unmap_cached_nil_for(dehydrated_value)
189
- if dehydrated_value.is_a?(Array)
190
- dehydrated_value.map { |coder| Encoder.decode(coder, associated_class) }
191
- else
192
- Encoder.decode(dehydrated_value, associated_class)
193
- end
194
- end
195
-
196
- def set_embedded_association(association_name, association_target) #:nodoc:
197
- model = self.class
198
- cached_association = model.cached_association(association_name)
199
-
200
- set_inverse_of_cached_association(cached_association, association_target)
201
-
202
- instance_variable_set(cached_association.records_variable_name, association_target)
203
- end
204
-
205
- def set_inverse_of_cached_association(cached_association, association_target)
206
- return if association_target.nil?
207
- associated_class = cached_association.reflection.klass
208
- inverse_name = cached_association.inverse_name
209
- inverse_cached_association = associated_class.cached_belongs_tos[inverse_name]
210
- return unless inverse_cached_association
211
-
212
- if association_target.is_a?(Array)
213
- association_target.each do |child_record|
214
- child_record.instance_variable_set(
215
- inverse_cached_association.records_variable_name, self
216
- )
217
- end
218
- else
219
- association_target.instance_variable_set(
220
- inverse_cached_association.records_variable_name, self
221
- )
222
- end
223
- end
224
-
225
185
  def expire_attribute_indexes # :nodoc:
226
186
  cache_indexes.each do |cached_attribute|
227
187
  cached_attribute.expire(self)