identity_cache 1.0.1 → 1.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.
@@ -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)