dalli 5.0.0 → 5.0.1

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: 16d7d5950137f4a51ae41392eaa576732595ac4020ae7be972d6fb4b33b9861d
4
- data.tar.gz: ebb799522259a9a32a86e1dbfd13e810427f787f8110415509288655a5a5460b
3
+ metadata.gz: 02d0fa949b7a065f86fb3ac7b511a3feacc50b700e82dcb385e0e79c56583a9d
4
+ data.tar.gz: d72b9e4b014ae1ae0b0d5a1ebe09ea48cdf9fbf0a2cda000efc19f17d5d34368
5
5
  SHA512:
6
- metadata.gz: 74e32eace9001bd50be0617f10234f1440f86e506facc7247cf6041cf81fa85e966a5721e2a7ff364eb89da19bd46cc92b2631d99d4cbb21aee8240a475a76b5
7
- data.tar.gz: 8e2bc294a1fa08a04f0bdfa300394751e63a7c1a21cac6aa1080a37e9491fe33091150047f664607ae42596d0bb2ac60f05c06ad97aee83165de754b9cceeed2
6
+ metadata.gz: bf26484aa345df2d43a78da570027b68a7fd0dd70d7a62275ebe95a5e29ed36c11a8b1385ff0c71818f55184245e085cd75962510f9f8559aad10b0d284f8d71
7
+ data.tar.gz: 0b3913a33f61873d6e3da0d62ec643dbf49f679740d3469b7cb826083f02f3b32d37e2a20548634d09cecdc4389da7c5bdc43af530bb48956cb4084f91f10d9e
data/CHANGELOG.md CHANGED
@@ -1,6 +1,29 @@
1
1
  Dalli Changelog
2
2
  =====================
3
3
 
