dalli 4.2.1 → 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: 533bed149f02ca2960fa436d776b920cce0c95b841296c678a247141f8f7f239
4
- data.tar.gz: cd3ca6a066d005fd177726b3c29ab460cba9d8ddc9937c1809053bf22b20e387
3
+ metadata.gz: e97e2a407956737b411c33627d1f771be758017b881c68fab66edee95dc3249e
4
+ data.tar.gz: b57638f133a592e9d57b71530bc925ff6589b27549e8785bc6c7f334906636ff
5
5
  SHA512:
6
- metadata.gz: cc927b7e0f8906d326ede287b9d0fb9b3c1fb216b271350bc1e02cd4531b86d365e286da73cb8be9082646d747a494ccc0cdd81d0f9f053cf2439dd58bc40157
7
- data.tar.gz: ce17e49264f9cf1550f709eb6d67a3f0f2f7fefaecfab04bf2c3f4356b7636ff95e2d6f76a44abcd8f0efae79ab8bbf21c5ba7d59a802735626af73e7bf0d777
6
+ metadata.gz: 7dd43e9e5b09b65174f2e46b84de40e4e5bcfae9645c6bb67017ca81a64d344e7345cc4bf685f11d4779aeaafd7c7163df5a67bf8410153034f4adf523385b95
7
+ data.tar.gz: b57506702dbdcb387490b3c0c56d2407aff4f0f789aec01e2b1a92c9f8d925a0225b8563e3dbc1ccf3c3ca72a25ca58f4199f1738d93bdf65a07893ed2930b6d
data/CHANGELOG.md CHANGED
@@ -1,20 +1,35 @@
1
1
  Dalli Changelog
2
2
  =====================
3
3
 
4
- 4.2.1
4
+ 4.3.0
5
5
  ==========
6
6
 
7
- OpenTelemetry:
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:
8
31
 
