dalli 5.0.10 → 5.1.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.
@@ -35,10 +35,7 @@ module Dalli
35
35
  @request_in_progress = false
36
36
  @sock = nil
37
37
  @pid = nil
38
- @write_buffer = []
39
- @write_buffer_bytes = 0
40
38
 
41
- @fail_count = 0
42
39
  reset_down_info
43
40
  end
44
41
 
@@ -54,8 +51,7 @@ module Dalli
54
51
  Dalli.logger.debug { "Dalli::Server#connect #{name}" }
55
52
 
56
53
  @sock = memcached_socket
57
- # Writes are buffered in @write_buffer instead; see WRITE_BUFFER_FLUSH_BYTES
58
- @sock.sync = true
54
+ @sock.sync = false # Enable buffered I/O for better performance
59
55
  @pid = Process.pid
60
56
  @request_in_progress = false
61
57
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
@@ -87,8 +83,6 @@ module Dalli
87
83
  def down!
88
84
  close
89
85
  log_down_detected
90
- # Once down_retry_delay passes, the server gets a full set of attempts
91
- @fail_count = 0
92
86
 
93
87
  @error = $ERROR_INFO&.class&.name
94
88
  @msg ||= $ERROR_INFO&.message
@@ -118,7 +112,7 @@ module Dalli
118
112
  return unless @sock
119
113
 
120
114
  begin
121
- close_socket
115
+ @sock.close
122
116
  rescue StandardError
123
117
  nil
124
118
  ensure
@@ -129,18 +123,10 @@ module Dalli
129
123
  # @request_in_progress == true.
130
124
  @sock = nil
131
125
  @pid = nil
132
- discard_write_buffer
133
126
  abort_request!
134
127
  end
135
128
  end
136
129
 
137
- # A forked child shares the parent's connection, so it closes only its
138
- # own file descriptor. Closing the TLS socket itself would send
139
- # close_notify and end the parent's TLS session.
140
- def close_socket
141
- fork_detected? ? @sock.to_io.close : @sock.close
142
- end
143
-
144
130
  def connected?
145
131
  !@sock.nil?
146
132
  end
@@ -159,11 +145,6 @@ module Dalli
159
145
  raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless @request_in_progress
160
146
 
161
147
  @request_in_progress = false
162
- # A completed request is what proves the server healthy again, so the
163
- # failure count resets here rather than on reconnect: a server that
164
- # accepts connections but never answers would otherwise reset it on
165
- # every retry and never reach socket_max_failures.
166
- @fail_count = 0
167
148
  end
168
149
 
169
150
  def abort_request!
@@ -171,7 +152,6 @@ module Dalli
171
152
  end
172
153
 
173
154
  def read_line
174
- flush_write_buffer
175
155
  data = @sock.gets("\r\n")
176
156
  error_on_request!('EOF in read_line') if data.nil?
177
157
  data
@@ -181,29 +161,14 @@ module Dalli
181
161
 
182
162
  # JRuby doesn't support IO#timeout=, so use custom readfull implementation
183
163
  # CRuby 3.3+ has IO#timeout= which makes IO#read work with timeouts
184
- # memcached can't store an item larger than 1 GiB (its -I maximum), so a
185
- # reply claiming more (or a negative size) is malformed or hostile.
186
- # IO#read allocates the full count up front, so check before reading.
187
- MAX_READ_BYTES = (1024 * 1024 * 1024) + 2 # plus the value's trailing "\r\n"
188
-
189
- def check_read_size!(count)
190
- return if count.between?(0, MAX_READ_BYTES)
191
-
192
- raise Dalli::DalliError, "Reply size #{count} from #{name} is out of range"
193
- end
194
-
195
164
  if RUBY_ENGINE == 'jruby'
196
165
  def read(count)
197
- check_read_size!(count)
198
- flush_write_buffer
199
166
  @sock.readfull(count)
200
167
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
201
168
  error_on_request!(e)
202
169
  end
203
170
  else
204
171
  def read(count)
205
- check_read_size!(count)
206
- flush_write_buffer
207
172
  read_bytes(count)
208
173
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
209
174
  error_on_request!(e)
@@ -214,34 +179,22 @@ module Dalli
214
179
  # at the call site.
