dalli 5.1.4 → 5.2.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.
@@ -18,7 +18,8 @@ module Dalli
18
18
 
19
19
  def_delegators :@value_marshaller, :serializer, :compressor, :compression_min_size, :compress_by_default?
20
20
  def_delegators :@connection_manager, :name, :hostname, :port, :close, :socket_timeout,
21
- :socket_type, :up!, :down!, :write, :reconnect_down_server?, :raise_down_error
21
+ :socket_type, :up!, :down!, :write, :reconnect_down_server?, :raise_down_error,
22
+ :quiet_responses_pending?
22
23
 
23
24
  # Delegated by hand rather than with def_delegators because they run on
24
25
  # every request, and a plain method call is cheaper than a Forwardable one.
@@ -41,6 +42,7 @@ module Dalli
41
42
  warn_uri_credentials(user_creds)
42
43
  @options = client_options.merge(user_creds)
43
44
  @raw_mode = client_options[:raw]
45
+ @defer_drain = client_options[:defer_drain] ? true : false
44
46
  @value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
45
47
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
46
48
  end
@@ -51,14 +53,6 @@ module Dalli
51
53
  @raw_mode
52
54
  end
53
55
 
54
- # True when values should come back as stored: the client is in raw mode,
55
- # or this request passed raw: true. Retrieval paths use it to skip
56
- # requesting flags, so a stored value is never deserialized or
57
- # decompressed for a caller who asked for raw bytes.
58
- def raw_request?(options)
59
- raw_mode? || (options.is_a?(Hash) && options[:raw]) ? true : false
60
- end
61
-
62
56
  # Chokepoint method for error handling and ensuring liveness
63
57
  def request(opkey, *args)
64
58
  verify_state(opkey)
@@ -66,7 +60,11 @@ module Dalli
66
60
  begin
67
61
  request_completed = false
68
62
  @connection_manager.start_request!
63
+ drain_deferred_quiet_responses(opkey)
69
64
  response = send(opkey, *args)
65
+ # Quiet requests can leave replies on the socket; remember that so the
66
+ # end of the quiet block knows this server needs draining
67
+ @connection_manager.mark_quiet_responses_pending! if quiet? && opkey != :noop
70
68
 
71
69
  # pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
72
70
  @connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)
@@ -150,15 +148,7 @@ module Dalli
150
148
  reconnect_on_pipeline_complete!
151
149
  values = nil
152
150
 
153
- # Only the read is socket I/O. The caller's block runs below, outside
154
- # this rescue, so an Errno or Timeout::Error it raises reaches the
155
- # caller instead of being mistaken for a network failure (which closed
156
- # the connection and retried the whole get_multi, yielding keys twice).
157
- begin
158
- response_buffer.read
159
- rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
160
- @connection_manager.error_on_request!(e)
161
- end
151
+ response_buffer.read
162
152
 
163
153
  status, cas, key, value = response_buffer.process_single_getk_response
164
154
  # status is not nil only if we have a full response to parse
@@ -184,6 +174,8 @@ module Dalli
184
174
  end
185
175
 
186
176
  values || {}
177
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
178
+ @connection_manager.error_on_request!(e)
187
179
  end
188
180
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
189
181
 
@@ -197,13 +189,10 @@ module Dalli
197
189
  @connection_manager.abort_request!
198
190
  return true unless connected?
199
191
 
200
- # Closes the connection, which ensures that our connection is in a
201
- # clean state for future requests. The get_multi ran out of time
202
- # overall rather than hitting a socket error, so it doesn't count
203
- # toward socket_max_failures: two slow get_multis in a row would
204
- # otherwise mark a healthy server down.
205
- Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
206
- @connection_manager.close
192
+ # Closes the connection, which ensures that our connection
193
+ # is in a clean state for future requests
194
+ @connection_manager.error_on_request!('External timeout')
195
+ rescue NetworkError
207
196
  true
208
197
  end
209
198
 
@@ -234,6 +223,14 @@ module Dalli
234
223
  ALLOWED_QUIET_OPS = %i[add replace set delete incr decr append prepend flush noop].freeze
235
224
  private_constant :ALLOWED_QUIET_OPS
236
225
 
