identity_cache 1.3.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dedbf4998ab4c83ea5d6198af733c3165b9978bd509d9cb135aefd1c1979415d
4
- data.tar.gz: 03cda95c71ef97b89d2b321cbbf8737f12cb841a7eb89bc072419e3fb06b9b9d
3
+ metadata.gz: 0d167207235c4ac1deec2f1d6f383949b4c74031733c72ab700471b7f5a66407
4
+ data.tar.gz: 55e77933d0fbc095d1cad6ba038b2900edecb268504ff2238628828b10de9d39
5
5
  SHA512:
6
- metadata.gz: 9e81b1259c650b6043e890ea3cdcba24c8e27442ae1609868c82eb1ec2fc2cfb89aae26693bda3cd5489937e78ab959d2fc8c101528238ebdb02bd20832d1408
7
- data.tar.gz: f5bec7a26ff5d896915c6644d475f92429934f179e5d51e8eff9cfa491c117451d542ef6c745509bfead84b631df08ff8f9c8f5dba4b6076ee5b1baf789e94c6
6
+ metadata.gz: 270532b8a523b9f14f1d55c6a147d83844b4b4fb0b4b954d696bacc6de0437835dd3244126f136013450160de488e4c834211e86d9a2da8b191042fdaea63d93
7
+ data.tar.gz: 3bba5cd0734150975ef4b475c00f1c15d72c3a0d919dc84c6abd6b51ed7c9dc309dcb647d01212d6028f1c37a73a23cbab00daa3521a4aeaccbffcf9a01f3d09
data/CHANGELOG.md CHANGED
@@ -1,6 +1,16 @@
1
1
  # Identity Cache Changelog
2
2
 
3
- ## unreleased
3
+ ## Unreleased
4
+
5
+ ## 1.4.0
6
+
7
+ ### Features
8
+ - Add `fetch_multi_by` support for composite-key indexes. (#534)
9
+
10
+ ## 1.3.1
11
+
12
+ ### Fixes
13
+ - Remove N+1 queries from embedded associations when using `fetch` while `should_use_cache` is false. (#531)
4
14
 
5
15
  ## 1.3.0
6
16
 
data/README.md CHANGED
@@ -101,7 +101,15 @@ product = Product.fetch_by_handle(handle)
101
101
  # Fetch multiple products by providing an array of index values.
102
102
  products = Product.fetch_multi_by_handle(handles)
103
103
 
104
+ # Fetch a single product by providing composite attributes.
104
105
  products = Product.fetch_by_vendor_and_product_type(vendor, product_type)
106
+
107
+ # Fetch multiple products by providing an array of composite attributes.
108
+ products = Product.fetch_multi_by_vendor_and_product_type([
109
+ [vendor_1, product_type_1],
110
+ [vendor_2, product_type_2],
111
+ # ...
112
+ ])
105
113
  ```
106
114
 
107
115
  This gives you a lot of freedom to use your objects the way you want to, and doesn't get in your way. This does keep an independent cache copy in Memcached so you might want to watch the number of different caches that are being added.
@@ -254,11 +262,26 @@ Cache keys include a version number by default, specified in `IdentityCache::CAC
254
262
 
255
263
  ## Caveats
256
264
 
257
- A word of warning. If an `after_commit` fails before the cache expiry `after_commit` the cache will not be expired and you will be left with stale data.
258
-
259
- Since everything is being marshalled and unmarshalled from Memcached changing Ruby or Rails versions could mean your objects cannot be unmarshalled from Memcached. There are a number of ways to get around this such as namespacing keys when you upgrade or rescuing marshal load errors and treating it as a cache miss. Just something to be aware of if you are using IdentityCache and upgrade Ruby or Rails.
260
-
261
- IdentityCache is also very much _opt-in_ by deliberate design. This means IdentityCache does not mess with the way normal Rails associations work, and including it in a model won't change any clients of that model until you switch them to use `fetch` instead of `find`. This is because there is no way IdentityCache is ever going to be 100% consistent. Processes die, exceptions happen, and network blips occur, which means there is a chance that some database transaction might commit but the corresponding memcached cache invalidation operation does not make it. This means that you need to think carefully about when you use `fetch` and when you use `find`. For example, at Shopify, we never use any `fetch`ers on the path which moves money around, because IdentityCache could simply be wrong, and we want to charge people the right amount of money. We do however use the fetchers on performance critical paths where absolute correctness isn't the most important thing, and this is what IdentityCache is intended for.
265
+ IdentityCache is never going to be 100% consistent, since cache invalidations can be lost. As such, it was intentionally designed to be _opt-in_, so it is only used where cache inconsistency is tolerated. This means IdentityCache does not mess with the way normal Rails associations work, and including it in a model won't change any clients of that model until you switch them to use `fetch` instead of `find`. This means that you need to think carefully about when you use `fetch` and when you use `find`.
266
+
267
+ Expected sources of lost cache invalidations include:
268
+ * Database write performed that doesn't trigger an after_commit callback
269
+ * Process/system getting killed or crashing between the database commit and cache invalidation
270
+ * Network unavailability, including transient failures, preventing the delivery of the cache invalidation
271
+ * Memcached unavailability or failure preventing the processing of the cache invalidation request
272
+ * Memcached flush / restart could remove a cache invalidation that would normally interrupt a cache fill that started when the cache key was absent. E.g.
273
+ 1. cache key absent (not just invalidated)
274
+ 2. process 1 reads cache key
275
+ 3. process 1 starts reading from the database
276
+ 4. process 2 writes to the database
277
+ 5. process 2 writes a cache invalidation marker to cache key
278
+ 6. memcached flush
279
+ 7. process 1 uses an `ADD` operation, which succeeds in filling the cache with the now stale data
280
+ * Rollout of cache namespace changes (e.g. from upgrading IdentityCache, adding columns, cached associations or from application changes to IdentityCache.cache_namespace) can result in cache fills to the new namespace that aren't invalidated by cache invalidations from a process still using the old namespace
281
+
282
+ Cache expiration is meant to be used to help the system recover, but it only works if the application avoids using the cache data as a transaction to write data. IdentityCache avoids loading cached data from its methods during an open transaction, but can't prevent cache data that was loaded before the transaction was opened from being used in a transaction. IdentityCache won't help with scaling write traffic, it was intended for scaling database queries from read-only requests.
283
+
284
+ IdentityCache also caches the absence of database values (e.g. to avoid performance problems when it is destroyed), so lost cache invalidations can also result in that value continuing to remain absent. As such, avoid sending the id of an uncommitted database record to another process (e.g. queuing it to a background job), since that could result in an attempt to read the record by its id before it has been created. A cache invalidation will still be attempted when the record is created, but that could be lost.
262
285
 
263
286
  ## Notes
264
287
 
@@ -63,6 +63,48 @@ module IdentityCache
63
63
  unique ? results.first : results
64
64
  end
65
65
 
66
+ def fetch_multi(keys)
67
+ keys = keys.map { |key| cast_db_key(key) }
68
+
69
+ unless model.should_use_cache?
70
+ return load_multi_from_db(keys)
71
+ end
72
+
73
+ unordered_hash = CacheKeyLoader.load_multi(self, keys)
74
+
75
+ # Calling `values` on the result is expected to return the values in the same order as their
76
+ # corresponding keys. The fetch_multi_by_#{field_list} generated methods depend on this.
77
+ keys.each_with_object({}) do |key, ordered_hash|
78
+ ordered_hash[key] = unordered_hash.fetch(key)
79
+ end
80
+ end
81
+
82
+ def load_multi_from_db(keys)
83
+ result = {}
84
+ return result if keys.empty?
85
+
86
+ rows = load_multi_rows(keys)
87
+ default = unique ? nil : []
88
+ keys.each do |index_value|
89
+ result[index_value] = default.try!(:dup)
90
+ end
91
+ if unique
92
+ rows.each do |index_value, attribute_value|
93
+ result[index_value] = attribute_value
94
+ end
95
+ else
96
+ rows.each do |index_value, attribute_value|
97
+ result[index_value] << attribute_value
98
+ end
99
+ end
100
+ result
101
+ end
102
+
103
+ def cache_encode(db_value)
104
+ db_value
105
+ end
106
+ alias_method :cache_decode, :cache_encode
107
+
66
108
  private
67
109
 
68
110
  # @abstract
@@ -80,6 +122,11 @@ module IdentityCache
80
122
  raise NotImplementedError
81
123
  end
82
124
 
125
+ # @abstract
126
+ def load_multi_rows(_index_keys)
127
+ raise NotImplementedError
128
+ end
129
+
83
130
  # @abstract
84
131
  def cache_key_from_key_values(_key_values)
85
132
  raise NotImplementedError
@@ -6,9 +6,14 @@ module IdentityCache
6
6
  def build
7
7
  cached_attribute = self
8
8
 
9
- model.define_singleton_method(:"fetch_#{fetch_method_suffix}") do |*key_values|
9
+ model.define_singleton_method(:"fetch_#{fetch_method_suffix}") do |*keys|
10
10
  raise_if_scoped
11
- cached_attribute.fetch(key_values)
11
+ cached_attribute.fetch(keys)
12
+ end
13
+
14
+ model.define_singleton_method(:"fetch_multi_#{fetch_method_suffix}") do |keys|
15
+ raise_if_scoped
16
+ cached_attribute.fetch_multi(keys)
12
17
  end
13
18
  end
14
19
 
@@ -16,22 +21,102 @@ module IdentityCache
16
21
 
17
22
  # Attribute method overrides
18
23
 
19
- def cast_db_key(key_values)
24
+ def cast_db_key(keys)
20
25
  field_types.each_with_index do |type, i|
21
- key_values[i] = type.cast(key_values[i])
26
+ keys[i] = type.cast(keys[i])
22
27
  end
23
- key_values
28
+ keys
24
29
  end
25
30
 
26
- def unhashed_values_cache_key_string(key_values)
27
- key_values.map { |v| v.try!(:to_s).inspect }.join("/")
31
+ def unhashed_values_cache_key_string(keys)
32
+ keys.map { |v| v.try!(:to_s).inspect }.join("/")
28
33
  end
29
34
 
30
- def load_from_db_where_conditions(key_values)
31
- Hash[key_fields.zip(key_values)]
35
+ def load_from_db_where_conditions(keys)
36
+ Hash[key_fields.zip(keys)]
37
+ end
38
+
39
+ def load_multi_rows(keys)
40
+ query = load_multi_rows_query(keys)
41
+ fields = key_fields
42
+ if (attribute_index = key_fields.index(attribute))
43
+ fields = fields.dup
44
+ fields.delete(attribute)
45
+ end
46
+
47
+ query.pluck(attribute, *fields).map do |attribute, *key_values|
48
+ key_values.insert(attribute_index, attribute) if attribute_index
49
+ [key_values, attribute]
50
+ end
32
51
  end
33
52
 
34
53
  alias_method :cache_key_from_key_values, :cache_key
54
+
55
+ # Helper methods
56
+
57
+ def load_multi_rows_query(keys)
58
+ # Find fields with a common value for the below common_query optimization
59
+ common_conditions = {}
60
+ other_field_indexes = []
61
+ key_fields.each_with_index do |field, i|
62
+ first_value = keys.first[i]
63
+ is_unique = keys.all? { |key_values| first_value == key_values[i] }
64
+
65
+ if is_unique
66
+ common_conditions[field] = first_value
67
+ else
68
+ other_field_indexes << i
69
+ end
70
+ end
71
+
72
+ common_query = if common_conditions.any?
73
+ # Optimization for the case of fields in which the key being searched
74
+ # for is always the same. This results in simple equality conditions
75
+ # being produced for these fields (e.g. "WHERE field = value").
76
+ unsorted_model.where(common_conditions)
77
+ end
78
+
79
+ case other_field_indexes.size
80
+ when 0
81
+ common_query
82
+ when 1
83
+ # Micro-optimization for the case of a single unique field.
84
+ # This results in a single "WHERE field IN (values)" statement being
85
+ # produced from a single query.
86
+ field_idx = other_field_indexes.first
87
+ field_name = key_fields[i]
88
+ field_values = keys.map { |key| key[field_idx] }
89
+ (common_query || unsorted_model).where(field_name => field_values)
90
+ else
91
+ # More than one unique field, so we need to generate a query for each
92
+ # set of values for each unique field.
93
+ #
94
+ # This results in multiple
95
+ # "WHERE field = value AND field_2 = value_2 OR ..."
96
+ # statements being produced from an object like
97
+ # [{ field: value, field_2: value_2 }, ...]
98
+ query = keys.reduce(nil) do |query, key|
99
+ condition = {}
100
+ other_field_indexes.each do |field_idx|
101
+ field = key_fields[field_idx]
102
+ condition[field] = key[field_idx]
103
+ end
104
+ subquery = unsorted_model.where(condition)
105
+
106
+ query ? query.or(subquery) : subquery
107
+ end
108
+
109
+ if common_query
110
+ common_query.merge(query)
111
+ else
112
+ query
113
+ end
114
+ end
115
+ end
116
+
117
+ def unsorted_model
118
+ model.reorder(nil)
119
+ end
35
120
  end
36
121
  end
37
122
  end
@@ -24,46 +24,6 @@ module IdentityCache
24
24
  end
25
25
  end
26
26
 
27
- def fetch_multi(keys)
28
- keys = keys.map { |key| cast_db_key(key) }
29
-
30
- unless model.should_use_cache?
31
- return load_multi_from_db(keys)
32
- end
33
-
34
- unordered_hash = CacheKeyLoader.load_multi(self, keys)
35
-
36
- # Calling `values` on the result is expected to return the values in the same order as their
37
- # corresponding keys. The fetch_multi_by_#{field_list} generated methods depend on this.
38
- ordered_hash = {}
39
- keys.each { |key| ordered_hash[key] = unordered_hash.fetch(key) }
40
- ordered_hash
41
- end
42
-
43
- def load_multi_from_db(keys)
44
- rows = model.reorder(nil).where(load_from_db_where_conditions(keys)).pluck(key_field, attribute)
45
- result = {}
46
- default = unique ? nil : []
47
- keys.each do |index_value|
48
- result[index_value] = default.try!(:dup)
49
- end
50
- if unique
51
- rows.each do |index_value, attribute_value|
52
- result[index_value] = attribute_value
53
- end
54
- else
55
- rows.each do |index_value, attribute_value|
56
- result[index_value] << attribute_value
57
- end
58
- end
59
- result
60
- end
61
-
62
- def cache_encode(db_value)
63
- db_value
64
- end
65
- alias_method :cache_decode, :cache_encode
66
-
67
27
  private
68
28
 
69
29
  # Attribute method overrides
@@ -80,6 +40,10 @@ module IdentityCache
80
40
  { key_field => key_values }
81
41
  end
82
42
 
43
+ def load_multi_rows(keys)
44
+ model.reorder(nil).where(load_from_db_where_conditions(keys)).pluck(key_field, attribute)
45
+ end
46
+
83
47
  def cache_key_from_key_values(key_values)
84
48
  cache_key(key_values.first)
85
49
  end
@@ -70,6 +70,8 @@ module IdentityCache
70
70
  readonly: IdentityCache.fetch_read_only_records && should_use_cache?)
71
71
  return if records.empty?
72
72
 
73
+ return unless should_use_cache?
74
+
73
75
  records.each(&:readonly!) if readonly
74
76
  each_id_embedded_association do |cached_association|
75
77
  preload_id_embedded_association(records, cached_association)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IdentityCache
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.0"
5
5
  CACHE_VERSION = 8
6
6
  end
@@ -35,8 +35,8 @@ module IdentityCache
35
35
  # Declares a new index in the cache for the class where IdentityCache was
36
36
  # included.
37
37
  #
38
- # IdentityCache will add a fetch_by_field1_and_field2_and_...field for every
39
- # index.
38
+ # IdentityCache will add a fetch_by_field1_and_field2_and_...field and
39
+ # fetch_multi_by_field1_and_field2_and_...field for every index.
40
40
  #
41
41
  # == Example:
42
42
  #
@@ -45,7 +45,10 @@ module IdentityCache
45
45
  # cache_index :name, :vendor
46
46
  # end
47
47
  #
48
- # Will add Product.fetch_by_name_and_vendor
48
+ # Will add:
49
+ #
50
+ # Product.fetch_by_name_and_vendor
51
+ # Product.fetch_multi_by_name_and_vendor
49
52
  #
50
53
  # == Parameters
51
54
  #
@@ -82,15 +85,13 @@ module IdentityCache
82
85
  CODE
83
86
  end
84
87
 
85
- if fields.length == 1
86
- instance_eval(<<-CODE, __FILE__, __LINE__ + 1)
87
- def fetch_multi_by_#{field_list}(index_values, includes: nil)
88
- ids = fetch_multi_id_by_#{field_list}(index_values).values.flatten(1)
89
- return ids if ids.empty?
90
- fetch_multi(ids, includes: includes)
91
- end
92
- CODE
93
- end
88
+ instance_eval(<<-CODE, __FILE__, __LINE__ + 1)
89
+ def fetch_multi_by_#{field_list}(index_values, includes: nil)
90
+ ids = fetch_multi_id_by_#{field_list}(index_values).values.flatten(1)
91
+ return ids if ids.empty?
92
+ fetch_multi(ids, includes: includes)
93
+ end
94
+ CODE
94
95
  end
95
96
 
96
97
  # Similar to ActiveRecord::Base#exists? will return true if the id can be
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: identity_cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Camilo Lopez
@@ -14,7 +14,7 @@ authors:
14
14
  autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
- date: 2023-01-16 00:00:00.000000000 Z
17
+ date: 2023-04-06 00:00:00.000000000 Z
18
18
  dependencies:
19
19
  - !ruby/object:Gem::Dependency
20
20
  name: activerecord
@@ -190,7 +190,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
190
190
  - !ruby/object:Gem::Version
191
191
  version: '0'
192
192
  requirements: []
193
- rubygems_version: 3.3.3
193
+ rubygems_version: 3.4.10
194
194
  signing_key:
195
195
  specification_version: 4
196
196
  summary: IdentityCache lets you specify how you want to cache your model objects,