dalli 5.0.5 → 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.
@@ -5,16 +5,16 @@ require 'timeout'
5
5
 
6
6
  module Dalli
7
7
  module Protocol
8
+ # Compact the buffer when the consumed portion exceeds this
9
+ # threshold and represents more than half the buffer
10
+ COMPACT_THRESHOLD = 4096
11
+
8
12
  ##
9
13
  # Manages the buffer for responses from memcached.
10
14
  # Uses an offset-based approach to avoid string allocations
11
15
  # when advancing through parsed responses.
12
16
  ##
13
17
  class ResponseBuffer
14
- # Compact the buffer when the consumed portion exceeds this
15
- # threshold and represents more than half the buffer
16
- COMPACT_THRESHOLD = 4096
17
-
18
18
  def initialize(io_source, response_processor)
19
19
  @io_source = io_source
20
20
  @response_processor = response_processor
@@ -23,22 +23,32 @@ module Dalli
23
23
  end
24
24
 
25
25
  def read
26
- @buffer << @io_source.read_nonblock
26
+ remaining_bytes = @buffer.bytesize - @offset
27
+ if remaining_bytes.zero?
28
+ @offset = 0
29
+ @buffer = @io_source.read_available(@buffer)
30
+ else
31
+ if @offset > COMPACT_THRESHOLD && @offset > (@buffer.bytesize / 2)
32
+ @buffer.bytesplice(0, @offset, '')
33
+ @offset = 0
34
+ end
35
+ @buffer << @io_source.read_available
36
+ end
27
37
  end
28
38
 
29
39
  # Attempts to process a single response from the buffer,
30
40
  # advancing the offset past the consumed bytes.
31
41
  def process_single_getk_response
32
- bytes, status, cas, key, value = @response_processor.getk_response_from_buffer(@buffer, @offset)
33
- @offset += bytes
34
- compact_if_needed
35
- [status, cas, key, value]
42
+ response = @response_processor.getk_response_from_buffer(@buffer, @offset)
43
+ @offset += response.pop
44
+ response
36
45
  end
37
46
 
38
47
  # Resets the internal buffer to an empty state,
39
48
  # so that we're ready to read pipelined responses
40
49
  def reset
41
- @buffer = ''.b
50
+ @buffer&.clear
51
+ @buffer ||= ''.b
42
52
  @offset = 0
43
53
  end
44
54
 
@@ -54,6 +64,7 @@ module Dalli
54
64
 
55
65
  # Clear the internal response buffer
56
66
  def clear
67
+ @buffer&.clear
57
68
  @buffer = nil
58
69
  @offset = 0
59
70
  end
@@ -61,18 +72,6 @@ module Dalli
61
72
  def in_progress?
62
73
  !@buffer.nil?
63
74
  end
64
-
65
- private
66
-
67
- # Only compact when we've consumed a significant portion of the buffer.
68
- # This avoids per-response string allocation while preventing unbounded
69
- # memory growth for large pipelines.
70
- def compact_if_needed
71
- return unless @offset > COMPACT_THRESHOLD && @offset > @buffer.bytesize / 2
72
-
73
- @buffer = @buffer.byteslice(@offset..)
74
- @offset = 0
75
- end
76
75
  end
77
76
  end
78
77
  end
@@ -72,11 +72,13 @@ module Dalli
72
72
  #
73
73
  # Used by meta_get for comprehensive metadata retrieval.
74
74
  # Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
75
- def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false)
75
+ def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
76
+ return_ttl_remaining: false)
76
77
  tokens = error_on_unexpected!(T_VA_EN_HD)
77
78
  result = build_metadata_result(tokens)
78
79
  result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
79
80
  result[:last_access] = last_access_from_tokens(tokens) if return_last_access
81
+ result[:ttl_remaining] = ttl_remaining_from_tokens(tokens) if return_ttl_remaining
80
82
  result[:value] = parse_value_from_tokens(tokens, cache_nils)
81
83
  result
82
84
  end
