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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9fe5a9190c32271b431faed20e0b6ddbeb195cd05c927cae94c03b5c7d20bc8a
4
- data.tar.gz: 8f8bc59c1a0eb6240712e50fb34da28f45d071e1c6b59e28d647d8bcf7e17896
3
+ metadata.gz: 0e6656d6580218249c30dbfb1f21213b1f0cd17c43c7ec81bca00a376edd1a38
4
+ data.tar.gz: 63ead57c3891ca076810d70cd64b81073bed609468ac6b4e865d47cd2ee61031
5
5
  SHA512:
6
- metadata.gz: c833468a94fed3f16efd45022c56cbf19c755d6f0e2f48a2d93b8b92c2d946bd46f246170a623f2edeade892aa017851d581794fc7505c4efd5c5bce3fdf29b9
7
- data.tar.gz: d4307c2d653184fe890e8aa8af0c548ef340d817141fddcb69e8a083ffcd235fa7d0d124b98e6ece0dd99e5a8fc549db1a8a986acf571aada17cb6f0619c67e1
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.5
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
- if cache
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
- alias_method :#{association_name}_without_cache, :#{association_name}
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. This implementation
5
- # does not pollute the global namespace.
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
- @mutex = Mutex.new
9
- @locals = {}
11
+ @locals_key = :"support_table_cache_locals_#{object_id}"
10
12
  end
11
13
 
12
14
  def [](key)
13
- fiber_locals = nil
14
- @mutex.synchronize do
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
- fiber_id = Fiber.current.object_id
24
- fiber_locals = nil
25
- previous_value = nil
26
- inited_vars = false
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
- previous_value = fiber_locals[key]
39
- fiber_locals[key] = value
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 inited_vars
44
- @mutex.synchronize do
45
- @locals.delete(fiber_id)
46
- end
33
+ if exists
34
+ locals[key] = previous_value
47
35
  else
48
- fiber_locals[key] = previous_value
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
- cache_key = nil
12
- attributes = ((args.size == 1 && args.first.is_a?(Hash)) ? args.first.stringify_keys : {})
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
- if respond_to?(:scope_attributes) && scope_attributes.present?
15
- attributes = scope_attributes.stringify_keys.merge(attributes)
16
- end
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
- if attributes.present?
19
- support_table_cache_by_attributes.each do |attribute_names, case_sensitive, where|
20
- where&.each do |name, value|
21
- if attributes.include?(name) && attributes[name] == value
22
- attributes.delete(name)
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
- find_by_attribute_names = support_table_find_by_attribute_names(attributes)
46
- unless support_table_cache_by_attributes.any? { |attribute_names, _ci, _where| attribute_names == find_by_attribute_names }
47
- raise ArgumentError.new("#{name} does not cache queries by #{find_by_attribute_names.to_sentence}")
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
- def support_table_find_by_attribute_names(attributes)
69
- attributes ||= {}
70
- if respond_to?(:scope_attributes) && scope_attributes.present?
71
- attributes = scope_attributes.merge(attributes)
72
- end
73
- attributes.keys.map(&:to_s).sort
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, expire_at = @cache[key]
27
- if serialized_value.nil? || (expire_at && expire_at < Process.clock_gettime(Process::CLOCK_MONOTONIC))
28
- value = yield if block_given?
29
- return nil if value.nil?
30
- write(key, value, expires_in: expires_in)
31
- serialized_value = Marshal.dump(value)
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
- @cache.delete(key)
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
- @cache.clear
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
- cache_key = nil
23
- attributes = ((args.size == 1 && args.first.is_a?(Hash)) ? args.first.stringify_keys : {})
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
- # Apply any attributes from the current relation chain
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
- if attributes.present?
31
- support_table_cache_by_attributes.each do |attribute_names, case_sensitive, where|
32
- where&.each do |name, value|
33
- if attributes.include?(name) && attributes[name] == value
34
- attributes.delete(name)
35
- else
36
- return super
37
- end
38
- end
39
- cache_key = SupportTableCache.cache_key(klass, attributes, attribute_names, case_sensitive)
40
- break if cache_key
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
- find_by_attribute_names = support_table_find_by_attribute_names(attributes)
71
- unless klass.support_table_cache_by_attributes.any? { |attribute_names, _ci| attribute_names == find_by_attribute_names }
72
- raise ArgumentError.new("#{name} does not cache queries by #{find_by_attribute_names.to_sentence}")
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
- def support_table_find_by_attribute_names(attributes)
94
- attributes ||= {}
95
- if scope_attributes.present?
96
- attributes = scope_attributes.merge(attributes)
97
- end
98
- attributes.keys.map(&:to_s).sort
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
@@ -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 super if cache.nil?
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
- cache.fetch(cache_key, expires_in: support_table_cache_ttl) { record }
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
- self.support_table_cache_by_attributes ||= []
124
- support_table_cache_by_attributes.delete_if { |data| data.first == attributes }
125
- self.support_table_cache_by_attributes += [[attributes, case_sensitive, where]]
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] || attributes[attribute_name.to_sym])
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(:current_support_table_cache)
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(:current_support_table_cache)
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|
@@ -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.add_dependency "activerecord"
31
+ spec.required_ruby_version = ">= 2.6"
31
32
 
32
- spec.add_development_dependency "bundler"
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.5
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: '0'
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