Skip to content

Commit 8f2bf0d

Browse files
committed
Refactor KeyRegularizer to reduce allocations
Interfaces that return multi values aren't great in hotspot as they require to allocate a very short lived array and the Ruby VM isn't yet sophisticated enough to elude it. But since in the happy path we only need to check if the key need to be encoded, we can decompose that single method into two calls, and save more performance on the happy path (one allocation) than we lose on the slow path (one method call). Also refactor `multi_get / multi_set / multi_delete` to be generated by `RequestFormatter` so that they can use its internal helpers to encode keys.
1 parent d9751b6 commit 8f2bf0d

8 files changed

Lines changed: 274 additions & 192 deletions

File tree

.rubocop_todo.yml

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,34 @@
11
# This configuration was generated by
22
# `rubocop --auto-gen-config`
3-
# on 2025-04-01 20:25:11 UTC using RuboCop version 1.75.1.
3+
# on 2026-06-03 14:43:04 UTC using RuboCop version 1.85.0.
44
# The point is for the user to remove these configuration records
55
# one by one as the offenses are removed from the code base.
66
# Note that changes in the inspected code, or installation of new
77
# versions of RuboCop, may require this file to be generated again.
88

9-
# Offense count: 1
9+
# Offense count: 2
1010
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
1111
Metrics/AbcSize:
12-
Max: 20
12+
Max: 19
1313

14-
# Offense count: 9
14+
# Offense count: 7
1515
# Configuration parameters: CountComments, CountAsOne.
1616
Metrics/ClassLength:
17-
Max: 350
17+
Max: 338
1818

19-
# Offense count: 4
20-
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
19+
# Offense count: 1
20+
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
2121
Metrics/MethodLength:
22-
Max: 20
2322
Exclude:
24-
- 'lib/dalli/pipelined_getter.rb'
25-
- 'lib/dalli/protocol/base.rb'
2623
- 'lib/dalli/socket.rb'
2724

28-
# Offense count: 1
25+
# Offense count: 2
2926
# Configuration parameters: CountComments, CountAsOne.
3027
Metrics/ModuleLength:
31-
Max: 108
28+
Max: 128
29+
30+
# Offense count: 1
31+
# Configuration parameters: AllowedClasses.
32+
Style/OneClassPerFile:
33+
Exclude:
34+
- 'test/helper.rb'

lib/dalli/protocol/key_regularizer.rb

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ class Meta
99
# allowed.
1010
# memcached supports the use of base64 hashes for keys containing
1111
# whitespace or non-ASCII characters, provided the 'b' flag is included in the request.
12-
class KeyRegularizer
13-
def self.encode(key)
14-
return [key, false] if key.ascii_only? && !/\s/.match?(key)
12+
module KeyRegularizer
13+
module_function
1514

16-
strict_base64_encoded = [key].pack('m0')
17-
[strict_base64_encoded, true]
15+
def required?(key)
16+
!key.ascii_only? || /\s/.match?(key)
1817
end
1918

20-
def self.decode(encoded_key, base64_encoded)
21-
return encoded_key unless base64_encoded
19+
def encode(key)
20+
[key].pack('m0')
21+
end
2222

23+
def decode(encoded_key)
2324
strict_base64_decoded = encoded_key.unpack1('m0')
2425
strict_base64_decoded.force_encoding(Encoding::UTF_8)
2526
end

lib/dalli/protocol/meta.rb

Lines changed: 22 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -24,43 +24,37 @@ def response_processor
2424

2525
# Retrieval Commands
2626
def get(key, options = nil)
27-
encoded_key, base64 = KeyRegularizer.encode(key)
2827
# Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
2928
skip_flags = raw_mode? || (options && options[:raw])
30-
req = RequestFormatter.meta_get(key: encoded_key, base64: base64, skip_flags: skip_flags)
29+
req = RequestFormatter.meta_get(key: key, skip_flags: skip_flags)
3130
flushed_write(req)
3231
response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
3332
end
3433

3534
def quiet_get_request(key)
36-
encoded_key, base64 = KeyRegularizer.encode(key)
3735
# Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
38-
RequestFormatter.meta_get(key: encoded_key, return_cas: true, base64: base64, quiet: true,
39-
skip_flags: raw_mode?)
36+
RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_mode?)
4037
end
4138

4239
def gat(key, ttl, options = nil)
4340
ttl = TtlSanitizer.sanitize(ttl)
44-
encoded_key, base64 = KeyRegularizer.encode(key)
4541
skip_flags = raw_mode? || (options && options[:raw])
46-
req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, base64: base64, skip_flags: skip_flags)
42+
req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags)
4743
flushed_write(req)
4844
response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
4945
end
5046

