Skip to content

Commit c94760c

Browse files
Merge pull request rails#48122 from jonathanhefner/cache-serializer_with_fallback-string-bypass
Improve cache performance for bare string values
2 parents 533161a + daa0cb8 commit c94760c

File tree

8 files changed

+153
-16
lines changed

8 files changed

+153
-16
lines changed

activesupport/CHANGELOG.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
1+
* A new `7.1` cache format is available which includes an optimization for
2+
bare string values such as view fragments. The `:message_pack` cache format
3+
has also been modified to include this optimization.
4+
5+
The `7.1` cache format is used by default for new apps, and existing apps
6+
can enable the format by setting `config.load_defaults 7.1` or by setting
7+
`config.active_support.cache_format_version = 7.1` in `config/application.rb`
8+
or a `config/environments/*.rb` file.
9+
10+
Cache entries written using the `6.1` or `7.0` cache formats can be read
11+
when using the `7.1` format. To perform a rolling deploy of a Rails 7.1
12+
upgrade, wherein servers that have not yet been upgraded must be able to
13+
read caches from upgraded servers, leave the cache format unchanged on the
14+
first deploy, then enable the `7.1` cache format on a subsequent deploy.
15+
16+
*Jonathan Hefner*
17+
118
* `config.active_support.cache_format_version` now accepts `:message_pack` as
219
an option. `:message_pack` can reduce cache entry sizes and improve
320
performance, but requires the [`msgpack` gem](https://rubygems.org/gems/msgpack)
421
(>= 1.7.0).
522

6-
Cache entries written using the `6.1` or `7.0` cache formats can be read
23+
Cache entries written using the `6.1`, `7.0`, or `7.1` cache formats can be read
724
when using the `:message_pack` cache format. Additionally, cache entries
825
written using the `:message_pack` cache format can now be read when using
9-
the `6.1` or `7.0` cache formats. These behaviors makes it easy to migrate
26+
the `6.1`, `7.0`, or `7.1` cache formats. These behaviors makes it easy to migrate
1027
between formats without invalidating the entire cache.
1128

1229
*Jonathan Hefner*

activesupport/lib/active_support/cache.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,8 @@ def default_coder
651651
Cache::SerializerWithFallback[:marshal_6_1]
652652
when 7.0
653653
Cache::SerializerWithFallback[:marshal_7_0]
654+
when 7.1
655+
Cache::SerializerWithFallback[:marshal_7_1]
654656
else
655657
Cache::SerializerWithFallback[Cache.format_version]
656658
end

activesupport/lib/active_support/cache/serializer_with_fallback.rb

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ def self.[](format)
1111
SERIALIZERS.fetch(format)
1212
end
1313

14+
def dump(entry)
15+
try_dump_bare_string(entry) || _dump(entry)
16+
end
17+
1418
def dump_compressed(entry, threshold)
1519
dumped = dump(entry)
1620
try_compress(dumped, threshold) || dumped
@@ -21,10 +25,12 @@ def load(dumped)
2125
dumped = decompress(dumped) if compressed?(dumped)
2226

2327
case
28+
when loaded = try_load_bare_string(dumped)
29+
loaded
2430
when MessagePackWithFallback.dumped?(dumped)
2531
MessagePackWithFallback._load(dumped)
26-
when Marshal70WithFallback.dumped?(dumped)
27-
Marshal70WithFallback._load(dumped)
32+
when Marshal71WithFallback.dumped?(dumped)
33+
Marshal71WithFallback._load(dumped)
2834
when Marshal61WithFallback.dumped?(dumped)
2935
Marshal61WithFallback._load(dumped)
3036
else
@@ -40,6 +46,45 @@ def load(dumped)
4046
end
4147

4248
private
49+
BARE_STRING_SIGNATURES = {
50+
255 => Encoding::UTF_8,
51+
254 => Encoding::BINARY,
52+
253 => Encoding::US_ASCII,
53+
}
54+
BARE_STRING_TEMPLATE = "CEl<"
55+
BARE_STRING_EXPIRES_AT_TEMPLATE = "@1E"
56+
BARE_STRING_VERSION_LENGTH_TEMPLATE = "@#{[0].pack(BARE_STRING_EXPIRES_AT_TEMPLATE).bytesize}l<"
57+
BARE_STRING_VERSION_INDEX = [0].pack(BARE_STRING_VERSION_LENGTH_TEMPLATE).bytesize
58+
59+
def try_dump_bare_string(entry)
60+
value = entry.value
61+
return if !value.instance_of?(String)
62+
63+
version = entry.version
64+
return if version && version.encoding != Encoding::UTF_8
65+
66+
signature = BARE_STRING_SIGNATURES.key(value.encoding)
67+
return if !signature
68+
69+
packed = [signature, entry.expires_at || -1.0, version&.bytesize || -1].pack(BARE_STRING_TEMPLATE)
70+
packed << version if version
71+
packed << value
72+
end
73+
74+
def try_load_bare_string(dumped)
75+
encoding = BARE_STRING_SIGNATURES[dumped.getbyte(0)]
76+
return if !encoding
77+
expires_at = dumped.unpack1(BARE_STRING_EXPIRES_AT_TEMPLATE)
78+
version_length = dumped.unpack1(BARE_STRING_VERSION_LENGTH_TEMPLATE)
79+
value_index = BARE_STRING_VERSION_INDEX + [version_length, 0].max
80+
81+
Cache::Entry.new(
82+
dumped.byteslice(value_index..-1).force_encoding(encoding),
83+
version: dumped.byteslice(BARE_STRING_VERSION_INDEX, version_length)&.force_encoding(Encoding::UTF_8),
84+
expires_at: (expires_at unless expires_at < 0),
85+
)
86+
end
87+
4388
ZLIB_HEADER = "\x78".b.freeze
4489

4590
def compressed?(dumped)
@@ -105,14 +150,14 @@ def dumped?(dumped)
105150
end
106151
end
107152

108-
module Marshal70WithFallback
153+
module Marshal71WithFallback
109154
include SerializerWithFallback
110155
extend self
111156

112157
MARK_UNCOMPRESSED = "\x00".b.freeze
113158
MARK_COMPRESSED = "\x01".b.freeze
114159

115-
def dump(entry)
160+
def _dump(entry)
116161
MARK_UNCOMPRESSED + Marshal.dump(entry.pack)
117162
end
118163

@@ -136,11 +181,17 @@ def dumped?(dumped)
136181
end
137182
end
138183

184+
module Marshal70WithFallback
185+
include Marshal71WithFallback
186+
extend self
187+
alias :dump :_dump # Prevent dumping bare strings.
188+
end
189+
139190
module MessagePackWithFallback
140191
include SerializerWithFallback
141192
extend self
142193

143-
def dump(entry)
194+
def _dump(entry)
144195
ActiveSupport::MessagePack::CacheSerializer.dump(entry.pack)
145196
end
146197

@@ -167,6 +218,7 @@ def available?
167218
passthrough: PassthroughWithFallback,
168219
marshal_6_1: Marshal61WithFallback,
169220
marshal_7_0: Marshal70WithFallback,
221+
marshal_7_1: Marshal71WithFallback,
170222
message_pack: MessagePackWithFallback,
171223
}
172224
end

