dalli 5.2.7 → 5.3.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.
@@ -21,7 +21,10 @@ module Dalli
21
21
  # amount of time to sleep between retries when a failure occurs
22
22
  socket_failure_delay: 0.1,
23
23
  # Set keepalive
24
- keepalive: true
24
+ keepalive: true,
25
+ # On JRuby and TruffleRuby, time out reads after socket_timeout (see
26
+ # ReadTimeout). CRuby always does.
27
+ enforce_read_timeout: false
25
28
  }.freeze
26
29
 
27
30
  attr_accessor :hostname, :port, :socket_type, :options
@@ -36,6 +39,7 @@ module Dalli
36
39
  @socket_type = socket_type
37
40
  @options = DEFAULTS.merge(client_options)
38
41
  @request_in_progress = false
42
+ @request_sent = false
39
43
  @quiet_responses_pending = false
40
44
  @sock = nil
41
45
  @pid = nil
@@ -45,6 +49,7 @@ module Dalli
45
49
 
46
50
  @fail_count = 0
47
51
  reset_down_info
52
+ extend ReadTimeout if @options[:enforce_read_timeout] && RUBY_ENGINE != 'ruby'
48
53
  end
49
54
 
50
55
  def name
@@ -64,6 +69,7 @@ module Dalli
64
69
  @pid = Process.pid
65
70
  @request_in_progress = false
66
71
  @quiet_responses_pending = false
72
+ @last_used_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
67
73
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError, SocketError => e
68
74
  # SocketError = DNS resolution failure; SSL_ERRORS = failed TLS handshake
69
75
  error_on_request!(e)
@@ -109,9 +115,46 @@ module Dalli
109
115
  @socket_timeout ||= @options[:socket_timeout]
110
116
  end
111
117
 
118
+ # Before a request: closes a connection left mid-request, reconnects
119
+ # after a fork, and checks a connection that sat idle (see
120
+ # #close_if_closed_by_peer!). Idle time runs from the start of the last
121
+ # request, so the clock is read once per request; after a request that
122
+ # itself took over a second, the next just makes a check that finds
123
+ # nothing. Pending quiet replies are readable and expected, so the
124
+ # check, and the clock read, are skipped while there are any; in a quiet
125
+ # block that is every write after the first.
112
126
  def confirm_ready!
113
127
  close if request_in_progress?
114
128
  reconnect_on_fork if fork_detected?
129
+ return if @sock.nil? || @quiet_responses_pending
130
+
131
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
132
+ idle = now - @last_used_at
133
+ @last_used_at = now
134
+ close_if_closed_by_peer! if idle >= IDLE_CHECK_MS
135
+ end
136
+
137
+ # How long, in milliseconds, a connection goes without a request before
138
+ # the next one first checks that the server hasn't closed it. Integer
139
+ # milliseconds keep Float arithmetic off the request path.
140
+ IDLE_CHECK_MS = 1000
141
+
142
+ # memcached's idle_timeout, a load balancer or a firewall can close a
143
+ # connection that sat unused. The next write still succeeds, into the
144
+ # kernel's buffer, and only the read fails, by which time the request
145
+ # counts as sent: one that isn't safe to send twice, such as an incr or
146
+ # an add, then raises instead of being retried. With no replies pending,
147
+ # an idle connection has nothing to read, so a readable one was closed
148
+ # by the other end (or holds bytes nothing asked for). Closing it here
149
+ # lets the request go out on a new connection. A connection dropped
150
+ # without the other end being told can't be detected this way.
151
+ def close_if_closed_by_peer!
152
+ return unless @sock.to_io.wait_readable(0)
153
+
154
+ Dalli.logger.debug { "#{name} closed the connection while it was idle; reconnecting" }
155
+ close
156
+ rescue IOError, SystemCallError
157
+ close
115
158
  end
116
159
 
117
160
  def confirm_in_progress!
@@ -176,12 +219,14 @@ module Dalli
176
219
  raise '[Dalli] Request already in progress. This may be a bug in Dalli.' if @request_in_progress
177
220
 