5147
def touch(key, ttl)
5248
ttl = TtlSanitizer.sanitize(ttl)
53-
encoded_key, base64 = KeyRegularizer.encode(key)
54-
req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, value: false, base64: base64)
49+
req = RequestFormatter.meta_get(key: key, ttl: ttl, value: false)
5550
flushed_write(req)
5651
response_processor.meta_get_without_value
5752
end
5853

5954
# TODO: This is confusing, as there's a cas command in memcached
6055
# and this isn't it. Maybe rename? Maybe eliminate?
6156
def cas(key)
62-
encoded_key, base64 = KeyRegularizer.encode(key)
63-
req = RequestFormatter.meta_get(key: encoded_key, value: true, return_cas: true, base64: base64)
57+
req = RequestFormatter.meta_get(key: key, value: true, return_cas: true)
6458
flushed_write(req)
6559
response_processor.meta_get_with_value_and_cas
6660
end
@@ -90,9 +84,8 @@ def cas(key)
9084
# - :hit_before - true/false if previously accessed (only if return_hit_status: true)
9185
# - :last_access - seconds since last access (only if return_last_access: true)
9286
def meta_get(key, options = {})
93-
encoded_key, base64 = KeyRegularizer.encode(key)
9487
req = RequestFormatter.meta_get(
95-
key: encoded_key, value: true, return_cas: true, base64: base64,
88+
key: key, value: true, return_cas: true,
9689
vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
9790
return_hit_status: options[:return_hit_status],
9891
return_last_access: options[:return_last_access], skip_lru_bump: options[:skip_lru_bump]
@@ -112,8 +105,7 @@ def meta_get(key, options = {})
112105
# @param cas [Integer] optional CAS value for compare-and-swap
113106
# @return [Boolean] true if successful
114107
def delete_stale(key, cas = nil)
115-
encoded_key, base64 = KeyRegularizer.encode(key)
116-
req = RequestFormatter.meta_delete(key: encoded_key, cas: cas, base64: base64, stale: true)
108+
req = RequestFormatter.meta_delete(key: key, cas: cas, stale: true)
117109
flushed_write(req)
118110
response_processor.meta_delete
119111
end
@@ -144,10 +136,9 @@ def replace(key, value, ttl, cas, options)
144136
def write_storage_req(mode, key, raw_value, ttl = nil, cas = nil, options = {}, quiet: quiet?)
145137
(value, bitflags) = @value_marshaller.store(key, raw_value, options)
146138
ttl = TtlSanitizer.sanitize(ttl) if ttl
147-
encoded_key, base64 = KeyRegularizer.encode(key)
148-
req = RequestFormatter.meta_set(key: encoded_key, value: value,
139+
req = RequestFormatter.meta_set(key: key, value: value,
149140
bitflags: bitflags, cas: cas,
150-
ttl: ttl, mode: mode, quiet: quiet, base64: base64)
141+
ttl: ttl, mode: mode, quiet: quiet)
151142
write("#{req}#{value}#{TERMINATOR}")
152143
@connection_manager.flush unless quiet
153144
end
@@ -166,8 +157,7 @@ def prepend(key, value)
166157
# rubocop:disable Metrics/ParameterLists
167158
def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, _options = {})
168159
ttl = TtlSanitizer.sanitize(ttl) if ttl
169-
encoded_key, base64 = KeyRegularizer.encode(key)
170-
req = RequestFormatter.meta_set(key: encoded_key, value: value, base64: base64,
160+
req = RequestFormatter.meta_set(key: key, value: value,
171161
cas: cas, ttl: ttl, mode: mode, quiet: quiet?)
172162
write("#{req}#{value}#{TERMINATOR}")
173163
@connection_manager.flush unless quiet?
@@ -176,9 +166,7 @@ def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, _options =
176166

177167
# Delete Commands
178168
def delete(key, cas)
179-
encoded_key, base64 = KeyRegularizer.encode(key)
180-
req = RequestFormatter.meta_delete(key: encoded_key, cas: cas,
181-
base64: base64, quiet: quiet?)
169+
req = RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?)
182170
write(req)
183171
@connection_manager.flush unless quiet?
184172
response_processor.meta_delete unless quiet?
@@ -187,8 +175,7 @@ def delete(key, cas)
187175
# Pipelined delete - writes a quiet delete request without reading response.
188176
# Used by PipelinedDeleter for bulk operations.
189177
def pipelined_delete(key)
190-
encoded_key, base64 = KeyRegularizer.encode(key)
191-
req = RequestFormatter.meta_delete(key: encoded_key, base64: base64, quiet: true)
178+
req = RequestFormatter.meta_delete(key: key, quiet: true)
192179
write(req)
193180
end
194181