activesupport/test/cache/behaviors/cache_store_format_version_behavior.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
module CacheStoreFormatVersionBehavior
66
extend ActiveSupport::Concern
77

8-
FORMAT_VERSIONS = [6.1, 7.0, :message_pack]
8+
FORMAT_VERSIONS = [6.1, 7.0, 7.1, :message_pack]
99

1010
included do
1111
test "format version affects default coder" do

activesupport/test/cache/serializer_with_fallback_test.rb

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,64 @@ class CacheSerializerWithFallbackTest < ActiveSupport::TestCase
4545
end
4646
end
4747

48+
(FORMATS - [:passthrough, :marshal_6_1, :marshal_7_0]).each do |format|
49+
test "#{format.inspect} serializer preserves version with bare string" do
50+
entry = ActiveSupport::Cache::Entry.new("abc", version: "123")
51+
assert_entry entry, roundtrip(format, entry)
52+
end
53+
54+
test "#{format.inspect} serializer preserves expiration with bare string" do
55+
entry = ActiveSupport::Cache::Entry.new("abc", expires_in: 123)
56+
assert_entry entry, roundtrip(format, entry)
57+
end
58+
59+
test "#{format.inspect} serializer preserves encoding of version with bare string" do
60+
[Encoding::UTF_8, Encoding::BINARY].each do |encoding|
61+
version = "123".encode(encoding)
62+
roundtripped = roundtrip(format, ActiveSupport::Cache::Entry.new("abc", version: version))
63+
assert_equal version.encoding, roundtripped.version.encoding
64+
end
65+
end
66+
67+
test "#{format.inspect} serializer preserves encoding of bare string" do
68+
[Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].each do |encoding|
69+
string = "abc".encode(encoding)
70+
roundtripped = roundtrip(format, ActiveSupport::Cache::Entry.new(string))
71+
assert_equal string.encoding, roundtripped.value.encoding
72+
end
73+
end
74+
75+
test "#{format.inspect} serializer dumps bare string with reduced overhead when possible" do
76+
string = "abc"
77+
options = { version: "123", expires_in: 123 }
78+
79+
unsupported = string.encode(Encoding::WINDOWS_1252)
80+
unoptimized = serializer(format).dump(ActiveSupport::Cache::Entry.new(unsupported, **options))
81+
82+
[Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].each do |encoding|
83+
supported = string.encode(encoding)
84+
optimized = serializer(format).dump(ActiveSupport::Cache::Entry.new(supported, **options))
85+
assert_operator optimized.size, :<, unoptimized.size
86+
end
87+
end
88+
89+
test "#{format.inspect} serializer can compress bare strings" do
90+
entry = ActiveSupport::Cache::Entry.new("abc" * 100, version: "123", expires_in: 123)
91+
compressed = serializer(format).dump_compressed(entry, 1)
92+
uncompressed = serializer(format).dump_compressed(entry, 100_000)
93+
assert_operator compressed.bytesize, :<, uncompressed.bytesize
94+
end
95+
end
96+
97+
[:passthrough, :marshal_6_1, :marshal_7_0].each do |format|
98+
test "#{format.inspect} serializer dumps bare string in a backward compatible way" do
99+
string = +"abc"
100+
string.instance_variable_set(:@baz, true)
101+
roundtripped = roundtrip(format, ActiveSupport::Cache::Entry.new(string))
102+
assert roundtripped.value.instance_variable_get(:@baz)
103+
end
104+
end
105+
48106
test ":message_pack serializer handles missing class gracefully" do
49107
klass = Class.new do
50108
def self.name; "DoesNotActuallyExist"; end
@@ -68,10 +126,14 @@ def serializer(format)
68126
ActiveSupport::Cache::SerializerWithFallback[format]
69127
end
70128

129+
def roundtrip(format, entry)
130+
serializer(format).load(serializer(format).dump(entry))
131+
end
132+
71133
def assert_entry(expected, actual)
72-
assert_equal expected.value, actual.value
73-
assert_equal expected.version, actual.version
74-
assert_equal expected.expires_at, actual.expires_at
134+
assert_equal \
135+
[expected.value, expected.version, expected.expires_at],
136+
[actual.value, actual.version, actual.expires_at]
75137
end
76138

77139
def assert_logs(pattern, &block)

guides/source/configuring.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Below are the default values associated with each target version. In cases of co
7474
- [`config.active_record.run_after_transaction_callbacks_in_order_defined`](#config-active-record-run-after-transaction-callbacks-in-order-defined): `true`
7575
- [`config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction`](#config-active-record-run-commit-callbacks-on-first-saved-instances-in-transaction): `false`
7676
- [`config.active_record.sqlite3_adapter_strict_strings_by_default`](#config-active-record-sqlite3-adapter-strict-strings-by-default): `true`
77+
- [`config.active_support.cache_format_version`](#config-active-support-cache-format-version): `7.1`
7778
- [`config.active_support.default_message_encryptor_serializer`](#config-active-support-default-message-encryptor-serializer): `:json`
7879
- [`config.active_support.default_message_verifier_serializer`](#config-active-support-default-message-verifier-serializer): `:json`
7980
- [`config.active_support.raise_on_invalid_cache_expiration_time`](#config-active-support-raise-on-invalid-cache-expiration-time): `true`
@@ -2266,10 +2267,11 @@ The default value depends on the `config.load_defaults` target version:
22662267
#### `config.active_support.cache_format_version`
22672268
22682269
Specifies which serialization format to use for the cache. Possible values are
2269-
`6.1`, `7.0`, and `:message_pack`.
2270+
`6.1`, `7.0`, `7.1`, and `:message_pack`.
22702271
2271-
The `6.1` and `7.0` formats both use `Marshal`, but the latter uses a more
2272-
efficient cache entry representation.
2272+
The `6.1`, `7.0`, and `7.1` formats all use `Marshal`, but `7.0` uses a more
2273+
efficient representation for cache entries, and `7.1` includes an additional
2274+
optimization for bare string values such as view fragments.
22732275
22742276
The `:message_pack` format uses `ActiveSupport::MessagePack`, and may further
22752277
reduce cache entry sizes and improve performance, but requires the
@@ -2285,6 +2287,7 @@ The default value depends on the `config.load_defaults` target version:
22852287
| --------------------- | -------------------- |
22862288
| (original) | `6.1` |
22872289
| 7.0 | `7.0` |
2290+
| 7.1 | `7.1` |
22882291
22892292
#### `config.active_support.deprecation`
22902293

railties/lib/rails/application/configuration.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ def load_defaults(target_version)
302302
end
303303

304304
if respond_to?(:active_support)
305+
active_support.cache_format_version = 7.1
305306
active_support.default_message_encryptor_serializer = :json
306307
active_support.default_message_verifier_serializer = :json
307308
active_support.use_message_serializer_for_metadata = true

railties/test/application/configuration_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4125,10 +4125,10 @@ def new(app); self; end
41254125
assert_equal :fiber, ActiveSupport::IsolatedExecutionState.isolation_level
41264126
end
41274127

4128-
test "ActiveSupport::Cache.format_version is 7.0 by default for new apps" do
4128+
test "ActiveSupport::Cache.format_version is 7.1 by default for new apps" do
41294129
app "development"
41304130

4131-
assert_equal 7.0, ActiveSupport::Cache.format_version
4131+
assert_equal 7.1, ActiveSupport::Cache.format_version
41324132
end
41334133

41344134
test "ActiveSupport::Cache.format_version is 6.1 by default for upgraded apps" do

0 commit comments

Comments
 (0)