support_table_cache 1.1.5 → 1.1.6
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +10 -0
- data/VERSION +1 -1
- data/lib/support_table_cache/associations.rb +6 -2
- data/lib/support_table_cache/fiber_locals.rb +21 -32
- data/lib/support_table_cache/find_by_override.rb +23 -27
- data/lib/support_table_cache/memory_cache.rb +61 -8
- data/lib/support_table_cache/relation_override.rb +48 -28
- data/lib/support_table_cache.rb +113 -9
- data/support_table_cache.gemspec +3 -2
- metadata +2 -17
- data/AGENTS.md +0 -74
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e6656d6580218249c30dbfb1f21213b1f0cd17c43c7ec81bca00a376edd1a38
|
|
4
|
+
data.tar.gz: 63ead57c3891ca076810d70cd64b81073bed609468ac6b4e865d47cd2ee61031
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6dfa8b6ff0474608194dc1694ea452cdc51aaf6b05d528d6c86dcee927e08299d7d95bcacf3b7bf1f6a9e39971a0181b0290f4d3a72f5750d7f314ddf5354ef9
|
|
7
|
+
data.tar.gz: 94b88ef70a916052633d1c7e61d047fc70b1de9c2f111c247747a9e99ddeda47295c96f3214e264fd5975b09d8fb75170a941ee2f6dba27bdcda41f587c86fbf
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 1.1.6
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Queries on relations with conditions that cannot be represented in the cache key (SQL string conditions, ranges, `not`, `or`, joins, `group`, `having`, `from`, `offset`, locking, or non-hash `find_by` arguments) now bypass the cache instead of silently ignoring those conditions and returning or caching the wrong record.
|
|
12
|
+
- `create_with` values are no longer included in cache keys, so queries using `create_with` can now be cached correctly.
|
|
13
|
+
- Queries inside an open transaction now bypass the cache so that uncommitted data can never be cached. Previously a rolled back transaction could permanently poison the cache with data that was never committed. Transactions created with `joinable: false` (such as Rails transactional test fixtures) still use the cache.
|
|
14
|
+
- Cache invalidation now runs even when caching is disabled. Previously records changed inside a `disable` block (or while caching was disabled globally) left stale entries behind for when caching was re-enabled.
|
|
15
|
+
- Cache key values are now cast through the attribute type, so equivalent values (e.g. `:one` and `"one"`, or `"5"` and `5`) produce the same cache key. Previously such entries could be written under keys that the invalidation callbacks could never delete. This also fixes cache keys for `false` attribute values, which were previously indistinguishable from `nil`.
|
|
16
|
+
- `load_cache` no longer raises an error when caching is disabled and now honors `where` conditions on `cache_by` configurations instead of caching records that do not match them. It also refreshes existing cache entries instead of skipping them.
|
|
17
|
+
- A `cache_by` configuration whose `where` clause does not match a query no longer prevents later configurations from matching, and no longer mutates the query attributes while matching.
|
|
18
|
+
- Calling `cache_by` in a subclass no longer mutates the superclass's cache configuration.
|
|
19
|
+
- Models that include `SupportTableCache` without calling `cache_by` no longer raise an error on `find_by`.
|
|
20
|
+
- Calling `cache_belongs_to` more than once for the same association no longer causes infinite recursion when reading the association.
|
|
21
|
+
- `SupportTableCache::MemoryCache` now synchronizes all access to the underlying hash (previously reads, deletes, and clears were unsynchronized), purges expired entries, and no longer serializes values twice on a cache miss. A `fetch` that races with a concurrent `delete` or `clear` no longer stores its stale value back in the cache.
|
|
22
|
+
- The `where` clause on a `cache_by` configuration is now matched against query attributes using values cast through the attribute type, so equivalent values (e.g. `1` and `"1"`) match the same way they do when building cache keys.
|
|
23
|
+
- A `find_by` that specifies a different value for an attribute than the relation it is chained onto (i.e. `where(name: "Two").find_by(name: "One")`) now bypasses the cache. Previously the `find_by` value replaced the relation's value in the cache key so a cached record excluded by the relation could be returned.
|
|
24
|
+
- `fetch_by` and `fetch_by!` now raise an `ArgumentError` if a query cannot be matched to a cache key because it does not satisfy the `where` condition on a `cache_by` configuration or because it conflicts with the relation it is chained onto. Previously these queries silently fell back to the database, which defeats the purpose of the safety check. Queries on a model with a default scope matching a `cache_by` `where` condition no longer raise an error.
|
|
25
|
+
- `SupportTableCache::FiberLocals` now stores state in the fiber's native local storage so that state cannot leak from fibers that are garbage collected while suspended inside a block.
|
|
26
|
+
|
|
7
27
|
## 1.1.5
|
|
8
28
|
|
|
9
29
|
### Fixed
|
data/README.md
CHANGED
|
@@ -89,6 +89,12 @@ You can also set a cache per class. For instance, you can set an in-memory cache
|
|
|
89
89
|
|
|
90
90
|
Note that in-memory caches exist separately within each process and will not be cleared when records are changed in the database. The only way to refresh elements in an in-memory cache is to restart the process or set the `support_table_cache_ttl` value so that the entries will expire.
|
|
91
91
|
|
|
92
|
+
It is a good idea to always set `support_table_cache_ttl`, even when using a shared cache store. Cache invalidation happens when a record's changes are committed, but a process reading the old row at the same moment can still write the stale value back to the cache just after it was invalidated. A TTL puts an upper bound on how long such a stale entry can survive.
|
|
93
|
+
|
|
94
|
+
The global cache and disabled settings (`SupportTableCache.cache=` and `SupportTableCache.disable` without a block) are intended to be set during application initialization and are not synchronized for concurrent modification at runtime.
|
|
95
|
+
|
|
96
|
+
Queries made inside an open database transaction always bypass the cache. This prevents uncommitted data from being cached (which would never be cleared if the transaction were rolled back). Transactions created with `joinable: false`, such as the ones wrapping Rails transactional test fixtures, do not bypass the cache.
|
|
97
|
+
|
|
92
98
|
### Disabling Caching
|
|
93
99
|
|
|
94
100
|
You can disable the cache within a block either globally or only for a specific class. If the cache is disabled, then all queries will pass through to the database.
|
|
@@ -108,6 +114,10 @@ SupportTableCache.enable do
|
|
|
108
114
|
end
|
|
109
115
|
```
|
|
110
116
|
|
|
117
|
+
Cache entries are still cleared when records are changed while caching is disabled, so re-enabling the cache will not expose stale data.
|
|
118
|
+
|
|
119
|
+
Note that the class-level `disable_cache` and `enable_cache` settings apply only to the class they are called on; they do not apply to subclasses in a single table inheritance hierarchy.
|
|
120
|
+
|
|
111
121
|
### Caching Belongs to Associations
|
|
112
122
|
|
|
113
123
|
You can also cache belongs to associations to cacheable models.
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.1.
|
|
1
|
+
1.1.6
|
|
@@ -41,7 +41,9 @@ module SupportTableCache
|
|
|
41
41
|
key = [reflection.class_name, {reflection.association_primary_key => foreign_key}]
|
|
42
42
|
cache = reflection.klass.send(:current_support_table_cache)
|
|
43
43
|
ttl = reflection.klass.send(:support_table_cache_ttl)
|
|
44
|
-
|
|
44
|
+
# Queries inside a transaction could see uncommitted data that would be invalid
|
|
45
|
+
# if the transaction is rolled back, so they cannot be cached.
|
|
46
|
+
if cache && !SupportTableCache.open_transaction?(reflection.klass)
|
|
45
47
|
cache.fetch(key, expires_in: ttl) do
|
|
46
48
|
#{association_name}_without_cache
|
|
47
49
|
end
|
|
@@ -50,7 +52,9 @@ module SupportTableCache
|
|
|
50
52
|
end
|
|
51
53
|
end
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
unless method_defined?(:#{association_name}_without_cache) || private_method_defined?(:#{association_name}_without_cache)
|
|
56
|
+
alias_method :#{association_name}_without_cache, :#{association_name}
|
|
57
|
+
end
|
|
54
58
|
alias_method :#{association_name}, :#{association_name}_with_cache
|
|
55
59
|
RUBY
|
|
56
60
|
end
|
|
@@ -1,51 +1,40 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SupportTableCache
|
|
4
|
-
# Utility class for managing fiber-local variables.
|
|
5
|
-
#
|
|
4
|
+
# Utility class for managing fiber-local variables. All values are stored in a single
|
|
5
|
+
# hash inside the fiber's native local storage (Thread.current[], which is fiber-local
|
|
6
|
+
# in Ruby) so the fiber-local namespace is not polluted with individual keys. Because
|
|
7
|
+
# the state lives on the fiber itself, it is garbage collected along with the fiber
|
|
8
|
+
# and cannot leak or be picked up by another fiber.
|
|
6
9
|
class FiberLocals
|
|
7
10
|
def initialize
|
|
8
|
-
@
|
|
9
|
-
@locals = {}
|
|
11
|
+
@locals_key = :"support_table_cache_locals_#{object_id}"
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def [](key)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
fiber_locals = @locals[Fiber.current.object_id]
|
|
16
|
-
end
|
|
17
|
-
return nil if fiber_locals.nil?
|
|
18
|
-
|
|
19
|
-
fiber_locals[key]
|
|
15
|
+
locals = Thread.current[@locals_key]
|
|
16
|
+
locals[key] if locals
|
|
20
17
|
end
|
|
21
18
|
|
|
22
19
|
def with(key, value)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
begin
|
|
29
|
-
@mutex.synchronize do
|
|
30
|
-
fiber_locals = @locals[fiber_id]
|
|
31
|
-
if fiber_locals.nil?
|
|
32
|
-
fiber_locals = {}
|
|
33
|
-
@locals[fiber_id] = fiber_locals
|
|
34
|
-
inited_vars = true
|
|
35
|
-
end
|
|
36
|
-
end
|
|
20
|
+
locals = Thread.current[@locals_key]
|
|
21
|
+
if locals.nil?
|
|
22
|
+
locals = {}
|
|
23
|
+
Thread.current[@locals_key] = locals
|
|
24
|
+
end
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
|
|
26
|
+
exists = locals.key?(key)
|
|
27
|
+
previous_value = locals[key]
|
|
28
|
+
locals[key] = value
|
|
40
29
|
|
|
30
|
+
begin
|
|
41
31
|
yield
|
|
42
32
|
ensure
|
|
43
|
-
if
|
|
44
|
-
|
|
45
|
-
@locals.delete(fiber_id)
|
|
46
|
-
end
|
|
33
|
+
if exists
|
|
34
|
+
locals[key] = previous_value
|
|
47
35
|
else
|
|
48
|
-
|
|
36
|
+
locals.delete(key)
|
|
37
|
+
Thread.current[@locals_key] = nil if locals.empty?
|
|
49
38
|
end
|
|
50
39
|
end
|
|
51
40
|
end
|
|
@@ -8,26 +8,18 @@ module SupportTableCache
|
|
|
8
8
|
cache = current_support_table_cache
|
|
9
9
|
return super unless cache
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# Only queries by simple attribute equality can be matched against cache keys.
|
|
12
|
+
return super unless args.size == 1 && args.first.is_a?(Hash)
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
# If the class has any scope applied (a default scope or a scoping block), defer to
|
|
15
|
+
# the relation override, which checks whether the scoped query can be cached.
|
|
16
|
+
return super if all.values.present?
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
else
|
|
24
|
-
return super
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
cache_key = SupportTableCache.cache_key(self, attributes, attribute_names, case_sensitive)
|
|
28
|
-
break if cache_key
|
|
29
|
-
end
|
|
30
|
-
end
|
|
18
|
+
# Queries inside a transaction could see uncommitted data that would be invalid
|
|
19
|
+
# if the transaction is rolled back, so they cannot be cached.
|
|
20
|
+
return super if SupportTableCache.open_transaction?(self)
|
|
21
|
+
|
|
22
|
+
cache_key = SupportTableCache.cache_key_for_query(self, args.first.stringify_keys)
|
|
31
23
|
|
|
32
24
|
if cache_key
|
|
33
25
|
cache.fetch(cache_key, expires_in: support_table_cache_ttl) { super }
|
|
@@ -42,9 +34,13 @@ module SupportTableCache
|
|
|
42
34
|
# @return [ActiveRecord::Base, nil] The found record or nil if not found.
|
|
43
35
|
# @raise [ArgumentError] if the query cannot use the cache.
|
|
44
36
|
def fetch_by(attributes)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
attributes = (attributes || {}).stringify_keys
|
|
38
|
+
query_attributes = support_table_query_attributes(attributes)
|
|
39
|
+
unless SupportTableCache.cacheable_query?(self, query_attributes)
|
|
40
|
+
raise ArgumentError.new("#{name} does not cache queries by #{(query_attributes || attributes).keys.sort.to_sentence}")
|
|
41
|
+
end
|
|
42
|
+
unless all.send(:support_table_cacheable_scope?)
|
|
43
|
+
raise ArgumentError.new("#{name} cannot use the support table cache on an uncacheable scope")
|
|
48
44
|
end
|
|
49
45
|
find_by(attributes)
|
|
50
46
|
end
|
|
@@ -65,12 +61,12 @@ module SupportTableCache
|
|
|
65
61
|
|
|
66
62
|
private
|
|
67
63
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
64
|
+
# The attributes a query on this class is filtering on, including any conditions from a
|
|
65
|
+
# default scope. Returns nil if the default scope conditions conflict with the passed in
|
|
66
|
+
# attributes. Note that the where clause is used rather than `scope_attributes` since that
|
|
67
|
+
# method also includes `create_with` values, which are not part of the query.
|
|
68
|
+
def support_table_query_attributes(attributes)
|
|
69
|
+
SupportTableCache.merge_query_attributes(self, all.where_values_hash.stringify_keys, attributes)
|
|
74
70
|
end
|
|
75
71
|
end
|
|
76
72
|
end
|
|
@@ -14,6 +14,10 @@ module SupportTableCache
|
|
|
14
14
|
def initialize
|
|
15
15
|
@cache = {}
|
|
16
16
|
@mutex = Mutex.new
|
|
17
|
+
# Maps a cache key to the tokens of the fetches currently generating a value for it.
|
|
18
|
+
# Invalidating a key drops its tokens so that those fetches will not store the stale
|
|
19
|
+
# value they generated. Only keys with a fetch in flight are tracked.
|
|
20
|
+
@pending = {}
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
# Fetch a value from the cache. If the key is not found or has expired, yields to get a new value.
|
|
@@ -23,13 +27,49 @@ module SupportTableCache
|
|
|
23
27
|
# @yield Block to execute to get a new value if the key is not cached.
|
|
24
28
|
# @return [Object, nil] The cached value or the result of the block, or nil if no value is found.
|
|
25
29
|
def fetch(key, expires_in: nil)
|
|
26
|
-
serialized_value
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
serialized_value = nil
|
|
31
|
+
token = nil
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
cached_value, cached_expire_at = @cache[key]
|
|
34
|
+
if cached_expire_at && cached_expire_at < Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
@cache.delete(key)
|
|
36
|
+
else
|
|
37
|
+
serialized_value = cached_value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if serialized_value.nil? && block_given?
|
|
41
|
+
token = Object.new
|
|
42
|
+
(@pending[key] ||= []) << token
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if serialized_value.nil?
|
|
47
|
+
begin
|
|
48
|
+
value = yield if block_given?
|
|
49
|
+
return nil if value.nil?
|
|
50
|
+
|
|
51
|
+
serialized_value = Marshal.dump(value)
|
|
52
|
+
# The expiration is always recalculated from the expires_in argument so that replacing
|
|
53
|
+
# an expired entry without an expiration does not carry over the old expiration time.
|
|
54
|
+
expire_at = (Process.clock_gettime(Process::CLOCK_MONOTONIC) + expires_in if expires_in)
|
|
55
|
+
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
# Only store the value if this key was not invalidated while the value was being
|
|
58
|
+
# generated. Otherwise a record deleted or overwritten by a concurrent update could
|
|
59
|
+
# be resurrected in the cache with stale data.
|
|
60
|
+
@cache[key] = [serialized_value, expire_at] if @pending[key]&.include?(token)
|
|
61
|
+
end
|
|
62
|
+
ensure
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
tokens = @pending[key]
|
|
65
|
+
if tokens
|
|
66
|
+
tokens.delete(token)
|
|
67
|
+
@pending.delete(key) if tokens.empty?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
32
71
|
end
|
|
72
|
+
|
|
33
73
|
Marshal.load(serialized_value)
|
|
34
74
|
end
|
|
35
75
|
|
|
@@ -57,8 +97,13 @@ module SupportTableCache
|
|
|
57
97
|
serialized_value = Marshal.dump(value)
|
|
58
98
|
|
|
59
99
|
@mutex.synchronize do
|
|
100
|
+
# Discard any fetch generating a value for this key so that it cannot overwrite the
|
|
101
|
+
# newer value being written here.
|
|
102
|
+
@pending.delete(key)
|
|
60
103
|
@cache[key] = [serialized_value, expire_at]
|
|
61
104
|
end
|
|
105
|
+
|
|
106
|
+
nil
|
|
62
107
|
end
|
|
63
108
|
|
|
64
109
|
# Delete a value from the cache.
|
|
@@ -66,14 +111,22 @@ module SupportTableCache
|
|
|
66
111
|
# @param key [Object] The cache key.
|
|
67
112
|
# @return [void]
|
|
68
113
|
def delete(key)
|
|
69
|
-
@
|
|
114
|
+
@mutex.synchronize do
|
|
115
|
+
@pending.delete(key)
|
|
116
|
+
@cache.delete(key)
|
|
117
|
+
end
|
|
118
|
+
nil
|
|
70
119
|
end
|
|
71
120
|
|
|
72
121
|
# Clear all values from the cache.
|
|
73
122
|
#
|
|
74
123
|
# @return [void]
|
|
75
124
|
def clear
|
|
76
|
-
@
|
|
125
|
+
@mutex.synchronize do
|
|
126
|
+
@pending.clear
|
|
127
|
+
@cache.clear
|
|
128
|
+
end
|
|
129
|
+
nil
|
|
77
130
|
end
|
|
78
131
|
end
|
|
79
132
|
end
|
|
@@ -19,27 +19,22 @@ module SupportTableCache
|
|
|
19
19
|
|
|
20
20
|
return super if select_values.present?
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
# Only queries by simple attribute equality can be matched against cache keys.
|
|
23
|
+
return super unless args.size == 1 && args.first.is_a?(Hash)
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
if scope_attributes.present?
|
|
27
|
-
attributes = scope_attributes.stringify_keys.merge(attributes)
|
|
28
|
-
end
|
|
25
|
+
return super unless support_table_cacheable_scope?
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
end
|
|
42
|
-
end
|
|
27
|
+
# Queries inside a transaction could see uncommitted data that would be invalid
|
|
28
|
+
# if the transaction is rolled back, so they cannot be cached.
|
|
29
|
+
return super if SupportTableCache.open_transaction?(klass)
|
|
30
|
+
|
|
31
|
+
# Apply any conditions from the current relation chain. This returns nil if the relation
|
|
32
|
+
# and the find_by arguments specify different values for the same attribute since both
|
|
33
|
+
# conditions have to be applied by the database in that case.
|
|
34
|
+
attributes = support_table_query_attributes(args.first)
|
|
35
|
+
return super if attributes.nil?
|
|
36
|
+
|
|
37
|
+
cache_key = SupportTableCache.cache_key_for_query(klass, attributes)
|
|
43
38
|
|
|
44
39
|
if cache_key
|
|
45
40
|
cache.fetch(cache_key, expires_in: support_table_cache_ttl) { super }
|
|
@@ -67,9 +62,16 @@ module SupportTableCache
|
|
|
67
62
|
# @return [ActiveRecord::Base, nil] The found record or nil if not found.
|
|
68
63
|
# @raise [ArgumentError] if the query cannot use the cache.
|
|
69
64
|
def fetch_by(attributes)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
attributes = (attributes || {}).stringify_keys
|
|
66
|
+
query_attributes = support_table_query_attributes(attributes)
|
|
67
|
+
unless SupportTableCache.cacheable_query?(klass, query_attributes)
|
|
68
|
+
raise ArgumentError.new("#{klass.name} does not cache queries by #{(query_attributes || attributes).keys.sort.to_sentence}")
|
|
69
|
+
end
|
|
70
|
+
unless support_table_cacheable_scope?
|
|
71
|
+
raise ArgumentError.new("#{klass.name} cannot cache queries on a relation with conditions that cannot be represented in the cache key")
|
|
72
|
+
end
|
|
73
|
+
if SupportTableCache.open_transaction?(klass)
|
|
74
|
+
raise ArgumentError.new("#{klass.name} cannot cache queries inside a transaction")
|
|
73
75
|
end
|
|
74
76
|
find_by(attributes)
|
|
75
77
|
end
|
|
@@ -90,12 +92,30 @@ module SupportTableCache
|
|
|
90
92
|
|
|
91
93
|
private
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
# A relation can only be cached if all of its conditions are simple equality conditions
|
|
96
|
+
# on the model's own attributes that can be represented in a cache key. Anything else
|
|
97
|
+
# (SQL string conditions, ranges, OR clauses, joins, etc.) is not visible in
|
|
98
|
+
# where_values_hash and could silently change which record the query returns.
|
|
99
|
+
def support_table_cacheable_scope?
|
|
100
|
+
# WhereClause#predicates is not part of the public Rails API (its visibility has varied
|
|
101
|
+
# across Rails versions), but there is no public way to detect conditions that cannot
|
|
102
|
+
# be represented in where_values_hash. If the method is ever removed in a future Rails
|
|
103
|
+
# version, fail safe by bypassing the cache rather than raising. There is a spec
|
|
104
|
+
# asserting the method exists so an incompatible Rails upgrade fails explicitly.
|
|
105
|
+
return false unless where_clause.respond_to?(:predicates, true)
|
|
106
|
+
return false unless where_clause.send(:predicates).size == where_values_hash.size
|
|
107
|
+
return false if joins_values.present? || left_outer_joins_values.present?
|
|
108
|
+
return false if group_values.present? || !having_clause.empty?
|
|
109
|
+
return false if !from_clause.empty? || offset_value.present? || lock_value
|
|
110
|
+
return false if eager_loading?
|
|
111
|
+
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The attributes a query on this relation is filtering on. Returns nil if the relation
|
|
116
|
+
# conditions conflict with the passed in attributes.
|
|
117
|
+
def support_table_query_attributes(attributes)
|
|
118
|
+
SupportTableCache.merge_query_attributes(klass, where_values_hash.stringify_keys, (attributes || {}).stringify_keys)
|
|
99
119
|
end
|
|
100
120
|
end
|
|
101
121
|
end
|
data/lib/support_table_cache.rb
CHANGED
|
@@ -28,6 +28,8 @@ module SupportTableCache
|
|
|
28
28
|
# @api private
|
|
29
29
|
class_attribute :support_table_cache_impl, instance_accessor: false
|
|
30
30
|
|
|
31
|
+
self.support_table_cache_by_attributes = []
|
|
32
|
+
|
|
31
33
|
unless ActiveRecord::Relation.include?(RelationOverride)
|
|
32
34
|
ActiveRecord::Relation.prepend(RelationOverride)
|
|
33
35
|
end
|
|
@@ -68,13 +70,17 @@ module SupportTableCache
|
|
|
68
70
|
# @return [void]
|
|
69
71
|
def load_cache
|
|
70
72
|
cache = current_support_table_cache
|
|
71
|
-
return
|
|
73
|
+
return if cache.nil?
|
|
72
74
|
|
|
73
75
|
find_each do |record|
|
|
74
|
-
support_table_cache_by_attributes.each do |attribute_names, case_sensitive|
|
|
76
|
+
support_table_cache_by_attributes.each do |attribute_names, case_sensitive, where|
|
|
77
|
+
next unless where.nil? || where.all? { |name, value| record[name] == type_for_attribute(name).cast(value) }
|
|
78
|
+
|
|
75
79
|
attributes = record.attributes.slice(*attribute_names)
|
|
76
80
|
cache_key = SupportTableCache.cache_key(self, attributes, attribute_names, case_sensitive)
|
|
77
|
-
|
|
81
|
+
next if cache_key.nil?
|
|
82
|
+
|
|
83
|
+
cache.write(cache_key, record, expires_in: support_table_cache_ttl)
|
|
78
84
|
end
|
|
79
85
|
end
|
|
80
86
|
end
|
|
@@ -120,9 +126,10 @@ module SupportTableCache
|
|
|
120
126
|
where = where.stringify_keys
|
|
121
127
|
end
|
|
122
128
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
# Build a new array rather than mutating the existing one since the class attribute
|
|
130
|
+
# value can be shared with the superclass.
|
|
131
|
+
existing = (support_table_cache_by_attributes || []).reject { |data| data.first == attributes }
|
|
132
|
+
self.support_table_cache_by_attributes = existing + [[attributes, case_sensitive, where]]
|
|
126
133
|
end
|
|
127
134
|
|
|
128
135
|
private
|
|
@@ -138,6 +145,13 @@ module SupportTableCache
|
|
|
138
145
|
|
|
139
146
|
def current_support_table_cache
|
|
140
147
|
return nil if support_table_cache_disabled?
|
|
148
|
+
support_table_cache_for_invalidation
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The cache used when removing entries. Invalidation must ignore the disabled flag;
|
|
152
|
+
# otherwise records changed while caching is disabled would leave stale entries behind
|
|
153
|
+
# for when caching is re-enabled.
|
|
154
|
+
def support_table_cache_for_invalidation
|
|
141
155
|
SupportTableCache.testing_cache || support_table_cache_impl || SupportTableCache.cache
|
|
142
156
|
end
|
|
143
157
|
end
|
|
@@ -241,7 +255,11 @@ module SupportTableCache
|
|
|
241
255
|
|
|
242
256
|
sorted_attributes = {}
|
|
243
257
|
sorted_names.each do |attribute_name|
|
|
244
|
-
value = (attributes[attribute_name]
|
|
258
|
+
value = (attributes.key?(attribute_name) ? attributes[attribute_name] : attributes[attribute_name.to_sym])
|
|
259
|
+
# Cast the value through the attribute type so that equivalent values (e.g. a symbol
|
|
260
|
+
# and a string, or "5" and 5) always produce the same cache key. Otherwise entries
|
|
261
|
+
# could be written under keys that the invalidation callbacks can never delete.
|
|
262
|
+
value = klass.type_for_attribute(attribute_name).cast(value)
|
|
245
263
|
if !case_sensitive && (value.is_a?(String) || value.is_a?(Symbol))
|
|
246
264
|
value = value.to_s.downcase
|
|
247
265
|
end
|
|
@@ -251,6 +269,92 @@ module SupportTableCache
|
|
|
251
269
|
[klass.name, sorted_attributes]
|
|
252
270
|
end
|
|
253
271
|
|
|
272
|
+
# Find the cache key for a query on a set of attributes by matching the attributes
|
|
273
|
+
# against the cacheable attribute configuration for a class. Returns nil if the
|
|
274
|
+
# query cannot be cached.
|
|
275
|
+
#
|
|
276
|
+
# @param klass [Class] The class that is being queried.
|
|
277
|
+
# @param attributes [Hash] The query attributes with stringified keys.
|
|
278
|
+
# @return [Array(String, Hash), nil] The cache key or nil if the query is not cacheable.
|
|
279
|
+
# @api private
|
|
280
|
+
def cache_key_for_query(klass, attributes)
|
|
281
|
+
return nil if attributes.blank?
|
|
282
|
+
|
|
283
|
+
Array(klass.support_table_cache_by_attributes).each do |attribute_names, case_sensitive, where|
|
|
284
|
+
# Cast both sides through the attribute type so that equivalent values (e.g. 1 and "1")
|
|
285
|
+
# match the where clause the same way they are matched when building cache keys.
|
|
286
|
+
where_matched = where.nil? || where.all? do |name, value|
|
|
287
|
+
type = klass.type_for_attribute(name)
|
|
288
|
+
attributes.include?(name) && type.cast(attributes[name]) == type.cast(value)
|
|
289
|
+
end
|
|
290
|
+
next unless where_matched
|
|
291
|
+
|
|
292
|
+
key_attributes = (where ? attributes.except(*where.keys) : attributes)
|
|
293
|
+
key = cache_key(klass, key_attributes, attribute_names, case_sensitive)
|
|
294
|
+
return key if key
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Return true if a query on a set of attributes can be looked up in the cache.
|
|
301
|
+
#
|
|
302
|
+
# @param klass [Class] The class that is being queried.
|
|
303
|
+
# @param attributes [Hash, nil] The query attributes with stringified keys.
|
|
304
|
+
# @return [Boolean]
|
|
305
|
+
# @api private
|
|
306
|
+
def cacheable_query?(klass, attributes)
|
|
307
|
+
return false if attributes.nil?
|
|
308
|
+
|
|
309
|
+
!cache_key_for_query(klass, attributes).nil?
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Merge the conditions from a relation's where clause with the attributes passed to a
|
|
313
|
+
# finder method. A cache key can only represent a single value per attribute, so if both
|
|
314
|
+
# specify a different value for the same attribute the query cannot be cached; the
|
|
315
|
+
# database has to apply both conditions.
|
|
316
|
+
#
|
|
317
|
+
# @param klass [Class] The class that is being queried.
|
|
318
|
+
# @param scope_conditions [Hash] The relation's where conditions with stringified keys.
|
|
319
|
+
# @param attributes [Hash] The finder attributes with stringified keys.
|
|
320
|
+
# @return [Hash, nil] The merged attributes or nil if the conditions conflict.
|
|
321
|
+
# @api private
|
|
322
|
+
def merge_query_attributes(klass, scope_conditions, attributes)
|
|
323
|
+
return attributes if scope_conditions.blank?
|
|
324
|
+
|
|
325
|
+
conflict = scope_conditions.any? do |name, value|
|
|
326
|
+
next false unless attributes.include?(name)
|
|
327
|
+
|
|
328
|
+
type = klass.type_for_attribute(name)
|
|
329
|
+
type.cast(value) != type.cast(attributes[name])
|
|
330
|
+
end
|
|
331
|
+
return nil if conflict
|
|
332
|
+
|
|
333
|
+
scope_conditions.merge(attributes)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Return true if there is an open transaction on the class' connection. Queries should
|
|
337
|
+
# not be cached inside a transaction since they could return uncommitted data that would
|
|
338
|
+
# be invalid if the transaction is rolled back. Transactions opened with joinable: false
|
|
339
|
+
# (i.e. Rails transactional test fixtures) are ignored.
|
|
340
|
+
#
|
|
341
|
+
# @param klass [Class] The model class being queried.
|
|
342
|
+
# @return [Boolean]
|
|
343
|
+
# @api private
|
|
344
|
+
def open_transaction?(klass)
|
|
345
|
+
return false unless klass.connection_pool.active_connection?
|
|
346
|
+
|
|
347
|
+
connection = klass.connection
|
|
348
|
+
return false unless connection.transaction_open?
|
|
349
|
+
|
|
350
|
+
# current_transaction and joinable? are internal Rails APIs. If a future Rails version
|
|
351
|
+
# changes them, fail safe by treating the transaction as open (bypassing the cache)
|
|
352
|
+
# rather than raising. There is a spec asserting the API exists so an incompatible
|
|
353
|
+
# Rails upgrade fails explicitly.
|
|
354
|
+
transaction = connection.current_transaction
|
|
355
|
+
!transaction.respond_to?(:joinable?) || transaction.joinable?
|
|
356
|
+
end
|
|
357
|
+
|
|
254
358
|
def fiber_local_value(varname)
|
|
255
359
|
@fiber_locals[varname]
|
|
256
360
|
end
|
|
@@ -267,7 +371,7 @@ module SupportTableCache
|
|
|
267
371
|
cache_by_attributes = self.class.support_table_cache_by_attributes
|
|
268
372
|
return if cache_by_attributes.blank?
|
|
269
373
|
|
|
270
|
-
cache = self.class.send(:
|
|
374
|
+
cache = self.class.send(:support_table_cache_for_invalidation)
|
|
271
375
|
return if cache.nil?
|
|
272
376
|
|
|
273
377
|
cache_by_attributes.each do |attribute_names, case_sensitive|
|
|
@@ -289,7 +393,7 @@ module SupportTableCache
|
|
|
289
393
|
cache_by_attributes = self.class.support_table_cache_by_attributes
|
|
290
394
|
return if cache_by_attributes.blank?
|
|
291
395
|
|
|
292
|
-
cache = self.class.send(:
|
|
396
|
+
cache = self.class.send(:support_table_cache_for_invalidation)
|
|
293
397
|
return if cache.nil?
|
|
294
398
|
|
|
295
399
|
cache_by_attributes.each do |attribute_names, case_sensitive|
|
data/support_table_cache.gemspec
CHANGED
|
@@ -13,6 +13,7 @@ Gem::Specification.new do |spec|
|
|
|
13
13
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
14
14
|
ignore_files = %w[
|
|
15
15
|
.
|
|
16
|
+
AGENTS.md
|
|
16
17
|
Appraisals
|
|
17
18
|
Gemfile
|
|
18
19
|
Gemfile.lock
|
|
@@ -27,7 +28,7 @@ Gem::Specification.new do |spec|
|
|
|
27
28
|
|
|
28
29
|
spec.require_paths = ["lib"]
|
|
29
30
|
|
|
30
|
-
spec.
|
|
31
|
+
spec.required_ruby_version = ">= 2.6"
|
|
31
32
|
|
|
32
|
-
spec.
|
|
33
|
+
spec.add_dependency "activerecord"
|
|
33
34
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: support_table_cache
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Durand
|
|
@@ -23,27 +23,12 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0'
|
|
26
|
-
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: bundler
|
|
28
|
-
requirement: !ruby/object:Gem::Requirement
|
|
29
|
-
requirements:
|
|
30
|
-
- - ">="
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '0'
|
|
33
|
-
type: :development
|
|
34
|
-
prerelease: false
|
|
35
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
-
requirements:
|
|
37
|
-
- - ">="
|
|
38
|
-
- !ruby/object:Gem::Version
|
|
39
|
-
version: '0'
|
|
40
26
|
email:
|
|
41
27
|
- bbdurand@gmail.com
|
|
42
28
|
executables: []
|
|
43
29
|
extensions: []
|
|
44
30
|
extra_rdoc_files: []
|
|
45
31
|
files:
|
|
46
|
-
- AGENTS.md
|
|
47
32
|
- ARCHITECTURE.md
|
|
48
33
|
- CHANGELOG.md
|
|
49
34
|
- MIT-LICENSE
|
|
@@ -67,7 +52,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
67
52
|
requirements:
|
|
68
53
|
- - ">="
|
|
69
54
|
- !ruby/object:Gem::Version
|
|
70
|
-
version: '
|
|
55
|
+
version: '2.6'
|
|
71
56
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
57
|
requirements:
|
|
73
58
|
- - ">="
|
data/AGENTS.md
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# Copilot Instructions for support_table_cache
|
|
2
|
-
|
|
3
|
-
## Project Overview
|
|
4
|
-
|
|
5
|
-
This is a Ruby gem that adds transparent caching to ActiveRecord support/lookup tables. It intercepts `find_by` queries and `belongs_to` associations to cache small, rarely-changing reference tables (statuses, types, categories) without code changes.
|
|
6
|
-
|
|
7
|
-
**Core principle**: Cache entries keyed by unique attribute combinations, auto-invalidated on record changes via `after_commit` callbacks.
|
|
8
|
-
|
|
9
|
-
## Architecture
|
|
10
|
-
|
|
11
|
-
- **lib/support_table_cache.rb**: Main module with `cache_by` DSL, cache configuration, and invalidation logic
|
|
12
|
-
- **lib/support_table_cache/find_by_override.rb**: Prepends to model class to intercept `find_by` calls
|
|
13
|
-
- **lib/support_table_cache/relation_override.rb**: Prepends to `ActiveRecord::Relation` to handle scoped queries (e.g., `where(group: 'x').find_by(name: 'y')`)
|
|
14
|
-
- **lib/support_table_cache/associations.rb**: Extends `belongs_to` with `cache_belongs_to` to cache foreign key lookups
|
|
15
|
-
- **lib/support_table_cache/memory_cache.rb**: In-process cache implementation (use `support_table_cache = :memory`)
|
|
16
|
-
|
|
17
|
-
See [ARCHITECTURE.md](../ARCHITECTURE.md) for detailed flow diagrams showing cache key generation, invalidation, and association caching sequences.
|
|
18
|
-
|
|
19
|
-
## Key Patterns
|
|
20
|
-
|
|
21
|
-
### Model Configuration
|
|
22
|
-
Models use `cache_by` to declare unique keys that can be cached. Support composite keys and case-insensitivity:
|
|
23
|
-
```ruby
|
|
24
|
-
cache_by :name, case_sensitive: false
|
|
25
|
-
cache_by [:group, :code]
|
|
26
|
-
cache_by :name, where: {deleted_at: nil} # For default scopes
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
### Cache Key Structure
|
|
30
|
-
Cache keys are `[ClassName, {attr1: val1, attr2: val2}]` arrays with sorted attribute names. Case-insensitive values are downcased before keying.
|
|
31
|
-
|
|
32
|
-
### Module Prepending Pattern
|
|
33
|
-
Uses `prepend` to wrap ActiveRecord methods (`find_by`) rather than monkey-patching. This allows `super` to call original behavior on cache misses or when caching disabled.
|
|
34
|
-
|
|
35
|
-
## Testing
|
|
36
|
-
|
|
37
|
-
- **Multi-version testing**: Uses Appraisal gem to test against ActiveRecord 5.0-8.0 (see [Appraisals](../Appraisals))
|
|
38
|
-
- **Run tests**: `bundle exec rspec` (default rake task) or `bundle exec appraisal rspec` for all versions
|
|
39
|
-
- **Test setup**: In-memory SQLite database created in [spec/spec_helper.rb](../spec/spec_helper.rb) with test tables
|
|
40
|
-
- **Test isolation**: Tests wrapped with `SupportTableCache.testing!` in RSpec `config.before` to prevent cache pollution
|
|
41
|
-
|
|
42
|
-
### Code Style
|
|
43
|
-
Use **standardrb** for linting. Run `standardrb --fix` before committing. CI enforces this on ActiveRecord 8.0 matrix entry.
|
|
44
|
-
|
|
45
|
-
## Common Operations
|
|
46
|
-
|
|
47
|
-
### Adding Cache Support to Models
|
|
48
|
-
1. Include `SupportTableCache` in model class
|
|
49
|
-
2. Call `cache_by` with unique key attributes
|
|
50
|
-
3. Optionally set `self.support_table_cache_ttl = 5.minutes`
|
|
51
|
-
4. For associations: include `SupportTableCache::Associations` in parent model, then `cache_belongs_to :association_name`
|
|
52
|
-
|
|
53
|
-
### Cache Invalidation
|
|
54
|
-
Automatic via `after_commit` callback that clears all cache key variations (both old and new attribute values on updates). No manual invalidation needed unless using in-memory cache across processes.
|
|
55
|
-
|
|
56
|
-
### Debugging Cache Behavior
|
|
57
|
-
- Use `fetch_by` instead of `find_by` to raise error if query won't hit cache
|
|
58
|
-
- Disable caching in block: `Model.disable_cache { ... }` or globally `SupportTableCache.disable { ... }`
|
|
59
|
-
- Check if caching enabled: inspect `support_table_cache_by_attributes` class attribute
|
|
60
|
-
|
|
61
|
-
## Development Workflow
|
|
62
|
-
|
|
63
|
-
1. **Running specs locally**: `bundle exec rspec` (uses Ruby 3.3+ and ActiveRecord 8.0 from Gemfile)
|
|
64
|
-
2. **Testing specific AR version**: `bundle exec appraisal activerecord_7 rspec`
|
|
65
|
-
3. **Generating all gemfiles**: `bundle exec appraisal generate`
|
|
66
|
-
4. **Lint before commit**: `standardrb --fix`
|
|
67
|
-
5. **Release**: Only from `main` branch (enforced by `Rakefile` pre-release check)
|
|
68
|
-
|
|
69
|
-
## Important Constraints
|
|
70
|
-
|
|
71
|
-
- **Target models**: Only for small tables (few hundred rows max)
|
|
72
|
-
- **Unique keys only**: `cache_by` attributes must define unique constraints
|
|
73
|
-
- **No runtime scopes**: Cannot use `cache_belongs_to` with scoped associations (checked at configuration time)
|
|
74
|
-
- **In-memory cache caveat**: Per-process, not invalidated across processes—only use for truly static data or with TTL
|