support_table_cache 1.1.4 → 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: 5102ba8b7a75ecc7186dc527880ff0278f9e259b020fc8b36b8e3e72c4b0c150
4
- data.tar.gz: ebf2696d9ea3917895038b35989d2c23c5b418db4f405c4b36812e6da59f7691
3
+ metadata.gz: 0e6656d6580218249c30dbfb1f21213b1f0cd17c43c7ec81bca00a376edd1a38
4
+ data.tar.gz: 63ead57c3891ca076810d70cd64b81073bed609468ac6b4e865d47cd2ee61031
5
5
  SHA512:
6
- metadata.gz: eae75a3e8afea4fcf9cff14fec02110ab0f332685bd912ca3f37fdd17baeeaa2db107824cbd0f6bec18acdf501815012476fd48f50d63b3a38c0767daae7cf98
7
- data.tar.gz: 1be4a2c1013221d4af65eb26dbfd5e9c56eb130a4bdc94ce540854f11a3df7dd7526e264ba645f7e46340cd43de8bef8899837043031aeae8897e463113e4fc5
6
+ metadata.gz: 6dfa8b6ff0474608194dc1694ea452cdc51aaf6b05d528d6c86dcee927e08299d7d95bcacf3b7bf1f6a9e39971a0181b0290f4d3a72f5750d7f314ddf5354ef9
7
+ data.tar.gz: 94b88ef70a916052633d1c7e61d047fc70b1de9c2f111c247747a9e99ddeda47295c96f3214e264fd5975b09d8fb75170a941ee2f6dba27bdcda41f587c86fbf
data/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ 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
+
27
+ ## 1.1.5
28
+
29
+ ### Fixed
30
+
31
+ - Replaced thread local variables with fiber local variables to prevent the possibility of behavior from leaking across fibers when disabling the cache in a block.
32
+ - Allow setting the cache to an in-memory cache by setting `support_table_cache` to `true`.
33
+
7
34
  ## 1.1.4
8
35
 
9
36
  ### Fixed
@@ -13,21 +40,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
13
40
  ## 1.1.3
14
41
 
15
42
  ### Fixed
43
+
16
44
  - Avoid calling methods that require a database connection when setting up belongs to caching.
17
45
 
18
46
  ## 1.1.2
19
47
 
20
48
  ### Fixed
49
+
21
50
  - Do not cache records where only some of the columns have been loaded with a call to `select`.
22
51
 
23
52
  ## 1.1.1
24
53
 
25
54
  ### Fixed
55
+
26
56
  - Fixed disabled and disable_cache methods to yield a block to match the documentation.
27
57
 
28
58
  ## 1.1.0
29
59
 
30
60
  ### Added
61
+
31
62
  - Added fetch_by and fetch_by! methods that can verify the result will be cacheable.
32
63
  - Allow configuring cache storage on a per class basis.
33
64
  - Allow disabling caching on per class basis.
@@ -36,15 +67,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
36
67
  - Added test mode to intialize new caches within a test block.
37
68
 
38
69
  ### Changed
70
+
39
71
  - Changed fiber local variables used for disabling the cache to thread local variables.
40
72
  - Using find_by! on a relation will now use the cache.
41
73
 
42
74
  ## 1.0.1
43
75
 
44
76
  ### Added
77
+
45
78
  - Preserve scope on relations terminated with a `find_by`.
46
79
 
47
80
  ## 1.0.0
48
81
 
49
82
  ### Added
83
+
50
84
  - Add SupportTableCache concern to enable automatic caching on models when calling `find_by` with unique key parameters.
data/README.md CHANGED
@@ -19,6 +19,18 @@ end
19
19
 
20
20
  With this gem, you can avoid the database query associated with the `find_by` call. You don't need to alter your code in any way other than to include `SupportTableCache` in your model and telling it the attributes that comprise a unique key, which can be used for caching.
21
21
 
22
+ ## Table of Contents
23
+
24
+ - [Usage](#usage)
25
+ - [Setting the Cache](#setting-the-cache)
26
+ - [Disabling Caching](#disabling-caching)
27
+ - [Caching Belongs to Associations](#caching-belongs-to-associations)
28
+ - [Testing](#testing)
29
+ - [Companion Gems](#companion-gems)
30
+ - [Installation](#installation)
31
+ - [Contributing](#contributing)
32
+ - [License](#license)
33
+
22
34
  ## Usage
23
35
 
24
36
  To use the gem, you need to include it in you models and then specify which attributes can be used for caching with the `cache_by` method. A caching attribute must be a unique key on the model. For a composite unique key, you can specify an array of attributes. If any of the attributes are case-insensitive strings, you need to specify that as well.
@@ -77,6 +89,12 @@ You can also set a cache per class. For instance, you can set an in-memory cache
77
89
 
78
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.
79
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
+
80
98
  ### Disabling Caching
81
99
 
82
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.
@@ -96,6 +114,10 @@ SupportTableCache.enable do
96
114
  end
97
115
  ```
98
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
+
99
121
  ### Caching Belongs to Associations
100
122
 
101
123
  You can also cache belongs to associations to cacheable models.
@@ -115,6 +137,9 @@ end
115
137
 
116
138
  You can include `SupportTableCache::Associations` in your `ApplicationRecord` class to make association caching available on all models.
117
139
 
140
+ > [!NOTE]
141
+ > You still need to set up the target model to cache by the primary key used by the belongs to association. Otherwise the association will not be cached.
142
+
118
143
  ### Testing
119
144
 
120
145
  Caching may interfere with tests by allowing data created in one test to leak into subsequent tests. You can resolve this by wrapping your tests with the `SupportTableCache.testing!` method.
@@ -132,14 +157,16 @@ class MiniTest::Spec
132
157
  around do |tests|
133
158
  SupportTableCache.testing!(&tests)
134
159
  end
135
- =end
136
-
160
+ end
137
161
  ```
138
162
 
139
- ### Maintaining Data
163
+ ### Companion Gems
140
164
 
141
165
  You can use the companion [support_table_data gem](https://github.com/bdurand/support_table_data) to provide functionality for loading static data into your support tables as well as adding helper functions to make looking up specific rows much easier.
142
166
 
167
+ > [!TIP]
168
+ > The [support_table](https://github.com/bdurand/support_table) gem combines both gems in a drop in solution for Rails applications.
169
+
143
170
  ## Installation
144
171
 
145
172
  Add this line to your application's Gemfile:
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.4
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
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportTableCache
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.
9
+ class FiberLocals
10
+ def initialize
11
+ @locals_key = :"support_table_cache_locals_#{object_id}"
12
+ end
13
+
14
+ def [](key)
15
+ locals = Thread.current[@locals_key]
16
+ locals[key] if locals
17
+ end
18
+
19
+ def with(key, value)
20
+ locals = Thread.current[@locals_key]
21
+ if locals.nil?
22
+ locals = {}
23
+ Thread.current[@locals_key] = locals
24
+ end
25
+
26
+ exists = locals.key?(key)
27
+ previous_value = locals[key]
28
+ locals[key] = value
29
+
30
+ begin
31
+ yield
32
+ ensure
33
+ if exists
34
+ locals[key] = previous_value
35
+ else
36
+ locals.delete(key)
37
+ Thread.current[@locals_key] = nil if locals.empty?
38
+ end
39
+ end
40
+ end
41
+ end
42
+ 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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "support_table_cache/associations"
4
+ require_relative "support_table_cache/fiber_locals"
4
5
  require_relative "support_table_cache/find_by_override"
5
6
  require_relative "support_table_cache/relation_override"
6
7
  require_relative "support_table_cache/memory_cache"
@@ -10,6 +11,13 @@ require_relative "support_table_cache/memory_cache"
10
11
  module SupportTableCache
11
12
  extend ActiveSupport::Concern
12
13
 
14
+ NOT_SET = Object.new.freeze
15
+ private_constant :NOT_SET
16
+
17
+ @fiber_locals = FiberLocals.new
18
+ @cache = NOT_SET
19
+ @disabled = false
20
+
13
21
  included do
14
22
  # @api private Used to store the list of attribute names used for caching.
15
23
  class_attribute :support_table_cache_by_attributes, instance_accessor: false
@@ -20,6 +28,8 @@ module SupportTableCache
20
28
  # @api private
21
29
  class_attribute :support_table_cache_impl, instance_accessor: false
22
30
 
31
+ self.support_table_cache_by_attributes = []
32
+
23
33
  unless ActiveRecord::Relation.include?(RelationOverride)
24
34
  ActiveRecord::Relation.prepend(RelationOverride)
25
35
  end
@@ -42,14 +52,7 @@ module SupportTableCache
42
52
  # @yield Executes the provided block with caching disabled or enabled.
43
53
  # @return [Object] The return value of the block.
44
54
  def disable_cache(disabled = true, &block)
45
- varname = "support_table_cache_disabled:#{name}"
46
- save_val = Thread.current.thread_variable_get(varname)
47
- begin
48
- Thread.current.thread_variable_set(varname, !!disabled)
49
- yield
50
- ensure
51
- Thread.current.thread_variable_set(varname, save_val)
52
- end
55
+ SupportTableCache.with_fiber_local("support_table_cache_disabled:#{name}", !!disabled, &block)
53
56
  end
54
57
 
55
58
  # Enable the caching behavior for this class within the block. The enabled setting
@@ -67,13 +70,17 @@ module SupportTableCache
67
70
  # @return [void]
68
71
  def load_cache
69
72
  cache = current_support_table_cache
70
- return super if cache.nil?
73
+ return if cache.nil?
71
74
 
72
75
  find_each do |record|
73
- 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
+
74
79
  attributes = record.attributes.slice(*attribute_names)
75
80
  cache_key = SupportTableCache.cache_key(self, attributes, attribute_names, case_sensitive)
76
- 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)
77
84
  end
78
85
  end
79
86
  end
@@ -81,10 +88,10 @@ module SupportTableCache
81
88
  # Set a class-specific cache to use in lieu of the global cache.
82
89
  #
83
90
  # @param cache [ActiveSupport::Cache::Store, Symbol] The cache instance to use. You can also
84
- # specify the value :memory to use an optimized in-memory cache.
91
+ # specify the value :memory or true to use an optimized in-memory cache.
85
92
  # @return [void]
86
93
  def support_table_cache=(cache)
87
- cache = MemoryCache.new if cache == :memory
94
+ cache = MemoryCache.new if cache == :memory || cache == true
88
95
  self.support_table_cache_impl = cache
89
96
  end
90
97
 
@@ -119,15 +126,16 @@ module SupportTableCache
119
126
  where = where.stringify_keys
120
127
  end
121
128
 
122
- self.support_table_cache_by_attributes ||= []
123
- support_table_cache_by_attributes.delete_if { |data| data.first == attributes }
124
- 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]]
125
133
  end
126
134
 
127
135
  private
128
136
 
129
137
  def support_table_cache_disabled?
130
- current_block_value = Thread.current.thread_variable_get("support_table_cache_disabled:#{name}")
138
+ current_block_value = SupportTableCache.fiber_local_value("support_table_cache_disabled:#{name}")
131
139
  if current_block_value.nil?
132
140
  SupportTableCache.disabled?
133
141
  else
@@ -137,6 +145,13 @@ module SupportTableCache
137
145
 
138
146
  def current_support_table_cache
139
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
140
155
  SupportTableCache.testing_cache || support_table_cache_impl || SupportTableCache.cache
141
156
  end
142
157
  end
@@ -150,13 +165,7 @@ module SupportTableCache
150
165
  # @return [Object, nil] The return value of the block if a block is given, nil otherwise.
151
166
  def disable(disabled = true, &block)
152
167
  if block
153
- save_val = Thread.current.thread_variable_get(:support_table_cache_disabled)
154
- begin
155
- Thread.current.thread_variable_set(:support_table_cache_disabled, !!disabled)
156
- yield
157
- ensure
158
- Thread.current.thread_variable_set(:support_table_cache_disabled, save_val)
159
- end
168
+ SupportTableCache.with_fiber_local("support_table_cache_disabled", !!disabled, &block)
160
169
  else
161
170
  @disabled = !!disabled
162
171
  end
@@ -174,9 +183,9 @@ module SupportTableCache
174
183
  # Return true if caching has been disabled.
175
184
  # @return [Boolean]
176
185
  def disabled?
177
- block_value = Thread.current.thread_variable_get(:support_table_cache_disabled)
186
+ block_value = SupportTableCache.fiber_local_value("support_table_cache_disabled")
178
187
  if block_value.nil?
179
- !!(defined?(@disabled) && @disabled)
188
+ !!@disabled
180
189
  else
181
190
  block_value
182
191
  end
@@ -197,7 +206,7 @@ module SupportTableCache
197
206
  def cache
198
207
  if testing_cache
199
208
  testing_cache
200
- elsif defined?(@cache)
209
+ elsif @cache != NOT_SET
201
210
  @cache
202
211
  elsif defined?(Rails.cache)
203
212
  Rails.cache
@@ -211,14 +220,11 @@ module SupportTableCache
211
220
  # @yield Executes the provided block in test mode.
212
221
  # @return [Object] The return value of the block.
213
222
  def testing!(&block)
214
- save_val = Thread.current.thread_variable_get(:support_table_cache_test_cache)
223
+ save_val = SupportTableCache.fiber_local_value("support_table_cache_test_cache")
215
224
  if save_val.nil?
216
- Thread.current.thread_variable_set(:support_table_cache_test_cache, MemoryCache.new)
217
- end
218
- begin
225
+ SupportTableCache.with_fiber_local("support_table_cache_test_cache", MemoryCache.new, &block)
226
+ else
219
227
  yield
220
- ensure
221
- Thread.current.thread_variable_set(:support_table_cache_test_cache, save_val)
222
228
  end
223
229
  end
224
230
 
@@ -227,9 +233,9 @@ module SupportTableCache
227
233
  # @return [SupportTableCache::MemoryCache, nil] The test cache or nil if not in test mode.
228
234
  # @api private
229
235
  def testing_cache
230
- unless defined?(@cache) && @cache.nil?
231
- Thread.current.thread_variable_get(:support_table_cache_test_cache)
232
- end
236
+ return nil if @cache.nil?
237
+
238
+ SupportTableCache.fiber_local_value("support_table_cache_test_cache")
233
239
  end
234
240
 
235
241
  # Generate a consistent cache key for a set of attributes. It will return nil if the attributes
@@ -249,7 +255,11 @@ module SupportTableCache
249
255
 
250
256
  sorted_attributes = {}
251
257
  sorted_names.each do |attribute_name|
252
- 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)
253
263
  if !case_sensitive && (value.is_a?(String) || value.is_a?(Symbol))
254
264
  value = value.to_s.downcase
255
265
  end
@@ -258,6 +268,100 @@ module SupportTableCache
258
268
 
259
269
  [klass.name, sorted_attributes]
260
270
  end
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
+
358
+ def fiber_local_value(varname)
359
+ @fiber_locals[varname]
360
+ end
361
+
362
+ def with_fiber_local(varname, value, &block)
363
+ @fiber_locals.with(varname, value, &block)
364
+ end
261
365
  end
262
366
 
263
367
  # Remove the cache entry for this record.
@@ -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.4
4
+ version: 1.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -23,20 +23,6 @@ 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: []
@@ -50,6 +36,7 @@ files:
50
36
  - VERSION
51
37
  - lib/support_table_cache.rb
52
38
  - lib/support_table_cache/associations.rb
39
+ - lib/support_table_cache/fiber_locals.rb
53
40
  - lib/support_table_cache/find_by_override.rb
54
41
  - lib/support_table_cache/memory_cache.rb
55
42
  - lib/support_table_cache/relation_override.rb
@@ -65,14 +52,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
65
52
  requirements:
66
53
  - - ">="
67
54
  - !ruby/object:Gem::Version
68
- version: '0'
55
+ version: '2.6'
69
56
  required_rubygems_version: !ruby/object:Gem::Requirement
70
57
  requirements:
71
58
  - - ">="
72
59
  - !ruby/object:Gem::Version
73
60
  version: '0'
74
61
  requirements: []
75
- rubygems_version: 3.6.9
62
+ rubygems_version: 4.0.3
76
63
  specification_version: 4
77
64
  summary: Automatic ActiveRecord caching for small support tables.
78
65
  test_files: []