dalli 5.2.1 → 5.2.3

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.
@@ -43,6 +43,7 @@ module Dalli
43
43
  @options = client_options.merge(user_creds)
44
44
  @raw_mode = client_options[:raw]
45
45
  @defer_drain = client_options[:defer_drain] ? true : false
46
+ @verify_reply_keys = client_options[:verify_reply_keys] ? true : false
46
47
  @value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
47
48
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
48
49
  end
@@ -79,8 +80,14 @@ module Dalli
79
80
 
80
81
  request_completed = true
81
82
  response
82
- rescue Dalli::MarshalError => e
83
- log_marshal_err(args.first, e)
83
+ rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
84
+ log_marshal_err(args.first, e) if e.is_a?(Dalli::MarshalError)
85
+ # Raised only while the connection is clean: a value is marshalled
86
+ # before any of its request is written, and write_multi_req raises
87
+ # only after reading every reply. Closing the connection would
88
+ # discard earlier quiet writes still waiting in the write buffer.
89
+ @connection_manager.abort_request!
90
+ request_completed = true
84
91
  raise
85
92
  rescue Dalli::DalliError
86
93
  raise
@@ -153,6 +160,8 @@ module Dalli
153
160
  # avoiding intermediate Hash allocation. Returns nil.
154
161
  # Without a block, returns a Hash of { key => [value, cas] }.
155
162
  def pipeline_next_responses(&block)
163
+ return abandon_pipeline if @pipeline_out_of_step
164
+
156
165
  reconnect_on_pipeline_complete!
157
166
  values = nil
158
167
 
@@ -174,6 +183,13 @@ module Dalli
174
183
  # to the noop at the end of the pipeline
175
184
  finish_pipeline && break if status && key.nil?
176
185
 
186
+ # A key that wasn't requested means the replies are out of step
187
+ # (see expect_reply_keys): keep what was read so far, drop the rest
188
+ if unexpected_reply_key?(key)
189
+ abandon_pipeline
190
+ break
191
+ end
192
+
177
193
  # If the status is ok and the key is not nil, then this is a
178
194
  # getkq response with a value that we want to set in the response hash
179
195
  unless key.nil?
@@ -203,10 +219,13 @@ module Dalli
203
219
  @connection_manager.abort_request!
204
220
  return true unless connected?
205
221
 
206
- # Closes the connection, which ensures that our connection
207
- # is in a clean state for future requests
208
- @connection_manager.error_on_request!('External timeout')
209
- rescue NetworkError
222
+ # Closes the connection, which ensures that our connection is in a
223
+ # clean state for future requests. The get_multi ran out of time
224
+ # overall rather than hitting a socket error, so it doesn't count
225
+ # toward socket_max_failures: two slow get_multis in a row would
226
+ # otherwise mark a healthy server down.
227
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
228
+ @connection_manager.close
210
229
  true
211
230
  end
212
231
 
@@ -342,6 +361,8 @@ module Dalli
342
361
  # Use clear (not reset) to keep pipeline_complete? = true, which is
343
362
  # the expected state before pipeline_response_setup is called.
344
363
  response_buffer.clear
364
+ response_buffer.raw = raw_request?(options)
365
+ expect_reply_keys(keys)
345
366
 
346
367
  # The terminating noop is sent by pipeline_response_setup
347
368
  write(quiet_get_requests(keys, options, return_cas: return_cas))
@@ -353,6 +374,8 @@ module Dalli
353
374
  def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
354
375
  # Initialize the response buffer for draining during send phase
355
376
  response_buffer.ensure_ready
377
+ response_buffer.raw = raw_request?(options)
378
+ expect_reply_keys(keys)
356
379
 
357
380
  keys.each_slice(chunk_size) do |chunk|
358
381
  # Build and write this chunk of requests
@@ -385,6 +408,12 @@ module Dalli
385
408
  status, cas, key, value = response_buffer.process_single_getk_response
386
409
  break if status.nil? # No complete response available
387
410
 
411
+ # Handled by the next pipeline_next_responses, once sending is done
412
+ if unexpected_reply_key?(key)
413
+ @pipeline_out_of_step = true
414
+ break
415
+ end
416
+
388
417
  results[key] = [value, cas] unless key.nil?