215
180
  alias read_exact read
216
181
 
217
- # Requests are buffered here rather than in the socket's own IO buffer
218
- # (the socket is sync). Ruby flushes an IO's write buffer when any process
219
- # holding it closes or finalizes the IO, so after a fork a child could
220
- # send bytes the parent had buffered but not sent yet: a quiet write would
221
- # run twice, or the parent's replies would shift onto the wrong requests.
222
- # A forked child discards this buffer instead. Like IO's own buffer, it
223
- # is sent once it grows past this size, or when a reply is read.
224
- WRITE_BUFFER_FLUSH_BYTES = 64 * 1024
225
-
226
182
  def write(bytes)
227
- @write_buffer << bytes
228
- @write_buffer_bytes += bytes.bytesize
229
- flush_write_buffer if @write_buffer_bytes >= WRITE_BUFFER_FLUSH_BYTES
230
- bytes.bytesize
183
+ @sock.write(bytes)
231
184
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
232
185
  error_on_request!(e)
233
186
  end
234
187
 
235
188
  def flush
236
- flush_write_buffer
189
+ @sock.flush
237
190
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
238
191
  error_on_request!(e)
239
192
  end
240
193
 
241
194
  def flushed_write(bytes)
242
- @write_buffer << bytes
243
- flush_write_buffer
244
- bytes.bytesize
195
+ written = @sock.write(bytes)
196
+ @sock.flush
197
+ written
245
198
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
246
199
  error_on_request!(e)
247
200
  end
@@ -249,7 +202,6 @@ module Dalli
249
202
  # Non-blocking read. Here to support the operation
250
203
  # of the get_multi operation
251
204
  def read_available(...)
252
- flush_write_buffer
253
205
  @sock.read_available(...)
254
206
  end
255
207
 
@@ -276,9 +228,8 @@ module Dalli
276
228
  raise Dalli::RetryableNetworkError, message
277
229
  end
278
230
 
279
- # Called on connect. Deliberately leaves @fail_count alone; see
280
- # finish_request!.
281
231
  def reset_down_info
232
+ @fail_count = 0
282
233
  @down_at = nil
283
234
  @last_down_at = nil
284
235
  @msg = nil
@@ -303,8 +254,7 @@ module Dalli
303
254
  def reconnect_on_fork
304
255
  message = 'Fork detected, re-connecting child process...'
305
256
  Dalli.logger.info { message }
306
- # Drops anything the parent had buffered, closes the inherited socket
307
- # without touching the parent's connection, and reconnects immediately
257
+ # Close socket on a fork and reconnect immediately
308
258
  close
309
259
  establish_connection
310
260
  end
@@ -334,37 +284,6 @@ module Dalli
334
284
 
335
285
  private
336
286
 
337
- # One write per flush. A TLS socket sends each argument of
338
- # write(*parts) as its own record and system call, so the parts are
339
- # joined first.
340
- def flush_write_buffer
341
- return if @write_buffer.empty?
342
-
343
- @sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
344
- discard_write_buffer
345
- end
346
-
347
- # Joined as bytes: requests can mix UTF-8 and binary strings, which
348
- # String#<< refuses to combine when both hold non-ASCII bytes
349
- if String.method_defined?(:append_as_bytes)
350
- def joined_write_buffer
351
- out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
352
- @write_buffer.each { |part| out.append_as_bytes(part) }
353
- out
354
- end
355
- else
356
- def joined_write_buffer
357
- out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
358
- @write_buffer.each { |part| out << part.b }
359
- out
360
- end
361
- end
362
-
363
- def discard_write_buffer
364
- @write_buffer.clear
365
- @write_buffer_bytes = 0
366
- end
367
-
368
287
  # Reads exactly `count` bytes. IO#read(count) on a blocking socket blocks
369
288
  # until it has `count` bytes, accumulating across TCP chunks internally,
370
289
  # and only hands back a shorter (or nil) buffer when the stream hits EOF.
@@ -12,8 +12,25 @@ module Dalli
12
12
  module KeyRegularizer
13
13
  module_function
14
14
 
