identity_cache 1.0.0 → 1.2.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +76 -9
  3. data/.github/workflows/cla.yml +22 -0
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +7 -3
  6. data/.spin/bootstrap +7 -0
  7. data/.spin/svc.yml +2 -0
  8. data/CAVEATS.md +25 -0
  9. data/CHANGELOG.md +56 -22
  10. data/Gemfile +15 -5
  11. data/LICENSE +1 -1
  12. data/README.md +27 -8
  13. data/Rakefile +13 -12
  14. data/dev.yml +5 -4
  15. data/gemfiles/Gemfile.latest-release +12 -5
  16. data/gemfiles/Gemfile.min-supported +12 -0
  17. data/gemfiles/Gemfile.rails-edge +9 -5
  18. data/identity_cache.gemspec +15 -24
  19. data/{railgun.yml → isogun.yml} +0 -5
  20. data/lib/identity_cache/belongs_to_caching.rb +1 -0
  21. data/lib/identity_cache/cache_fetcher.rb +241 -16
  22. data/lib/identity_cache/cache_hash.rb +7 -6
  23. data/lib/identity_cache/cache_invalidation.rb +2 -1
  24. data/lib/identity_cache/cache_key_generation.rb +22 -19
  25. data/lib/identity_cache/cache_key_loader.rb +2 -2
  26. data/lib/identity_cache/cached/association.rb +2 -4
  27. data/lib/identity_cache/cached/attribute.rb +3 -3
  28. data/lib/identity_cache/cached/attribute_by_multi.rb +1 -1
  29. data/lib/identity_cache/cached/belongs_to.rb +24 -14
  30. data/lib/identity_cache/cached/embedded_fetching.rb +2 -0
  31. data/lib/identity_cache/cached/prefetcher.rb +12 -2
  32. data/lib/identity_cache/cached/primary_index.rb +3 -3
  33. data/lib/identity_cache/cached/recursive/association.rb +55 -12
  34. data/lib/identity_cache/cached/recursive/has_many.rb +1 -0
  35. data/lib/identity_cache/cached/recursive/has_one.rb +1 -0
  36. data/lib/identity_cache/cached/reference/association.rb +1 -0
  37. data/lib/identity_cache/cached/reference/has_many.rb +3 -2
  38. data/lib/identity_cache/cached/reference/has_one.rb +3 -2
  39. data/lib/identity_cache/cached.rb +1 -0
  40. data/lib/identity_cache/configuration_dsl.rb +1 -0
  41. data/lib/identity_cache/encoder.rb +2 -1
  42. data/lib/identity_cache/expiry_hook.rb +2 -1
  43. data/lib/identity_cache/fallback_fetcher.rb +6 -1
  44. data/lib/identity_cache/mem_cache_store_cas.rb +63 -0
  45. data/lib/identity_cache/memoized_cache_proxy.rb +33 -23
  46. data/lib/identity_cache/parent_model_expiration.rb +6 -3
  47. data/lib/identity_cache/query_api.rb +29 -66
  48. data/lib/identity_cache/railtie.rb +1 -0
  49. data/lib/identity_cache/should_use_cache.rb +1 -0
  50. data/lib/identity_cache/version.rb +2 -1
  51. data/lib/identity_cache/with_primary_index.rb +37 -10
  52. data/lib/identity_cache/without_primary_index.rb +7 -3
  53. data/lib/identity_cache.rb +66 -26
  54. data/performance/cache_runner.rb +12 -51
  55. data/performance/cpu.rb +7 -6
  56. data/performance/externals.rb +6 -5
  57. data/performance/profile.rb +7 -6
  58. metadata +32 -112
  59. data/.github/probots.yml +0 -2
  60. data/.travis.yml +0 -45
  61. data/gemfiles/Gemfile.rails52 +0 -6
@@ -2,15 +2,10 @@
2
2
  name: identity-cache
3
3
 
4
4
  vm:
5
- image: /opt/dev/misc/railgun-images/default
6
5
  ip_address: 192.168.64.98
7
6
  memory: 1G
8
7
  cores: 2
9
8
 
10
- volumes:
11
- root: 1G
12
-
13
9
  services:
14
10
  - mysql
15
- - postgresql
16
11
  - memcached
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module IdentityCache
3
4
  module BelongsToCaching
4
5
  extend ActiveSupport::Concern
@@ -1,8 +1,53 @@
1
1
  # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
2
5
  module IdentityCache
3
6
  class CacheFetcher
4
7
  attr_accessor :cache_backend
5
8
 
9
+ EMPTY_HASH = {}.freeze
10
+
11
+ class FillLock
12
+ FILL_LOCKED = :fill_locked
13
+ FAILED_CLIENT_ID = "fill_failed"
14
+
15
+ class << self
16
+ def from_cache(marker, client_id, data_version)
17
+ raise ArgumentError unless marker == FILL_LOCKED
18
+
19
+ new(client_id: client_id, data_version: data_version)
20
+ end
21
+
22
+ def cache_value?(cache_value)
23
+ cache_value.is_a?(Array) && cache_value.length == 3 && cache_value.first == FILL_LOCKED
24
+ end
25
+ end
26
+
27
+ attr_reader :client_id, :data_version
28
+
29
+ def initialize(client_id:, data_version:)
30
+ @client_id = client_id
31
+ @data_version = data_version
32
+ end
33
+
34
+ def cache_value
35
+ [FILL_LOCKED, client_id, data_version]
36
+ end
37
+
38
+ def mark_failed
39
+ @client_id = FAILED_CLIENT_ID
40
+ end
41
+
42
+ def fill_failed?
43
+ @client_id == FAILED_CLIENT_ID
44
+ end
45
+
46
+ def ==(other)
47
+ self.class == other.class && client_id == other.client_id && data_version == other.data_version
48
+ end
49
+ end
50
+
6
51
  def initialize(cache_backend)
7
52
  @cache_backend = cache_backend
8
53
  end
@@ -25,27 +70,204 @@ module IdentityCache
25
70
  results
26
71
  end
27
72
 
28
- def fetch(key)
29
- result = nil
30
- yielded = false
31
- @cache_backend.cas(key) do |value|
32
- yielded = true
33
- unless IdentityCache::DELETED == value
34
- result = value
35
- break
73
+ def fetch(key, fill_lock_duration: nil, lock_wait_tries: 2, &block)
74
+ if fill_lock_duration && IdentityCache.should_fill_cache?
75
+ fetch_with_fill_lock(key, fill_lock_duration, lock_wait_tries, &block)
76
+ else
77
+ fetch_without_fill_lock(key, &block)
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def fetch_without_fill_lock(key)
84
+ data = nil
85
+ upsert(key) do |value|
86
+ value = nil if value == IdentityCache::DELETED || FillLock.cache_value?(value)
87
+ unless value.nil?
88
+ return value
36
89
  end
37
- result = yield
90
+
91
+ data = yield
38
92
  break unless IdentityCache.should_fill_cache?
39
- result
93
+
94
+ data
95
+ end
96
+ data
97
+ end
98
+
99
+ def fetch_with_fill_lock(key, fill_lock_duration, lock_wait_tries, &block)
100
+ raise ArgumentError, "fill_lock_duration must be greater than 0.0" unless fill_lock_duration > 0.0
101
+ raise ArgumentError, "lock_wait_tries must be greater than 0" unless lock_wait_tries > 0
102
+
103
+ lock = nil
104
+ using_fallback_key = false
105
+ expiration_options = EMPTY_HASH
106
+ (lock_wait_tries + 2).times do # +2 is for first attempt and retry with fallback key
107
+ result = fetch_or_take_lock(key, old_lock: lock, **expiration_options)
108
+ case result
109
+ when FillLock
110
+ lock = result
111
+ if lock.client_id == client_id # have lock
112
+ data = begin
113
+ yield
114
+ rescue
115
+ mark_fill_failure_on_lock(key, expiration_options)
116
+ raise
117
+ end
118
+
119
+ if !fill_with_lock(key, data, lock, expiration_options) && !using_fallback_key
120
+ # fallback to storing data in the fallback key so it is available to clients waiting on the lock
121
+ expiration_options = fallback_key_expiration_options(fill_lock_duration)
122
+ @cache_backend.write(lock_fill_fallback_key(key, lock), data, expiration_options)
123
+ end
124
+ return data
125
+ else
126
+ raise LockWaitTimeout if lock_wait_tries <= 0
127
+
128
+ lock_wait_tries -= 1
129
+
130
+ # If fill failed in the other client, then it might be failing fast
131
+ # so avoid waiting the typical amount of time for a lock wait. The
132
+ # semian gem can be used to handle failing fast when the database is slow.
133
+ if lock.fill_failed?
134
+ return fetch_without_fill_lock(key, &block)
135
+ end
136
+
137
+ # lock wait
138
+ sleep(fill_lock_duration)
139
+ # loop around to retry fetch_or_take_lock
140
+ end
141
+ when IdentityCache::DELETED # interrupted by cache invalidation
142
+ if using_fallback_key
143
+ raise "unexpected cache invalidation of versioned fallback key"
144
+ elsif lock
145
+ # Cache invalidated during lock wait, use a versioned fallback key
146
+ # to avoid further cache invalidation interruptions.
147
+ using_fallback_key = true
148
+ key = lock_fill_fallback_key(key, lock)
149
+ expiration_options = fallback_key_expiration_options(fill_lock_duration)
150
+ # loop around to retry with fallback key
151
+ else
152
+ # Cache invalidation prevented lock from being taken or read, so we don't
153
+ # have a data version to use to build a shared fallback key. In the future
154
+ # we could add the data version to the cache invalidation value so a fallback
155
+ # key could be used here. For now, we assume that a cache invalidation occuring
156
+ # just after the cache wasn't filled is more likely a sign of a key that is
157
+ # written more than read (which this cache isn't a good fit for), rather than
158
+ # a thundering herd or reads.
159
+ return yield
160
+ end
161
+ when nil # Errors talking to memcached
162
+ return yield
163
+ else # hit
164
+ return result
165
+ end
166
+ end
167
+ raise "unexpected number of loop iterations"
168
+ end
169
+
170
+ def mark_fill_failure_on_lock(key, expiration_options)
171
+ @cache_backend.cas(key, expiration_options) do |value|
172
+ break unless FillLock.cache_value?(value)
173
+
174
+ lock = FillLock.from_cache(*value)
175
+ break if lock.client_id != client_id
176
+
177
+ lock.mark_failed
178
+ lock.cache_value
179
+ end
180
+ end
181
+
182
+ def upsert(key, expiration_options = EMPTY_HASH)
183
+ yielded = false
184
+ upserted = @cache_backend.cas(key, expiration_options) do |value|
185
+ yielded = true
186
+ yield value
40
187
  end
41
188
  unless yielded
42
- result = yield
43
- add(key, result)
189
+ data = yield nil
190
+ upserted = add(key, data, expiration_options)
44
191
  end
45
- result
192
+ upserted
46
193
  end
47
194
 
48
- private
195
+ def fetch_or_take_lock(key, old_lock:, **expiration_options)
196
+ new_lock = nil
197
+ upserted = upsert(key, expiration_options) do |value|
198
+ if value.nil? || value == IdentityCache::DELETED
199
+ if old_lock # cache invalidated
200
+ return value
201
+ else
202
+ new_lock = FillLock.new(client_id: client_id, data_version: SecureRandom.uuid)
203
+ end
204
+ elsif FillLock.cache_value?(value)
205
+ fetched_lock = FillLock.from_cache(*value)
206
+ if old_lock == fetched_lock
207
+ # preserve data version since there hasn't been any cache invalidations
208
+ new_lock = FillLock.new(client_id: client_id, data_version: old_lock.data_version)
209
+ elsif old_lock && fetched_lock.data_version != old_lock.data_version
210
+ # Cache was invalidated, then another lock was taken during a lock wait.
211
+ # Treat it as any other cache invalidation, where the caller will switch
212
+ # to the fallback key.
213
+ return IdentityCache::DELETED
214
+ else
215
+ return fetched_lock
216
+ end
217
+ else # hit
218
+ return value
219
+ end
220
+ new_lock.cache_value # take lock
221
+ end
222
+
223
+ return new_lock if upserted
224
+
225
+ value = @cache_backend.read(key)
226
+ if FillLock.cache_value?(value)
227
+ FillLock.from_cache(*value)
228
+ else
229
+ value
230
+ end
231
+ end
232
+
233
+ def fill_with_lock(key, data, my_lock, expiration_options)
234
+ upserted = upsert(key, expiration_options) do |value|
235
+ return false if value.nil? || value == IdentityCache::DELETED
236
+ return true unless FillLock.cache_value?(value) # already filled
237
+
238
+ current_lock = FillLock.from_cache(*value)
239
+ if current_lock.data_version != my_lock.data_version
240
+ return false # invalidated then relocked
241
+ end
242
+
243
+ data
244
+ end
245
+
246
+ upserted
247
+ end
248
+
249
+ def lock_fill_fallback_key(key, lock)
250
+ "lock_fill:#{lock.data_version}:#{key}"
251
+ end
252
+
253
+ def fallback_key_expiration_options(fill_lock_duration)
254
+ # Override the default TTL for the fallback key lock since it won't be used for very long.
255
+ expires_in = fill_lock_duration * 2
256
+
257
+ # memcached uses integer number of seconds for TTL so round up to avoid having
258
+ # the cache store round down with `to_i`
259
+ expires_in = expires_in.ceil
260
+
261
+ # memcached TTL only gets part of the first second (https://github.com/memcached/memcached/issues/307),
262
+ # so increase TTL by 1 to compensate
263
+ expires_in += 1
264
+
265
+ { expires_in: expires_in }
266
+ end
267
+
268
+ def client_id
269
+ @client_id ||= SecureRandom.uuid
270
+ end
49
271
 
50
272
  def cas_multi(keys)
51
273
  result = nil
@@ -70,6 +292,7 @@ module IdentityCache
70
292
 
71
293
  break if updates.empty?
72
294
  break unless IdentityCache.should_fill_cache?
295
+
73
296
  updates
74
297
  end
75
298
  result
@@ -81,8 +304,10 @@ module IdentityCache
81
304
  result.each { |k, v| add(k, v) }
82
305
  end
83
306
 
84
- def add(key, value)
85
- @cache_backend.write(key, value, unless_exist: true) if IdentityCache.should_fill_cache?
307
+ def add(key, value, expiration_options = EMPTY_HASH)
308
+ return false unless IdentityCache.should_fill_cache?
309
+
310
+ @cache_backend.write(key, value, { unless_exist: true, **expiration_options })
86
311
  end
87
312
  end
88
313
  end
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  # Use CityHash for fast hashing if it is available; use Digest::MD5 otherwise
3
4
  begin
4
- require 'cityhash'
5
+ require "cityhash"
5
6
  rescue LoadError
6
- unless RUBY_PLATFORM == 'java'
7
+ unless RUBY_PLATFORM == "java"
7
8
  warn(<<-NOTICE)
8
9
  ** Notice: CityHash was not loaded. **
9
10
 
@@ -15,20 +16,20 @@ rescue LoadError
15
16
  NOTICE
16
17
  end
17
18
 
18
- require 'digest/md5'
19
+ require "digest/md5"
19
20
  end
20
21
 
21
22
  module IdentityCache
22
23
  module CacheHash
23
24
  if defined?(CityHash)
24
25
 
25
- def memcache_hash(key) #:nodoc:
26
+ def memcache_hash(key) # :nodoc:
26
27
  CityHash.hash64(key)
27
28
  end
28
29
  else
29
30
 
30
- def memcache_hash(key) #:nodoc:
31
- a = Digest::MD5.digest(key).unpack('LL')
31
+ def memcache_hash(key) # :nodoc:
32
+ a = Digest::MD5.digest(key).unpack("LL")
32
33
  (a[0] << 32) | a[1]
33
34
  end
34
35
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module IdentityCache
3
4
  module CacheInvalidation
4
5
  CACHE_KEY_NAMES = [:ids_variable_name, :id_variable_name, :records_variable_name]
@@ -11,7 +12,7 @@ module IdentityCache
11
12
  private
12
13
 
13
14
  def clear_cached_associations
14
- self.class.send(:all_cached_associations).each_value do |association|
15
+ self.class.all_cached_associations.each_value do |association|
15
16
  association.clear(self)
16
17
  end
17
18
  end
@@ -1,33 +1,36 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module IdentityCache
3
4
  module CacheKeyGeneration
4
5
  extend ActiveSupport::Concern
5
6
  DEFAULT_NAMESPACE = "IDC:#{CACHE_VERSION}:"
6
7
 
7
- def self.schema_to_string(columns)
8
- columns.sort_by(&:name).map { |c| "#{c.name}:#{c.type}" }.join(',')
9
- end
8
+ class << self
9
+ def schema_to_string(columns)
10
+ columns.sort_by(&:name).map { |c| "#{c.name}:#{c.type}" }.join(",")
11
+ end
10
12
 
11
- def self.denormalized_schema_string(klass)
12
- schema_to_string(klass.columns).tap do |schema_string|
13
- klass.send(:all_cached_associations).sort.each do |name, association|
14
- klass.send(:check_association_scope, name)
15
- association.validate if association.embedded?
16
- case association
17
- when Cached::Recursive::Association
18
- schema_string << ",#{name}:(#{denormalized_schema_hash(association.reflection.klass)})"
19
- when Cached::Reference::HasMany
20
- schema_string << ",#{name}:ids"
21
- when Cached::Reference::HasOne
22
- schema_string << ",#{name}:id"
13
+ def denormalized_schema_string(klass)
14
+ schema_to_string(klass.columns).tap do |schema_string|
15
+ klass.all_cached_associations.sort.each do |name, association|
16
+ klass.send(:check_association_scope, name)
17
+ association.validate if association.embedded?
18
+ case association
19
+ when Cached::Recursive::Association
20
+ schema_string << ",#{name}:(#{denormalized_schema_hash(association.reflection.klass)})"
21
+ when Cached::Reference::HasMany
22
+ schema_string << ",#{name}:ids"
23
+ when Cached::Reference::HasOne
24
+ schema_string << ",#{name}:id"
25
+ end
23
26
  end
24
27
  end
25
28
  end
26
- end
27
29
 
28
- def self.denormalized_schema_hash(klass)
29
- schema_string = denormalized_schema_string(klass)
30
- IdentityCache.memcache_hash(schema_string)
30
+ def denormalized_schema_hash(klass)
31
+ schema_string = denormalized_schema_string(klass)
32
+ IdentityCache.memcache_hash(schema_string)
33
+ end
31
34
  end
32
35
 
33
36
  module ClassMethods
@@ -25,12 +25,12 @@ module IdentityCache
25
25
  # @param cache_fetcher [_CacheFetcher]
26
26
  # @param db_key Reference to what to load from the database.
27
27
  # @return The database value corresponding to the database key.
28
- def load(cache_fetcher, db_key)
28
+ def load(cache_fetcher, db_key, cache_fetcher_options = {})
29
29
  cache_key = cache_fetcher.cache_key(db_key)
30
30
 
31
31
  db_value = nil
32
32
 
33
- cache_value = IdentityCache.fetch(cache_key) do
33
+ cache_value = IdentityCache.fetch(cache_key, cache_fetcher_options) do
34
34
  db_value = cache_fetcher.load_one_from_db(db_key)
35
35
  cache_fetcher.cache_encode(db_value)
36
36
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module IdentityCache
3
4
  module Cached
4
5
  class Association # :nodoc:
@@ -50,10 +51,7 @@ module IdentityCache
50
51
  end
51
52
 
52
53
  def inverse_name
53
- @inverse_name ||= begin
54
- reflection.inverse_of&.name ||
55
- reflection.active_record.name.underscore
56
- end
54
+ @inverse_name ||= reflection.inverse_of&.name || reflection.active_record.name.underscore
57
55
  end
58
56
 
59
57
  def validate
@@ -87,11 +87,11 @@ module IdentityCache
87
87
 
88
88
  def cache_key_prefix
89
89
  @cache_key_prefix ||= begin
90
- unique_indicator = unique ? '' : 's'
90
+ unique_indicator = unique ? "" : "s"
91
91
  "attr#{unique_indicator}" \
92
92
  ":#{model.base_class.name}" \
93
93
  ":#{attribute}" \
94
- ":#{key_fields.join('/')}:"
94
+ ":#{key_fields.join("/")}:"
95
95
  end
96
96
  end
97
97
 
@@ -116,7 +116,7 @@ module IdentityCache
116
116
  end
117
117
 
118
118
  def fetch_method_suffix
119
- "#{alias_name}_by_#{key_fields.join('_and_')}"
119
+ "#{alias_name}_by_#{key_fields.join("_and_")}"
120
120
  end
121
121
  end
122
122
  end
@@ -24,7 +24,7 @@ module IdentityCache
24
24
  end
25
25
 
26
26
  def unhashed_values_cache_key_string(key_values)