389
418
  end
390
419
  rescue SystemCallError, Dalli::NetworkError
@@ -396,6 +425,42 @@ module Dalli
396
425
  @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
397
426
  end
398
427
 
428
+ # A multi-get only accepts replies for the keys it asked for. memcached
429
+ # names an item created by auto-create (ma or mg with N) for a
430
+ # base64-encoded key by its raw bytes, without the b flag; such a key
431
+ # can contain spaces or CR/LF, which split the reply header and make the
432
+ # replies after it parse as different ones. A key that wasn't requested
433
+ # is how that shows up.
434
+ # Only with the verify_reply_keys option, since building the set costs a
435
+ # multi-get about 15%. Dalli itself no longer creates such items (see
436
+ # Meta#decr_incr); the check guards against ones that older versions or
437
+ # other clients created, on memcached versions with the behavior.
438
+ def expect_reply_keys(keys)
439
+ @pipeline_out_of_step = false
440
+ @expected_reply_keys = (keys.to_set { |k| reply_key_form(k) } if @verify_reply_keys)
441
+ end
442
+
443
+ def unexpected_reply_key?(key)
444
+ !key.nil? && @expected_reply_keys && !@expected_reply_keys.include?(reply_key_form(key))
445
+ end
446
+
447
+ # Keys compared as bytes; ASCII keys compare equal in any encoding as is
448
+ def reply_key_form(key)
449
+ key.ascii_only? ? key : key.b
450
+ end
451
+
452
+ # Keeps what was read, drops the rest of this server's replies, and
453
+ # closes the connection so nothing unread is left on it. Not a socket
454
+ # failure, so it doesn't count toward socket_max_failures; the keys not
455
+ # read are simply missing from the result.
456
+ def abandon_pipeline
457
+ Dalli.logger.warn { "#{name} replied to a get_multi with a key that wasn't requested; closing the connection" }
458
+ @pipeline_out_of_step = false
459
+ response_buffer.clear
460
+ @connection_manager.close
461
+ {}
462
+ end
463
+
399
464
  # Called after the noop response is received at the end of a set
400
465
  # of pipelined gets
401
466
  def finish_pipeline
@@ -409,7 +474,10 @@ module Dalli
409
474
  @connection_manager.reconnect! 'pipelined get has completed' if pipeline_complete?
410
475
  end
411
476
 
477
+ # The single-server set_multi path passes its whole pairs hash as the
478
+ # first argument; log only its keys, never the values
412
479
  def log_marshal_err(key, err)
480
+ key = key.keys.join("', '") if key.is_a?(Hash)
413
481
  Dalli.logger.error "Marshalling error for key '#{key}': #{err.message}"
414
482
  Dalli.logger.error 'You are trying to cache a Ruby object which cannot be serialized to memcached.'
415
483
  end
@@ -60,8 +60,8 @@ module Dalli
60
60
  @pid = Process.pid
61
61
  @request_in_progress = false
62
62
  @quiet_responses_pending = false
63
- rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
64
- # SocketError = DNS resolution failure
63
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError, SocketError => e
64
+ # SocketError = DNS resolution failure; SSL_ERRORS = failed TLS handshake
65
65
  error_on_request!(e)
66
66
  end
67
67
 
@@ -188,10 +188,22 @@ module Dalli
188
188
  @request_in_progress = false
189
189
  end
190
190
 
191
+ # The longest reply line Dalli reads, in bytes, CRLF included. A meta
192
+ # reply header is at most about 500 bytes: "VA <size>" plus its flags,
193
+ # the largest being a 250-byte key base64-encoded to 336 bytes, a
194
+ # 20-digit CAS, a 32-byte opaque and a few numeric fields. The longest
195
+ # stats line (stats settings) carries a file path such as ext_path, up
196
+ # to PATH_MAX (4096). 8 KiB leaves room for both, while a server that
197
+ # never sends CRLF can't grow the read buffer without bound.
198
+ MAX_LINE_BYTES = 8 * 1024
199
+
191
200
  def read_line
192
201
  flush_write_buffer
193
- data = @sock.gets("\r\n")
202
+ data = @sock.gets("\r\n", MAX_LINE_BYTES)
194
203
  error_on_request!('EOF in read_line') if data.nil?
204
+ # Without its CRLF the line either ran past MAX_LINE_BYTES or was cut
205
+ # short by EOF; either way the connection is out of step
206
+ error_on_request!("Reply line without CRLF in the first #{MAX_LINE_BYTES} bytes") unless data.end_with?("\r\n")
195
207
  data
196
208
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
197
209
  error_on_request!(e)
@@ -352,13 +364,32 @@ module Dalli
352
364
 
353
365
  private
354
366
 
367
+ # One write per flush. A TLS socket sends each argument of
368
+ # write(*parts) as its own record and system call, so the parts are
369
+ # joined first.
355
370
  def flush_write_buffer
356
371
  return if @write_buffer.empty?
357
372
 
358
- @sock.write(*@write_buffer)
373
+ @sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
359
374
  discard_write_buffer
360
375
  end
361
376
 
377
+ # Joined as bytes: requests can mix UTF-8 and binary strings, which
378
+ # String#<< refuses to combine when both hold non-ASCII bytes
379
+ if String.method_defined?(:append_as_bytes)
380
+ def joined_write_buffer
381
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
382
+ @write_buffer.each { |part| out.append_as_bytes(part) }
383
+ out
384
+ end
385
+ else
386
+ def joined_write_buffer
387
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
388
+ @write_buffer.each { |part| out << part.b }
389
+ out
390
+ end
391
+ end
392
+
362
393
  def discard_write_buffer
363
394
  @write_buffer.clear
364
395
  @write_buffer_bytes = 0
@@ -38,7 +38,7 @@ 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)
@@ -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)
@@ -75,7 +75,7 @@ module Dalli
75
75
  req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
76
76
  **routing_token_kwargs(options))
77
77
  flushed_write(req)
78
- response_processor.meta_get_with_value_and_cas
78
+ response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
79
79
  end
80
80
 
81
81
  # Comprehensive meta get with support for all metadata flags.
@@ -116,7 +116,7 @@ module Dalli
116
116
  response_processor.meta_get_with_metadata(
117
117
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
118
118
  return_last_access: options[:return_last_access],
119
- return_ttl_remaining: options[:return_ttl_remaining]
119
+ return_ttl_remaining: options[:return_ttl_remaining], raw: raw_request?(options)
120
120
  )
121
121
  end
122
122
 
@@ -234,12 +234,45 @@ module Dalli
234
234
  # rubocop:disable-next Metrics/ParameterLists
235
235
  def decr_incr(incr, key, delta, ttl, initial, options = nil)
236
236
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
237
+ # memcached stores an item created by ma's auto-create (N/J) for a
238
+ # base64-encoded key without its binary-key flag, and later replies
239
+ # name it with the raw key bytes. Create such counters with an add.
240
+ return decr_incr_with_add(incr, key, delta, ttl, initial, options) if initial && KeyRegularizer.required?(key)
241
+
237
242
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
238
243
  quiet: quiet?, **routing_token_kwargs(options)))
239
244
  finish_write(quiet?)
240
245
  response_processor.decr_incr unless quiet?
241
246
  end
242
247
 
248
+ # Like decr_incr with an initial value, without memcached's auto-create:
249
+ # on a miss the counter is added with the initial value (which is
250
+ # returned, as auto-create would), and the add losing a race to another
251
+ # client means the counter now exists, so the incr is tried again. In a
252
+ # quiet block both requests are sent and whichever applies takes effect.
253
+ # rubocop:disable-next Metrics/ParameterLists
254
+ def decr_incr_with_add(incr, key, delta, ttl, initial, options)
255
+ add_options = (options || {}).merge(raw: true) # counters are stored raw
256
+ return quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options) if quiet?
257
+
258
+ 2.times do
259
+ value = decr_incr(incr, key, delta, nil, nil, options)
260
+ return value unless value.nil?
261
+
262
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: false)
263
+ return initial if response_processor.meta_set_with_cas
264
+ end
265
+ decr_incr(incr, key, delta, nil, nil, options)
266
+ end
267
+
268
+ # rubocop:disable-next Metrics/ParameterLists
269
+ def quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options)
270
+ write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: nil, incr: incr, ttl: nil,
271
+ quiet: true, **routing_token_kwargs(add_options)))
272
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: true)
273
+ finish_write(true)
274
+ end
275
+
243
276
  # Other Commands