9
- - Migrate to stable OTel semantic conventions
10
- - `db.system` renamed to `db.system.name`
11
- - `db.operation` renamed to `db.operation.name`
12
- - `server.address` now contains hostname only; `server.port` is a separate integer attribute
13
- - `get_with_metadata` and `fetch_with_lock` now include `server.address`/`server.port`
14
- - Add `db.query.text` span attribute with configurable modes
15
- - `:otel_db_statement` option: `:include`, `:obfuscate`, or `nil` (default: omitted)
16
- - Add `peer.service` span attribute
17
- - `:otel_peer_service` option for logical service naming
32
+ - Add TruffleRuby to CI test matrix (#988)
18
33
 
19
34
  4.2.0
20
35
  ==========
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:
data/lib/dalli/client.rb CHANGED
@@ -50,10 +50,6 @@ 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.
57
53
  #
58
54
  def initialize(servers = nil, options = {})
59
55
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@@ -138,8 +134,8 @@ module Dalli
138
134
  key = key.to_s
139
135
  key = @key_manager.validate_key(key)
140
136
 
141
- server = ring.server_for_key(key)
142
- Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
137
+ Instrumentation.trace('get_with_metadata', { 'db.operation' => 'get_with_metadata' }) do
138
+ server = ring.server_for_key(key)
143
139
  server.request(:meta_get, key, options)
144
140
  end
145
141
  rescue NetworkError => e
@@ -241,8 +237,7 @@ module Dalli
241
237
  key = key.to_s
242
238
  key = @key_manager.validate_key(key)
243
239
 
244
- server = ring.server_for_key(key)
245
- Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
240
+ Instrumentation.trace('fetch_with_lock', { 'db.operation' => 'fetch_with_lock' }) do
246
241
  fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
247
242
  end
248
243
  rescue NetworkError => e
@@ -323,7 +318,10 @@ module Dalli
323
318
  def set_multi(hash, ttl = nil, req_options = nil)
324
319
  return if hash.empty?
325
320
 
326
- Instrumentation.trace('set_multi', multi_trace_attrs('set_multi', hash.size, hash.keys)) do
321
+ Instrumentation.trace('set_multi', {
322
+ 'db.operation' => 'set_multi',
323
+ 'db.memcached.key_count' => hash.size
324
+ }) do
327
325
  pipelined_setter.process(hash, ttl_or_default(ttl), req_options)
328
326
  end
329
327
  end
@@ -380,7 +378,10 @@ module Dalli
380
378
  def delete_multi(keys)
381
379
  return if keys.empty?
382
380
 
383
- Instrumentation.trace('delete_multi', multi_trace_attrs('delete_multi', keys.size, keys)) do
381
+ Instrumentation.trace('delete_multi', {
382
+ 'db.operation' => 'delete_multi',
383
+ 'db.memcached.key_count' => keys.size
384
+ }) do
384
385
  pipelined_deleter.process(keys)
385
386
  end
386
387
  end
@@ -547,30 +548,7 @@ module Dalli
547
548
  end
548
549
 
549
550
  def get_multi_attributes(keys)
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
551
+ { 'db.operation' => 'get_multi', 'db.memcached.key_count' => keys.size }
574
552
  end
575
553
 
576
554
  def check_positive!(amt)
@@ -638,7 +616,10 @@ module Dalli
638
616
  key = @key_manager.validate_key(key)
639
617
 
640
618
  server = ring.server_for_key(key)
641
- Instrumentation.trace(op.to_s, trace_attrs(op.to_s, key, server)) do
619
+ Instrumentation.trace(op.to_s, {
620
+ 'db.operation' => op.to_s,
621
+ 'server.address' => server.name
622
+ }) do
642
623
  server.request(op, key, *args)
643
624
  end
644
625
  rescue NetworkError => e
@@ -8,36 +8,25 @@ 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.2.1 uses the stable OTel semantic conventions for database spans.
12
- #
13
11
  # == Span Attributes
14
12
  #
15
13
  # All spans include the following default attributes:
16
- # - +db.system.name+ - Always "memcached"
14
+ # - +db.system+ - Always "memcached"
17
15
  #
18
16
  # Single-key operations (+get+, +set+, +delete+, +incr+, +decr+, etc.) add:
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
17
+ # - +db.operation+ - The operation name (e.g., "get", "set")
18
+ # - +server.address+ - The memcached server handling the request (e.g., "localhost:11211")
22
19
  #
23
20
  # Multi-key operations (+get_multi+) add:
24
- # - +db.operation.name+ - "get_multi"
21
+ # - +db.operation+ - "get_multi"
25
22
  # - +db.memcached.key_count+ - Number of keys requested
26
23
  # - +db.memcached.hit_count+ - Number of keys found in cache
27
24
  # - +db.memcached.miss_count+ - Number of keys not found
28
25
  #
29
26
  # Bulk write operations (+set_multi+, +delete_multi+) add:
30
- # - +db.operation.name+ - The operation name
27
+ # - +db.operation+ - The operation name
31
28
  # - +db.memcached.key_count+ - Number of keys in the operation
32
29
  #
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
- #
41
30
  # == Error Handling
42
31
  #
43
32
  # When an exception occurs during a traced operation:
@@ -51,8 +40,8 @@ module Dalli
51
40
  ##
52
41
  module Instrumentation
53
42
  # Default attributes included on all memcached spans.
54
- # @return [Hash] frozen hash with 'db.system.name' => 'memcached'
55
- DEFAULT_ATTRIBUTES = { 'db.system.name' => 'memcached' }.freeze
43
+ # @return [Hash] frozen hash with 'db.system' => 'memcached'
44
+ DEFAULT_ATTRIBUTES = { 'db.system' => 'memcached' }.freeze
56
45
 
57
46
  class << self
58
47
  # Returns the OpenTelemetry tracer if available, nil otherwise.
@@ -86,16 +75,15 @@ module Dalli
86
75
  # @param name [String] the span name (e.g., 'get', 'set', 'delete')
87
76
  # @param attributes [Hash] span attributes to merge with defaults.
88
77
  # Common attributes include:
89
- # - 'db.operation.name' - the operation name
90
- # - 'server.address' - the server hostname
91
- # - 'server.port' - the server port (integer)
78
+ # - 'db.operation' - the operation name
79
+ # - 'server.address' - the target server
92
80
  # - 'db.memcached.key_count' - number of keys (for multi operations)
93
81
  # @yield the cache operation to trace
94
82
  # @return [Object] the result of the block
95
83
  # @raise [StandardError] re-raises any exception from the block
96
84
  #
97
85
  # @example Tracing a set operation
98
- # trace('set', { 'db.operation.name' => 'set', 'server.address' => 'localhost', 'server.port' => 11211 }) do
86
+ # trace('set', { 'db.operation' => 'set', 'server.address' => 'localhost:11211' }) do
99
87
  # server.set(key, value, ttl)
100
88
  # end
101
89
  #
@@ -126,7 +114,7 @@ module Dalli
126
114
  # @raise [StandardError] re-raises any exception from the block
127
115
  #
128
116
  # @example Recording hit/miss metrics after get_multi
129
- # trace_with_result('get_multi', { 'db.operation.name' => 'get_multi' }) do |span|
117
+ # trace_with_result('get_multi', { 'db.operation' => 'get_multi' }) do |span|
130
118
  # results = fetch_from_cache(keys)
131
119
  # if span
132
120
  # span.set_attribute('db.memcached.hit_count', results.size)
@@ -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
@@ -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.1'
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.1
4
+ version: 4.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -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.6
96
+ rubygems_version: 4.0.4
97
97
  specification_version: 4
98
98
  summary: High performance memcached client for Ruby
99
99
  test_files: []