15
+ # protocol.txt requires that a key "must not include control
16
+ # characters or whitespace" -- \p{Cntrl} is C0 (0x00-0x1F) plus DEL
17
+ # (0x7F). \s alone misses NUL and the rest of that range: a key
18
+ # containing one of those bytes but no whitespace is ASCII-only, so
19
+ # it would otherwise be written to the wire unencoded. Not a
20
+ # protocol-injection risk (the text protocol splits on CRLF, not
21
+ # other control bytes), but a downstream consumer that treats the key
22
+ # specially at one of those bytes (a C string terminating at NUL, a
23
+ # terminal or log line interpreting an escape byte) could silently
24
+ # act on a different key than Dalli believes it sent.
25
+ #
26
+ # Written as \p{Cntrl} rather than the POSIX [:cntrl:] bracket class:
27
+ # \s and [:cntrl:] overlap (tab, newline, CR are in both), and Ruby
28
+ # warns "character class has duplicated range" when they're combined
29
+ # in one -- fatal here, since this suite's -w run treats warnings as
30
+ # errors (see test_strict_warnings.rb). \p{Cntrl} matches the same
31
+ # bytes without the overlap warning.
15
32
  def required?(key)
16
- !key.ascii_only? || /\s/.match?(key)
33
+ !key.ascii_only? || /[\p{Cntrl}\s]/.match?(key)
17
34
  end
18
35
 
19
36
  def encode(key)
@@ -26,22 +26,23 @@ module Dalli
26
26
  def get(key, options = nil)
27
27
  # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
28
28
  skip_flags = raw_mode? || (options && options[:raw])
29
- req = RequestFormatter.meta_get(key: key, skip_flags: skip_flags)
29
+ req = RequestFormatter.meta_get(key: key, skip_flags: skip_flags, **routing_token_kwargs(options))
30
30
  flushed_write(req)
31
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
31
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
32
32
  end
33
33
 
34
- def quiet_get_request(key)
34
+ def quiet_get_request(key, options = nil)
35
35
  # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
36
- RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_mode?)
36
+ RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_mode?,
37
+ **routing_token_kwargs(options))
37
38
  end
38
39
 
39
40
  def gat(key, ttl, options = nil)
40
41
  ttl = TtlSanitizer.sanitize(ttl)
41
42
  skip_flags = raw_mode? || (options && options[:raw])
42
- req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags)
43
+ req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags, **routing_token_kwargs(options))
43
44
  flushed_write(req)
44
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
45
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
45
46
  end
46
47
 
47
48
  def touch(key, ttl)
@@ -54,9 +55,9 @@ module Dalli
54
55
  # TODO: This is confusing, as there's a cas command in memcached
55
56
  # and this isn't it. Maybe rename? Maybe eliminate?
56
57
  def cas(key, options = nil)
57
- req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, skip_flags: raw_request?(options))
58
+ req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, **routing_token_kwargs(options))
58
59
  flushed_write(req)
59
- response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
60
+ response_processor.meta_get_with_value_and_cas
60
61
  end
61
62
 
62
63
  # Comprehensive meta get with support for all metadata flags.
@@ -85,15 +86,19 @@ module Dalli
85
86
  # - :last_access - seconds since last access (only if return_last_access: true)
86
87
  def meta_get(key, options = {})
87
88
  req = RequestFormatter.meta_get(
88
- key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
89
+ key: key, value: true, return_cas: true,
89
90
  vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
90
91
  return_hit_status: options[:return_hit_status],
91
- return_last_access: options[:return_last_access], skip_lru_bump: options[:skip_lru_bump]
92
+ return_last_access: options[:return_last_access],
93
+ return_ttl_remaining: options[:return_ttl_remaining],
94
+ skip_lru_bump: options[:skip_lru_bump],
95
+ **routing_token_kwargs(options)
92
96
  )
93
97
  flushed_write(req)
94
98
  response_processor.meta_get_with_metadata(
95
99
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
96
- return_last_access: options[:return_last_access], raw: raw_request?(options)
100
+ return_last_access: options[:return_last_access],
101
+ return_ttl_remaining: options[:return_ttl_remaining]
97
102
  )
98
103
  end
99
104
 
@@ -137,34 +142,41 @@ module Dalli
137
142
  ttl = TtlSanitizer.sanitize(ttl) if ttl