4
+ 5.0.1
5
+ ==========
6
+
7
+ Performance:
8
+
9
+ - Reduce object allocations in pipelined get response processing (#1072, #1078)
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
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
+ - Rescue `IOError` in connection manager `write`/`flush` methods (#1075)
19
+ - Prevents unhandled exceptions when a connection is closed mid-operation
20
+ - Thanks to Graham Cooper (Shopify) for this fix
21
+
22
+ Development:
23
+
24
+ - Add `rubocop-thread_safety` for detecting thread-safety issues (#1076)
25
+ - Add CONTRIBUTING.md with AI contribution policy (#1074)
26
+
4
27
  5.0.0
5
28
  ==========
6
29
 
@@ -44,6 +67,37 @@ Internal:
44
67
  - Removed deprecated binary protocol files and SASL authentication code
45
68
  - Removed `require 'set'` (autoloaded in Ruby 3.3+)
46
69
 
70
+ 4.3.3
71
+ ==========
72
+
73
+ Performance:
74
+
75
+ - Reduce object allocations in pipelined get response processing (#1072)
76
+ - 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
77
+ - Inline response processor parsing: avoid intermediate array allocations from `split`-based header parsing in both binary and meta protocols
78
+ - Block-based `pipeline_next_responses`: yield `(key, value, cas)` directly when a block is given, avoiding per-call Hash allocation
79
+ - `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`
80
+ - Add cross-version benchmark script (`bin/compare_versions`) for reproducible performance comparisons across Dalli versions
81
+
82
+ Bug Fixes:
83
+
84
+ - Skip OTel integration tests when meta protocol is unavailable (#1072)
85
+
86
+ 4.3.2
87
+ ==========
88
+
89
+ OpenTelemetry:
90
+
91
+ - Migrate to stable OTel semantic conventions
92
+ - `db.system` renamed to `db.system.name`
93
+ - `db.operation` renamed to `db.operation.name`
94
+ - `server.address` now contains hostname only; `server.port` is a separate integer attribute
95
+ - `get_with_metadata` and `fetch_with_lock` now include `server.address`/`server.port`
96
+ - Add `db.query.text` span attribute with configurable modes
97
+ - `:otel_db_statement` option: `:include`, `:obfuscate`, or `nil` (default: omitted)
98
+ - Add `peer.service` span attribute
99
+ - `:otel_peer_service` option for logical service naming
100
+
47
101
  4.3.1
48
102
  ==========
49
103
 
data/Gemfile CHANGED
@@ -22,6 +22,7 @@ group :development, :test do
22
22
  gem 'rubocop-minitest'
23
23
  gem 'rubocop-performance'
24
24
  gem 'rubocop-rake'
25
+ gem 'rubocop-thread_safety'
25
26
  gem 'simplecov'
26
27
  end
27
28
 
data/README.md CHANGED
@@ -113,7 +113,7 @@ To install this gem onto your local machine, run `bundle exec rake install`.
113
113
 
114
114
  ## Contributing
115
115
 
116
- If you have a fix you wish to provide, please fork the code, fix in your local project and then send a pull request on github. Please ensure that you include a test which verifies your fix and update the [changelog](CHANGELOG.md) with a one sentence description of your fix so you get credit as a contributor.
116
+ Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute, including our policy on AI-authored contributions.
117
117
 
118
118
  ## Appreciation
119
119
 
@@ -61,11 +61,13 @@ module Dalli
61
61
  # Uses the library name 'dalli' and current Dalli::VERSION.
62
62
  #
63
63
  # @return [OpenTelemetry::Trace::Tracer, nil] the tracer or nil if OTel unavailable
64
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
64
65
  def tracer
65
66
  return @tracer if defined?(@tracer)
66
67
 
67
68
  @tracer = (OpenTelemetry.tracer_provider.tracer('dalli', Dalli::VERSION) if defined?(OpenTelemetry))
68
69
  end
70
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
69
71
 
70
72
  # Returns true if instrumentation is enabled (OpenTelemetry SDK is available).
71
73
  #
@@ -13,7 +13,7 @@ module Dalli
13
13
  attr_reader :pid
14
14
 
15
15
  def update!
16
- @pid = Process.pid
16
+ @pid = Process.pid # rubocop:disable ThreadSafety/ClassInstanceVariable
17
17
  end
18
18
  end
19
19
  update!
@@ -27,7 +27,7 @@ module Dalli
27
27
  # Stores partial results collected during interleaved send phase
28
28
  @partial_results = {}
29
29
  servers = setup_requests(keys)
30
- start_time = Time.now
30
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
31
 
32
32
  # First yield any partial results collected during interleaved send
33
33
  yield_partial_results(&block)
@@ -147,7 +147,7 @@ module Dalli
147
147
  end
148
148
 
149
149
  def remaining_time(start, timeout)
150
- elapsed = Time.now - start
150
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
151
151
  return 0 if elapsed > timeout
152
152
 
153
153
  timeout - elapsed
@@ -166,8 +166,8 @@ module Dalli
166
166
  # Processes responses from a server. Returns true if there are no
167
167
  # additional responses from this server.
168
168
  def process_server(server)
169
- server.pipeline_next_responses.each_pair do |key, value_list|
170
- yield @key_manager.key_without_namespace(key), value_list
169
+ server.pipeline_next_responses do |key, value, cas|
170
+ yield @key_manager.key_without_namespace(key), [value, cas]
171
171
  end
172
172
 
173
173
  server.pipeline_complete?
@@ -176,18 +176,13 @@ module Dalli
176
176
  def servers_with_response(servers, timeout)
177
177
  return [] if servers.empty?
178
178
 
179
- # TODO: - This is a bit challenging. Essentially the PipelinedGetter
180
- # is a reactor, but without the benefit of a Fiber or separate thread.
181
- # My suspicion is that we may want to try and push this down into the
182
- # individual servers, but I'm not sure. For now, we keep the
183
- # mapping between the alerted object (the socket) and the
184
- # corrresponding server here.
185
- server_map = servers.each_with_object({}) { |s, h| h[s.sock] = s }
186
-
187
- readable, = IO.select(server_map.keys, nil, nil, timeout)
179
+ sockets = servers.map(&:sock)
180
+ readable, = IO.select(sockets, nil, nil, timeout)
188
181
  return [] if readable.nil?
189
182
 
190
- readable.map { |sock| server_map[sock] }
183
+ # For typical server counts (1-5), linear scan is faster than
184
+ # building and looking up a hash map
185
+ readable.filter_map { |sock| servers.find { |s| s.sock == sock } }
191
186
  end
192
187
 
193
188
  def groups_for_keys(*keys)
@@ -92,10 +92,13 @@ module Dalli
92
92
  # repeatedly whenever this server's socket is readable until
93
93
  # #pipeline_complete?.
94
94
  #
95
- # Returns a Hash of kv pairs received.
96
- def pipeline_next_responses
95
+ # When a block is given, yields (key, value, cas) for each response,
96
+ # avoiding intermediate Hash allocation. Returns nil.
97
+ # Without a block, returns a Hash of { key => [value, cas] }.
98
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
99
+ def pipeline_next_responses(&block)
97
100
  reconnect_on_pipeline_complete!
98
- values = {}
101
+ values = nil
99
102
 
100
103
  response_buffer.read
101
104
 
@@ -109,16 +112,24 @@ module Dalli
109
112
 
110
113
  # If the status is ok and the key is not nil, then this is a
111
114
  # getkq response with a value that we want to set in the response hash
112
- values[key] = [value, cas] unless key.nil?
115
+ unless key.nil?
116
+ if block
117
+ yield key, value, cas
118
+ else
119
+ values ||= {}
120
+ values[key] = [value, cas]
121
+ end
122
+ end
113
123
 
114
124
  # Get the next response from the buffer
115
125
  status, cas, key, value = response_buffer.process_single_getk_response
116
126
  end
117
127
 
118
- values
128
+ values || {}
119
129
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
120
130
  @connection_manager.error_on_request!(e)
121
131
  end
132
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
122
133
 
123
134
  # Abort current pipelined get. Generally used to signal an external
124
135
  # timeout during pipelined get. The underlying socket is
@@ -169,13 +169,13 @@ module Dalli
169
169
 
170
170
  def write(bytes)
171
171
  @sock.write(bytes)
172
- rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS => e
172
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
173
173
  error_on_request!(e)
174
174
  end
175
175
 
176
176
  def flush
177
177
  @sock.flush
178
- rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS => e
178
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
179
179
  error_on_request!(e)
180
180
  end
181
181
 
@@ -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
@@ -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
 
data/lib/dalli/socket.rb CHANGED
@@ -108,12 +108,14 @@ module Dalli
108
108
  # Detect and cache whether TCPSocket supports the connect_timeout: keyword argument.
109
109
  # Returns false if TCPSocket#initialize has been monkey-patched by gems like
110
110
  # socksify or resolv-replace, which don't support keyword arguments.
111
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
111
112
  def self.supports_connect_timeout?
112
113
  return @supports_connect_timeout if defined?(@supports_connect_timeout)
113
114
 
114
115
  @supports_connect_timeout = RUBY_VERSION >= '3.0' &&
115
116
  ::TCPSocket.instance_method(:initialize).parameters == TCPSOCKET_NATIVE_PARAMETERS
116
117
  end
118
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
117
119
 
118
120
  def self.create_socket_with_timeout(host, port, options)
119
121
  if supports_connect_timeout?
@@ -170,12 +172,14 @@ module Dalli
170
172
 
171
173
  # Detect and cache the correct pack format for struct timeval on this platform.
172
174
  # Different architectures have different sizes for time_t and suseconds_t.
175
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
173
176
  def self.timeval_pack_format(sock)
174
177
  @timeval_pack_format ||= begin
175
178
  expected_size = sock.getsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO).data.bytesize
176
179
  TIMEVAL_PACK_FORMATS.find { |fmt| TIMEVAL_TEST_VALUES.pack(fmt).bytesize == expected_size } || 'll'
177
180
  end
178
181
  end
182
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
179
183
 
180
184
  def self.pack_timeval(sock, seconds, microseconds)
181
185
  [seconds, microseconds].pack(timeval_pack_format(sock))
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.0'
4
+ VERSION = '5.0.1'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -38,7 +38,7 @@ module Dalli
38
38
  QUIET = :dalli_multi
39
39
 
40
40
  def self.logger
41
- @logger ||= rails_logger || default_logger
41
+ @logger ||= rails_logger || default_logger # rubocop:disable ThreadSafety/ClassInstanceVariable
42
42
  end
43
43
 
44
44
  def self.rails_logger
@@ -54,7 +54,7 @@ module Dalli
54
54
  end
55
55
 
56
56
  def self.logger=(logger)
57
- @logger = logger
57
+ @logger = logger # rubocop:disable ThreadSafety/ClassInstanceVariable
58
58
  end
59
59
  end
60
60
 
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.0
4
+ version: 5.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein