Skip to content

Commit 14de5c9

Browse files
committed
Allow to configure maximum cache key sizes
1 parent 0480d91 commit 14de5c9

File tree

6 files changed

+65
-38
lines changed

6 files changed

+65
-38
lines changed

activesupport/CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
* Allow to configure maximum cache key sizes
2+
3+
When the key exceeds the configured limit (250 bytes by default), it will be truncated and
4+
the digest of the rest of the key appended to it.
5+
6+
Note that previously `ActiveSupport::Cache::RedisCacheStore` allowed up to 1kb cache keys before
7+
truncation, which is now reduced to 250 bytes.
8+
9+
```ruby
10+
config.cache_store = :redis_cache_store, { max_key_size: 64 }
11+
```
12+
13+
*fatkodima*
14+
115
* Use `UNLINK` command instead of `DEL` in `ActiveSupport::Cache::RedisCacheStore` for non-blocking deletion.
216

317
*Aron Roh*

activesupport/lib/active_support/cache.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ module Cache
3636
:serializer,
3737
:skip_nil,
3838
:raw,
39+
:max_key_size,
3940
]
4041

4142
# Mapping of canonical option names to aliases that a store will recognize.
@@ -190,6 +191,9 @@ class Store
190191
# Default +ConnectionPool+ options
191192
DEFAULT_POOL_OPTIONS = { size: 5, timeout: 5 }.freeze
192193

194+
# Keys are truncated with the Active Support digest if they exceed the limit.
195+
MAX_KEY_SIZE = 250
196+
193197
cattr_accessor :logger, instance_writer: true
194198
cattr_accessor :raise_on_invalid_cache_expiration_time, default: false
195199

@@ -299,6 +303,9 @@ def initialize(options = nil)
299303
@options[:compress] = true unless @options.key?(:compress)
300304
@options[:compress_threshold] ||= DEFAULT_COMPRESS_LIMIT
301305

306+
@max_key_size = @options.delete(:max_key_size)
307+
@max_key_size = MAX_KEY_SIZE if @max_key_size.nil? # allow 'false' as a value
308+
302309
@coder = @options.delete(:coder) do
303310
legacy_serializer = Cache.format_version < 7.1 && !@options[:serializer]
304311
serializer = @options.delete(:serializer) || default_serializer
@@ -955,16 +962,33 @@ def validate_options(options)
955962
options
956963
end
957964

958-
# Expands and namespaces the cache key.
965+
# Expands, namespaces and truncates the cache key.
959966
# Raises an exception when the key is +nil+ or an empty string.
960967
# May be overridden by cache stores to do additional normalization.
961968
def normalize_key(key, options = nil)
969+
key = expand_and_namespace_key(key, options)
970+
truncate_key(key)
971+
end
972+
973+
def expand_and_namespace_key(key, options = nil)
962974
str_key = expanded_key(key)
963975
raise(ArgumentError, "key cannot be blank") if !str_key || str_key.empty?
964976

965977
namespace_key str_key, options
966978
end
967979

980+
def truncate_key(key)
981+
if key && @max_key_size && key.bytesize > @max_key_size
982+
suffix = ":hash:#{ActiveSupport::Digest.hexdigest(key)}"
983+
truncate_at = @max_key_size - suffix.bytesize
984+
key = key.byteslice(0, truncate_at)
985+
key.scrub!("")
986+
"#{key}#{suffix}"
987+
else
988+
key
989+
end
990+
end
991+
968992
# Prefix the key with a namespace string:
969993
#
970994
# namespace_key 'foo', namespace: 'cache'

activesupport/lib/active_support/cache/mem_cache_store.rb

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ def self.supports_cache_versioning?
4141

4242
prepend Strategy::LocalCache
4343

44-
KEY_MAX_SIZE = 250
4544
ESCAPE_KEY_CHARS = /[\x00-\x20%\x7F-\xFF]/n
4645

4746
# Creates a new Dalli::Client instance with specified addresses and options.
@@ -80,6 +79,7 @@ def initialize(*addresses)
8079
if options.key?(:cache_nils)
8180
options[:skip_nil] = !options.delete(:cache_nils)
8281
end
82+
options[:max_key_size] ||= MAX_KEY_SIZE
8383
super(options)
8484

8585
unless [String, Dalli::Client, NilClass].include?(addresses.first.class)
@@ -258,19 +258,12 @@ def serialize_entry(entry, raw: false, **options)
258258
# before applying the regular expression to ensure we are escaping all
259259
# characters properly.
260260
def normalize_key(key, options)
261-
key = super
261+
key = expand_and_namespace_key(key, options)
262262
if key
263263
key = key.dup.force_encoding(Encoding::ASCII_8BIT)
264264
key = key.gsub(ESCAPE_KEY_CHARS) { |match| "%#{match.getbyte(0).to_s(16).upcase}" }
265-
266-
if key.size > KEY_MAX_SIZE
267-
key_separator = ":hash:"
268-
key_hash = ActiveSupport::Digest.hexdigest(key)
269-
key_trim_size = KEY_MAX_SIZE - key_separator.size - key_hash.size
270-
key = "#{key[0, key_trim_size]}#{key_separator}#{key_hash}"
271-
end
272265
end
273-
key
266+
truncate_key(key)
274267
end
275268

