dalli 4.3.1 → 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: c21c31c46fc4381658892703d01baf8611392f5c1f72b9473013d2e53bc23254
4
- data.tar.gz: 1fd1a3ea06cc4bab1ebc1192e62ede8318521e7867a84b3dd09096364560979f
3
+ metadata.gz: 57b78e30ee409a2d742fc47ee57ccdd482fe9c75f369366ae315b7ef8b649934
4
+ data.tar.gz: b8cad66f3cba53bbcb84b18f406eed60f22209c10dd4a312063a1e13189185ca
5
5
  SHA512:
6
- metadata.gz: 9c79888857cb9c9edc9a1f86447e630b54f52040305806ffe92d0966d326e78607e06387c6bbbcac8b96b380737389da2bcf98a9f93ef91e487e2a2152e7aad7
7
- data.tar.gz: f1783996f1d49db8985b755e78c3df6cee25776504504aa1ce303d665fc4798d3b09b8136d97e23dc2a3de8243bca68e7db2e00dbd2942c05e2f918d8efd8d1a
6
+ metadata.gz: 18b26e3c4aa30e5f8b4195d95307afca6c9240dd1154a36966d40d52047e638758850ec06e2a40fd1bd8e69fe150dee7409b1f4a565f0a80e397aaf115315463
7
+ data.tar.gz: b6db69ecbc1e6d34587d8ec29b861f6a71c863b36229d3c642e3d0e646ba6234e3bac04225d64ff174d12f35b728663e82e1d2b5f7e93dc9ac3e1ff33fd58db3
data/CHANGELOG.md CHANGED
@@ -1,6 +1,37 @@
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
+
20
+ 4.3.2
21
+ ==========
22
+
23
+ OpenTelemetry:
24
+
25
+ - Migrate to stable OTel semantic conventions
26
+ - `db.system` renamed to `db.system.name`
27
+ - `db.operation` renamed to `db.operation.name`
28
+ - `server.address` now contains hostname only; `server.port` is a separate integer attribute
29
+ - `get_with_metadata` and `fetch_with_lock` now include `server.address`/`server.port`
30
+ - Add `db.query.text` span attribute with configurable modes
31
+ - `:otel_db_statement` option: `:include`, `:obfuscate`, or `nil` (default: omitted)
32
+ - Add `peer.service` span attribute
33
+ - `:otel_peer_service` option for logical service naming
34
+
4
35
  4.3.1
5
36
  ==========
6
37
 
data/lib/dalli/client.rb CHANGED
@@ -50,6 +50,10 @@ module Dalli
50
50
  # useful for injecting a FIPS compliant hash object.
51
51
  # - :protocol - one of either :binary or :meta, defaulting to :binary. This sets the protocol that Dalli uses
52
52
  # to communicate with memcached.
53
+ # - :otel_db_statement - controls the +db.query.text+ span attribute when OpenTelemetry is loaded.
54
+ # +:include+ logs the full operation and key(s), +:obfuscate+ replaces keys with "?",
55
+ # +nil+ (default) omits the attribute entirely.
56
+ # - :otel_peer_service - when set, adds a +peer.service+ span attribute with this value for logical service naming.
53
57
  #
54
58
  def initialize(servers = nil, options = {})
55
59
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@@ -134,8 +138,8 @@ module Dalli
134
138
  key = key.to_s
135
139
  key = @key_manager.validate_key(key)
136
140
 
137
- Instrumentation.trace('get_with_metadata', { 'db.operation' => 'get_with_metadata' }) do
138
- server = ring.server_for_key(key)
141
+ server = ring.server_for_key(key)
142
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
139
143
  server.request(:meta_get, key, options)
140
144
  end
141
145
  rescue NetworkError => e
@@ -237,7 +241,8 @@ module Dalli
237
241
  key = key.to_s
238
242
  key = @key_manager.validate_key(key)
239
243
 
240
- Instrumentation.trace('fetch_with_lock', { 'db.operation' => 'fetch_with_lock' }) do
244
+ server = ring.server_for_key(key)
245
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
241
246
  fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
242
247
  end
243
248
  rescue NetworkError => e
@@ -318,10 +323,7 @@ module Dalli
318
323
  def set_multi(hash, ttl = nil, req_options = nil)
319
324
  return if hash.empty?
320
325
 
321
- Instrumentation.trace('set_multi', {
322
- 'db.operation' => 'set_multi',
323
- 'db.memcached.key_count' => hash.size
324
- }) do
326
+ Instrumentation.trace('set_multi', multi_trace_attrs('set_multi', hash.size, hash.keys)) do
325
327
  pipelined_setter.process(hash, ttl_or_default(ttl), req_options)
326
328
  end
327
329
  end
@@ -378,10 +380,7 @@ module Dalli
378
380
  def delete_multi(keys)
379
381
  return if keys.empty?
380
382
 
381
- Instrumentation.trace('delete_multi', {
382
- 'db.operation' => 'delete_multi',
383
- 'db.memcached.key_count' => keys.size
384
- }) do
383
+ Instrumentation.trace('delete_multi', multi_trace_attrs('delete_multi', keys.size, keys)) do
385
384
  pipelined_deleter.process(keys)
386
385
  end
387
386
  end
@@ -522,10 +521,8 @@ module Dalli
522
521
  def record_hit_miss_metrics(span, key_count, hit_count)
523
522
  return unless span
524
523
 
525
- span.add_attributes({
526
- 'db.memcached.hit_count' => hit_count,
527
- 'db.memcached.miss_count' => key_count - hit_count
528
- })
524
+ span.add_attributes('db.memcached.hit_count' => hit_count,
525
+ 'db.memcached.miss_count' => key_count - hit_count)
529
526
  end
530
527
 
531
528
  def get_multi_yielding(keys)
@@ -550,7 +547,30 @@ module Dalli
550
547
  end
551
548
 
552
549
  def get_multi_attributes(keys)
553
- { 'db.operation' => 'get_multi', 'db.memcached.key_count' => keys.size }
550
+ multi_trace_attrs('get_multi', keys.size, keys)
551
+ end
552
+
553
+ def trace_attrs(operation, key, server)
554
+ attrs = { 'db.operation.name' => operation, 'server.address' => server.hostname }
555
+ attrs['server.port'] = server.port if server.socket_type == :tcp
556
+ attrs['peer.service'] = @options[:otel_peer_service] if @options[:otel_peer_service]
557
+ add_query_text(attrs, operation, key)
558
+ end
559
+
560
+ def multi_trace_attrs(operation, key_count, keys)
561
+ attrs = { 'db.operation.name' => operation, 'db.memcached.key_count' => key_count }
562
+ attrs['peer.service'] = @options[:otel_peer_service] if @options[:otel_peer_service]
563
+ add_query_text(attrs, operation, keys)
564
+ end
565
+
566
+ def add_query_text(attrs, operation, key_or_keys)
567
+ case @options[:otel_db_statement]
568
+ when :include
569
+ attrs['db.query.text'] = "#{operation} #{Array(key_or_keys).join(' ')}"
570
+ when :obfuscate
571
+ attrs['db.query.text'] = "#{operation} ?"
572
+ end
573
+ attrs
554
574
  end
555
575
 
556
576
  def check_positive!(amt)
@@ -618,10 +638,7 @@ module Dalli
618
638
  key = @key_manager.validate_key(key)
619
639
 
620
640
  server = ring.server_for_key(key)
621
- Instrumentation.trace(op.to_s, {
622
- 'db.operation' => op.to_s,
623
- 'server.address' => server.name
624
- }) do
641
+ Instrumentation.trace(op.to_s, trace_attrs(op.to_s, key, server)) do
625
642
  server.request(op, key, *args)
626
643
  end
627
644
  rescue RetryableNetworkError => e
@@ -8,25 +8,36 @@ module Dalli
8
8
  # When OpenTelemetry is loaded, Dalli automatically creates spans for cache operations.
9
9
  # When OpenTelemetry is not available, all tracing methods are no-ops with zero overhead.
10
10
  #
11
+ # Dalli 4.3.2 uses the stable OTel semantic conventions for database spans.
12
+ #
11
13
  # == Span Attributes
12
14
  #
13
15
  # All spans include the following default attributes:
14
- # - +db.system+ - Always "memcached"
16
+ # - +db.system.name+ - Always "memcached"
15
17
  #
16
18
  # Single-key operations (+get+, +set+, +delete+, +incr+, +decr+, etc.) add:
17
- # - +db.operation+ - The operation name (e.g., "get", "set")
18
- # - +server.address+ - The memcached server handling the request (e.g., "localhost:11211")
19
+ # - +db.operation.name+ - The operation name (e.g., "get", "set")
20
+ # - +server.address+ - The server hostname (e.g., "localhost")
21
+ # - +server.port+ - The server port as an integer (e.g., 11211); omitted for Unix sockets
19
22
  #
20
23
  # Multi-key operations (+get_multi+) add:
21
- # - +db.operation+ - "get_multi"
24
+ # - +db.operation.name+ - "get_multi"
22
25
  # - +db.memcached.key_count+ - Number of keys requested
23
26
  # - +db.memcached.hit_count+ - Number of keys found in cache
24
27
  # - +db.memcached.miss_count+ - Number of keys not found
25
28
  #
26
29
  # Bulk write operations (+set_multi+, +delete_multi+) add:
27
- # - +db.operation+ - The operation name
30
+ # - +db.operation.name+ - The operation name
28
31
  # - +db.memcached.key_count+ - Number of keys in the operation
29
32
  #
33
+ # == Optional Attributes
34
+ #
35
+ # - +db.query.text+ - The operation and key(s), controlled by the +:otel_db_statement+ client option:
36
+ # - +:include+ - Full text (e.g., "get mykey")
37
+ # - +:obfuscate+ - Obfuscated (e.g., "get ?")
38
+ # - +nil+ (default) - Attribute omitted
39
+ # - +peer.service+ - Logical service name, set via the +:otel_peer_service+ client option
40
+ #
30
41
  # == Error Handling
31
42
  #
32
43
  # When an exception occurs during a traced operation:
@@ -40,8 +51,8 @@ module Dalli
40
51
  ##
41
52
  module Instrumentation
42
53
  # Default attributes included on all memcached spans.
43
- # @return [Hash] frozen hash with 'db.system' => 'memcached'
44
- DEFAULT_ATTRIBUTES = { 'db.system' => 'memcached' }.freeze
54
+ # @return [Hash] frozen hash with 'db.system.name' => 'memcached'
55
+ DEFAULT_ATTRIBUTES = { 'db.system.name' => 'memcached' }.freeze
45
56
 
46
57
  class << self
47
58
  # Returns the OpenTelemetry tracer if available, nil otherwise.
@@ -75,15 +86,16 @@ module Dalli
75
86
  # @param name [String] the span name (e.g., 'get', 'set', 'delete')
76
87
  # @param attributes [Hash] span attributes to merge with defaults.
77
88
  # Common attributes include:
78
- # - 'db.operation' - the operation name
79
- # - 'server.address' - the target server
89
+ # - 'db.operation.name' - the operation name
90
+ # - 'server.address' - the server hostname
91
+ # - 'server.port' - the server port (integer)
80
92
  # - 'db.memcached.key_count' - number of keys (for multi operations)
81
93
  # @yield the cache operation to trace
82
94
  # @return [Object] the result of the block
83
95
  # @raise [StandardError] re-raises any exception from the block
84
96
  #
85
97
  # @example Tracing a set operation
86
- # trace('set', { 'db.operation' => 'set', 'server.address' => 'localhost:11211' }) do
98
+ # trace('set', { 'db.operation.name' => 'set', 'server.address' => 'localhost', 'server.port' => 11211 }) do
87
99
  # server.set(key, value, ttl)
88
100
  # end
89
101
  #
@@ -110,7 +122,7 @@ module Dalli
110
122
  # @raise [StandardError] re-raises any exception from the block
111
123
  #
112
124
  # @example Recording hit/miss metrics after get_multi
113
- # trace_with_result('get_multi', { 'db.operation' => 'get_multi' }) do |span|
125
+ # trace_with_result('get_multi', { 'db.operation.name' => 'get_multi' }) do |span|
114
126
  # results = fetch_from_cache(keys)
115
127
  # if span
116
128
  # span.set_attribute('db.memcached.hit_count', results.size)
@@ -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.1'
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.1
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:
@@ -93,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
93
93
  - !ruby/object:Gem::Version
94
94
  version: '0'
95
95
  requirements: []
96
- rubygems_version: 4.0.4
96
+ rubygems_version: 4.0.6
97
97
  specification_version: 4
98
98
  summary: High performance memcached client for Ruby
99
99
  test_files: []