dalli 5.1.1 → 5.2.2

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