dalli 5.0.5 → 5.1.0

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.
@@ -24,43 +24,38 @@ module Dalli
24
24
 
25
25
  # Retrieval Commands
26
26
  def get(key, options = nil)
27
- encoded_key, base64 = KeyRegularizer.encode(key)
28
27
  # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
29
28
  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, **routing_token_kwargs(options))
31
30
  flushed_write(req)
32
31
  response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
33
32
  end
34
33
 
35
- def quiet_get_request(key)
36
- encoded_key, base64 = KeyRegularizer.encode(key)
34
+ def quiet_get_request(key, options = nil)
37
35
  # 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?,
37
+ **routing_token_kwargs(options))
40
38
  end
41
39
 
42
40
  def gat(key, ttl, options = nil)
43
41
  ttl = TtlSanitizer.sanitize(ttl)
44
- encoded_key, base64 = KeyRegularizer.encode(key)
45
42
  skip_flags = raw_mode? || (options && options[:raw])
46
- req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, base64: base64, skip_flags: skip_flags)
43
+ req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags, **routing_token_kwargs(options))
47
44
  flushed_write(req)
48
45
  response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
49
46
  end
50
47
 
51
48
  def touch(key, ttl)
52
49
  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)
50
+ req = RequestFormatter.meta_get(key: key, ttl: ttl, value: false)
55
51
  flushed_write(req)
56
52
  response_processor.meta_get_without_value
57
53
  end
58
54
 
59
55
  # TODO: This is confusing, as there's a cas command in memcached
60
56
  # and this isn't it. Maybe rename? Maybe eliminate?
61
- 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
+ def cas(key, options = nil)
58
+ req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, **routing_token_kwargs(options))
64
59
  flushed_write(req)
65
60
  response_processor.meta_get_with_value_and_cas
66
61
  end
@@ -90,17 +85,20 @@ module Dalli
90
85
  # - :hit_before - true/false if previously accessed (only if return_hit_status: true)
91
86
  # - :last_access - seconds since last access (only if return_last_access: true)
92
87
  def meta_get(key, options = {})
93
- encoded_key, base64 = KeyRegularizer.encode(key)
94
88
  req = RequestFormatter.meta_get(
95
- key: encoded_key, value: true, return_cas: true, base64: base64,
89
+ key: key, value: true, return_cas: true,
96
90
  vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
97
91
  return_hit_status: options[:return_hit_status],
98
- return_last_access: options[:return_last_access], skip_lru_bump: options[:skip_lru_bump]
92
+ return_last_access: options[:return_last_access],
93
+ return_ttl_remaining: options[:return_ttl_remaining],
94
+ skip_lru_bump: options[:skip_lru_bump],
95
+ **routing_token_kwargs(options)
99
96
  )
100
97
  flushed_write(req)
101
98
  response_processor.meta_get_with_metadata(
102
99
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
103
- return_last_access: options[:return_last_access]
100
+ return_last_access: options[:return_last_access],
101
+ return_ttl_remaining: options[:return_ttl_remaining]
104
102
  )
105
103
  end
106
104
 
@@ -112,8 +110,7 @@ module Dalli
112
110
  # @param cas [Integer] optional CAS value for compare-and-swap
113
111
  # @return [Boolean] true if successful
114
112
  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)
113
+ req = RequestFormatter.meta_delete(key: key, cas: cas, stale: true)
117
114
  flushed_write(req)
118
115
  response_processor.meta_delete
119
116
  end
@@ -140,45 +137,46 @@ module Dalli
140
137
  response_processor.meta_set_with_cas unless quiet?
141
138
  end
142
139
 
143
- # rubocop:disable Metrics/ParameterLists
144
140
  def write_storage_req(mode, key, raw_value, ttl = nil, cas = nil, options = {}, quiet: quiet?)
145
141
  (value, bitflags) = @value_marshaller.store(key, raw_value, options)
146
142
  ttl = TtlSanitizer.sanitize(ttl) if ttl
147
- encoded_key, base64 = KeyRegularizer.encode(key)
148
- req = RequestFormatter.meta_set(key: encoded_key, value: value,
143
+ req = RequestFormatter.meta_set(key: key, value: value,
149
144
  bitflags: bitflags, cas: cas,
150
- ttl: ttl, mode: mode, quiet: quiet, base64: base64)
145
+ ttl: ttl, mode: mode, quiet: quiet,
146
+ **routing_token_kwargs(options))
151
147
  write("#{req}#{value}#{TERMINATOR}")
152
148
  @connection_manager.flush unless quiet
153
149
  end
154
150
  # rubocop:enable Metrics/ParameterLists
155
151
 
156
- def append(key, value)
157
- write_append_prepend_req(:append, key, value)
152
+ def append(key, value, options = nil)
153
+ write_append_prepend_req(:append, key, value, nil, nil, options)
158
154
  response_processor.meta_set_append_prepend unless quiet?
159
155
  end
160
156
 
161
- def prepend(key, value)
162
- write_append_prepend_req(:prepend, key, value)
157
+ def prepend(key, value, options = nil)
158
+ write_append_prepend_req(:prepend, key, value, nil, nil, options)
163
159
  response_processor.meta_set_append_prepend unless quiet?
164
160
  end
165
161
 
166
162
  # rubocop:disable Metrics/ParameterLists
167
- def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, _options = {})
163
+ def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, options = nil)
168
164
  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,
171
- cas: cas, ttl: ttl, mode: mode, quiet: quiet?)
165
+ req = RequestFormatter.meta_set(key: key, value: value,
166
+ cas: cas, ttl: ttl, mode: mode, quiet: quiet?,
167
+ **routing_token_kwargs(options))
172
168
  write("#{req}#{value}#{TERMINATOR}")
173
169
  @connection_manager.flush unless quiet?
174
170
  end
175
171
  # rubocop:enable Metrics/ParameterLists
176
172
 
177
173
  # Delete Commands
178
- 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?)
174
+ #
175
+ # `options` supports the meta-delete keys :invalidate, :tombstone_ttl and
176
+ # :drop_value, plus :p_token/:l_token; see Dalli::Client#delete.
177
+ def delete(key, cas, options = nil)
178
+ req = RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?,
179
+ **tombstone_kwargs(options), **routing_token_kwargs(options))
182
180
  write(req)
183
181
  @connection_manager.flush unless quiet?
184
182
  response_processor.meta_delete unless quiet?
@@ -186,29 +184,35 @@ module Dalli
186
184
 
187
185
  # Pipelined delete - writes a quiet delete request without reading response.
188
186
  # Used by PipelinedDeleter for bulk operations.
189
- def pipelined_delete(key)
190
- encoded_key, base64 = KeyRegularizer.encode(key)
191
- req = RequestFormatter.meta_delete(key: encoded_key, base64: base64, quiet: true)
187
+ def pipelined_delete(key, req_options = nil)
188
+ req = RequestFormatter.meta_delete(key: key, quiet: true,
189
+ **tombstone_kwargs(req_options), **routing_token_kwargs(req_options))
192
190
  write(req)
193
191
  end
194
192
 
193
+ def finish_pipelined_delete(sent)
194
+ write_noop
195
+ sent - response_processor.pipelined_delete_non_deletions
196
+ end
197
+
195
198
  # Arithmetic Commands
196
- def decr(key, count, ttl, initial)
197
- decr_incr false, key, count, ttl, initial
199
+ def decr(key, count, ttl, initial, options = nil)
200
+ decr_incr false, key, count, ttl, initial, options
198
201
  end
199
202
 
200
- def incr(key, count, ttl, initial)
201
- decr_incr true, key, count, ttl, initial
203
+ def incr(key, count, ttl, initial, options = nil)
204
+ decr_incr true, key, count, ttl, initial, options
202
205
  end
203
206
 