@@ -85,7 +87,12 @@ module Dalli
85
87
  {
86
88
  value: nil, cas: cas_from_tokens(tokens),
87
89
  won_recache: tokens.include?('W'), stale: tokens.include?('X'),
88
- lost_recache: tokens.include?('Z')
90
+ lost_recache: tokens.include?('Z'),
91
+ # Explicit miss marker: EN means the key does not exist. A tombstoned
92
+ # item is NOT a miss -- it answers VA/HD with the X flag set -- and a
93
+ # stored nil under cache_nils is not one either, so neither can be
94
+ # inferred from value or cas alone.
95
+ miss: tokens.first == EN
89
96
  }
90
97
  end
91
98
 
@@ -157,9 +164,26 @@ module Dalli
157
164
  true
158
165
  end
159
166
 
167
+ # Consumes the responses to a batch of quiet (pipelined) delete
168
+ # requests, which are terminated by a noop (MN). In quiet mode
169
+ # memcached suppresses the success response for each deleted key, so
170
+ # every line received before the terminator corresponds to a key that
171
+ # was NOT deleted -- a miss (NF) or an error. Returns that count so
172
+ # callers can derive the number of successful deletes as
173
+ # (keys_sent - non_deletions).
174
+ def pipelined_delete_non_deletions
175
+ non_deletions = 0
176
+ tokens = next_line_to_tokens
177
+ until tokens.first == MN
178
+ non_deletions += 1
179
+ tokens = next_line_to_tokens
180
+ end
181
+ non_deletions
182
+ end
183
+
160
184
  def full_response_from_buffer(tokens, body, resp_size)
161
185
  value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
162
- [resp_size, tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value]
186
+ [tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value, resp_size]
163
187
  end
164
188
 
165
189
  ##
@@ -174,24 +198,26 @@ module Dalli
174
198
  ##
175
199
  def getk_response_from_buffer(buf, offset = 0)
176
200
  # Find the header terminator starting from offset
177
- term_idx = buf.index(TERMINATOR, offset)
178
- return [0, nil, nil, nil, nil] unless term_idx
201
+ term_idx = buf.byteindex(TERMINATOR, offset)
202
+ return [0] unless term_idx
179
203
 
180
204
  header = buf.byteslice(offset, term_idx - offset)
181
205
  tokens = header.split
182
206
  header_len = header.bytesize + TERMINATOR.length
207
+
208
+ # The body len is removed from the tokens array
183
209
  body_len = body_len_from_tokens(tokens)
184
210
 
185
211
  # We have a complete response that has no body.
186
212
  # This is either the response to the terminating
187
213
  # noop or, if the status is not MN, an intermediate
188
214
  # error response that needs to be discarded.
189
- return [header_len, true, nil, nil, nil] if body_len.zero?
215
+ return [true, header_len] if body_len.zero?
190
216
 
191
217
  resp_size = header_len + body_len + TERMINATOR.length
192
218
  # The header is in the buffer, but the body is not. As we don't have
193
219
  # a complete response, don't advance the buffer
194
- return [0, nil, nil, nil, nil] unless buf.bytesize >= offset + resp_size
220
+ return [0] unless buf.bytesize >= offset + resp_size
195
221
 
196
222
  # The full response is in our buffer, so parse it and return
197
223
  # the values
@@ -210,17 +236,28 @@ module Dalli
210
236
  end
211
237
 
212
238
  def bitflags_from_tokens(tokens)
213
- value_from_tokens(tokens, 'f')&.to_i
239
+ value_from_tokens(tokens, 'f').to_i
214
240
  end
215
241
 
216
242
  def cas_from_tokens(tokens)
217
- value_from_tokens(tokens, 'c')&.to_i
243
+ value_from_tokens(tokens, 'c').to_i
244
+ end
245
+
246
+ # Detects the X presence flag, set when an item has been marked stale by a
247
+ # prior `md key I`. Uses strict equality (Array#any? with a String pattern
248
+ # compares with ==) so a future value-bearing flag beginning with X cannot
249
+ # be mistaken for it.
250
+ def stale_from_tokens(tokens)
251
+ tokens.any?('X')
218
252
  end