178
221
  @request_in_progress = true
222
+ @request_sent = false
179
223
  end
180
224
 
181
225
  def finish_request!
182
226
  raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless @request_in_progress
183
227
 
184
228
  @request_in_progress = false
229
+ @request_sent = false
185
230
  # A completed request is what proves the server healthy again, so the
186
231
  # failure count resets here rather than on reconnect: a server that
187
232
  # accepts connections but never answers would otherwise reset it on
@@ -189,8 +234,26 @@ module Dalli
189
234
  @fail_count = 0
190
235
  end
191
236
 
237
+ # Ends a request whose reply isn't read with it: a quiet write, or one of
238
+ # set_multi's or delete_multi's writes. The server hasn't answered, so the
239
+ # failure count stays; the noop that reads the replies resets it.
240
+ def finish_unanswered_request!
241
+ raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless @request_in_progress
242
+
243
+ @request_in_progress = false
244
+ @request_sent = false
245
+ end
246
+
192
247
  def abort_request!
193
248
  @request_in_progress = false
249
+ @request_sent = false
250
+ end
251
+
252
+ # True once any of the request in progress has been handed to the
253
+ # socket. Writes made outside a request (the version check on connect,
254
+ # draining an earlier quiet block's replies) don't count.
255
+ def request_sent?
256
+ @request_sent
194
257
  end
195
258
 
196
259
  # The longest reply line Dalli reads, in bytes, CRLF included. A meta
@@ -306,9 +369,11 @@ module Dalli
306
369
  end
307
370
 
308
371
  def reconnect!(message)
372
+ # Read before close, which ends the request
373
+ request_sent = @request_sent
309
374
  close
310
375
  sleep(options[:socket_failure_delay]) if options[:socket_failure_delay]
311
- raise Dalli::RetryableNetworkError, message
376
+ raise Dalli::RetryableNetworkError.new(message, request_sent: request_sent)
312
377
  end
313
378
 
314
379
  # Called on connect. Deliberately leaves @fail_count alone; see
@@ -375,6 +440,8 @@ module Dalli
375
440
  def flush_write_buffer
376
441
  return if @write_buffer.empty?
377
442
 
443
+ # Set before writing: a write that fails may still have sent some bytes
444
+ @request_sent = true if @request_in_progress
378
445
  @sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
379
446
  discard_write_buffer
380
447
  end
@@ -413,6 +480,43 @@ module Dalli
413
480
 
414
481
  raise EOFError, "EOF reading #{count} bytes; received #{buffer ? buffer.bytesize : 0}"
415
482
  end
483
+
484
+ # CRuby enforces socket_timeout on reads through IO#timeout=. TruffleRuby
485
+ # has no IO#timeout=, and JRuby (as of 10.1) defines it but doesn't
486
+ # enforce it; both ignore the SO_RCVTIMEO fallback, so a read from a
487
+ # server that stopped answering blocks forever. With
488
+ # enforce_read_timeout, the first read after a request is sent waits for
489
+ # the socket to be readable, up to socket_timeout. It's opt-in because
490
+ # the wait costs about 8% of single-key throughput on TruffleRuby and
491
+ # 20-29% on JRuby. A server that stops partway through a reply line can
492
+ # still block. So can one that stops partway through a value on
493
+ # TruffleRuby; JRuby reads values without blocking.
494
+ #
495
+ # TLS connections are left as they were: OpenSSL buffers decrypted
496
+ # bytes that wait_readable can't see.
497
+ module ReadTimeout
498
+ def read_line
499
+ flush_write_buffer
500
+ if @awaiting_reply
501
+ @awaiting_reply = false
502
+ wait_readable! unless @sock.is_a?(::OpenSSL::SSL::SSLSocket)
503
+ end
504
+ super
505
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
506
+ error_on_request!(e)
507
+ end
508
+
509
+ private
510
+
511
+ def flush_write_buffer
512
+ @awaiting_reply = true unless @write_buffer.empty?
513
+ super
514
+ end
515
+
516
+ def wait_readable!
517
+ @sock.wait_readable(socket_timeout) or raise Timeout::Error, "IO timeout reading from #{name}"
518
+ end
519
+ end
416
520
  end
417
521
  end
418
522
  end
@@ -44,9 +44,14 @@ module Dalli
44
44
  [key].pack('m0')
45
45
  end
46
46
 
47
+ # Decodes a key from a reply. One that isn't valid base64 means the
48
+ # reply is malformed, so it raises Dalli::DalliError like other bad
49
+ # replies, rather than unpack1's ArgumentError.
47
50
  def decode(encoded_key)
48
51
  strict_base64_decoded = encoded_key.unpack1('m0')
49
52
  strict_base64_decoded.force_encoding(Encoding::UTF_8)
53
+ rescue ArgumentError
54
+ raise Dalli::DalliError, "Reply key #{encoded_key.inspect} is marked base64 but isn't valid base64"
50
55
  end
51
56
  end
52
57
  end
@@ -103,9 +103,14 @@ module Dalli
103
103
  # - :hit_before - true/false if previously accessed (only if return_hit_status: true)
104
104
  # - :last_access - seconds since last access (only if return_last_access: true)
105
105
  def meta_get(key, options = {})
106
+ # vivify_ttl is how long the stub lives, so like any TTL it must be
107
+ # sanitized: memcached reads a value over 30 days as a Unix timestamp.
108
+ # recache_ttl is a threshold compared with the remaining TTL, not an
109
+ # expiry, so it's sent as given.
110
+ vivify_ttl = options[:vivify_ttl] && TtlSanitizer.sanitize(options[:vivify_ttl])
106
111
  req = RequestFormatter.meta_get(
107
112
  key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
108
- vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
113
+ vivify_ttl: vivify_ttl, recache_ttl: options[:recache_ttl],
109
114
  return_hit_status: options[:return_hit_status],
110
115
  return_last_access: options[:return_last_access],
111
116
  return_ttl_remaining: options[:return_ttl_remaining],
@@ -157,7 +162,11 @@ module Dalli
157
162
 
158
163
  def write_storage_req(mode, key, raw_value, ttl = nil, cas = nil, options = {}, quiet: quiet?)
159
164
  (value, bitflags) = @value_marshaller.store(key, raw_value, options)
160
- ttl = TtlSanitizer.sanitize(ttl) if ttl
165
+ # An Integer TTL of up to 30 days, the usual case, is left as it is by
166
+ # TtlSanitizer, so the call is skipped
167
+ ttl = TtlSanitizer.sanitize(ttl) unless ttl.nil? ||
168
+ (ttl.is_a?(Integer) &&
169
+ ttl <= TtlSanitizer::MAX_ACCEPTABLE_EXPIRATION_INTERVAL)
161
170
  # eql?(0): only Integer 0 means "no CAS" here; anything else takes the general path
162
171
  req = if mode == :set && !quiet && (cas.nil? || cas.eql?(0)) && !routing_tokens?(options)
163
172
  RequestFormatter.plain_meta_set(key, value.bytesize, bitflags, ttl)
@@ -194,8 +203,8 @@ module Dalli
194
203
 
195
204
  # Ruby refuses to join Strings whose encodings clash, such as a raw
196
205
  # UTF-16 value, or a routing token with non-ASCII characters next to a
197
- # binary value. memcached stores bytes, so join the bytes then.
198
- # Joining the Strings first keeps the common case free of extra checks.
206
+ # binary value. memcached stores bytes, so join the bytes then. Joining
207
+ # the Strings first keeps the common case free of extra checks.
199
208
  def request_with_value(req, value)
200
209
  "#{req}#{value}#{TERMINATOR}"
201
210
  rescue Encoding::CompatibilityError
@@ -363,6 +372,7 @@ module Dalli
363
372
  def read_multi_metadata_responses(is_raw, keys)
364
373
  expect_reply_keys(keys)
365
374
  hash = {}
375
+ unmarshal_error = nil
366
376
  while (line = @connection_manager.read_line)
367
377
  break if line.start_with?('MN')
368
378
  next unless line.start_with?('VA ')
@@ -370,7 +380,7 @@ module Dalli
370
380
  tokens = line.chomp!(TERMINATOR).split
371
381
  size = tokens[1].to_i
372
382
  response_processor.check_value_size!(size)
373
- value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
383
+ value = response_processor.strip_value_terminator!(@connection_manager.read(size + TERMINATOR.bytesize))
374
384
  stale = response_processor.stale_from_tokens(tokens)
375
385
  cas = response_processor.cas_from_tokens(tokens)
376
386
  bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
@@ -379,22 +389,39 @@ module Dalli
379
389
 
380
390
  raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
381
391
 
382
- hash[key] = { value: @value_marshaller.retrieve(value, bitflags), cas: cas, stale: stale, miss: false }
392
+ begin
393
+ hash[key] = { value: @value_marshaller.retrieve(value, bitflags), cas: cas, stale: stale, miss: false }
394
+ rescue Dalli::UnmarshalError => e
395
+ # Raised once the rest of the replies are read; see read_multi_get_responses
396
+ unmarshal_error ||= e
397
+ end
383
398
  end
399
+ raise unmarshal_error if unmarshal_error
400
+
384
401
  hash
385
402
  end
386
403
 
404
+ # A value that can't be deserialized raises Dalli::UnmarshalError, as
405
+ # get does, but only after the rest of the replies are read, so the
406
+ # connection is left in step. The first such error is raised.
387
407
  def read_multi_get_responses(is_raw, keys)
388
408
  expect_reply_keys(keys)
389
409
  hash = {}
410
+ unmarshal_error = nil
390
411
  while (line = @connection_manager.read_line)
391
412
  break if line.start_with?('MN')
392
413
  next unless line.start_with?('VA ')
393
414
 
394
- key, value = parse_multi_get_value(line, is_raw)
395
- raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
415
+ begin
416
+ key, value = parse_multi_get_value(line, is_raw)
417
+ rescue Dalli::UnmarshalError => e
418
+ unmarshal_error ||= e
419
+ next
420
+ end
396
421
  hash[key] = value if key
397
422
  end
423
+ raise unmarshal_error if unmarshal_error
424
+
398
425
  hash
399
426
  end
400
427
 
@@ -411,10 +438,11 @@ module Dalli
411
438
  processor = response_processor
412
439
  size = processor.size_from_va_line(line)
413
440
  processor.check_value_size!(size)
414
- value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
441
+ value = processor.strip_value_terminator!(@connection_manager.read(size + TERMINATOR.bytesize))
415
442
  key = processor.key_from_va_line(line)
416
443
  return unless key
417
444
 
445
+ raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
418
446
  bitflags = is_raw ? 0 : processor.bitflags_from_va_line(line)
419
447
  [key, @value_marshaller.retrieve(value, bitflags)]
420
448
  end
@@ -43,10 +43,10 @@ module Dalli
43
43
  # This saves 2 bytes per request and skips parsing on response.
44
44
  cmd << (skip_flags ? ' v' : ' v f') if value
45
45
  cmd << ' c' if return_cas
46
- cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
46
+ cmd << " T#{ttl_flag(:ttl, ttl)}" if ttl
47
47
  cmd << routing_tokens(p_token: p_token, l_token: l_token)
48
48
  cmd << ' k q s' if quiet # Return the key in the response if quiet
49
- cmd << " N#{integer_flag(:vivify_ttl, vivify_ttl)}" if vivify_ttl # Thundering herd: vivify on miss
49
+ cmd << " N#{ttl_flag(:vivify_ttl, vivify_ttl)}" if vivify_ttl # Thundering herd: vivify on miss
50
50
  # Thundering herd: win recache if TTL below threshold
51
51
  cmd << " R#{integer_flag(:recache_ttl, recache_ttl)}" if recache_ttl
52
52
  cmd << ' h' if return_hit_status # Return hit status (0 or 1)
@@ -68,7 +68,7 @@ module Dalli
68
68
  # without the keyword-argument handling and incremental string building.
69
69
  def plain_meta_set(key, value_bytesize, bitflags, ttl)
70
70
  flags = bitflags ? " F#{bitflags}" : ''
71
- flags += " T#{integer_flag(:ttl, ttl)}" if ttl
71
+ flags += " T#{ttl_flag(:ttl, ttl)}" if ttl
72
72
  if KeyRegularizer.required?(key)
73
73
  "ms #{KeyRegularizer.encode(key)} #{value_bytesize} c b#{flags} MS#{TERMINATOR}"
74
74
  else
@@ -111,7 +111,7 @@ module Dalli
111
111
  cmd << ' b' if base64
112
112
  cmd << " F#{bitflags}" if bitflags
113
113
  cmd << cas_string(cas)
114
- cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
114
+ cmd << " T#{ttl_flag(:ttl, ttl)}" if ttl
115
115
  cmd << " M#{mode_to_token(mode)}"
116
116
  cmd << ' q' if quiet
117
117
  cmd << routing_tokens(p_token: p_token, l_token: l_token)
@@ -123,7 +123,7 @@ module Dalli
123
123
  # built once rather than per entry. It's binary, like the buffer, so
124
124
  # a token with non-ASCII characters doesn't turn the buffer UTF-8.
125
125
  token_suffix = routing_tokens(p_token: p_token, l_token: l_token).b
126
- ttl = integer_flag(:ttl, ttl) if ttl
126
+ ttl = ttl_flag(:ttl, ttl) if ttl
127
127
 
128
128
  buffer = ''.b
129
129
  entries.each do |key, pair|
@@ -144,22 +144,6 @@ module Dalli
144
144
  buffer << META_NOOP
145
145
  end
146
146
 
147
- # Values are appended as bytes. One batch can hold UTF-8 and binary
148
- # values, which String#<< refuses to combine, and a raw value can be in
149
- # an encoding that isn't ASCII-compatible, such as UTF-16.
150
- # Don't chain on the result: TruffleRuby's append_as_bytes returns its
151
- # arguments rather than the buffer.
152
- if String.method_defined?(:append_as_bytes)
153
- def append_bytes(buffer, value)
154
- buffer.append_as_bytes(value)
155
- end
156
- else
157
- def append_bytes(buffer, value)
158
- buffer << (value.encoding == Encoding::BINARY ? value : value.b)
159
- end
160
- end
161
- private :append_bytes
162
-
163
147
  # Thundering herd protection flag:
164
148
  # - stale (I flag): Instead of deleting the item, mark it as stale. Other clients
165
149
  # using N/R flags will see the X flag and know the item is being regenerated.
@@ -183,7 +167,7 @@ module Dalli
183
167
  cmd = "md #{encoded_key(key)}"
184
168
  cmd << cas_string(cas)
185
169
  cmd << ' I' if stale # Mark stale instead of deleting
186
- cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
170
+ cmd << " T#{ttl_flag(:ttl, ttl)}" if ttl
187
171
  cmd << ' x' if drop_value # Drop the value but keep the item
188
172
  cmd << ' q' if quiet
189
173
  cmd << routing_tokens(p_token: p_token, l_token: l_token)
@@ -202,7 +186,7 @@ module Dalli
202
186
 
203
187
  suffix = +''
204
188
  suffix << ' I' if stale
205
- suffix << " T#{integer_flag(:ttl, ttl)}" if ttl
189
+ suffix << " T#{ttl_flag(:ttl, ttl)}" if ttl
206
190
  suffix << ' x' if drop_value
207
191
  suffix << ' q'
208
192
  suffix << routing_tokens(p_token: p_token, l_token: l_token)
@@ -218,10 +202,10 @@ module Dalli
218
202
  def meta_arithmetic(key:, delta:, initial:, incr: true, cas: nil, ttl: nil, quiet: false,
219
203
  p_token: nil, l_token: nil)
220
204
  cmd = "ma #{encoded_key(key)} v"
221
- cmd << " D#{integer_flag(:delta, delta)}" if delta
222
- cmd << " J#{integer_flag(:initial, initial)}" if initial
205
+ cmd << " D#{parse_to_64_bit_int(:delta, delta)}" if delta
206
+ cmd << " J#{parse_to_64_bit_int(:initial, initial)}" if initial
223
207
  # Always set a TTL if an initial value is specified
224
- cmd << " N#{ttl ? integer_flag(:ttl, ttl) : 0}" if ttl || initial
208
+ cmd << " N#{ttl ? ttl_flag(:ttl, ttl) : 0}" if ttl || initial
225
209
  cmd << cas_string(cas)
226
210
  cmd << ' q' if quiet
227
211
  cmd << " M#{incr ? 'I' : 'D'}"
@@ -273,7 +257,7 @@ module Dalli
273
257
 
274
258
  def flush(delay: nil, quiet: false)
275
259
  cmd = +'flush_all'
276
- cmd << " #{parse_to_64_bit_int(delay, 0)}" if delay
260
+ cmd << " #{parse_to_64_bit_int(:delay, delay)}" if delay
277
261
  cmd << ' noreply' if quiet
278
262
  cmd << TERMINATOR
279
263
  end
@@ -298,6 +282,21 @@ module Dalli
298
282
 
299
283
  private
300
284
 
285
+ # Values are appended as bytes. One batch can hold UTF-8 and binary
286
+ # values, which String#<< refuses to combine, and a raw value can be in
287
+ # an encoding that isn't ASCII-compatible, such as UTF-16.
288
+ # Don't chain on the result: TruffleRuby's append_as_bytes returns its
289
+ # arguments rather than the buffer.
290
+ if String.method_defined?(:append_as_bytes)
291
+ def append_bytes(buffer, value)
292
+ buffer.append_as_bytes(value)
293
+ end
294
+ else
295
+ def append_bytes(buffer, value)
296
+ buffer << (value.encoding == Encoding::BINARY ? value : value.b)
297
+ end
298
+ end
299
+
301
300
  # Disallowed bytes: whitespace and control characters. CR, LF or NUL
302
301
  # would let a token inject a second command; a space would let it add
303
302
  # meta flags (T, N, I, q, ...) to the request it rides on.
@@ -323,6 +322,20 @@ module Dalli
323
322
  raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
324
323
  end
325
324
 
325
+ # A TTL becomes memcached's expiration time, which must fit in a signed
326
+ # 32-bit value; anything else gets a CLIENT_ERROR reply, which a quiet
327
+ # request never reports. Callers pass TTLs through TtlSanitizer first.
328
+ def ttl_flag(name, value)
329
+ min = TtlSanitizer::MIN_EXPIRATION_TIME
330
+ max = TtlSanitizer::MAX_EXPIRATION_TIME
331
+ return value if value.is_a?(Integer) && value >= min && value <= max
332
+
333
+ ttl = integer_flag(name, value)
334
+ return ttl if ttl.between?(min, max)
335
+
336
+ raise ArgumentError, "#{name} must be between #{min} and #{max}, got #{value.inspect}"
337
+ end
338
+
326
339
  def mode_to_token(mode)
327
340
  case mode
328
341
  when :add
@@ -339,15 +352,31 @@ module Dalli
339
352
  end
340
353
 
341
354
  def cas_string(cas)
342
- cas = parse_to_64_bit_int(cas, nil)
355
+ # No CAS, the usual case
356
+ return '' if cas.nil? || cas.eql?(0)
357
+
358
+ cas = parse_to_64_bit_int(:cas, cas)
343
359
  cas.nil? || cas.zero? ? '' : " C#{cas}"
344
360
  end
345
361
 
346
- def parse_to_64_bit_int(val, default)
347
- val.nil? ? nil : Integer(val)
348
- rescue ArgumentError
349
- # Sanitize to default if it isn't parsable as an integer
350
- default
362
+ MAX_UINT64 = (2**64) - 1
363
+ private_constant :MAX_UINT64
364
+
365
+ # Parses a value memcached reads as an unsigned 64-bit integer (a CAS,
366
+ # an incr/decr delta or initial value, a flush delay): an Integer, or a
367
+ # String of decimal digits. Anything else raises. A bad CAS used to be
368
+ # dropped, which turned a CAS write into an unconditional one; a Float
369
+ # delta was truncated, so incr by 0.9 did nothing.
370
+ def parse_to_64_bit_int(name, val)
371
+ return nil if val.nil?
372
+
373
+ int = case val
374
+ when Integer then val
375
+ when String then Integer(val, 10, exception: false)
376
+ end
377
+ return int if int && int >= 0 && int <= MAX_UINT64
378
+
379
+ raise ArgumentError, "#{name} must be an Integer between 0 and #{MAX_UINT64}, got #{val.inspect}"
351
380
  end
352
381
  end
353
382
  end
@@ -35,6 +35,8 @@ module Dalli
35
35
  BYTE_F = 'f'.ord
36
36
  BYTE_K = 'k'.ord
37
37
  BYTE_S = 's'.ord
38
+ BYTE_CR = "\r".ord
39
+ BYTE_LF = "\n".ord
38
40
 
39
41
  T_OK = [OK].freeze
40
42
  T_RESET = [RESET].freeze
@@ -280,16 +282,23 @@ module Dalli
280
282
  # a complete response, don't advance the buffer
281
283
  return [0] unless buf.bytesize >= offset + resp_size
282
284
 
283
- # The full response is in our buffer, so parse it and return
284
- # the values
285
- body = buf.byteslice(offset + header_len, body_len)
286
- value = retrieve(body, raw ? 0 : flag_int(bitflags, 'f'), raw)
285
+ # The CRLF after the value; see strip_value_terminator!
286
+ term = offset + header_len + body_len
287
+ raise_bad_value_terminator unless buf.getbyte(term) == BYTE_CR && buf.getbyte(term + 1) == BYTE_LF
288
+
287
289
  # A hit with no key can't be matched to a request: skip it (false
288
290
  # status) rather than yield it, or take it for the end of the pipeline
289
291
  return [false, resp_size] unless key
290
292
 
291
293
  key = key.delete_prefix!('k')
292
294
  key = KeyRegularizer.decode(key) if base64
295
+ # The full response is in our buffer, so parse it and return
296
+ # the values
297
+ begin
298
+ value = retrieve(buf.byteslice(offset + header_len, body_len), raw ? 0 : flag_int(bitflags, 'f'), raw)
299
+ rescue Dalli::UnmarshalError => e
300
+ return unmarshal_failed(e, key, resp_size)
301
+ end
293
302
  [tokens.first == VA, flag_int(cas, 'c'), key, value, resp_size]
294
303
  end
295
304
 
@@ -320,14 +329,37 @@ module Dalli
320
329
  resp_size = header_len + size + TERMINATOR.length
321
330
  return [0] unless buf.bytesize >= offset + resp_size
322
331
 
323
- value = retrieve(buf.byteslice(offset + header_len, size), raw ? 0 : bitflags || 0, raw)
332
+ term = offset + header_len + size
333
+ raise_bad_value_terminator unless buf.getbyte(term) == BYTE_CR && buf.getbyte(term + 1) == BYTE_LF
334
+
324
335
  # Skipped like the token path does; see getk_response_from_buffer
325
336
  return [false, resp_size] unless key
326
337
 
327
338
  key = KeyRegularizer.decode(key) if base64
339
+ begin
340
+ value = retrieve(buf.byteslice(offset + header_len, size), raw ? 0 : bitflags || 0, raw)
341
+ rescue Dalli::UnmarshalError => e
342
+ return unmarshal_failed(e, key, resp_size)
343
+ end
328
344
  [true, cas || 0, key, value, resp_size]
329
345
  end
330
346
 
347
+ # A pipelined reply whose value can't be deserialized is consumed and
348
+ # skipped (false status), so the replies after it stay in step. Its
349
+ # key is still returned for the reply key check. The first such error
350
+ # is held until the pipeline finishes; see take_unmarshal_error.
351
+ def unmarshal_failed(error, key, resp_size)
352
+ @unmarshal_error ||= error
353
+ [false, 0, key, nil, resp_size]
354
+ end
355
+
356
+ # Returns the error held by unmarshal_failed, if any, and forgets it
357
+ def take_unmarshal_error
358
+ error = @unmarshal_error
359
+ @unmarshal_error = nil
360
+ error
361
+ end
362
+
331
363
  # Values from a raw request are returned as stored
332
364
  def retrieve(value, bitflags, raw)
333
365
  raw ? value : @value_marshaller.retrieve(value, bitflags)
@@ -461,6 +493,17 @@ module Dalli
461
493
  raise Dalli::DalliError, "Reply value size #{size} is out of range"
462
494
  end
463
495
 
496
+ # A value is read by its declared size, so check the CRLF that should
497
+ # follow it. If the size doesn't match the value, the rest of the
498
+ # reply would otherwise be read as the next one. Returns the value.
499
+ def strip_value_terminator!(data)
500
+ data&.chomp!(TERMINATOR) || raise_bad_value_terminator
501
+ end
502
+
503
+ def raise_bad_value_terminator
504
+ raise Dalli::DalliError, "Reply value isn't followed by CRLF, so its size doesn't match the value"
505
+ end
506
+
464
507
  def read_line
465
508
  @io_source.read_line&.chomp!(TERMINATOR)
466
509
  end
@@ -474,7 +517,7 @@ module Dalli
474
517
  # Checked before the terminator is added: -1 or -2 would otherwise
475
518
  # read 1 or 0 bytes and leave the connection out of step
476
519
  check_value_size!(data_size)
477
- @io_source.read(data_size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
520
+ strip_value_terminator!(@io_source.read(data_size + TERMINATOR.bytesize))
478
521
  end
479
522
  end
480
523
  end
@@ -14,9 +14,17 @@ module Dalli
14
14
  # treated as a unix timestamp of an exact date.
15
15
  MAX_ACCEPTABLE_EXPIRATION_INTERVAL = 30 * 24 * 60 * 60 # 30 days
16
16
 
17
+ # memcached holds an expiration time in a signed 32-bit integer, so a
18
+ # sanitized TTL outside this range is rejected with CLIENT_ERROR. A
19
+ # negative TTL is accepted and stores the item as already expired.
20
+ MIN_EXPIRATION_TIME = -(2**31)
21
+ MAX_EXPIRATION_TIME = (2**31) - 1
22
+
17
23
  # Ensures the TTL passed to Memcached is a valid TTL in the expected format.
24
+ # A String must be a whole number in decimal; it used to go through
25
+ # String#to_i, so 'abc' became 0, which means no expiry.
18
26
  def self.sanitize(ttl)
19
- ttl_as_i = ttl.to_i
27
+ ttl_as_i = ttl.is_a?(String) ? Integer(ttl, 10) : ttl.to_i
20
28
  return ttl_as_i if less_than_max_expiration_interval?(ttl_as_i)
21
29
 
22
30
  as_timestamp(ttl_as_i)
@@ -30,9 +38,10 @@ module Dalli
30
38
  now = current_timestamp
31
39
  return ttl_as_i if ttl_as_i > now # Already a timestamp
32
40
 
33
- Dalli.logger.debug "Expiration interval (#{ttl_as_i}) too long for Memcached " \
34
- 'and too short to be a future timestamp, ' \
35
- 'converting to an expiration timestamp'
41
+ Dalli.logger.debug do
42
+ "Expiration interval (#{ttl_as_i}) too long for Memcached " \
43
+ 'and too short to be a future timestamp, converting to an expiration timestamp'
44
+ end
36
45
  now + ttl_as_i
37
46
  end
38
47
 
data/lib/dalli/ring.rb CHANGED
@@ -34,6 +34,10 @@ module Dalli
34
34
  @failover = options[:failover] != false
35
35
  end
36
36
 
37
+ def failover?
38
+ @failover
39
+ end
40
+
37
41
  def continuum=(entries)
38
42
  @continuum = entries
39
43
  # Plain integers, so the lookup in server_for_hash_key needn't call