204
- def decr_incr(incr, key, delta, ttl, initial)
207
+ # rubocop:disable Metrics/ParameterLists
208
+ def decr_incr(incr, key, delta, ttl, initial, options = nil)
205
209
  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))
210
+ write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
211
+ quiet: quiet?, **routing_token_kwargs(options)))
209
212
  @connection_manager.flush unless quiet?
210
213
  response_processor.decr_incr unless quiet?
211
214
  end
215
+ # rubocop:enable Metrics/ParameterLists
212
216
 
213
217
  # Other Commands
214
218
  def flush(delay = 0)
@@ -246,28 +250,52 @@ module Dalli
246
250
  # Single-server fast path for get_multi. Inlines request formatting and
247
251
  # response parsing to minimize per-key overhead. Avoids the PipelinedGetter
248
252
  # machinery (IO.select, response buffering, server grouping).
249
- def read_multi_req(keys)
253
+ def read_multi_req(keys, options = nil)
250
254
  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
255
+ buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
265
256
  flushed_write(buffer)
266
257
  buffer.clear
267
-
268
258
  read_multi_get_responses(is_raw)
269
259
  end
270
260
 
261
+ # Stale-aware bulk get. Returns { key => { value:, cas:, stale:, miss: } }
262
+ # for the keys the server returned. Keys that were not found are absent
263
+ # from the hash, matching read_multi_req and the get_multi family; a
264
+ # tombstoned item is present (it answers VA with the X flag) with
265
+ # stale: true, which is the distinction callers need.
266
+ #
267
+ # Shared by both the single-server fast path and PipelinedGetter's
268
+ # per-server-group request, so this one change covers both routes.
269
+ def read_multi_with_metadata_req(keys, options = nil)
270
+ is_raw = raw_mode?
271
+ buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, return_cas: true,
272
+ **routing_token_kwargs(options))
273
+ flushed_write(buffer)
274
+ buffer.clear
275
+ read_multi_metadata_responses(is_raw)
276
+ end
277
+
278
+ # Unlike read_multi_get_responses this locates tokens by flag rather than
279
+ # by position, because the c flag shifts the key's index.
280
+ def read_multi_metadata_responses(is_raw)
281
+ hash = {}
282
+ while (line = @connection_manager.read_line)
283
+ break if line.start_with?('MN')
284
+ next unless line.start_with?('VA ')
285
+
286
+ tokens = line.chomp!(TERMINATOR).split
287
+ value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
288
+ stale = response_processor.stale_from_tokens(tokens)
289
+ cas = response_processor.cas_from_tokens(tokens)
290
+ bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
291
+ key = response_processor.key_from_tokens(tokens)
292
+ next if key.nil?
293
+
294
+ hash[key] = { value: @value_marshaller.retrieve(value, bitflags), cas: cas, stale: stale, miss: false }
295
+ end
296
+ hash
297
+ end
298
+
271
299
  def read_multi_get_responses(is_raw)
272
300
  hash = {}
273
301
  key_index = is_raw ? 2 : 3
@@ -287,52 +315,34 @@ module Dalli
287
315
  raw_key = tokens[key_index]
288
316
  return unless raw_key
289
317
 
290
- key = KeyRegularizer.decode(raw_key[1..], tokens.include?('b'))
318
+ key = raw_key[1..]
319
+ key = KeyRegularizer.decode(key) if tokens.include?('b')
291
320
  bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
292
321
  [key, @value_marshaller.retrieve(value, bitflags)]
293
322
  end
294
323
 
295
- # rubocop:disable Metrics/AbcSize
296
-
297
324
  # Single-server fast path for set_multi. Inlines request formatting to
298
325
  # minimize per-key overhead. Avoids PipelinedSetter server grouping.
299
326
  def write_multi_req(pairs, ttl, req_options)
300
327
  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
328
+ entries = pairs.map do |key, raw_value|
329
+ [key, @value_marshaller.store(key, raw_value, req_options)]
311
330
  end
312
- buffer << RequestFormatter.meta_noop
331
+
332
+ buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl, **routing_token_kwargs(req_options))
313
333
  flushed_write(buffer)
314
334
  buffer.clear
315
335
  response_processor.consume_all_responses_until_mn
316
336
  end
317
- # rubocop:enable Metrics/AbcSize
318
337
 
319
338
  # Single-server fast path for delete_multi. Writes all quiet delete requests
320
339
  # terminated by a noop, then consumes all responses.
321
- 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
340
+ def delete_multi_req(keys, req_options = nil)
341
+ buffer = RequestFormatter.multi_meta_delete(keys, **tombstone_kwargs(req_options),
342
+ **routing_token_kwargs(req_options))
333
343
  flushed_write(buffer)
334
344
  buffer.clear
335
- response_processor.consume_all_responses_until_mn
345
+ keys.size - response_processor.pipelined_delete_non_deletions
336
346
  end
337
347
 
338
348
  require_relative 'key_regularizer'
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: false
1
+ # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
4
  module Protocol
@@ -7,14 +7,13 @@ module Dalli
7
7
  # Class that encapsulates logic for formatting meta protocol requests
8
8
  # to memcached.
9
9
  ##
10
- class RequestFormatter
10
+ module RequestFormatter
11
+ extend self
12
+
11
13
  # Since these are string construction methods, we're going to disable these
12
14
  # Rubocop directives. We really can't make this construction much simpler,
13
15
  # and introducing an intermediate object seems like overkill.
14
16
  #
15
- # rubocop:disable Metrics/CyclomaticComplexity
16
- # rubocop:disable Metrics/ParameterLists
17
- # rubocop:disable Metrics/PerceivedComplexity
18
17
  #
19
18
  # Meta get flags:
20
19
  #
@@ -35,54 +34,142 @@ module Dalli
35
34
  # - Z: Client lost the recache race (another client is already regenerating)
36
35
  # - h0/h1: Hit status (0 = first access, 1 = previously accessed)
37
36
  # - l<N>: Seconds since last access
38
- def self.meta_get(key:, value: true, return_cas: false, ttl: nil, base64: false, quiet: false,
39
- vivify_ttl: nil, recache_ttl: nil,
40
- return_hit_status: false, return_last_access: false, skip_lru_bump: false,
41
- skip_flags: false)
42
- cmd = "mg #{key}"
37
+ def meta_get(key:, value: true, return_cas: false, ttl: nil, quiet: false,
38
+ vivify_ttl: nil, recache_ttl: nil,
39
+ return_hit_status: false, return_last_access: false, return_ttl_remaining: false,
40
+ skip_lru_bump: false, skip_flags: false, p_token: nil, l_token: nil)
41
+ cmd = "mg #{encoded_key(key)}"
43
42
  # In raw mode (skip_flags: true), we don't request bitflags since they're not used.
44
43
  # This saves 2 bytes per request and skips parsing on response.
45
44
  cmd << (skip_flags ? ' v' : ' v f') if value
46
45
  cmd << ' c' if return_cas
47
- cmd << ' b' if base64
48
46
  cmd << " T#{ttl}" if ttl
47
+ cmd << routing_tokens(p_token: p_token, l_token: l_token)
49
48
  cmd << ' k q s' if quiet # Return the key in the response if quiet
50
49
  cmd << " N#{vivify_ttl}" if vivify_ttl # Thundering herd: vivify on miss
51
50
  cmd << " R#{recache_ttl}" if recache_ttl # Thundering herd: win recache if TTL below threshold
52
51
  cmd << ' h' if return_hit_status # Return hit status (0 or 1)
53
52
  cmd << ' l' if return_last_access # Return seconds since last access
53
+ cmd << ' t' if return_ttl_remaining # Return seconds of TTL remaining (-1 = no TTL)
54
54
  cmd << ' u' if skip_lru_bump # Don't bump LRU or update access stats
55
55
  cmd << TERMINATOR
56
56
  end
57
57
 
58
- def self.meta_set(key:, value:, bitflags: nil, cas: nil, ttl: nil, mode: :set, base64: false, quiet: false)
58
+ def multi_meta_get(keys, skip_flags: false, return_cas: false, p_token: nil, l_token: nil)
59
+ # In raw mode: "mg <key> v k q s\r\n" (no f flag, key at index 2)
60
+ # Normal mode: "mg <key> v f k q s\r\n" (key at index 3)
61
+ # With return_cas a "c" flag follows, which shifts those indexes --
62
+ # callers of that variant locate tokens by flag rather than position.
63
+ # Routing tokens apply to every key in the batch, so the suffix is
64
+ # built once rather than per key.
65
+ post_get = if return_cas
66
+ skip_flags ? ' v c' : ' v f c'
67
+ else
68
+ skip_flags ? ' v' : ' v f'
69
+ end
70
+ post_get += routing_tokens(p_token: p_token, l_token: l_token)
71
+ post_get += " k q s#{TERMINATOR}"
72
+
73
+ buffer = ''.b
74
+ keys.each do |key|
75
+ buffer << 'mg ' << encoded_key(key) << post_get
76
+ end
77
+ buffer << 'mn' << TERMINATOR
78
+ end
79
+
80
+ def meta_set(key:, value:, bitflags: nil, cas: nil, ttl: nil, mode: :set, quiet: false,
81
+ p_token: nil, l_token: nil)
82
+ base64 = KeyRegularizer.required?(key)
83
+ key = KeyRegularizer.encode(key) if base64
59
84
  cmd = "ms #{key} #{value.bytesize}"
60
- cmd << ' c' unless %i[append prepend].include?(mode)
85
+ # Skip the cas-return flag in quiet mode: the response is suppressed,
86
+ # so requesting it only adds bytes to the request.
87
+ cmd << ' c' if !quiet && !%i[append prepend].include?(mode)
61
88
  cmd << ' b' if base64
62
89
  cmd << " F#{bitflags}" if bitflags
63
90
  cmd << cas_string(cas)
64
91
  cmd << " T#{ttl}" if ttl
65
92
  cmd << " M#{mode_to_token(mode)}"
66
93
  cmd << ' q' if quiet
94
+ cmd << routing_tokens(p_token: p_token, l_token: l_token)
67
95
  cmd << TERMINATOR
68
96
  end
69
97
 
98
+ def multi_meta_set(entries, ttl: nil, p_token: nil, l_token: nil)
99
+ # Routing tokens apply to every entry in the batch, so the suffix is
100
+ # built once rather than per entry.
101
+ token_suffix = routing_tokens(p_token: p_token, l_token: l_token)
102
+
103
+ buffer = ''.b
104
+ entries.each do |key, pair|
105
+ value, bitflags = pair
106
+
107
+ base64 = KeyRegularizer.required?(key)
108
+ key = KeyRegularizer.encode(key) if base64
109
+
110
+ # Inline format: "ms <key> <size> c [b] F<flags> T<ttl> MS q [P/L]\r\n"
111
+ buffer << "ms #{key} #{value.bytesize} c"
112
+ buffer << ' b' if base64
113
+ buffer << " F#{bitflags}" if bitflags
114
+ buffer << " T#{ttl}" if ttl
115
+ buffer << ' MS q' << token_suffix << TERMINATOR << value << TERMINATOR
116
+ end
117
+ buffer << META_NOOP
118
+ end
119
+
70
120
  # Thundering herd protection flag:
71
121
  # - stale (I flag): Instead of deleting the item, mark it as stale. Other clients
72
122
  # using N/R flags will see the X flag and know the item is being regenerated.