226
+ # With defer_drain, quiet blocks leave their replies unread. Read them
227
+ # before any non-quiet request, which reads a reply of its own.
228
+ def drain_deferred_quiet_responses(opkey)
229
+ return unless @defer_drain && !quiet? && opkey != :noop && @connection_manager.quiet_responses_pending?
230
+
231
+ noop
232
+ end
233
+
237
234
  def verify_allowed_quiet!(opkey)
238
235
  return if ALLOWED_QUIET_OPS.include?(opkey)
239
236
 
@@ -331,7 +328,6 @@ module Dalli
331
328
  # Use clear (not reset) to keep pipeline_complete? = true, which is
332
329
  # the expected state before pipeline_response_setup is called.
333
330
  response_buffer.clear
334
- response_buffer.raw = raw_request?(options)
335
331
 
336
332
  # The terminating noop is sent by pipeline_response_setup
337
333
  write(quiet_get_requests(keys, options, return_cas: return_cas))
@@ -343,7 +339,6 @@ module Dalli
343
339
  def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
344
340
  # Initialize the response buffer for draining during send phase
345
341
  response_buffer.ensure_ready
346
- response_buffer.raw = raw_request?(options)
347
342
 
348
343
  keys.each_slice(chunk_size) do |chunk|
349
344
  # Build and write this chunk of requests
@@ -33,12 +33,10 @@ module Dalli
33
33
  @socket_type = socket_type
34
34
  @options = DEFAULTS.merge(client_options)
35
35
  @request_in_progress = false
36
+ @quiet_responses_pending = false
36
37
  @sock = nil
37
38
  @pid = nil
38
- @write_buffer = []
39
- @write_buffer_bytes = 0
40
39
 
41
- @fail_count = 0
42
40
  reset_down_info
43
41
  end
44
42
 
@@ -54,10 +52,10 @@ module Dalli
54
52
  Dalli.logger.debug { "Dalli::Server#connect #{name}" }
55
53
 
56
54
  @sock = memcached_socket
57
- # Writes are buffered in @write_buffer instead; see WRITE_BUFFER_FLUSH_BYTES
58
- @sock.sync = true
55
+ @sock.sync = false # Enable buffered I/O for better performance
59
56
  @pid = Process.pid
60
57
  @request_in_progress = false
58
+ @quiet_responses_pending = false
61
59
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
62
60
  # SocketError = DNS resolution failure
63
61
  error_on_request!(e)
@@ -87,8 +85,6 @@ module Dalli
87
85
  def down!
88
86
  close
89
87
  log_down_detected
90
- # Once down_retry_delay passes, the server gets a full set of attempts
91
- @fail_count = 0
92
88
 
93
89
  @error = $ERROR_INFO&.class&.name
94
90
  @msg ||= $ERROR_INFO&.message
@@ -118,7 +114,7 @@ module Dalli
118
114
  return unless @sock
119
115
 
120
116
  begin
121
- close_socket
117
+ @sock.close
122
118
  rescue StandardError
123
119
  nil
124
120
  ensure
@@ -129,22 +125,30 @@ module Dalli
129
125
  # @request_in_progress == true.
130
126
  @sock = nil
131
127
  @pid = nil
132
- discard_write_buffer
128
+ @quiet_responses_pending = false
133
129
  abort_request!
134
130
  end
135
131
  end
136
132
 
137
- # A forked child shares the parent's connection, so it closes only its
138
- # own file descriptor. Closing the TLS socket itself would send
139
- # close_notify and end the parent's TLS session.
140
- def close_socket
141
- fork_detected? ? @sock.to_io.close : @sock.close
142
- end
143
-
144
133
  def connected?
145
134
  !@sock.nil?
146
135
  end
147
136
 
137
+ # True when a quiet request was written on this connection and its
138
+ # replies (memcached only suppresses some of them) haven't been
139
+ # consumed by a noop yet.
140
+ def quiet_responses_pending?
141
+ @quiet_responses_pending
142
+ end
143
+
144
+ def mark_quiet_responses_pending!
145
+ @quiet_responses_pending = true
146
+ end
147
+
148
+ def clear_quiet_responses_pending!
149
+ @quiet_responses_pending = false
150
+ end
151
+
148
152
  def request_in_progress?
149
153
  @request_in_progress