@@ -203,9 +190,8 @@ def incr(key, count, ttl, initial)
203190

204191
def decr_incr(incr, key, delta, ttl, initial)
205192
ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
206-
encoded_key, base64 = KeyRegularizer.encode(key)
207-
write(RequestFormatter.meta_arithmetic(key: encoded_key, delta: delta, initial: initial, incr: incr, ttl: ttl,
208-
quiet: quiet?, base64: base64))
193+
write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
194+
quiet: quiet?))
209195
@connection_manager.flush unless quiet?
210196
response_processor.decr_incr unless quiet?
211197
end
@@ -248,23 +234,9 @@ def write_noop
248234
# machinery (IO.select, response buffering, server grouping).
249235
def read_multi_req(keys)
250236
is_raw = raw_mode?
251-
# Inline request formatting — avoids RequestFormatter.meta_get overhead per key.
252-
# In raw mode: "mg <key> v k q s\r\n" (no f flag, key at index 2)
253-
# Normal mode: "mg <key> v f k q s\r\n" (key at index 3)
254-
post_get = is_raw ? " v k q s\r\n" : " v f k q s\r\n"
255-
buffer = ''.b
256-
keys.each do |key|
257-
encoded_key, base64 = KeyRegularizer.encode(key)
258-
if base64
259-
buffer << 'mg ' << encoded_key << ' b' << post_get
260-
else
261-
buffer << 'mg ' << encoded_key << post_get
262-
end
263-
end
264-
buffer << 'mn' << TERMINATOR
237+
buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw)
265238
flushed_write(buffer)
266239
buffer.clear
267-
268240
read_multi_get_responses(is_raw)
269241
end
270242

@@ -287,49 +259,30 @@ def parse_multi_get_value(line, key_index, is_raw)
287259
raw_key = tokens[key_index]
288260
return unless raw_key
289261

290-
key = KeyRegularizer.decode(raw_key[1..], tokens.include?('b'))
262+
key = raw_key[1..]
263+
key = KeyRegularizer.decode(key) if tokens.include?('b')
291264
bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
292265
[key, @value_marshaller.retrieve(value, bitflags)]
293266
end
294267

295-
# rubocop:disable Metrics/AbcSize
296-
297268
# Single-server fast path for set_multi. Inlines request formatting to
298269
# minimize per-key overhead. Avoids PipelinedSetter server grouping.
299270
def write_multi_req(pairs, ttl, req_options)
300271
ttl = TtlSanitizer.sanitize(ttl) if ttl
301-
buffer = ''.b
302-
pairs.each do |key, raw_value|
303-
(value, bitflags) = @value_marshaller.store(key, raw_value, req_options)
304-
encoded_key, base64 = KeyRegularizer.encode(key)
305-
# Inline format: "ms <key> <size> c [b] F<flags> T<ttl> MS q\r\n"
306-
buffer << "ms #{encoded_key} #{value.bytesize} c"
307-
buffer << ' b' if base64
308-
buffer << " F#{bitflags}" if bitflags
309-
buffer << " T#{ttl}" if ttl
310-
buffer << ' MS q' << TERMINATOR << value << TERMINATOR
272+
entries = pairs.map do |key, raw_value|
273+
[key, @value_marshaller.store(key, raw_value, req_options)]
311274
end
312-
buffer << RequestFormatter.meta_noop
275+
276+
buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl)
313277
flushed_write(buffer)
314278
buffer.clear
315279
response_processor.consume_all_responses_until_mn
316280
end
317-
# rubocop:enable Metrics/AbcSize
318281

319282
# Single-server fast path for delete_multi. Writes all quiet delete requests
320283
# terminated by a noop, then consumes all responses.
321284
def delete_multi_req(keys)
322-
buffer = ''.b
323-
keys.each do |key|
324-
encoded_key, base64 = KeyRegularizer.encode(key)
325-
# Inline format: "md <key> [b] q\r\n"
326-
if base64
327-
buffer << 'md ' << encoded_key << ' b q' << TERMINATOR
328-
else
329-
buffer << 'md ' << encoded_key << ' q' << TERMINATOR
330-
end
331-
end
332-
buffer << RequestFormatter.meta_noop
285+
buffer = RequestFormatter.multi_meta_delete(keys)
333286
flushed_write(buffer)
334287
buffer.clear
335288
response_processor.consume_all_responses_until_mn

0 commit comments

Comments
 (0)