73
- def self.meta_delete(key:, cas: nil, ttl: nil, base64: false, quiet: false, stale: false)
74
- cmd = "md #{key}"
75
- cmd << ' b' if base64
123
+ # Tombstone flags:
124
+ # - stale (I flag): mark the item stale instead of removing it. Readers
125
+ # using N/R flags, or get_with_metadata, see the X flag and know the
126
+ # item is being regenerated.
127
+ # - ttl (T flag): how long the stale marker lives. memcached only honors
128
+ # T on a delete when it is paired with I, so this raises rather than
129
+ # emitting a request the server would apply differently than intended.
130
+ # - drop_value (x flag): remove the item's value but leave the item, so a
131
+ # tombstone can be left without retaining the old payload.
132
+ def meta_delete(key:, cas: nil, ttl: nil, quiet: false, stale: false, drop_value: false,
133
+ p_token: nil, l_token: nil)
134
+ # Message uses this method's own parameter names (ttl/stale), not the
135
+ # client-facing tombstone_ttl/invalidate names Dalli::Client validates
136
+ # against -- this guard is also reachable by internal callers (tests,
137
+ # direct RequestFormatter use) that never go through the client.
138
+ raise ArgumentError, 'ttl requires stale: true' if ttl && !stale
139
+
140
+ cmd = "md #{encoded_key(key)}"
76
141
  cmd << cas_string(cas)
77
- cmd << " T#{ttl}" if ttl
78
142
  cmd << ' I' if stale # Mark stale instead of deleting
143
+ cmd << " T#{Integer(ttl)}" if ttl
144
+ cmd << ' x' if drop_value # Drop the value but keep the item
79
145
  cmd << ' q' if quiet
146
+ cmd << routing_tokens(p_token: p_token, l_token: l_token)
80
147
  cmd << TERMINATOR
81
148
  end
82
149
 
83
- def self.meta_arithmetic(key:, delta:, initial:, incr: true, cas: nil, ttl: nil, base64: false, quiet: false)
84
- cmd = "ma #{key} v"
85
- cmd << ' b' if base64
150
+ # Tombstone and routing-token flags apply to every key in the batch;
151
+ # see meta_delete.
152
+ def multi_meta_delete(keys, stale: false, ttl: nil, drop_value: false, p_token: nil, l_token: nil)
153
+ raise ArgumentError, 'tombstone_ttl requires invalidate: true' if ttl && !stale
154
+
155
+ suffix = +''
156
+ suffix << ' I' if stale
157
+ suffix << " T#{Integer(ttl)}" if ttl
158
+ suffix << ' x' if drop_value
159
+ suffix << ' q'
160
+ suffix << routing_tokens(p_token: p_token, l_token: l_token)
161
+ suffix << TERMINATOR
162
+
163
+ buffer = ''.b
164
+ keys.each do |key|
165
+ buffer << 'md ' << encoded_key(key) << suffix
166
+ end
167
+ buffer << META_NOOP
168
+ end
169
+
170
+ def meta_arithmetic(key:, delta:, initial:, incr: true, cas: nil, ttl: nil, quiet: false,
171
+ p_token: nil, l_token: nil)
172
+ cmd = "ma #{encoded_key(key)} v"
86
173
  cmd << " D#{delta}" if delta
87
174
  cmd << " J#{initial}" if initial
88
175
  # Always set a TTL if an initial value is specified
@@ -90,22 +177,53 @@ module Dalli
90
177
  cmd << cas_string(cas)
91
178
  cmd << ' q' if quiet
92
179
  cmd << " M#{incr ? 'I' : 'D'}"
180
+ cmd << routing_tokens(p_token: p_token, l_token: l_token)
93
181
  cmd << TERMINATOR
94
182
  end