150
154
  end
@@ -159,11 +163,6 @@ module Dalli
159
163
  raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless @request_in_progress
160
164
 
161
165
  @request_in_progress = false
162
- # A completed request is what proves the server healthy again, so the
163
- # failure count resets here rather than on reconnect: a server that
164
- # accepts connections but never answers would otherwise reset it on
165
- # every retry and never reach socket_max_failures.
166
- @fail_count = 0
167
166
  end
168
167
 
169
168
  def abort_request!
@@ -171,7 +170,6 @@ module Dalli
171
170
  end
172
171
 
173
172
  def read_line
174
- flush_write_buffer
175
173
  data = @sock.gets("\r\n")
176
174
  error_on_request!('EOF in read_line') if data.nil?
177
175
  data
@@ -181,29 +179,14 @@ module Dalli
181
179
 
182
180
  # JRuby doesn't support IO#timeout=, so use custom readfull implementation
183
181
  # CRuby 3.3+ has IO#timeout= which makes IO#read work with timeouts
184
- # memcached can't store an item larger than 1 GiB (its -I maximum), so a
185
- # reply claiming more (or a negative size) is malformed or hostile.
186
- # IO#read allocates the full count up front, so check before reading.
187
- MAX_READ_BYTES = (1024 * 1024 * 1024) + 2 # plus the value's trailing "\r\n"
188
-
189
- def check_read_size!(count)
190
- return if count.between?(0, MAX_READ_BYTES)
191
-
192
- raise Dalli::DalliError, "Reply size #{count} from #{name} is out of range"
193
- end
194
-
195
182
  if RUBY_ENGINE == 'jruby'
196
183
  def read(count)
197
- check_read_size!(count)
198
- flush_write_buffer
199
184
  @sock.readfull(count)
200
185
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
201
186
  error_on_request!(e)
202
187
  end
203
188
  else
204
189
  def read(count)
205
- check_read_size!(count)
206
- flush_write_buffer
207
190
  read_bytes(count)
208
191
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
209
192
  error_on_request!(e)
@@ -214,34 +197,22 @@ module Dalli
214
197
  # at the call site.
215
198
  alias read_exact read
216
199
 
217
- # Requests are buffered here rather than in the socket's own IO buffer
218
- # (the socket is sync). Ruby flushes an IO's write buffer when any process
219
- # holding it closes or finalizes the IO, so after a fork a child could
220
- # send bytes the parent had buffered but not sent yet: a quiet write would
221
- # run twice, or the parent's replies would shift onto the wrong requests.
222
- # A forked child discards this buffer instead. Like IO's own buffer, it
223
- # is sent once it grows past this size, or when a reply is read.
224
- WRITE_BUFFER_FLUSH_BYTES = 64 * 1024
225
-
226
200
  def write(bytes)
227
- @write_buffer << bytes
228
- @write_buffer_bytes += bytes.bytesize
229
- flush_write_buffer if @write_buffer_bytes >= WRITE_BUFFER_FLUSH_BYTES
230
- bytes.bytesize
201
+ @sock.write(bytes)
231
202
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
232
203
  error_on_request!(e)
233
204
  end
234
205
 
235
206
  def flush
236
- flush_write_buffer
207
+ @sock.flush
237
208
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
238
209
  error_on_request!(e)
239
210
  end
240
211
 
241
212
  def flushed_write(bytes)
242
- @write_buffer << bytes
243
- flush_write_buffer
244
- bytes.bytesize
213
+ written = @sock.write(bytes)
214
+ @sock.flush
215
+ written
245
216
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
246
217
  error_on_request!(e)
247
218
  end
@@ -249,7 +220,6 @@ module Dalli
249
220
  # Non-blocking read. Here to support the operation
250
221
  # of the get_multi operation
251
222
  def read_available(...)
252
- flush_write_buffer
253
223
  @sock.read_available(...)
254
224
  end
255
225
 
@@ -276,9 +246,8 @@ module Dalli
276
246
  raise Dalli::RetryableNetworkError, message
277
247
  end
278
248
 
279
- # Called on connect. Deliberately leaves @fail_count alone; see
280
- # finish_request!.
281
249
  def reset_down_info
250
+ @fail_count = 0
282
251
  @down_at = nil
