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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -3
- data/README.md +30 -0
- data/lib/dalli/client.rb +189 -76
- data/lib/dalli/key_grouping.rb +50 -0
- data/lib/dalli/key_manager.rb +30 -0
- data/lib/dalli/pipelined_deleter.rb +7 -16
- data/lib/dalli/pipelined_getter.rb +64 -58
- data/lib/dalli/pipelined_setter.rb +6 -15
- data/lib/dalli/protocol/base.rb +117 -41
- data/lib/dalli/protocol/connection_manager.rb +106 -2
- data/lib/dalli/protocol/key_regularizer.rb +5 -0
- data/lib/dalli/protocol/meta.rb +37 -9
- data/lib/dalli/protocol/request_formatter.rb +62 -33
- data/lib/dalli/protocol/response_processor.rb +49 -6
- data/lib/dalli/protocol/ttl_sanitizer.rb +13 -4
- data/lib/dalli/ring.rb +4 -0
- data/lib/dalli/servers_arg_normalizer.rb +19 -1
- data/lib/dalli/socket.rb +5 -0
- data/lib/dalli/version.rb +1 -1
- data/lib/dalli.rb +14 -1
- data/lib/rack/session/dalli.rb +13 -7
- metadata +2 -1
|
@@ -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,
|
|
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
|
data/lib/dalli/protocol/meta.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
#
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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)
|
|
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#{
|
|
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#{
|
|
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#{
|
|
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#{
|
|
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 =
|
|
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#{
|
|
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#{
|
|
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#{
|
|
222
|
-
cmd << " J#{
|
|
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 ?
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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