183
+
184
+ # Builds the wire-format suffix for opaque routing tokens (P and L).
185
+ # memcached itself ignores these; they exist as hints for a proxy or
186
+ # router sitting between the client and memcached. See protocol.txt:
187
+ # "All commands accept tokens 'P' and 'L' which are completely ignored.
188
+ # The arguments to 'P' and 'L' can be used as hints or path
189
+ # specifications to a proxy or router inbetween a client and a
190
+ # memcached daemon."
191
+ #
192
+ # Empty / nil tokens are treated as no-ops. CRLF and null bytes are
193
+ # rejected with ArgumentError to prevent the token from being used as a
194
+ # wire-protocol injection vector (e.g. "foo\r\nflush_all\r\n" would
195
+ # otherwise be parsed as a second command by memcached or any
196
+ # intermediate proxy/LB).
197
+ def routing_tokens(p_token: nil, l_token: nil)
198
+ # Only an empty *String* is a no-op. Checking respond_to?(:empty?)
199
+ # instead would also swallow p_token: [] / {} before the type check
200
+ # below ever runs, silently dropping caller mistakes that should
201
+ # raise "must be a String".
202
+ p_token = nil if p_token.is_a?(String) && p_token.empty?
203
+ l_token = nil if l_token.is_a?(String) && l_token.empty?
204
+ validate_routing_token!('p_token', p_token)
205
+ validate_routing_token!('l_token', l_token)
206
+ return '' unless p_token || l_token
207
+
208
+ s = +''
209
+ s << " P#{p_token}" if p_token
210
+ s << " L#{l_token}" if l_token
211
+ s
212
+ end
95
213
  # rubocop:enable Metrics/CyclomaticComplexity
96
214
  # rubocop:enable Metrics/ParameterLists
97
215
  # rubocop:enable Metrics/PerceivedComplexity
98
216
 
99
217
  META_NOOP = "mn#{TERMINATOR}".freeze
100
- def self.meta_noop
218
+ def meta_noop
101
219
  META_NOOP
102
220
  end
103
221
 
104
- def self.version
222
+ def version
105
223
  "version#{TERMINATOR}"
106
224
  end
107
225
 
108
- def self.flush(delay: nil, quiet: false)
226
+ def flush(delay: nil, quiet: false)
109
227
  cmd = +'flush_all'
110
228
  cmd << " #{parse_to_64_bit_int(delay, 0)}" if delay
111
229
  cmd << ' noreply' if quiet
@@ -114,7 +232,7 @@ module Dalli
114
232
 
115
233
  ALLOWED_STATS_ARGS = [nil, '', 'items', 'slabs', 'settings', 'reset'].freeze
116
234
 
117
- def self.stats(arg = nil)
235
+ def stats(arg = nil)
118
236
  raise ArgumentError, "Invalid stats argument: #{arg.inspect}" unless ALLOWED_STATS_ARGS.include?(arg)
119
237
 
120
238
  cmd = +'stats'
@@ -122,7 +240,28 @@ module Dalli
122
240
  cmd << TERMINATOR
123
241
  end
124
242
 
125
- def self.mode_to_token(mode)
243
+ def encoded_key(key)
244
+ if KeyRegularizer.required?(key)
245
+ KeyRegularizer.encode(key) << ' b'
246
+ else
247
+ key
248
+ end
249
+ end
250
+
251
+ private
252
+
253
+ # Disallowed bytes: CR, LF, NUL. Any of these embedded in a routing
254
+ # token would let the caller inject a second wire-protocol command.
255
+ ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
256
+ private_constant :ROUTING_TOKEN_FORBIDDEN
257
+
258
+ def validate_routing_token!(name, value)
259
+ return if value.nil?
260
+ raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
261
+ raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
262
+ end
263
+
264
+ def mode_to_token(mode)
126
265
  case mode
127
266
  when :add
128
267
  'E'
@@ -137,12 +276,12 @@ module Dalli
137
276
  end
138
277
  end
139
278
 
140
- def self.cas_string(cas)
279
+ def cas_string(cas)
141
280
  cas = parse_to_64_bit_int(cas, nil)
142
281
  cas.nil? || cas.zero? ? '' : " C#{cas}"
143
282
  end
144
283
 
145
- def self.parse_to_64_bit_int(val, default)
284
+ def parse_to_64_bit_int(val, default)
146
285
  val.nil? ? nil : Integer(val)
147
286
  rescue ArgumentError
148
287
  # Sanitize to default if it isn't parsable as an integer