dalli 4.3.2 → 4.3.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7eda4c85eb715e06ef3d8b34ea8e33a3cde89f19379a758f105363457451a034
4
- data.tar.gz: 36ae8b603f8f778edbbf3bed9b7504ff1a440382bf5520423f49c39961f04a55
3
+ metadata.gz: 57b78e30ee409a2d742fc47ee57ccdd482fe9c75f369366ae315b7ef8b649934
4
+ data.tar.gz: b8cad66f3cba53bbcb84b18f406eed60f22209c10dd4a312063a1e13189185ca
5
5
  SHA512:
6
- metadata.gz: b9a37ecfc73734a141979a34f2e155baa40cea5b22b016c192514c9d5847335d949ee642620c4508cc2ec8a2ceeb4dcec381715e633893623c42454975999d1a
7
- data.tar.gz: 2a5f38c78c371e0710354a2b915a074512a87225f33fca95c9088ded76cad0dfc22e454fc9f64bddd40d8b0c0a6ab65bb108f467cd5b2b2c39d651285665f845
6
+ metadata.gz: 18b26e3c4aa30e5f8b4195d95307afca6c9240dd1154a36966d40d52047e638758850ec06e2a40fd1bd8e69fe150dee7409b1f4a565f0a80e397aaf115315463
7
+ data.tar.gz: b6db69ecbc1e6d34587d8ec29b861f6a71c863b36229d3c642e3d0e646ba6234e3bac04225d64ff174d12f35b728663e82e1d2b5f7e93dc9ac3e1ff33fd58db3
data/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  Dalli Changelog
2
2
  =====================
3
3
 
4
+ 4.3.3
5
+ ==========
6
+
7
+ Performance:
8
+
9
+ - Reduce object allocations in pipelined get response processing (#1072)
10
+ - Offset-based `ResponseBuffer`: track a read offset instead of slicing a new string after every parsed response; compact only when the consumed portion exceeds 4KB and more than half the buffer
11
+ - Inline response processor parsing: avoid intermediate array allocations from `split`-based header parsing in both binary and meta protocols
12
+ - Block-based `pipeline_next_responses`: yield `(key, value, cas)` directly when a block is given, avoiding per-call Hash allocation
13
+ - `PipelinedGetter`: replace Hash-based socket-to-server mapping with linear scan (faster for typical 1-5 server counts); use `Process.clock_gettime(CLOCK_MONOTONIC)` instead of `Time.now`
14
+ - Add cross-version benchmark script (`bin/compare_versions`) for reproducible performance comparisons across Dalli versions
15
+
16
+ Bug Fixes:
17
+
18
+ - Skip OTel integration tests when meta protocol is unavailable (#1072)
19
+
4
20
  4.3.2
5
21
  ==========
6
22
 
@@ -29,7 +29,7 @@ module Dalli
29
29
  # Stores partial results collected during interleaved send phase
30
30
  @partial_results = {}
31
31
  servers = setup_requests(keys)
32
- start_time = Time.now
32
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
33
 
34
34
  # First yield any partial results collected during interleaved send
35
35
  yield_partial_results(&block)
@@ -149,7 +149,7 @@ module Dalli
149
149
  end
150
150
 
151
151
  def remaining_time(start, timeout)
152
- elapsed = Time.now - start
152
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
153
153
  return 0 if elapsed > timeout
154
154
 
155
155
  timeout - elapsed
@@ -168,8 +168,8 @@ module Dalli
168
168
  # Processes responses from a server. Returns true if there are no
169
169
  # additional responses from this server.
170
170
  def process_server(server)
171
- server.pipeline_next_responses.each_pair do |key, value_list|
172
- yield @key_manager.key_without_namespace(key), value_list
171
+ server.pipeline_next_responses do |key, value, cas|
172
+ yield @key_manager.key_without_namespace(key), [value, cas]
173
173
  end
174
174
 
175
175
  server.pipeline_complete?
@@ -178,18 +178,13 @@ module Dalli
178
178
  def servers_with_response(servers, timeout)
179
179
  return [] if servers.empty?
180
180
 
181
- # TODO: - This is a bit challenging. Essentially the PipelinedGetter
182
- # is a reactor, but without the benefit of a Fiber or separate thread.
183
- # My suspicion is that we may want to try and push this down into the
184
- # individual servers, but I'm not sure. For now, we keep the
185
- # mapping between the alerted object (the socket) and the
186
- # corrresponding server here.
187
- server_map = servers.each_with_object({}) { |s, h| h[s.sock] = s }
188
-
189
- readable, = IO.select(server_map.keys, nil, nil, timeout)
181
+ sockets = servers.map(&:sock)
182
+ readable, = IO.select(sockets, nil, nil, timeout)
190
183
  return [] if readable.nil?
191
184
 
192
- readable.map { |sock| server_map[sock] }
185
+ # For typical server counts (1-5), linear scan is faster than
186
+ # building and looking up a hash map
187
+ readable.filter_map { |sock| servers.find { |s| s.sock == sock } }
193
188
  end
194
189
 
195
190
  def groups_for_keys(*keys)
@@ -91,10 +91,13 @@ module Dalli
91
91
  # repeatedly whenever this server's socket is readable until
92
92
  # #pipeline_complete?.
93
93
  #
94
- # Returns a Hash of kv pairs received.
95
- def pipeline_next_responses
94
+ # When a block is given, yields (key, value, cas) for each response,
95
+ # avoiding intermediate Hash allocation. Returns nil.
96
+ # Without a block, returns a Hash of { key => [value, cas] }.
97
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
98
+ def pipeline_next_responses(&block)
96
99
  reconnect_on_pipeline_complete!
97
- values = {}
100
+ values = nil
98
101
 
99
102
  response_buffer.read
100
103
 
@@ -108,16 +111,24 @@ module Dalli
108
111
 
109
112
  # If the status is ok and the key is not nil, then this is a
110
113
  # getkq response with a value that we want to set in the response hash
111
- values[key] = [value, cas] unless key.nil?
114
+ unless key.nil?
115
+ if block
116
+ yield key, value, cas
117
+ else
118
+ values ||= {}
119
+ values[key] = [value, cas]
120
+ end
121
+ end
112
122
 
113
123
  # Get the next response from the buffer
114
124
  status, cas, key, value = response_buffer.process_single_getk_response
115
125
  end
116
126
 
117
- values
127
+ values || {}
118
128
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
119
129
  @connection_manager.error_on_request!(e)
120
130
  end
131
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
121
132
 
122
133
  # Abort current pipelined get. Generally used to signal an external
123
134
  # timeout during pipelined get. The underlying socket is
@@ -189,16 +189,6 @@ module Dalli
189
189
  [resp_header.status, content]
190
190
  end
191
191
 
192
- def contains_header?(buf)
193
- return false unless buf
194
-
195
- buf.bytesize >= ResponseHeader::SIZE
196
- end
197
-
198
- def response_header_from_buffer(buf)
199
- ResponseHeader.new(buf)
200
- end
201
-
202
192
  ##
203
193
  # This method returns an array of values used in a pipelined
204
194
  # getk process. The first value is the number of bytes by
@@ -209,11 +199,11 @@ module Dalli
209
199
  # The remaining three values in the array are the ResponseHeader,
210
200
  # key, and value.
211
201
  ##
212
- def getk_response_from_buffer(buf)
202
+ def getk_response_from_buffer(buf, offset = 0)
213
203
  # There's no header in the buffer, so don't advance
214
- return [0, nil, nil, nil, nil] unless contains_header?(buf)
204
+ return [0, nil, nil, nil, nil] unless buf && buf.bytesize >= offset + ResponseHeader::SIZE
215
205
 
216
- resp_header = response_header_from_buffer(buf)
206
+ resp_header = ResponseHeader.new(buf.byteslice(offset, ResponseHeader::SIZE))
217
207
  body_len = resp_header.body_len
218
208
 
219
209
  # We have a complete response that has no body.
@@ -225,11 +215,11 @@ module Dalli
225
215
  resp_size = ResponseHeader::SIZE + body_len
226
216
  # The header is in the buffer, but the body is not. As we don't have
227
217
  # a complete response, don't advance the buffer
228
- return [0, nil, nil, nil, nil] unless buf.bytesize >= resp_size
218
+ return [0, nil, nil, nil, nil] unless buf.bytesize >= offset + resp_size
229
219
 
230
220
  # The full response is in our buffer, so parse it and return
231
221
  # the values
232
- body = buf.byteslice(ResponseHeader::SIZE, body_len)
222
+ body = buf.byteslice(offset + ResponseHeader::SIZE, body_len)
233
223
  key, value = unpack_response_body(resp_header, body, true)
234
224
  [resp_size, resp_header.ok?, resp_header.cas, key, value]
235
225
  end
@@ -147,14 +147,6 @@ module Dalli
147
147
  true
148
148
  end
149
149
 
150
- def tokens_from_header_buffer(buf)
151
- header = header_from_buffer(buf)
152
- tokens = header.split
153
- header_len = header.bytesize + TERMINATOR.length
154
- body_len = body_len_from_tokens(tokens)
155
- [tokens, header_len, body_len]
156
- end
157
-
158
150
  def full_response_from_buffer(tokens, body, resp_size)
159
151
  value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
160
152
  [resp_size, tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value]
@@ -170,11 +162,15 @@ module Dalli
170
162
  # The remaining three values in the array are the ResponseHeader,
171
163
  # key, and value.
172
164
  ##
173
- def getk_response_from_buffer(buf)
174
- # There's no header in the buffer, so don't advance
175
- return [0, nil, nil, nil, nil] unless contains_header?(buf)
165
+ def getk_response_from_buffer(buf, offset = 0)
166
+ # Find the header terminator starting from offset
167
+ term_idx = buf.index(TERMINATOR, offset)
168
+ return [0, nil, nil, nil, nil] unless term_idx
176
169
 
177
- tokens, header_len, body_len = tokens_from_header_buffer(buf)
170
+ header = buf.byteslice(offset, term_idx - offset)
171
+ tokens = header.split
172
+ header_len = header.bytesize + TERMINATOR.length
173
+ body_len = body_len_from_tokens(tokens)
178
174
 
179
175
  # We have a complete response that has no body.
180
176
  # This is either the response to the terminating
@@ -185,22 +181,14 @@ module Dalli
185
181
  resp_size = header_len + body_len + TERMINATOR.length
186
182
  # The header is in the buffer, but the body is not. As we don't have
187
183
  # a complete response, don't advance the buffer
188
- return [0, nil, nil, nil, nil] unless buf.bytesize >= resp_size
184
+ return [0, nil, nil, nil, nil] unless buf.bytesize >= offset + resp_size
189
185
 
190
186
  # The full response is in our buffer, so parse it and return
191
187
  # the values
192
- body = buf.slice(header_len, body_len)
188
+ body = buf.byteslice(offset + header_len, body_len)
193
189
  full_response_from_buffer(tokens, body, resp_size)
194
190
  end
195
191
 
196
- def contains_header?(buf)
197
- buf.include?(TERMINATOR)
198
- end
199
-
200
- def header_from_buffer(buf)
201
- buf.split(TERMINATOR, 2).first
202
- end
203
-
204
192
  def error_on_unexpected!(expected_codes)
205
193
  tokens = next_line_to_tokens
206
194
 
@@ -7,38 +7,39 @@ module Dalli
7
7
  module Protocol
8
8
  ##
9
9
  # Manages the buffer for responses from memcached.
10
+ # Uses an offset-based approach to avoid string allocations
11
+ # when advancing through parsed responses.
10
12
  ##
11
13
  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
+
12
18
  def initialize(io_source, response_processor)
13
19
  @io_source = io_source
14
20
  @response_processor = response_processor
15
21
  @buffer = nil
22
+ @offset = 0
16
23
  end
17
24
 
18
25
  def read
19
26
  @buffer << @io_source.read_nonblock
20
27
  end
21
28
 
22
- # Attempts to process a single response from the buffer. Starts
23
- # by advancing the buffer to the specified start position
29
+ # Attempts to process a single response from the buffer,
30
+ # advancing the offset past the consumed bytes.
24
31
  def process_single_getk_response
25
- bytes, status, cas, key, value = @response_processor.getk_response_from_buffer(@buffer)
26
- advance(bytes)
32
+ bytes, status, cas, key, value = @response_processor.getk_response_from_buffer(@buffer, @offset)
33
+ @offset += bytes
34
+ compact_if_needed
27
35
  [status, cas, key, value]
28
36
  end
29
37
 
30
- # Advances the internal response buffer by bytes_to_advance
31
- # bytes. The
32
- def advance(bytes_to_advance)
33
- return unless bytes_to_advance.positive?
34
-
35
- @buffer = @buffer.byteslice(bytes_to_advance..-1)
36
- end
37
-
38
38
  # Resets the internal buffer to an empty state,
39
39
  # so that we're ready to read pipelined responses
40
40
  def reset
41
41
  @buffer = ''.b
42
+ @offset = 0
42
43
  end
43
44
 
44
45
  # Ensures the buffer is initialized for reading without discarding
@@ -48,16 +49,30 @@ module Dalli
48
49
  return if in_progress?
49
50
 
50
51
  @buffer = ''.b
52
+ @offset = 0
51
53
  end
52
54
 
53
55
  # Clear the internal response buffer
54
56
  def clear
55
57
  @buffer = nil
58
+ @offset = 0
56
59
  end
57
60
 
58
61
  def in_progress?
59
62
  !@buffer.nil?
60
63
  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
61
76
  end
62
77
  end
63
78
  end
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '4.3.2'
4
+ VERSION = '4.3.3'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.4'
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: 4.3.2
4
+ version: 4.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -77,7 +77,7 @@ licenses:
77
77
  - MIT
78
78
  metadata:
79
79
  bug_tracker_uri: https://github.com/petergoldstein/dalli/issues
80
- changelog_uri: https://github.com/petergoldstein/dalli/blob/main/CHANGELOG.md
80
+ changelog_uri: https://github.com/petergoldstein/dalli/blob/v4.3/CHANGELOG.md
81
81
  rubygems_mfa_required: 'true'
82
82
  rdoc_options: []
83
83
  require_paths: