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 +4 -4
- data/CHANGELOG.md +11 -1
- data/README.md +28 -5
- data/lib/identity_cache/cached/attribute.rb +47 -0
- data/lib/identity_cache/cached/attribute_by_multi.rb +94 -9
- data/lib/identity_cache/cached/attribute_by_one.rb +4 -40
- data/lib/identity_cache/query_api.rb +2 -0
- data/lib/identity_cache/version.rb +1 -1
- data/lib/identity_cache/with_primary_index.rb +13 -12
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0d167207235c4ac1deec2f1d6f383949b4c74031733c72ab700471b7f5a66407
|
4
|
+
data.tar.gz: 55e77933d0fbc095d1cad6ba038b2900edecb268504ff2238628828b10de9d39
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
##
|
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
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
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 |*
|
9
|
+
model.define_singleton_method(:"fetch_#{fetch_method_suffix}") do |*keys|
|
10
10
|
raise_if_scoped
|
11
|
-
cached_attribute.fetch(
|
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(
|
24
|
+
def cast_db_key(keys)
|
20
25
|
field_types.each_with_index do |type, i|
|
21
|
-
|
26
|
+
keys[i] = type.cast(keys[i])
|
22
27
|
end
|
23
|
-
|
28
|
+
keys
|
24
29
|
end
|
25
30
|
|
26
|
-
def unhashed_values_cache_key_string(
|
27
|
-
|
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(
|
31
|
-
Hash[key_fields.zip(
|
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)
|
@@ -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
|
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
|
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
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
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.
|
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-
|
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.
|
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,
|