244
277
  def flush(delay = 0)
245
278
  write(RequestFormatter.flush(delay: delay))
@@ -293,7 +326,7 @@ module Dalli
293
326
  buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
294
327
  flushed_write(buffer)
295
328
  buffer.clear
296
- read_multi_get_responses(is_raw)
329
+ read_multi_get_responses(is_raw, keys)
297
330
  end
298
331
 
299
332
  # Stale-aware bulk get. Returns { key => { value:, cas:, stale:, miss: } }
@@ -310,47 +343,63 @@ module Dalli
310
343
  **routing_token_kwargs(options))
311
344
  flushed_write(buffer)
312
345
  buffer.clear
313
- read_multi_metadata_responses(is_raw)
346
+ read_multi_metadata_responses(is_raw, keys)
314
347
  end
315
348
 
316
349
  # Unlike read_multi_get_responses this locates tokens by flag rather than
317
350
  # by position, because the c flag shifts the key's index.
318
- def read_multi_metadata_responses(is_raw)
351
+ def read_multi_metadata_responses(is_raw, keys)
352
+ expect_reply_keys(keys)
319
353
  hash = {}
320
354
  while (line = @connection_manager.read_line)
321
355
  break if line.start_with?('MN')
322
356
  next unless line.start_with?('VA ')
323
357
 
324
358
  tokens = line.chomp!(TERMINATOR).split
325
- value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
359
+ size = tokens[1].to_i
360
+ response_processor.check_value_size!(size)
361
+ value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
326
362
  stale = response_processor.stale_from_tokens(tokens)
327
363
  cas = response_processor.cas_from_tokens(tokens)
328
364
  bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
329
365
  key = response_processor.key_from_tokens(tokens)
330
366
  next if key.nil?
331
367
 
368
+ raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
369
+
332
370
  hash[key] = { value: @value_marshaller.retrieve(value, bitflags), cas: cas, stale: stale, miss: false }
333
371
  end
334
372
  hash
335
373
  end
336
374
 
337
- def read_multi_get_responses(is_raw)
375
+ def read_multi_get_responses(is_raw, keys)
376
+ expect_reply_keys(keys)
338
377
  hash = {}
339
378
  while (line = @connection_manager.read_line)
340
379
  break if line.start_with?('MN')
341
380
  next unless line.start_with?('VA ')
342
381
 
343
382
  key, value = parse_multi_get_value(line, is_raw)
383
+ raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
344
384
  hash[key] = value if key
345
385
  end
346
386
  hash
347
387
  end
348
388
 
389
+ # The replies after a key that wasn't requested can't be trusted (see
390
+ # Base#expect_reply_keys). Raising ends the request, which closes the
391
+ # connection; the caller treats this server's keys as missing.
392
+ def raise_unexpected_reply_key(key)
393
+ raise Dalli::UnexpectedReplyKeyError, "#{name} replied with a key that wasn't requested: #{key.inspect}"
394
+ end
395
+
349
396
  # Reads the "VA <size> [f<flags>] k<key> ..." line in place rather than
350
397
  # splitting it into tokens
351
398
  def parse_multi_get_value(line, is_raw)
352
399
  processor = response_processor
353
- value = @connection_manager.read(processor.size_from_va_line(line) + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
400
+ size = processor.size_from_va_line(line)
401
+ processor.check_value_size!(size)
402
+ value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
354
403
  key = processor.key_from_va_line(line)
355
404
  return unless key
356
405
 
@@ -360,16 +409,27 @@ module Dalli
360
409
 
361
410
  # Single-server fast path for set_multi. Inlines request formatting to
362
411
  # minimize per-key overhead. Avoids PipelinedSetter server grouping.
412
+ #
413
+ # A value that can't be marshalled or is over value_max_bytes is skipped;
414
+ # the other keys are still stored, then the first such error is raised
415
+ # (as PipelinedSetter does on the multi-server path).
363
416
  def write_multi_req(pairs, ttl, req_options)
364
417
  ttl = TtlSanitizer.sanitize(ttl) if ttl
365
- entries = pairs.map do |key, raw_value|
418
+ value_error = nil
419
+ entries = pairs.filter_map do |key, raw_value|
366
420
  [key, @value_marshaller.store(key, raw_value, req_options)]
421
+ rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
422
+ value_error ||= e
423
+ nil
367
424
  end
368
425
 
369
- buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl, **routing_token_kwargs(req_options))
370
- flushed_write(buffer)
371
- buffer.clear
372
- response_processor.consume_all_responses_until_mn
426
+ unless entries.empty?
427
+ buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl, **routing_token_kwargs(req_options))
428
+ flushed_write(buffer)
429
+ buffer.clear
430
+ response_processor.consume_all_responses_until_mn
431
+ end
432
+ raise value_error if value_error
373
433
  end
374
434
 
375
435
  # Single-server fast path for delete_multi. Writes all quiet delete requests
@@ -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
@@ -51,12 +51,15 @@ module Dalli
51
51
  @value_marshaller = value_marshaller
52
52
  end
53
53
 
54
- def meta_get_with_value(cache_nils: false)
54
+ # raw: true (a request made with raw: true) returns the value as stored,
55
+ # ignoring any flags in the reply. The request didn't ask for flags, so
56
+ # a reply carrying them anyway must not get the value deserialized.
57
+ def meta_get_with_value(cache_nils: false, raw: false)
55
58
  line = read_line
56
59
  # A hit ("VA <size> f<flags>") is parsed in place rather than split
57
60
  # into tokens, which saves several allocations on the hottest path.
58
61
  if line&.start_with?(VA_PREFIX)
59
- return @value_marshaller.retrieve(read_data(size_from_va_line(line)), bitflags_from_va_line(line))
62
+ return retrieve(read_data(size_from_va_line(line)), raw ? 0 : bitflags_from_va_line(line), raw)
60
63
  end
61
64
 
62
65
  tokens = line&.split || []
@@ -64,7 +67,7 @@ module Dalli
64
67
  when EN
65
68
  cache_nils ? ::Dalli::NOT_FOUND : nil
66
69
  when VA # only reached for an unusually formatted hit line
67
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
70
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
68
71
  when HD
69
72
  true
70
73
  else
@@ -72,14 +75,14 @@ module Dalli
72
75
  end
73
76
  end
74
77
 
75
- def meta_get_with_value_and_cas
78
+ def meta_get_with_value_and_cas(raw: false)
76
79
  tokens = error_on_unexpected!(T_VA_EN_HD)
77
80
  return [nil, 0] if tokens.first == EN
78
81
 
79
82
  cas = cas_from_tokens(tokens)
80
83
  return [nil, cas] unless tokens.first == VA
81
84
 
82
- [@value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens)), cas]
85
+ [retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw), cas]
83
86
  end
84
87
 
85
88
  def meta_get_without_value
@@ -99,13 +102,13 @@ module Dalli
99
102
  # Used by meta_get for comprehensive metadata retrieval.
100
103
  # Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
101
104
  def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
102
- return_ttl_remaining: false)
105
+ return_ttl_remaining: false, raw: false)
103
106
  tokens = error_on_unexpected!(T_VA_EN_HD)
104
107
  result = build_metadata_result(tokens)
105
108
  result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
106
109
  result[:last_access] = last_access_from_tokens(tokens) if return_last_access
107
110
  result[:ttl_remaining] = ttl_remaining_from_tokens(tokens) if return_ttl_remaining
108
- result[:value] = parse_value_from_tokens(tokens, cache_nils)
111
+ result[:value] = parse_value_from_tokens(tokens, cache_nils, raw)
109
112
  result
110
113
  end
111
114
 
@@ -122,11 +125,11 @@ module Dalli
122
125
  }