27
- key_values.map { |v| v.try!(:to_s).inspect }.join('/')
27
+ key_values.map { |v| v.try!(:to_s).inspect }.join("/")
28
28
  end
29
29
 
30
30
  def load_from_db_where_conditions(key_values)
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module IdentityCache
3
4
  module Cached
4
5
  class BelongsTo < Association # :nodoc:
@@ -27,34 +28,43 @@ module IdentityCache
27
28
  end
28
29
  end
29
30
 
31
+ def write(owner_record, associated_record)
32
+ owner_record.instance_variable_set(records_variable_name, associated_record)
33
+ end
34
+
30
35
  def fetch(records)
31
36
  fetch_async(LoadStrategy::Eager, records) { |associated_records| associated_records }
32
37
  end
33
38
 
34
39
  def fetch_async(load_strategy, records)
35
40
  if reflection.polymorphic?
36
- cache_keys_to_associated_ids = {}
41
+ type_fetcher_to_db_ids_hash = {}
37
42
 
38
43
  records.each do |owner_record|
39
44
  associated_id = owner_record.send(reflection.foreign_key)
40
45
  next unless associated_id && !owner_record.instance_variable_defined?(records_variable_name)
41
- associated_cache_key = Object.const_get(
46
+
47
+ foreign_type_fetcher = Object.const_get(
42
48
  owner_record.send(reflection.foreign_type)
43
49
  ).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
50
+ db_ids = type_fetcher_to_db_ids_hash[foreign_type_fetcher] ||= []
51
+ db_ids << associated_id
48
52
  end
49
53
 
50
- load_strategy.load_batch(cache_keys_to_associated_ids) do |associated_records_by_cache_key|
54
+ load_strategy.load_batch(type_fetcher_to_db_ids_hash) do |batch_load_result|
51
55
  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
56
+
57
+ records.each do |owner_record|
58
+ associated_id = owner_record.send(reflection.foreign_key)
59
+ next unless associated_id && !owner_record.instance_variable_defined?(records_variable_name)
60
+
61
+ foreign_type_fetcher = Object.const_get(
62
+ owner_record.send(reflection.foreign_type)
63
+ ).cached_model.cached_primary_index
64
+
65
+ associated_record = batch_load_result.fetch(foreign_type_fetcher).fetch(associated_id)
66
+ batch_records << owner_record
67
+ write(owner_record, associated_record)
58
68
  end
59
69
 
60
70
  yield batch_records
@@ -73,7 +83,7 @@ module IdentityCache
73
83
  ) do |associated_records_by_id|
74
84
  associated_records_by_id.each do |id, associated_record|
75
85
  owner_record = ids_to_owner_record.fetch(id)
76
- owner_record.instance_variable_set(records_variable_name, associated_record)
86
+ write(owner_record, associated_record)
77
87
  end
78
88
 
79
89
  yield associated_records_by_id.values.compact
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module IdentityCache
3
4
  module Cached
4
5
  module EmbeddedFetching
@@ -22,6 +23,7 @@ module IdentityCache
22
23
  cached_associations.each_value do |cached_association|
23
24
  records.each do |record|
24
25
  next unless (cached_record = cached_records_by_id[record.id])
26
+
25
27
  cached_value = cached_association.read(cached_record)
26
28
  cached_association.write(record, cached_value)
27
29
  end
@@ -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
@@ -9,11 +9,12 @@ module IdentityCache
9
9
  @model = model
10
10
  end
11
11
 
12
- def fetch(id)
12
+ def fetch(id, cache_fetcher_options)
13
13
  id = cast_id(id)
14
14
  return unless id
15
+
15
16
  record = if model.should_use_cache?
16
- object = CacheKeyLoader.load(self, id)
17
+ object = CacheKeyLoader.load(self, id, cache_fetcher_options)
17
18
  if object && object.id != id
18
19
  IdentityCache.logger.error(
19
20
  <<~MSG.squish
@@ -60,7 +61,6 @@ module IdentityCache
60
61
  def load_multi_from_db(ids)
61
62
  return {} if ids.empty?
62
63
 
63
- ids = ids.map { |id| model.connection.type_cast(id, id_column) }
64
64
  records = build_query(ids).to_a
65
65
  model.send(:setup_embedded_associations_on_miss, records)
66
66
  records.index_by(&:id)