219
253
 
220
254
  def key_from_tokens(tokens)
221
255
  encoded_key = value_from_tokens(tokens, 'k')
222
- base64_encoded = tokens.any?('b')
223
- KeyRegularizer.decode(encoded_key, base64_encoded)
256
+ if tokens.delete('b')
257
+ KeyRegularizer.decode(encoded_key)
258
+ else
259
+ encoded_key
260
+ end
224
261
  end
225
262
 
226
263
  # Returns true if item was previously hit, false if first access, nil if not requested
@@ -235,18 +272,28 @@ module Dalli
235
272
  # Returns seconds since last access, or nil if not requested
236
273
  # The l flag returns l<seconds>
237
274
  def last_access_from_tokens(tokens)
238
- value_from_tokens(tokens, 'l')&.to_i
275
+ value_from_tokens(tokens, 'l').to_i
276
+ end
277
+
278
+ # Returns seconds of TTL remaining; -1 when the item has no expiry.
279
+ # The t flag returns t<seconds>.
280
+ def ttl_remaining_from_tokens(tokens)
281
+ value_from_tokens(tokens, 't').to_i
239
282
  end
240
283
 
241
284
  def body_len_from_tokens(tokens)
242
- value_from_tokens(tokens, 's')&.to_i
285
+ value_from_tokens(tokens, 's').to_i
243
286
  end
244
287
 
245
288
  def value_from_tokens(tokens, flag)
246
- bitflags_token = tokens.find { |t| t.start_with?(flag) }
247
- return 0 unless bitflags_token
248
-
249
- bitflags_token[1..]
289
+ # NB: as an optimization, we're mutating the matching token in place
290
+ # so there is a baked assumption that we're only accessing each token at most once.
291
+ index = tokens.find_index { |t| t.start_with?(flag) }
292
+ if index
293
+ tokens.delete_at(index).delete_prefix!(flag)
294
+ else
295
+ 0
296
+ end
250
297
  end
251
298
 
252
299
  def read_line
data/lib/dalli/socket.rb CHANGED
@@ -12,48 +12,63 @@ module Dalli
12
12
  # Common methods for all socket implementations.
13
13
  ##
14
14
  module InstanceMethods
15
- def readfull(count)
16
- value = String.new(capacity: count + 1)
17
- loop do
18
- result = read_nonblock(count - value.bytesize, exception: false)
19
- value << result if append_to_buffer?(result)
20
- break if value.bytesize == count
15
+ def read_available(reusable_buffer = nil)
16
+ if reusable_buffer
17
+ value = read_nonblock(8196, reusable_buffer, exception: false)
18
+ case value
19
+ when :wait_writable, :wait_readable
20
+ return reusable_buffer.clear
21
+ when nil
22
+ raise Errno::ECONNRESET, "Connection reset: #{logged_options.inspect}"
23
+ end
24
+ else
25
+ value = ''.b
21
26
  end
22
- value
23
- end
24
27
 
25
- def read_available
26
- value = +''
28
+ buffer = ''.b
27
29
  loop do
28
- result = read_nonblock(8196, exception: false)
29
- break if WAIT_RCS.include?(result)
30
- raise Errno::ECONNRESET, "Connection reset: #{logged_options.inspect}" unless result
31
-
32
- value << result
30
+ result = read_nonblock(8196, buffer, exception: false)
31
+ case result
32
+ when :wait_writable, :wait_readable
33
+ buffer.clear
34
+ return value
35
+ when nil
36
+ raise Errno::ECONNRESET, "Connection reset: #{logged_options.inspect}"
37
+ else
38
+ value << result
39
+ end
33
40
  end
34
- value
35
- end
36
-
37
- WAIT_RCS = %i[wait_writable wait_readable].freeze
38
-
39
- def append_to_buffer?(result)
40
- raise Timeout::Error, "IO timeout: #{logged_options.inspect}" if nonblock_timed_out?(result)
41
- raise Errno::ECONNRESET, "Connection reset: #{logged_options.inspect}" unless result
42
-
43
- !WAIT_RCS.include?(result)
44
- end
45
-
46
- def nonblock_timed_out?(result)
47
- return true if result == :wait_readable && !wait_readable(options[:socket_timeout])
48
-
49
- # TODO: Do we actually need this? Looks to be only used in read_nonblock
50
- result == :wait_writable && !wait_writable(options[:socket_timeout])
51
41
  end
52
42
 
53
43
  FILTERED_OUT_OPTIONS = %i[username password].freeze
54
44
  def logged_options
55
45
  options.except(*FILTERED_OUT_OPTIONS)
56
46
  end
47
+
48
+ # JRuby doesn't support IO#timeout=, so use custom readfull implementation
49
+ # CRuby 3.3+ has IO#timeout= which makes IO#read work with timeouts
50
+ if RUBY_ENGINE == 'jruby'
51
+ def readfull(count)
52
+ value = String.new(capacity: count + 1)
53
+
54
+ until value.bytesize == count
55
+ result = read_nonblock(count - value.bytesize, exception: false)
56
+ case result
57
+ when :wait_readable
58
+ wait_readable(options[:socket_timeout]) or raise Timeout::Error, "IO timeout: #{logged_options.inspect}"
59
+ when :wait_writable
60
+ wait_writable(options[:socket_timeout]) or raise Timeout::Error, "IO timeout: #{logged_options.inspect}"
61
+ when nil
62
+ raise Errno::ECONNRESET, "Connection reset: #{logged_options.inspect}"
63
+ else
64
+ value << result
65
+ end
66
+ end
67
+
68
+ value
69
+ end
70
+ # rubocop:enable Metrics/AbcSize
71
+ end
57
72
  end
58
73
 
59
74
  ##
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '5.0.5'
4
+ VERSION = '5.1.0'
5
5
 
6
- MIN_SUPPORTED_MEMCACHED_VERSION = '1.6'
6
+ MIN_SUPPORTED_MEMCACHED_VERSION = '1.6.27'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dalli
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.5
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -44,7 +44,6 @@ files:
44
44
  - lib/dalli/instrumentation.rb
45
45
  - lib/dalli/key_manager.rb
46
46
  - lib/dalli/options.rb
47
- - lib/dalli/pid_cache.rb
48
47
  - lib/dalli/pipelined_deleter.rb
49
48
  - lib/dalli/pipelined_getter.rb
50
49
  - lib/dalli/pipelined_setter.rb
@@ -88,7 +87,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
88
87
  - !ruby/object:Gem::Version
89
88
  version: '0'
90
89
  requirements: []
91
- rubygems_version: 4.0.12
90
+ rubygems_version: 4.0.18
92
91
  specification_version: 4
93
92
  summary: High performance memcached client for Ruby
94
93
  test_files: []
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Dalli
4
- ##
5
- # Dalli::PIDCache is a wrapper class for PID checking to avoid system calls when checking the PID.
6
- ##
7
- module PIDCache
8
- if !Process.respond_to?(:fork) # JRuby or TruffleRuby
9
- @pid = Process.pid
10
- singleton_class.attr_reader(:pid)
11
- elsif Process.respond_to?(:_fork) # Ruby 3.1+
12
- class << self
13
- attr_reader :pid
14
-
15
- def update!
16
- @pid = Process.pid # rubocop:disable ThreadSafety/ClassInstanceVariable
17
- end
18
- end
19
- update!
20
-
21
- ##
22
- # Dalli::PIDCache::CoreExt hooks into Process to be able to reset the PID cache after fork
23
- ##
24
- module CoreExt
25
- def _fork
26
- child_pid = super
27
- PIDCache.update! if child_pid.zero?
28
- child_pid
29
- end
30
- end
31
- Process.singleton_class.prepend(CoreExt)
32
- else # Ruby 3.0 or older
33
- class << self
34
- def pid
35
- Process.pid
36
- end
37
- end
38
- end
39
- end
40
- end