138
143
  req = RequestFormatter.meta_set(key: key, value: value,
139
144
  bitflags: bitflags, cas: cas,
140
- ttl: ttl, mode: mode, quiet: quiet)
145
+ ttl: ttl, mode: mode, quiet: quiet,
146
+ **routing_token_kwargs(options))
141
147
  write("#{req}#{value}#{TERMINATOR}")
142
148
  @connection_manager.flush unless quiet
143
149
  end
144
150
  # rubocop:enable Metrics/ParameterLists
145
151
 
146
- def append(key, value)
147
- write_append_prepend_req(:append, key, value)
152
+ def append(key, value, options = nil)
153
+ write_append_prepend_req(:append, key, value, nil, nil, options)
148
154
  response_processor.meta_set_append_prepend unless quiet?
149
155
  end
150
156
 
151
- def prepend(key, value)
152
- write_append_prepend_req(:prepend, key, value)
157
+ def prepend(key, value, options = nil)
158
+ write_append_prepend_req(:prepend, key, value, nil, nil, options)
153
159
  response_processor.meta_set_append_prepend unless quiet?
154
160
  end
155
161
 
156
- # rubocop:disable-next Metrics/ParameterLists
157
- def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, _options = {})
162
+ # rubocop:disable Metrics/ParameterLists
163
+ def write_append_prepend_req(mode, key, value, ttl = nil, cas = nil, options = nil)
158
164
  ttl = TtlSanitizer.sanitize(ttl) if ttl
159
165
  req = RequestFormatter.meta_set(key: key, value: value,
160
- cas: cas, ttl: ttl, mode: mode, quiet: quiet?)
166
+ cas: cas, ttl: ttl, mode: mode, quiet: quiet?,
167
+ **routing_token_kwargs(options))
161
168
  write("#{req}#{value}#{TERMINATOR}")
162
169
  @connection_manager.flush unless quiet?
163
170
  end
171
+ # rubocop:enable Metrics/ParameterLists
164
172
 
165
173
  # Delete Commands
166
- def delete(key, cas)
167
- req = RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?)
174
+ #
175
+ # `options` supports the meta-delete keys :invalidate, :tombstone_ttl and
176
+ # :drop_value, plus :p_token/:l_token; see Dalli::Client#delete.
177
+ def delete(key, cas, options = nil)
178
+ req = RequestFormatter.meta_delete(key: key, cas: cas, quiet: quiet?,
179
+ **tombstone_kwargs(options), **routing_token_kwargs(options))
168
180
  write(req)
169
181
  @connection_manager.flush unless quiet?
170
182
  response_processor.meta_delete unless quiet?
@@ -172,8 +184,9 @@ module Dalli
172
184
 
173
185
  # Pipelined delete - writes a quiet delete request without reading response.
174
186
  # Used by PipelinedDeleter for bulk operations.
175
- def pipelined_delete(key)
176
- req = RequestFormatter.meta_delete(key: key, quiet: true)
187
+ def pipelined_delete(key, req_options = nil)
188
+ req = RequestFormatter.meta_delete(key: key, quiet: true,
189
+ **tombstone_kwargs(req_options), **routing_token_kwargs(req_options))
177
190
  write(req)
178
191
  end
179
192
 
@@ -183,21 +196,23 @@ module Dalli
183
196
  end
184
197
 
185
198
  # Arithmetic Commands
186
- def decr(key, count, ttl, initial)
187
- decr_incr false, key, count, ttl, initial
199
+ def decr(key, count, ttl, initial, options = nil)
200
+ decr_incr false, key, count, ttl, initial, options
188
201
  end
189
202
 
190
- def incr(key, count, ttl, initial)
191
- decr_incr true, key, count, ttl, initial
203
+ def incr(key, count, ttl, initial, options = nil)
204
+ decr_incr true, key, count, ttl, initial, options
192
205
  end
193
206
 
194
- def decr_incr(incr, key, delta, ttl, initial)
207
+ # rubocop:disable Metrics/ParameterLists
208
+ def decr_incr(incr, key, delta, ttl, initial, options = nil)
195
209
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
196
210
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
197
- quiet: quiet?))
211
+ quiet: quiet?, **routing_token_kwargs(options)))
198
212
  @connection_manager.flush unless quiet?