123
126
  end
124
127
 
125
- def parse_value_from_tokens(tokens, cache_nils)
128
+ def parse_value_from_tokens(tokens, cache_nils, raw = false) # rubocop:disable Style/OptionalBooleanParameter
126
129
  return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
127
130
  return unless tokens.first == VA
128
131
 
129
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
132
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
130
133
  end
131
134
 
132
135
  def meta_set_with_cas
@@ -229,13 +232,15 @@ module Dalli
229
232
  # The remaining three values in the array are the ResponseHeader,
230
233
  # key, and value.
231
234
  ##
232
- def getk_response_from_buffer(buf, offset = 0)
235
+ # raw: true returns values as stored, ignoring flags in the replies;
236
+ # see meta_get_with_value.
237
+ def getk_response_from_buffer(buf, offset = 0, raw: false)
233
238
  # Find the header terminator starting from offset
234
239
  term_idx = buf.byteindex(TERMINATOR, offset)
235
240
  return [0] unless term_idx
236
241
 
237
242
  if buf.byteslice(offset, VA_PREFIX.bytesize) == VA_PREFIX
238
- response = va_response_from_buffer(buf, offset, term_idx)
243
+ response = va_response_from_buffer(buf, offset, term_idx, raw)
239
244
  return response if response
240
245
  end
241
246
 
@@ -256,7 +261,9 @@ module Dalli
256
261
  when BYTE_B then base64 ||= token == 'b'
257
262
  end
258
263
  end
259
- body_len = flag_int(size, 's')
264
+ # memcached also gives the size positionally ("VA <size> ..."); use it
265
+ # when there's no s flag rather than reading the value as replies
266
+ body_len = size ? flag_int(size, 's') : positional_size(tokens)
260
267
 
261
268
  # A complete response with no body. Only the terminating noop's MN
262
269
  # ends the pipeline. Any other bodyless reply (CLIENT_ERROR,
@@ -276,8 +283,12 @@ module Dalli
276
283
  # The full response is in our buffer, so parse it and return
277
284
  # the values
278
285
  body = buf.byteslice(offset + header_len, body_len)
279
- value = @value_marshaller.retrieve(body, flag_int(bitflags, 'f'))
280
- key = key ? key.delete_prefix!('k') : 0
286
+ value = retrieve(body, raw ? 0 : flag_int(bitflags, 'f'), raw)
287
+ # A hit with no key can't be matched to a request: skip it (false
288
+ # status) rather than yield it, or take it for the end of the pipeline
289
+ return [false, resp_size] unless key
290
+
291
+ key = key.delete_prefix!('k')
281
292
  key = KeyRegularizer.decode(key) if base64
282
293
  [tokens.first == VA, flag_int(cas, 'c'), key, value, resp_size]
283
294
  end
@@ -286,7 +297,7 @@ module Dalli
286
297
  # without allocating the header, its tokens or the flag strings.
287
298
  # Gives the same result as the token path. Returns nil to fall back to
288
299
  # it when the header has no s flag or a zero size.
289
- def va_response_from_buffer(buf, offset, term_idx)
300
+ def va_response_from_buffer(buf, offset, term_idx, raw = false) # rubocop:disable Style/OptionalBooleanParameter
290
301
  size = bitflags = cas = key = nil
291
302
  base64 = false
292
303
  pos = buf.byteindex(' ', offset + VA_PREFIX.bytesize)
@@ -309,9 +320,17 @@ module Dalli
309
320
  resp_size = header_len + size + TERMINATOR.length
310
321
  return [0] unless buf.bytesize >= offset + resp_size
311
322
 
312
- value = @value_marshaller.retrieve(buf.byteslice(offset + header_len, size), bitflags || 0)
313
- key = KeyRegularizer.decode(key) if base64 && key
314
- [true, cas || 0, key || 0, value, resp_size]
323
+ value = retrieve(buf.byteslice(offset + header_len, size), raw ? 0 : bitflags || 0, raw)
324
+ # Skipped like the token path does; see getk_response_from_buffer
325
+ return [false, resp_size] unless key
326
+
327
+ key = KeyRegularizer.decode(key) if base64
328
+ [true, cas || 0, key, value, resp_size]
329
+ end
330
+
331
+ # Values from a raw request are returned as stored
332
+ def retrieve(value, bitflags, raw)
333
+ raw ? value : @value_marshaller.retrieve(value, bitflags)
315
334
  end
316
335
 
317
336
  # Integer value of a flag's token in buf, after its one-byte prefix,
@@ -431,6 +450,11 @@ module Dalli
431
450
 
432
451
  # A pipelined reply claiming an impossible size would otherwise have the
433
452
  # buffer wait for (and accumulate) that many bytes
453
+ # The size given right after "VA", or 0 when it isn't a number
454
+ def positional_size(tokens)
455
+ tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
456
+ end
457
+
434
458
  def check_value_size!(size)
435
459
  return if size.between?(0, MAX_VALUE_BYTES)
436
460
 
@@ -447,6 +471,9 @@ module Dalli
447
471
  end
448
472
 
449
473
  def read_data(data_size)
474
+ # Checked before the terminator is added: -1 or -2 would otherwise
475
+ # read 1 or 0 bytes and leave the connection out of step
476
+ check_value_size!(data_size)
450
477
  @io_source.read(data_size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
451
478
  end
452
479
  end
@@ -33,7 +33,7 @@ module Dalli
33
33
  username: uri.user,
34
34
  password: uri.password
35
35
  }
36
- [uri.host, normalize_port(uri.port), :tcp, DEFAULT_WEIGHT, auth_details]
36
+ [uri.hostname, normalize_port(uri.port), :tcp, DEFAULT_WEIGHT, auth_details]
37
37
  end
38
38
 
39
39
  def self.parse_non_uri(str)
@@ -61,11 +61,11 @@ module Dalli
61
61
  # in case of unix socket, allow only setting of weight, not port
62
62
  raise Dalli::DalliError, "Could not parse hostname #{res[0]}" if res[4]
63
63
 
64
- [nil, normalize_weight(res[3])]
64
+ [nil, normalize_weight(res[3], res[0])]
65
65
  end
66
66
 
67
67
  def self.attributes_for_tcp_socket(res)
68
- [normalize_port(res[3]), normalize_weight(res[4])]
68
+ [normalize_port(res[3]), normalize_weight(res[4], res[0])]
69
69
  end
70
70
 
71
71
  def self.normalize_host_from_match(str, res)
@@ -78,8 +78,13 @@ module Dalli
78
78
  Integer(port || DEFAULT_PORT)
79
79
  end
80
80
 
81
- def self.normalize_weight(weight)
82
- Integer(weight || DEFAULT_WEIGHT)
81
+ # A weight below 1 gives the server no points on the ring, and if every
82
+ # server has one the ring divides by a zero total weight.
83
+ def self.normalize_weight(weight, str)
84
+ value = Integer(weight || DEFAULT_WEIGHT)
85
+ raise ArgumentError, "Server weight must be at least 1, got #{value} in #{str}" if value < 1
86
+
87
+ value
83
88
  end
84
89
  end
85
90
  end
@@ -10,7 +10,8 @@ module Dalli
10
10
  ##
11
11
  class StringMarshaller
12
12
  DEFAULTS = {
13
- # max size of value in bytes (default is 1 MB, can be overriden with "memcached -I <size>")
13
+ # max memcached item size in bytes: value + key + ITEM_OVERHEAD_BYTES. Set it to
14
+ # memcached's -I value (default 1 MB).
14
15
  value_max_bytes: 1024 * 1024
15
16
  }.freeze
16
17
 
@@ -55,10 +56,7 @@ module Dalli
55
56
  private
56
57
 
57
58
  def error_if_over_max_value_bytes(key, value)
58
- return if value.bytesize <= value_max_bytes
59
-
60
- message = "Value for #{key} over max size: #{value_max_bytes} <= #{value.bytesize}"
61
- raise Dalli::ValueOverMaxSize, message
59
+ ValueMarshaller.error_if_over_max_value_bytes(key, value, value_max_bytes)
62
60
  end
63
61
  end
64
62
  end