dalli 5.2.5 → 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)
@@ -167,7 +176,7 @@ module Dalli
167
176
  ttl: ttl, mode: mode, quiet: quiet,
168
177
  **routing_token_kwargs(options))
169
178
  end
170
- write("#{req}#{value}#{TERMINATOR}")
179
+ write(request_with_value(req, value))
171
180
  finish_write(quiet)
172
181
  end
173
182
  # rubocop:enable Metrics/ParameterLists
@@ -188,10 +197,20 @@ module Dalli
188
197
  req = RequestFormatter.meta_set(key: key, value: value,
189
198
  cas: cas, ttl: ttl, mode: mode, quiet: quiet?,
190
199
  **routing_token_kwargs(options))
191
- write("#{req}#{value}#{TERMINATOR}")
200
+ write(request_with_value(req, value))
192
201
  finish_write(quiet?)
193
202
  end
194
203
 
204
+ # Ruby refuses to join Strings whose encodings clash, such as a raw
205
+ # UTF-16 value, or a routing token with non-ASCII characters next to a
206
+ # binary value. memcached stores bytes, so join the bytes then. Joining
207
+ # the Strings first keeps the common case free of extra checks.
208
+ def request_with_value(req, value)
209
+ "#{req}#{value}#{TERMINATOR}"
210
+ rescue Encoding::CompatibilityError
211
+ "#{req.b}#{value.b}#{TERMINATOR}"
212
+ end
213
+
195
214
  # Delete Commands
196
215
  #
197
216
  # `options` supports the meta-delete keys :invalidate, :tombstone_ttl and
@@ -353,6 +372,7 @@ module Dalli
353
372
  def read_multi_metadata_responses(is_raw, keys)
354
373
  expect_reply_keys(keys)
355
374
  hash = {}
375
+ unmarshal_error = nil
356
376
  while (line = @connection_manager.read_line)
357
377
  break if line.start_with?('MN')
358
378
  next unless line.start_with?('VA ')
@@ -360,7 +380,7 @@ module Dalli
360
380
  tokens = line.chomp!(TERMINATOR).split
361
381
  size = tokens[1].to_i
362
382
  response_processor.check_value_size!(size)
363
- value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
383
+ value = response_processor.strip_value_terminator!(@connection_manager.read(size + TERMINATOR.bytesize))
364
384
  stale = response_processor.stale_from_tokens(tokens)
365
385
  cas = response_processor.cas_from_tokens(tokens)
366
386
  bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
@@ -369,22 +389,39 @@ module Dalli
369
389
 
370
390
  raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
371
391
 
372
- 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
373
398
  end
399
+ raise unmarshal_error if unmarshal_error
400
+
374
401
  hash
375
402
  end
376
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.
377
407
  def read_multi_get_responses(is_raw, keys)
378
408
  expect_reply_keys(keys)
379
409
  hash = {}
410
+ unmarshal_error = nil
380
411
  while (line = @connection_manager.read_line)
381
412
  break if line.start_with?('MN')
382
413
  next unless line.start_with?('VA ')
383
414
 
384
- key, value = parse_multi_get_value(line, is_raw)
385
- 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
386
421
  hash[key] = value if key
387
422
  end
423
+ raise unmarshal_error if unmarshal_error
424
+
388
425
  hash
389
426
  end
390
427
 
@@ -401,10 +438,11 @@ module Dalli
401
438
  processor = response_processor
402
439
  size = processor.size_from_va_line(line)
403
440
  processor.check_value_size!(size)
404
- value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
441
+ value = processor.strip_value_terminator!(@connection_manager.read(size + TERMINATOR.bytesize))
405
442
  key = processor.key_from_va_line(line)
406
443
  return unless key
407
444
 
445
+ raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
408
446
  bitflags = is_raw ? 0 : processor.bitflags_from_va_line(line)
409
447
  [key, @value_marshaller.retrieve(value, bitflags)]
410
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)
@@ -120,9 +120,10 @@ module Dalli
120
120
 
121
121
  def multi_meta_set(entries, ttl: nil, p_token: nil, l_token: nil)
122
122
  # Routing tokens apply to every entry in the batch, so the suffix is
123
- # built once rather than per entry.
124
- token_suffix = routing_tokens(p_token: p_token, l_token: l_token)
125
- ttl = integer_flag(:ttl, ttl) if ttl
123
+ # built once rather than per entry. It's binary, like the buffer, so
124
+ # a token with non-ASCII characters doesn't turn the buffer UTF-8.
125
+ token_suffix = routing_tokens(p_token: p_token, l_token: l_token).b
126
+ ttl = ttl_flag(:ttl, ttl) if ttl
126
127
 
127
128
  buffer = ''.b
128
129
  entries.each do |key, pair|
@@ -136,7 +137,9 @@ module Dalli
136
137
  buffer << ' b' if base64
137
138
  buffer << " F#{bitflags}" if bitflags
138
139
  buffer << " T#{ttl}" if ttl
139
- buffer << ' MS q' << token_suffix << TERMINATOR << value << TERMINATOR
140
+ buffer << ' MS q' << token_suffix << TERMINATOR
141
+ append_bytes(buffer, value)
142
+ buffer << TERMINATOR
140
143
  end
141
144
  buffer << META_NOOP
142
145
  end
@@ -164,7 +167,7 @@ module Dalli
164
167
  cmd = "md #{encoded_key(key)}"
165
168
  cmd << cas_string(cas)
166
169
  cmd << ' I' if stale # Mark stale instead of deleting
167
- cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
170
+ cmd << " T#{ttl_flag(:ttl, ttl)}" if ttl
168
171
  cmd << ' x' if drop_value # Drop the value but keep the item
169
172
  cmd << ' q' if quiet
170
173
  cmd << routing_tokens(p_token: p_token, l_token: l_token)
@@ -183,7 +186,7 @@ module Dalli
183
186
 
184
187
  suffix = +''
185
188
  suffix << ' I' if stale
186
- suffix << " T#{integer_flag(:ttl, ttl)}" if ttl
189
+ suffix << " T#{ttl_flag(:ttl, ttl)}" if ttl
187
190
  suffix << ' x' if drop_value
188
191
  suffix << ' q'
189
192
  suffix << routing_tokens(p_token: p_token, l_token: l_token)
@@ -199,10 +202,10 @@ module Dalli
199
202
  def meta_arithmetic(key:, delta:, initial:, incr: true, cas: nil, ttl: nil, quiet: false,
200
203
  p_token: nil, l_token: nil)
201
204
  cmd = "ma #{encoded_key(key)} v"
202
- cmd << " D#{integer_flag(:delta, delta)}" if delta
203
- 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
204
207
  # Always set a TTL if an initial value is specified
205
- cmd << " N#{ttl ? integer_flag(:ttl, ttl) : 0}" if ttl || initial
208
+ cmd << " N#{ttl ? ttl_flag(:ttl, ttl) : 0}" if ttl || initial
206
209
  cmd << cas_string(cas)
207
210
  cmd << ' q' if quiet
208
211
  cmd << " M#{incr ? 'I' : 'D'}"
@@ -254,7 +257,7 @@ module Dalli
254
257
 
255
258
  def flush(delay: nil, quiet: false)
256
259
  cmd = +'flush_all'
257
- cmd << " #{parse_to_64_bit_int(delay, 0)}" if delay
260
+ cmd << " #{parse_to_64_bit_int(:delay, delay)}" if delay
258
261
  cmd << ' noreply' if quiet
259
262
  cmd << TERMINATOR
260
263
  end
@@ -279,6 +282,21 @@ module Dalli
279
282
 
280
283
  private
281
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
+
282
300
  # Disallowed bytes: whitespace and control characters. CR, LF or NUL
283
301
  # would let a token inject a second command; a space would let it add
284
302
  # meta flags (T, N, I, q, ...) to the request it rides on.
@@ -304,6 +322,20 @@ module Dalli
304
322
  raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
305
323
  end
306
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
+
307
339
  def mode_to_token(mode)
308
340
  case mode
309
341
  when :add
@@ -320,15 +352,31 @@ module Dalli
320
352
  end
321
353
 
322
354
  def cas_string(cas)
323
- 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)
324
359
  cas.nil? || cas.zero? ? '' : " C#{cas}"
325
360
  end
326
361
 
327
- def parse_to_64_bit_int(val, default)
328
- val.nil? ? nil : Integer(val)
329
- rescue ArgumentError
330
- # Sanitize to default if it isn't parsable as an integer
331
- 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}"
332
380
  end
333
381
  end
334
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