283
252
  @last_down_at = nil
284
253
  @msg = nil
@@ -303,8 +272,7 @@ module Dalli
303
272
  def reconnect_on_fork
304
273
  message = 'Fork detected, re-connecting child process...'
305
274
  Dalli.logger.info { message }
306
- # Drops anything the parent had buffered, closes the inherited socket
307
- # without touching the parent's connection, and reconnects immediately
275
+ # Close socket on a fork and reconnect immediately
308
276
  close
309
277
  establish_connection
310
278
  end
@@ -334,37 +302,6 @@ module Dalli
334
302
 
335
303
  private
336
304
 
337
- # One write per flush. A TLS socket sends each argument of
338
- # write(*parts) as its own record and system call, so the parts are
339
- # joined first.
340
- def flush_write_buffer
341
- return if @write_buffer.empty?
342
-
343
- @sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
344
- discard_write_buffer
345
- end
346
-
347
- # Joined as bytes: requests can mix UTF-8 and binary strings, which
348
- # String#<< refuses to combine when both hold non-ASCII bytes
349
- if String.method_defined?(:append_as_bytes)
350
- def joined_write_buffer
351
- out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
352
- @write_buffer.each { |part| out.append_as_bytes(part) }
353
- out
354
- end
355
- else
356
- def joined_write_buffer
357
- out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
358
- @write_buffer.each { |part| out << part.b }
359
- out
360
- end
361
- end
362
-
363
- def discard_write_buffer
364
- @write_buffer.clear
365
- @write_buffer_bytes = 0
366
- end
367
-
368
305
  # Reads exactly `count` bytes. IO#read(count) on a blocking socket blocks
369
306
  # until it has `count` bytes, accumulating across TCP chunks internally,
370
307
  # and only hands back a shorter (or nil) buffer when the stream hits EOF.
@@ -38,20 +38,20 @@ module Dalli
38
38
  RequestFormatter.plain_meta_get(key, skip_flags)
39
39
  end
40
40
  flushed_write(req)
41
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
41
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
42
42
  end
43
43
 
44
44
  def quiet_get_request(key, options = nil)
45
45
  # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
46
- RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_request?(options),
46
+ RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_mode?,
47
47
  **routing_token_kwargs(options))
48
48
  end
49
49
 
50
50
  # Same requests as quiet_get_request for each key, built in one pass
51
51
  # and without the trailing noop.
52
52
  def quiet_get_requests(keys, options = nil, return_cas: true)
53
- RequestFormatter.multi_meta_get(keys, skip_flags: raw_request?(options), return_cas: return_cas,
54
- terminate: false, **routing_token_kwargs(options))
53
+ RequestFormatter.multi_meta_get(keys, skip_flags: raw_mode?, return_cas: return_cas, terminate: false,
54
+ **routing_token_kwargs(options))
55
55
  end
56
56
 
57
57
  def gat(key, ttl, options = nil)
@@ -59,7 +59,7 @@ module Dalli
59
59
  skip_flags = raw_mode? || (options && options[:raw])
60
60
  req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags, **routing_token_kwargs(options))
61
61
  flushed_write(req)
62
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
62
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
63
63
  end
64
64
 
65
65
  def touch(key, ttl)
@@ -72,10 +72,9 @@ module Dalli
72
72
  # TODO: This is confusing, as there's a cas command in memcached
73
73
  # and this isn't it. Maybe rename? Maybe eliminate?
74
74
  def cas(key, options = nil)
75
- req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
76
- **routing_token_kwargs(options))
75
+ req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, **routing_token_kwargs(options))
77
76
  flushed_write(req)
78
- response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
77
+ response_processor.meta_get_with_value_and_cas
79
78
  end
80
79
 
81
80
  # Comprehensive meta get with support for all metadata flags.
@@ -104,7 +103,7 @@ module Dalli
104
103
  # - :last_access - seconds since last access (only if return_last_access: true)
105
104
  def meta_get(key, options = {})
106
105
  req = RequestFormatter.meta_get(
107
- key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
106
+ key: key, value: true, return_cas: true,
108
107
  vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
109
108
  return_hit_status: options[:return_hit_status],
110
109
  return_last_access: options[:return_last_access],
@@ -116,7 +115,7 @@ module Dalli
116
115
  response_processor.meta_get_with_metadata(
117
116
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
118
117
  return_last_access: options[:return_last_access],
119
- return_ttl_remaining: options[:return_ttl_remaining], raw: raw_request?(options)
118
+ return_ttl_remaining: options[:return_ttl_remaining]
120
119
  )
121
120
  end
122
121
 
@@ -158,12 +157,17 @@ module Dalli
158
157
  def write_storage_req(mode, key, raw_value, ttl = nil, cas = nil, options = {}, quiet: quiet?)
159
158
  (value, bitflags) = @value_marshaller.store(key, raw_value, options)
160
159
  ttl = TtlSanitizer.sanitize(ttl) if ttl
161
- req = RequestFormatter.meta_set(key: key, value: value,
162
- bitflags: bitflags, cas: cas,
163
- ttl: ttl, mode: mode, quiet: quiet,
164
- **routing_token_kwargs(options))
160
+ # eql?(0): only Integer 0 means "no CAS" here; anything else takes the general path
161
+ req = if mode == :set && !quiet && (cas.nil? || cas.eql?(0)) && !routing_tokens?(options)
162
+ RequestFormatter.plain_meta_set(key, value.bytesize, bitflags, ttl)
163
+ else
164
+ RequestFormatter.meta_set(key: key, value: value,
165
+ bitflags: bitflags, cas: cas,
166
+ ttl: ttl, mode: mode, quiet: quiet,
167
+ **routing_token_kwargs(options))
168
+ end
165
169
  write("#{req}#{value}#{TERMINATOR}")
166
- @connection_manager.flush unless quiet
170
+ finish_write(quiet)
167
171
  end
168
172
  # rubocop:enable Metrics/ParameterLists
169
173
 
@@ -184,7 +188,7 @@ module Dalli
184
188
  cas: cas, ttl: ttl, mode: mode, quiet: quiet?,
185
189
  **routing_token_kwargs(options))
186
190
  write("#{req}#{value}#{TERMINATOR}")
187
- @connection_manager.flush unless quiet?
191
+ finish_write(quiet?)
188
192
  end
189
193
 
190
194
  # Delete Commands
@@ -192,10 +196,15 @@ module Dalli
192
196
  # `options` supports the meta-delete keys :invalidate, :tombstone_ttl and
193
197
  # :drop_value, plus :p_token/:l_token; see Dalli::Client#delete.
194
198
  def delete(key, cas, options = nil)
195
- req = RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?,
196
- **tombstone_kwargs(options), **routing_token_kwargs(options))
199
+ # Fast path for a plain delete: no options, no CAS, not quiet
200
+ req = if options.nil? && (cas.nil? || cas.eql?(0)) && !quiet?
201
+ RequestFormatter.plain_meta_delete(key)
202
+ else
203
+ RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?,
204
+ **tombstone_kwargs(options), **routing_token_kwargs(options))
205
+ end
197
206
  write(req)
198
- @connection_manager.flush unless quiet?
207
+ finish_write(quiet?)
199
208
  response_processor.meta_delete unless quiet?
200
209
  end
201
210
 
@@ -226,22 +235,34 @@ module Dalli
226
235
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
227
236
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
228
237
  quiet: quiet?, **routing_token_kwargs(options)))
229
- @connection_manager.flush unless quiet?
238
+ finish_write(quiet?)
230
239
  response_processor.decr_incr unless quiet?
231
240
  end
232
241
 
233
242
  # Other Commands
234
243
  def flush(delay = 0)
235
244
  write(RequestFormatter.flush(delay: delay))
236
- @connection_manager.flush unless quiet?
245
+ finish_write(quiet?)
237
246
  response_processor.flush unless quiet?
238
247
  end
239
248
 
240
249
  # Noop is a keepalive operation but also used to demarcate the end of a set of pipelined commands.
241
250
  # We need to read all the responses at once.
251
+ # A write that expects a reply is flushed so the reply can be read. A write
252
+ # in a quiet block stays buffered until the block's noop, unless
253
+ # defer_drain is on: then no noop follows at the end of the block, so it is
254
+ # flushed right away. (pipelined_set passes quiet: true outside a quiet
255
+ # block; its caller flushes with a terminating noop.)
256
+ def finish_write(quiet)
257
+ @connection_manager.flush if !quiet || (@defer_drain && quiet?)
258
+ end
259
+
242
260
  def noop
243
261
  write_noop
244
- response_processor.consume_all_responses_until_mn
262
+ result = response_processor.consume_all_responses_until_mn
263
+ # Everything up to and including the MN has been read
264
+ @connection_manager.clear_quiet_responses_pending!
265
+ result
245
266
  end
246
267
 
247
268
  def stats(info = nil)
@@ -267,7 +288,7 @@ module Dalli
267
288
  # response parsing to minimize per-key overhead. Avoids the PipelinedGetter
268
289
  # machinery (IO.select, response buffering, server grouping).
269
290
  def read_multi_req(keys, options = nil)
270
- is_raw = raw_request?(options)
291
+ is_raw = raw_mode?
271
292
  buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
272
293
  flushed_write(buffer)
273
294
  buffer.clear
@@ -283,7 +304,7 @@ module Dalli
283
304
  # Shared by both the single-server fast path and PipelinedGetter's
284
305
  # per-server-group request, so this one change covers both routes.
285
306
  def read_multi_with_metadata_req(keys, options = nil)
286
- is_raw = raw_request?(options)
307
+ is_raw = raw_mode?
287
308
  buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, return_cas: true,
288
309
  **routing_token_kwargs(options))
289
310
  flushed_write(buffer)
@@ -300,9 +321,7 @@ module Dalli
300
321
  next unless line.start_with?('VA ')
301
322
 
302
323
  tokens = line.chomp!(TERMINATOR).split
303
- size = tokens[1].to_i
304
- response_processor.check_value_size!(size)
305
- value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
324
+ value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
306
325
  stale = response_processor.stale_from_tokens(tokens)
307
326
  cas = response_processor.cas_from_tokens(tokens)
308
327
  bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
@@ -316,28 +335,25 @@ module Dalli
316
335
 
317
336
  def read_multi_get_responses(is_raw)
318
337
  hash = {}
319
- key_index = is_raw ? 2 : 3
320
338
  while (line = @connection_manager.read_line)
321
339
  break if line.start_with?('MN')
322
340
  next unless line.start_with?('VA ')
323
341
 
324
- key, value = parse_multi_get_value(line, key_index, is_raw)
342
+ key, value = parse_multi_get_value(line, is_raw)
325
343
  hash[key] = value if key
326
344
  end
327
345
  hash
328
346
  end
329
347
 
330
- def parse_multi_get_value(line, key_index, is_raw)
331
- tokens = line.chomp!(TERMINATOR).split
332
- size = tokens[1].to_i
333
- response_processor.check_value_size!(size)
334
- value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
335
- raw_key = tokens[key_index]
336
- return unless raw_key
348
+ # Reads the "VA <size> [f<flags>] k<key> ..." line in place rather than
349
+ # splitting it into tokens
350
+ def parse_multi_get_value(line, is_raw)
351
+ processor = response_processor
352
+ value = @connection_manager.read(processor.size_from_va_line(line) + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
353
+ key = processor.key_from_va_line(line)
354
+ return unless key
337
355
 
338
- key = raw_key[1..]
339
- key = KeyRegularizer.decode(key) if tokens.include?('b')
340
- bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
356
+ bitflags = is_raw ? 0 : processor.bitflags_from_va_line(line)
341
357
  [key, @value_marshaller.retrieve(value, bitflags)]
342
358
  end
343
359
 
@@ -63,6 +63,19 @@ module Dalli
63
63
  skip_flags ? "mg #{encoded_key(key)} v#{TERMINATOR}" : "mg #{encoded_key(key)} v f#{TERMINATOR}"
64
64
  end
65
65
 
66
+ # Fast path for a plain set: mode :set, not quiet, no CAS and no routing
67
+ # tokens. Produces the same bytes as meta_set(key:, value:, bitflags:, ttl:)
68
+ # without the keyword-argument handling and incremental string building.
69
+ def plain_meta_set(key, value_bytesize, bitflags, ttl)
70
+ flags = bitflags ? " F#{bitflags}" : ''
71
+ flags += " T#{integer_flag(:ttl, ttl)}" if ttl
72
+ if KeyRegularizer.required?(key)
73
+ "ms #{KeyRegularizer.encode(key)} #{value_bytesize} c b#{flags} MS#{TERMINATOR}"
74
+ else
75
+ "ms #{key} #{value_bytesize} c#{flags} MS#{TERMINATOR}"
76
+ end
77
+ end
78
+
66
79
  # Pass terminate: false to leave off the trailing noop, for callers
67
80
  # (the pipelined get) that send it separately.
68
81
  def multi_meta_get(keys, skip_flags: false, return_cas: false, terminate: true, p_token: nil, l_token: nil)
@@ -158,6 +171,11 @@ module Dalli
158
171
  cmd << TERMINATOR
159
172
  end
160
173
 
174
+ # Same bytes as meta_delete(key: key), without the keyword-argument handling
175
+ def plain_meta_delete(key)
176
+ "md #{encoded_key(key)}#{TERMINATOR}"
177
+ end
178
+
161
179
  # Tombstone and routing-token flags apply to every key in the batch;
162
180
  # see meta_delete.
163
181
  def multi_meta_delete(keys, stale: false, ttl: nil, drop_value: false, p_token: nil, l_token: nil)
@@ -200,11 +218,11 @@ module Dalli
200
218
  # specifications to a proxy or router inbetween a client and a
201
219
  # memcached daemon."
202
220
  #
203
- # Empty / nil tokens are treated as no-ops. Whitespace and control
204
- # characters are rejected with ArgumentError so a token can't be used
205
- # for wire-protocol injection: CRLF would add a second command (e.g.
206
- # "foo\r\nflush_all\r\n"), and a space would add meta flags to this one
207
- # (e.g. "foo T1" changing an item's TTL on a read).
221
+ # Empty / nil tokens are treated as no-ops. CRLF and null bytes are
222
+ # rejected with ArgumentError to prevent the token from being used as a
223
+ # wire-protocol injection vector (e.g. "foo\r\nflush_all\r\n" would
224
+ # otherwise be parsed as a second command by memcached or any
225
+ # intermediate proxy/LB).
208
226
  def routing_tokens(p_token: nil, l_token: nil)
209
227
  # Only an empty *String* is a no-op. Checking respond_to?(:empty?)
210
228
  # instead would also swallow p_token: [] / {} before the type check
@@ -261,18 +279,15 @@ module Dalli
261
279
 
262
280
  private
263
281
 
264
- # Disallowed bytes: whitespace and control characters. CR, LF or NUL
265
- # would let a token inject a second command; a space would let it add
266
- # meta flags (T, N, I, q, ...) to the request it rides on.
267
- ROUTING_TOKEN_FORBIDDEN = /[\x00-\x20\x7F]/
282
+ # Disallowed bytes: CR, LF, NUL. Any of these embedded in a routing
283
+ # token would let the caller inject a second wire-protocol command.
284
+ ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
268
285
  private_constant :ROUTING_TOKEN_FORBIDDEN
269
286
 
270
287
  def validate_routing_token!(name, value)
271
288
  return if value.nil?
272
289
  raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
273
- return unless value.match?(ROUTING_TOKEN_FORBIDDEN)
274
-
275
- raise ArgumentError, "#{name} must not contain whitespace or control characters"
290
+ raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
276
291
  end
277
292
 
278
293
  # Numeric flag values are written straight into the command line, so a
@@ -15,9 +15,6 @@ module Dalli
15
15
  # when advancing through parsed responses.
16
16
  ##
17
17
  class ResponseBuffer
18
- # Whether the current pipeline was requested with raw: true
19
- attr_writer :raw
20
-
21
18
  def initialize(io_source, response_processor)
22
19
  @io_source = io_source
23
20
  @response_processor = response_processor
@@ -42,7 +39,7 @@ module Dalli
42
39
  # Attempts to process a single response from the buffer,
43
40
  # advancing the offset past the consumed bytes.
44
41
  def process_single_getk_response
45
- response = @response_processor.getk_response_from_buffer(@buffer, @offset, raw: @raw || false)
42
+ response = @response_processor.getk_response_from_buffer(@buffer, @offset)
46
43
  @offset += response.pop
47
44
  response
48
45
  end