dalli 4.2.0 → 4.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7966ac393d9c0f41d61b244c6a5832fbcab5705ed4daa4189b6e1ffc92db600f
4
- data.tar.gz: da320eac7a8d553f33fe81315750297f97edb75c6da79197ef1f974d47ac8081
3
+ metadata.gz: e97e2a407956737b411c33627d1f771be758017b881c68fab66edee95dc3249e
4
+ data.tar.gz: b57638f133a592e9d57b71530bc925ff6589b27549e8785bc6c7f334906636ff
5
5
  SHA512:
6
- metadata.gz: 5d29cfbe8d2ee6e2993e8a292238f2762ac1de29b8d8623f0a521f810edf866ffd9d631905d5b7cb939d44216fb7e8d6edabf4c09ba388162fef97a772254352
7
- data.tar.gz: ef1c636e6aab12a630ddb7e4d7081c29b0e257203b97f78ccde207170802d1c3619db7057093d4140fcc9cbc23f979bf6f60be4a64011bde983062a724ea59fc
6
+ metadata.gz: 7dd43e9e5b09b65174f2e46b84de40e4e5bcfae9645c6bb67017ca81a64d344e7345cc4bf685f11d4779aeaafd7c7163df5a67bf8410153034f4adf523385b95
7
+ data.tar.gz: b57506702dbdcb387490b3c0c56d2407aff4f0f789aec01e2b1a92c9f8d925a0225b8563e3dbc1ccf3c3ca72a25ca58f4199f1738d93bdf65a07893ed2930b6d
data/CHANGELOG.md CHANGED
@@ -1,6 +1,36 @@
1
1
  Dalli Changelog
2
2
  =====================
3
3
 