276269
def deserialize_entry(payload, raw: false, **)

activesupport/lib/active_support/cache/redis_cache_store.rb

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,6 @@ module Cache
3535
# +Redis::Distributed+ 4.0.1+ for distributed mget support.
3636
# * +delete_matched+ support for Redis KEYS globs.
3737
class RedisCacheStore < Store
38-
# Keys are truncated with the Active Support digest if they exceed 1kB
39-
MAX_KEY_BYTESIZE = 1024
40-
4138
DEFAULT_REDIS_OPTIONS = {
4239
connect_timeout: 1,
4340
read_timeout: 1,
@@ -106,7 +103,6 @@ def build_redis_client(**redis_options)
106103
end
107104
end
108105

109-
attr_reader :max_key_bytesize
110106
attr_reader :redis
111107

112108
# Creates a new Redis cache store.
@@ -169,7 +165,6 @@ def initialize(error_handler: DEFAULT_ERROR_HANDLER, **redis_options)
169165
@redis = self.class.build_redis(**redis_options)
170166
end
171167

172-
@max_key_bytesize = MAX_KEY_BYTESIZE
173168
@error_handler = error_handler
174169

175170
super(universal_options)
@@ -436,21 +431,6 @@ def write_multi_entries(entries, **options)
436431
end
437432
end
438433

439-
# Truncate keys that exceed 1kB.
440-
def normalize_key(key, options)
441-
truncate_key super&.b
442-
end
443-
444-
def truncate_key(key)
445-
if key && key.bytesize > max_key_bytesize
446-
suffix = ":hash:#{ActiveSupport::Digest.hexdigest(key)}"
447-
truncate_at = max_key_bytesize - suffix.bytesize
448-
"#{key.byteslice(0, truncate_at)}#{suffix}"
449-
else
450-
key
451-
end
452-
end
453-
454434
def deserialize_entry(payload, raw: false, **)
455435
if raw && !payload.nil?
456436
Entry.new(payload)

activesupport/test/cache/behaviors/cache_store_behavior.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,20 @@ def test_configuring_store_with_raw
748748
assert_equal "bar", cache.read("foo")
749749
end
750750

751+
def test_max_key_size
752+
cache = lookup_store(max_key_size: 64)
753+
key = "foobar" * 20
754+
cache.write(key, "bar")
755+
assert_equal "bar", cache.read(key)
756+
end
757+
758+
def test_max_key_size_disabled
759+
cache = lookup_store(max_key_size: false)
760+
key = "a" * 1000
761+
cache.write(key, "bar")
762+
assert_equal "bar", cache.read(key)
763+
end
764+
751765
private
752766
def with_raise_on_invalid_cache_expiration_time(new_value, &block)
753767
old_value = ActiveSupport::Cache::Store.raise_on_invalid_cache_expiration_time

activesupport/test/cache/stores/memory_store_test.rb

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,13 @@
44
require "active_support/cache"
55
require_relative "../behaviors"
66

7-
class MemoryStoreTest < ActiveSupport::TestCase
8-
def setup
9-
@cache = lookup_store(expires_in: 60)
10-
end
11-
7+
class StoreTest < ActiveSupport::TestCase
128
def lookup_store(options = {})
139
ActiveSupport::Cache.lookup_store(:memory_store, options)
1410
end
11+
end
1512

13+
class MemoryStoreTest < StoreTest
1614
include CacheStoreBehavior
1715
include CacheStoreVersionBehavior
1816
include CacheStoreCoderBehavior
@@ -23,6 +21,10 @@ def lookup_store(options = {})
2321
include CacheInstrumentationBehavior
2422
include CacheLoggingBehavior
2523

24+
def setup
25+
@cache = lookup_store(expires_in: 60)
26+
end
27+
2628
def test_increment_preserves_expiry
2729
@cache = lookup_store
2830
@cache.write("counter", 1, raw: true, expires_in: 30.seconds)
@@ -92,7 +94,7 @@ def compression_always_disabled_by_default?
9294
end
9395
end
9496

95-
class MemoryStorePruningTest < ActiveSupport::TestCase
97+
class MemoryStorePruningTest < StoreTest
9698
def setup
9799
@record_size = ActiveSupport::Cache.lookup_store(:memory_store).send(:cached_size, 1, ActiveSupport::Cache::Entry.new("aaaaaaaaaa"))
98100
@cache = ActiveSupport::Cache.lookup_store(:memory_store, expires_in: 60, size: @record_size * 10 + 1)
@@ -158,7 +160,7 @@ def test_prune_size_on_write_based_on_key_length
158160
assert @cache.exist?(8)
159161
assert @cache.exist?(7)
160162
assert @cache.exist?(6)
161-
assert_not @cache.exist?(5), "no entry"
163+
assert @cache.exist?(5)
162164
assert_not @cache.exist?(4), "no entry"
163165
assert_not @cache.exist?(3), "no entry"
164166
assert_not @cache.exist?(2), "no entry"

0 commit comments

Comments
 (0)