199
213
  response_processor.decr_incr unless quiet?
200
214
  end
215
+ # rubocop:enable Metrics/ParameterLists
201
216
 
202
217
  # Other Commands
203
218
  def flush(delay = 0)
@@ -235,14 +250,52 @@ module Dalli
235
250
  # Single-server fast path for get_multi. Inlines request formatting and
236
251
  # response parsing to minimize per-key overhead. Avoids the PipelinedGetter
237
252
  # machinery (IO.select, response buffering, server grouping).
238
- def read_multi_req(keys)
253
+ def read_multi_req(keys, options = nil)
239
254
  is_raw = raw_mode?
240
- buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw)
255
+ buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
241
256
  flushed_write(buffer)
242
257
  buffer.clear
243
258
  read_multi_get_responses(is_raw)
244
259
  end
245
260
 
261
+ # Stale-aware bulk get. Returns { key => { value:, cas:, stale:, miss: } }
262
+ # for the keys the server returned. Keys that were not found are absent
263
+ # from the hash, matching read_multi_req and the get_multi family; a
264
+ # tombstoned item is present (it answers VA with the X flag) with
265
+ # stale: true, which is the distinction callers need.
266
+ #
267
+ # Shared by both the single-server fast path and PipelinedGetter's
268
+ # per-server-group request, so this one change covers both routes.
269
+ def read_multi_with_metadata_req(keys, options = nil)
270
+ is_raw = raw_mode?
271
+ buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, return_cas: true,
272
+ **routing_token_kwargs(options))
273
+ flushed_write(buffer)
274
+ buffer.clear
275
+ read_multi_metadata_responses(is_raw)
276
+ end
277
+
278
+ # Unlike read_multi_get_responses this locates tokens by flag rather than
279
+ # by position, because the c flag shifts the key's index.
280
+ def read_multi_metadata_responses(is_raw)
281
+ hash = {}
282
+ while (line = @connection_manager.read_line)
283
+ break if line.start_with?('MN')
284
+ next unless line.start_with?('VA ')
285
+
286
+ tokens = line.chomp!(TERMINATOR).split
287
+ value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
288
+ stale = response_processor.stale_from_tokens(tokens)
289
+ cas = response_processor.cas_from_tokens(tokens)
290
+ bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
291
+ key = response_processor.key_from_tokens(tokens)
292
+ next if key.nil?
293
+
294
+ hash[key] = { value: @value_marshaller.retrieve(value, bitflags), cas: cas, stale: stale, miss: false }
295
+ end
296
+ hash
297
+ end
298
+
246
299
  def read_multi_get_responses(is_raw)
247
300
  hash = {}
248
301
  key_index = is_raw ? 2 : 3
@@ -258,9 +311,7 @@ module Dalli
258
311
 
259
312
  def parse_multi_get_value(line, key_index, is_raw)
260
313
  tokens = line.chomp!(TERMINATOR).split
261
- size = tokens[1].to_i
262
- response_processor.check_value_size!(size)
263
- value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
314
+ value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
264
315
  raw_key = tokens[key_index]
265
316
  return unless raw_key
266
317
 
@@ -278,7 +329,7 @@ module Dalli
278
329
  [key, @value_marshaller.store(key, raw_value, req_options)]
279
330
  end
280
331
 
281
- buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl)
332
+ buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl, **routing_token_kwargs(req_options))
282
333
  flushed_write(buffer)
283
334
  buffer.clear
284
335
  response_processor.consume_all_responses_until_mn
@@ -286,8 +337,9 @@ module Dalli
286
337
 
287
338
  # Single-server fast path for delete_multi. Writes all quiet delete requests
288
339
  # terminated by a noop, then consumes all responses.
289
- def delete_multi_req(keys)
290
- buffer = RequestFormatter.multi_meta_delete(keys)
340
+ def delete_multi_req(keys, req_options = nil)
341
+ buffer = RequestFormatter.multi_meta_delete(keys, **tombstone_kwargs(req_options),
342
+ **routing_token_kwargs(req_options))
291
343
  flushed_write(buffer)
292
344
  buffer.clear
293
345
  keys.size - response_processor.pipelined_delete_non_deletions