4
+ 4.3.0
5
+ ==========
6
+
7
+ New Features:
8
+
9
+ - Add `namespace_separator` option to customize the separator between namespace and key (#1019)
10
+ - Default is `:` for backward compatibility
11
+ - Must be a single non-alphanumeric character (e.g., `:`, `/`, `|`, `.`)
12
+ - Example: `Dalli::Client.new(servers, namespace: 'myapp', namespace_separator: '/')`
13
+
14
+ Bug Fixes:
15
+
16
+ - Fix architecture-dependent struct timeval packing for socket timeouts (#1034)
17
+ - Detects correct pack format for time_t and suseconds_t on each platform
18
+ - Fixes timeout issues on architectures with 64-bit time_t
19
+
20
+ - Fix get_multi hanging with large key counts (#776, #941)
21
+ - Add interleaved read/write for pipelined gets to prevent socket buffer deadlock
22
+ - For batches over 10,000 keys per server, requests are now sent in chunks
23
+
24
+ - **Breaking:** Enforce string-only values in raw mode (#1022)
25
+ - `set(key, nil, raw: true)` now raises `MarshalError` instead of storing `""`
26
+ - `set(key, 123, raw: true)` now raises `MarshalError` instead of storing `"123"`
27
+ - This matches the behavior of client-level `raw: true` mode
28
+ - To store counters, use string values: `set('counter', '0', raw: true)`
29
+
30
+ CI:
31
+
32
+ - Add TruffleRuby to CI test matrix (#988)
33
+
4
34
  4.2.0
5
35
  ==========
6
36
 
data/README.md CHANGED
@@ -31,6 +31,31 @@ Dalli supports two protocols for communicating with memcached:
31
31
  Dalli::Client.new('localhost:11211', protocol: :meta)
32
32
  ```
33
33
 
34
+ ## Configuration Options
35
+
36
+ ### Namespace
37
+
38
+ Use namespaces to partition your cache and avoid key collisions between different applications or environments:
39
+
40
+ ```ruby
41
+ # All keys will be prefixed with "myapp:"
42
+ Dalli::Client.new('localhost:11211', namespace: 'myapp')
43
+
44
+ # Dynamic namespace using a Proc (evaluated on each operation)
45
+ Dalli::Client.new('localhost:11211', namespace: -> { "tenant:#{Thread.current[:tenant_id]}" })
46
+ ```
47
+
48
+ ### Namespace Separator
49
+
50
+ By default, the namespace and key are joined with a colon (`:`). You can customize this with the `namespace_separator` option:
51
+
52
+ ```ruby
53
+ # Keys will be prefixed with "myapp/" instead of "myapp:"
54
+ Dalli::Client.new('localhost:11211', namespace: 'myapp', namespace_separator: '/')
55
+ ```
56
+
57
+ The separator must be a single non-alphanumeric character. Valid examples: `:`, `/`, `|`, `.`, `-`, `_`, `#`
58
+
34
59
  ## Security Note
35
60
 
36
61
  By default, Dalli uses Ruby's Marshal for serialization. Deserializing untrusted data with Marshal can lead to remote code execution. If you cache user-controlled data, consider using a safer serializer:
@@ -12,7 +12,7 @@ module Dalli
12
12
  class KeyManager
13
13
  MAX_KEY_LENGTH = 250
14
14
 
15
- NAMESPACE_SEPARATOR = ':'
15
+ DEFAULT_NAMESPACE_SEPARATOR = ':'
16
16
 
17
17
  # This is a hard coded md5 for historical reasons
18
18
  TRUNCATED_KEY_SEPARATOR = ':md5:'
@@ -21,19 +21,26 @@ module Dalli
21
21
  TRUNCATED_KEY_TARGET_SIZE = 249
22
22
 
23
23
  DEFAULTS = {
24
- digest_class: ::Digest::MD5
24
+ digest_class: ::Digest::MD5,
25
+ namespace_separator: DEFAULT_NAMESPACE_SEPARATOR
25
26
  }.freeze
26
27
 
27
- OPTIONS = %i[digest_class namespace].freeze
28
+ OPTIONS = %i[digest_class namespace namespace_separator].freeze
28
29
 
29
- attr_reader :namespace
30
+ attr_reader :namespace, :namespace_separator
31
+
32
+ # Valid separators: non-alphanumeric, single printable ASCII characters
33
+ # Excludes: alphanumerics, whitespace, control characters
34
+ VALID_NAMESPACE_SEPARATORS = /\A[^a-zA-Z0-9\s\x00-\x1F\x7F]\z/
30
35
 
31
36
  def initialize(client_options)
32
37
  @key_options =
33
38
  DEFAULTS.merge(client_options.slice(*OPTIONS))
34
39
  validate_digest_class_option(@key_options)
40
+ validate_namespace_separator_option(@key_options)
35
41
 
36
42
  @namespace = namespace_from_options
43
+ @namespace_separator = @key_options[:namespace_separator]
37
44
  end
38
45
 
39
46
  ##
@@ -61,7 +68,7 @@ module Dalli
61
68
  def key_with_namespace(key)
62
69
  return key if namespace.nil?
63
70
 
64
- "#{evaluate_namespace}#{NAMESPACE_SEPARATOR}#{key}"
71
+ "#{evaluate_namespace}#{namespace_separator}#{key}"
65
72
  end
66
73
 
67
74
  def key_without_namespace(key)
@@ -75,9 +82,9 @@ module Dalli
75
82
  end
76
83
 
77
84
  def namespace_regexp
78
- return /\A#{Regexp.escape(evaluate_namespace)}:/ if namespace.is_a?(Proc)
85
+ return /\A#{Regexp.escape(evaluate_namespace)}#{Regexp.escape(namespace_separator)}/ if namespace.is_a?(Proc)
79
86
 
80
- @namespace_regexp ||= /\A#{Regexp.escape(namespace)}:/ unless namespace.nil?
87
+ @namespace_regexp ||= /\A#{Regexp.escape(namespace)}#{Regexp.escape(namespace_separator)}/ unless namespace.nil?
81
88
  end
82
89
 
83
90
  def validate_digest_class_option(opts)
@@ -86,6 +93,14 @@ module Dalli
86
93
  raise ArgumentError, 'The digest_class object must respond to the hexdigest method'
87
94
  end
88
95
 
96
+ def validate_namespace_separator_option(opts)
97
+ sep = opts[:namespace_separator]
98
+ return if VALID_NAMESPACE_SEPARATORS.match?(sep)
99
+
100
+ raise ArgumentError,
101
+ 'namespace_separator must be a single non-alphanumeric character (e.g., ":", "/", "|")'
102
+ end
103
+
89
104
  def namespace_from_options
90
105
  raw_namespace = @key_options[:namespace]
91
106
  return nil unless raw_namespace
@@ -7,6 +7,13 @@ module Dalli
7
7
  # Contains logic for the pipelined gets implemented by the client.
8
8
  ##
9
9
  class PipelinedGetter
10
+ # For large batches, interleave sends with response draining to prevent
11
+ # socket buffer deadlock. Only kicks in above this threshold.
12
+ INTERLEAVE_THRESHOLD = 10_000
13
+
14
+ # Number of keys to send before draining responses during interleaved mode
15
+ CHUNK_SIZE = 10_000
16
+
10
17
  def initialize(ring, key_manager)
11
18
  @ring = ring
12
19
  @key_manager = key_manager
@@ -19,8 +26,14 @@ module Dalli
19
26
  return {} if keys.empty?
20
27
 
21
28
  @ring.lock do
29
+ # Stores partial results collected during interleaved send phase
30
+ @partial_results = {}
22
31
  servers = setup_requests(keys)
23
32
  start_time = Time.now
33
+
34
+ # First yield any partial results collected during interleaved send
35
+ yield_partial_results(&block)
36
+
24
37
  servers = fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
25
38
  end
26
39
  rescue NetworkError => e
@@ -29,6 +42,15 @@ module Dalli
29
42
  retry
30
43
  end
31
44
 
45
+ private
46
+
47
+ def yield_partial_results
48
+ @partial_results.each_pair do |key, value_list|
49
+ yield @key_manager.key_without_namespace(key), value_list
50
+ end
51
+ @partial_results.clear
52
+ end
53
+
32
54
  def setup_requests(keys)
33
55
  groups = groups_for_keys(keys)
34
56
  make_getkq_requests(groups)
@@ -47,7 +69,14 @@ module Dalli
47
69
  ##
48
70
  def make_getkq_requests(groups)
49
71
  groups.each do |server, keys_for_server|
50
- server.request(:pipelined_get, keys_for_server)
72
+ if keys_for_server.size <= INTERLEAVE_THRESHOLD
73
+ # Small batch - send all at once (existing behavior)
74
+ server.request(:pipelined_get, keys_for_server)
75
+ else
76
+ # Large batch - interleave sends with response draining
77
+ # Pass @partial_results directly to avoid hash allocation/merge overhead
78
+ server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results)
79
+ end
51
80
  rescue DalliError, NetworkError => e
52
81
  Dalli.logger.debug { e.inspect }
53
82
  Dalli.logger.debug { "unable to get keys for server #{server.name}" }
@@ -42,8 +42,8 @@ module Dalli
42
42
  @connection_manager.start_request!
43
43
  response = send(opkey, *args)
44
44
 
45
- # pipelined_get emit query but doesn't read the response(s)
46
- @connection_manager.finish_request! unless opkey == :pipelined_get
45
+ # pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
46
+ @connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)
47
47
 
48
48
  response
49
49
  rescue Dalli::MarshalError => e
@@ -81,7 +81,9 @@ module Dalli
81
81
  def pipeline_response_setup
82
82
  verify_pipelined_state(:getkq)
83
83
  write_noop
84
- response_buffer.reset
84
+ # Use ensure_ready instead of reset to preserve any data already buffered
85
+ # during interleaved pipelined get draining
86
+ response_buffer.ensure_ready
85
87
  end
86
88
 
87
89
  # Attempt to receive and parse as many key/value pairs as possible
@@ -220,6 +222,11 @@ module Dalli
220
222
  end
221
223
 
222
224
  def pipelined_get(keys)
225
+ # Clear buffer to remove any stale data from interrupted operations.
226
+ # Use clear (not reset) to keep pipeline_complete? = true, which is
227
+ # the expected state before pipeline_response_setup is called.
228
+ response_buffer.clear
229
+
223
230
  req = +''
224
231
  keys.each do |key|
225
232
  req << quiet_get_request(key)
@@ -228,6 +235,51 @@ module Dalli
228
235
  write(req)
229
236
  end
230
237
 
238
+ # For large batches, interleave writing requests with draining responses.
239
+ # This prevents socket buffer deadlock when sending many keys.
240
+ # Populates the provided results hash with any responses drained during send.
241
+ def pipelined_get_interleaved(keys, chunk_size, results)
242
+ # Initialize the response buffer for draining during send phase
243
+ response_buffer.ensure_ready
244
+
245
+ keys.each_slice(chunk_size) do |chunk|
246
+ # Build and write this chunk of requests
247
+ req = +''
248
+ chunk.each do |key|
249
+ req << quiet_get_request(key)
250
+ end
251
+ write(req)
252
+ @connection_manager.flush
253
+
254
+ # Drain any available responses directly into results hash
255
+ drain_pipeline_responses(results)
256
+ end
257
+ end
258
+
259
+ # Non-blocking read and processing of any available pipeline responses.
260
+ # Used during interleaved pipelined gets to prevent buffer deadlock.
261
+ # Populates the provided results hash directly to avoid allocation overhead.
262
+ def drain_pipeline_responses(results)
263
+ return unless connected?
264
+
265
+ # Non-blocking check if socket has data available
266
+ return unless sock.wait_readable(0)
267
+
268
+ # Read available data without blocking
269
+ response_buffer.read
270
+
271
+ # Process any complete responses in the buffer
272
+ loop do
273
+ status, cas, key, value = response_buffer.process_single_getk_response
274
+ break if status.nil? # No complete response available
275
+
276
+ results[key] = [value, cas] unless key.nil?
277
+ end
278
+ rescue SystemCallError, Dalli::NetworkError
279
+ # Ignore errors during drain - they'll be handled in fetch_responses
280
+ nil
281
+ end
282
+
231
283
  def response_buffer
232
284
  @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
233
285
  end
@@ -41,6 +41,15 @@ module Dalli
41
41
  @buffer = ''.b
42
42
  end
43
43
 
44
+ # Ensures the buffer is initialized for reading without discarding
45
+ # existing data. Used by interleaved pipelined get which may have
46
+ # already buffered partial responses during the send phase.
47
+ def ensure_ready
48
+ return if in_progress?
49
+
50
+ @buffer = ''.b
51
+ end
52
+
44
53
  # Clear the internal response buffer
45
54
  def clear
46
55
  @buffer = nil
@@ -32,22 +32,8 @@ module Dalli
32
32
  end
33
33
 
34
34
  def store(value, req_options, bitflags)
35
- if req_options
36
- return [value.to_s, bitflags] if req_options[:raw]
37
-
38
- # If the value is a simple string, going through serialization is costly
39
- # for no benefit other than preserving encoding.
40
- # Assuming most strings are either UTF-8 or BINARY we can just store
41
- # that information in the bitflags.
42
- if req_options[:string_fastpath] && value.instance_of?(String)
43
- case value.encoding
44
- when Encoding::BINARY
45
- return [value, bitflags]
46
- when Encoding::UTF_8
47
- return [value, bitflags | FLAG_UTF8]
48
- end
49
- end
50
- end
35
+ return store_raw(value, bitflags) if req_options&.dig(:raw)
36
+ return store_string_fastpath(value, bitflags) if use_string_fastpath?(value, req_options)
51
37
 
52
38
  [serialize_value(value), bitflags | FLAG_SERIALIZED]
53
39
  end
@@ -85,6 +71,30 @@ module Dalli
85
71
 
86
72
  private
87
73
 
74
+ def store_raw(value, bitflags)
75
+ unless value.is_a?(String)
76
+ raise Dalli::MarshalError, "Dalli raw mode requires string values, got: #{value.class}"
77
+ end
78
+
79
+ [value, bitflags]
80
+ end
81
+
82
+ # If the value is a simple string, going through serialization is costly
83
+ # for no benefit other than preserving encoding.
84
+ # Assuming most strings are either UTF-8 or BINARY we can just store
85
+ # that information in the bitflags.
86
+ def store_string_fastpath(value, bitflags)
87
+ case value.encoding
88
+ when Encoding::BINARY then [value, bitflags]
89
+ when Encoding::UTF_8 then [value, bitflags | FLAG_UTF8]
90
+ else [serialize_value(value), bitflags | FLAG_SERIALIZED]
91
+ end
92
+ end
93
+
94
+ def use_string_fastpath?(value, req_options)
95
+ req_options&.dig(:string_fastpath) && value.instance_of?(String)
96
+ end
97
+
88
98
  def warn_if_marshal_default(protocol_options)
89
99
  return if protocol_options.key?(:serializer)
90
100
  return if @@marshal_warning_logged
data/lib/dalli/socket.rb CHANGED
@@ -107,7 +107,7 @@ module Dalli
107
107
  # aliases TCPSocket#initialize method to #original_resolv_initialize.
108
108
  # https://github.com/ruby/resolv-replace/blob/v0.1.1/lib/resolv-replace.rb#L21
109
109
  if RUBY_VERSION >= '3.0' &&
110
- !::TCPSocket.private_instance_methods.include?(:original_resolv_initialize)
110
+ !::TCPSocket.private_method_defined?(:original_resolv_initialize)
111
111
  sock = new(host, port, connect_timeout: options[:socket_timeout])
112
112
  yield(sock)
113
113
  else
@@ -138,16 +138,40 @@ module Dalli
138
138
  return unless options[:socket_timeout]
139
139
 
140
140
  if sock.respond_to?(:timeout=)
141
+ # Ruby 3.2+ has IO#timeout for reliable cross-platform timeout handling
141
142
  sock.timeout = options[:socket_timeout]
142
143
  else
144
+ # Ruby 3.1 fallback using socket options
145
+ # struct timeval has architecture-dependent sizes (time_t, suseconds_t)
143
146
  seconds, fractional = options[:socket_timeout].divmod(1)
144
- timeval = [seconds, fractional * 1_000_000].pack('l_2')
147
+ microseconds = (fractional * 1_000_000).to_i
148
+ timeval = pack_timeval(sock, seconds, microseconds)
145
149
 
146
150
  sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO, timeval)
147
151
  sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_SNDTIMEO, timeval)
148
152
  end
149
153
  end
150
154
 
155
+ # Pack formats for struct timeval across architectures.
156
+ # Uses fixed-size formats for JRuby compatibility (JRuby doesn't support _ modifier on q).
157
+ # - ll: 8 bytes (32-bit time_t, 32-bit suseconds_t)
158
+ # - qq: 16 bytes (64-bit time_t, 64-bit suseconds_t or padded 32-bit)
159
+ TIMEVAL_PACK_FORMATS = %w[ll qq].freeze
160
+ TIMEVAL_TEST_VALUES = [0, 0].freeze
161
+
162
+ # Detect and cache the correct pack format for struct timeval on this platform.
163
+ # Different architectures have different sizes for time_t and suseconds_t.
164
+ def self.timeval_pack_format(sock)
165
+ @timeval_pack_format ||= begin
166
+ expected_size = sock.getsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO).data.bytesize
167
+ TIMEVAL_PACK_FORMATS.find { |fmt| TIMEVAL_TEST_VALUES.pack(fmt).bytesize == expected_size } || 'll'
168
+ end
169
+ end
170
+
171
+ def self.pack_timeval(sock, seconds, microseconds)
172
+ [seconds, microseconds].pack(timeval_pack_format(sock))
173
+ end
174
+
151
175
  def self.wrapping_ssl_socket(tcp_socket, host, ssl_context)
152
176
  ssl_socket = Dalli::Socket::SSLSocket.new(tcp_socket, ssl_context)
153
177
  ssl_socket.hostname = host
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.2.0'
4
+ VERSION = '4.3.0'
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.2.0
4
+ version: 4.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein