dalli 4.1.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: e342d39d58d552607783486a9c9f0ffa23d9c479079c62cb760ec84a97d064da
4
- data.tar.gz: 1ae923ede204d0a3e82426404cfb4882f4414a12cb351cf3d04b9ef8653051ce
3
+ metadata.gz: e97e2a407956737b411c33627d1f771be758017b881c68fab66edee95dc3249e
4
+ data.tar.gz: b57638f133a592e9d57b71530bc925ff6589b27549e8785bc6c7f334906636ff
5
5
  SHA512:
6
- metadata.gz: aeb1bec84092f4e07db884b415d6b25375dc995fff04cc58a1764d9ad0937986ba7ff5db5a665a831bd8ab488b3fa4868fce79834e93a26848bc37ac1c9f9855
7
- data.tar.gz: 8c5a4e21f4b0a86279bf28edc729c0a1981964438342117f3bc180d15d5cdfc93e395913267db7af66b3907416051be7629e687708c163f4d4015d4e2a768508
6
+ metadata.gz: 7dd43e9e5b09b65174f2e46b84de40e4e5bcfae9645c6bb67017ca81a64d344e7345cc4bf685f11d4779aeaafd7c7163df5a67bf8410153034f4adf523385b95
7
+ data.tar.gz: b57506702dbdcb387490b3c0c56d2407aff4f0f789aec01e2b1a92c9f8d925a0225b8563e3dbc1ccf3c3ca72a25ca58f4199f1738d93bdf65a07893ed2930b6d
data/CHANGELOG.md CHANGED
@@ -1,6 +1,56 @@
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
+
34
+ 4.2.0
35
+ ==========
36
+
37
+ Performance:
38
+
39
+ - Buffered I/O: Use `socket.sync = false` with explicit flush to reduce syscalls for pipelined operations
40
+ - get_multi optimizations: Use Set for O(1) server tracking lookups
41
+ - Raw mode optimization: Skip bitflags request in meta protocol when in raw mode (saves 2 bytes per request)
42
+
43
+ New Features:
44
+
45
+ - OpenTelemetry tracing support: Automatically instruments operations when OpenTelemetry SDK is present
46
+ - Zero overhead when OpenTelemetry is not loaded
47
+ - Traces `get`, `set`, `delete`, `get_multi`, `set_multi`, `delete_multi`, `get_with_metadata`, and `fetch_with_lock`
48
+ - Spans include `db.system: memcached` and `db.operation` attributes
49
+ - Single-key operations include `server.address` attribute
50
+ - Multi-key operations include `db.memcached.key_count` attribute
51
+ - `get_multi` spans include `db.memcached.hit_count` and `db.memcached.miss_count` for cache efficiency metrics
52
+ - Exceptions are automatically recorded on spans with error status
53
+
4
54
  4.1.0
5
55
  ==========
6
56
 
data/README.md CHANGED
@@ -11,6 +11,7 @@ Dalli supports:
11
11
  * Thread-safe operation (either through use of a connection pool, or by using the Dalli client in threadsafe mode)
12
12
  * SSL/TLS connections to memcached
13
13
  * SASL authentication
14
+ * OpenTelemetry distributed tracing (automatic when SDK is present)
14
15
 
15
16
  The name is a variant of Salvador Dali for his famous painting [The Persistence of Memory](http://en.wikipedia.org/wiki/The_Persistence_of_Memory).
16
17
 
@@ -30,6 +31,31 @@ Dalli supports two protocols for communicating with memcached:
30
31
  Dalli::Client.new('localhost:11211', protocol: :meta)
31
32
  ```
32
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
+
33
59
  ## Security Note
34
60
 
35
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:
@@ -40,6 +66,46 @@ Dalli::Client.new('localhost:11211', serializer: JSON)
40
66
 
41
67
  See the [4.0-Upgrade.md](4.0-Upgrade.md) guide for more information.
42
68
 
69
+ ## OpenTelemetry Tracing
70
+
71
+ Dalli automatically instruments operations with [OpenTelemetry](https://opentelemetry.io/) when the SDK is present. No configuration is required - just add the OpenTelemetry gems to your application:
72
+
73
+ ```ruby
74
+ # Gemfile
75
+ gem 'opentelemetry-sdk'
76
+ gem 'opentelemetry-exporter-otlp' # or your preferred exporter
77
+ ```
78
+
79
+ When OpenTelemetry is loaded, Dalli creates spans for:
80
+ - Single key operations: `get`, `set`, `delete`, `add`, `replace`, `incr`, `decr`, etc.
81
+ - Multi-key operations: `get_multi`, `set_multi`, `delete_multi`
82
+ - Advanced operations: `get_with_metadata`, `fetch_with_lock`
83
+
84
+ ### Span Attributes
85
+
86
+ All spans include:
87
+ - `db.system`: `memcached`
88
+ - `db.operation`: The operation name (e.g., `get`, `set_multi`)
89
+
90
+ Single-key operations also include:
91
+ - `server.address`: The memcached server that handled the request (e.g., `localhost:11211`)
92
+
93
+ Multi-key operations include cache efficiency metrics:
94
+ - `db.memcached.key_count`: Number of keys in the request
95
+ - `db.memcached.hit_count`: Number of keys found (for `get_multi`)
96
+ - `db.memcached.miss_count`: Number of keys not found (for `get_multi`)
97
+
98
+ ### Error Handling
99
+
100
+ Exceptions are automatically recorded on spans with error status. When an operation fails:
101
+ 1. The exception is recorded on the span via `span.record_exception(e)`
102
+ 2. The span status is set to error with the exception message
103
+ 3. The exception is re-raised to the caller
104
+
105
+ ### Zero Overhead
106
+
107
+ When OpenTelemetry is not present, there is zero overhead - the tracing code checks once at startup and bypasses all instrumentation logic entirely when the SDK is not loaded.
108
+
43
109
  ![Persistence of Memory](https://upload.wikimedia.org/wikipedia/en/d/dd/The_Persistence_of_Memory.jpg)
44
110
 
45
111
 
data/lib/dalli/client.rb CHANGED
@@ -134,8 +134,10 @@ module Dalli
134
134
  key = key.to_s
135
135
  key = @key_manager.validate_key(key)
136
136
 
137
- server = ring.server_for_key(key)
138
- server.request(:meta_get, key, options)
137
+ Instrumentation.trace('get_with_metadata', { 'db.operation' => 'get_with_metadata' }) do
138
+ server = ring.server_for_key(key)
139
+ server.request(:meta_get, key, options)
140
+ end
139
141
  rescue NetworkError => e
140
142
  Dalli.logger.debug { e.inspect }
141
143
  Dalli.logger.debug { 'retrying get_with_metadata with new server' }
@@ -146,20 +148,19 @@ module Dalli
146
148
  # Fetch multiple keys efficiently.
147
149
  # If a block is given, yields key/value pairs one at a time.
148
150
  # Otherwise returns a hash of { 'key' => 'value', 'key2' => 'value1' }
151
+ # rubocop:disable Style/ExplicitBlockArgument
149
152
  def get_multi(*keys)
150
153
  keys.flatten!
151
154
  keys.compact!
152
-
153
155
  return {} if keys.empty?
154
156
 
155
157
  if block_given?
156
- pipelined_getter.process(keys) { |k, data| yield k, data.first }
158
+ get_multi_yielding(keys) { |k, v| yield k, v }
157
159
  else
158
- {}.tap do |hash|
159
- pipelined_getter.process(keys) { |k, data| hash[k] = data.first }
160
- end
160
+ get_multi_hash(keys)
161
161
  end
162
162
  end
163
+ # rubocop:enable Style/ExplicitBlockArgument
163
164
 
164
165
  ##
165
166
  # Fetch multiple keys efficiently, including available metadata such as CAS.
@@ -228,7 +229,7 @@ module Dalli
228
229
  # expensive_operation
229
230
  # end
230
231
  #
231
- def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil)
232
+ def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil, &block)
232
233
  raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
233
234
 
234
235
  raise_unless_meta_protocol!
@@ -236,21 +237,8 @@ module Dalli
236
237
  key = key.to_s
237
238
  key = @key_manager.validate_key(key)
238
239
 
239
- server = ring.server_for_key(key)
240
- result = server.request(:meta_get, key, {
241
- vivify_ttl: lock_ttl,
242
- recache_ttl: recache_threshold
243
- })
244
-
245
- if result[:won_recache]
246
- # This client won the race - regenerate the value
247
- new_val = yield
248
- set(key, new_val, ttl_or_default(ttl), req_options)
249
- new_val
250
- else
251
- # Another client is regenerating, or value exists and isn't stale
252
- # Return the existing value
253
- result[:value]
240
+ Instrumentation.trace('fetch_with_lock', { 'db.operation' => 'fetch_with_lock' }) do
241
+ fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
254
242
  end
255
243
  rescue NetworkError => e
256
244
  Dalli.logger.debug { e.inspect }
@@ -330,7 +318,12 @@ module Dalli
330
318
  def set_multi(hash, ttl = nil, req_options = nil)
331
319
  return if hash.empty?
332
320
 
333
- pipelined_setter.process(hash, ttl_or_default(ttl), req_options)
321
+ Instrumentation.trace('set_multi', {
322
+ 'db.operation' => 'set_multi',
323
+ 'db.memcached.key_count' => hash.size
324
+ }) do
325
+ pipelined_setter.process(hash, ttl_or_default(ttl), req_options)
326
+ end
334
327
  end
335
328
 
336
329
  ##
@@ -385,7 +378,12 @@ module Dalli
385
378
  def delete_multi(keys)
386
379
  return if keys.empty?
387
380
 
388
- pipelined_deleter.process(keys)
381
+ Instrumentation.trace('delete_multi', {
382
+ 'db.operation' => 'delete_multi',
383
+ 'db.memcached.key_count' => keys.size
384
+ }) do
385
+ pipelined_deleter.process(keys)
386
+ end
389
387
  end
390
388
 
391
389
  ##
@@ -517,6 +515,42 @@ module Dalli
517
515
 
518
516
  private
519
517
 
518
+ # Records hit/miss metrics on a span for cache observability.
519
+ # @param span [OpenTelemetry::Trace::Span, nil] the span to record on
520
+ # @param key_count [Integer] total keys requested
521
+ # @param hit_count [Integer] keys found in cache
522
+ def record_hit_miss_metrics(span, key_count, hit_count)
523
+ return unless span
524
+
525
+ span.set_attribute('db.memcached.hit_count', hit_count)
526
+ span.set_attribute('db.memcached.miss_count', key_count - hit_count)
527
+ end
528
+
529
+ def get_multi_yielding(keys)
530
+ Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
531
+ hit_count = 0
532
+ pipelined_getter.process(keys) do |k, data|
533
+ hit_count += 1
534
+ yield k, data.first
535
+ end
536
+ record_hit_miss_metrics(span, keys.size, hit_count)
537
+ nil
538
+ end
539
+ end
540
+
541
+ def get_multi_hash(keys)
542
+ Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
543
+ {}.tap do |hash|
544
+ pipelined_getter.process(keys) { |k, data| hash[k] = data.first }
545
+ record_hit_miss_metrics(span, keys.size, hash.size)
546
+ end
547
+ end
548
+ end
549
+
550
+ def get_multi_attributes(keys)
551
+ { 'db.operation' => 'get_multi', 'db.memcached.key_count' => keys.size }
552
+ end
553
+
520
554
  def check_positive!(amt)
521
555
  raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
522
556
  end
@@ -529,6 +563,17 @@ module Dalli
529
563
  perform(:set, key, newvalue, ttl_or_default(ttl), cas, req_options)
530
564
  end
531
565
 
566
+ def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
567
+ server = ring.server_for_key(key)
568
+ result = server.request(:meta_get, key, { vivify_ttl: lock_ttl, recache_ttl: recache_threshold })
569
+
570
+ return result[:value] unless result[:won_recache]
571
+
572
+ new_val = yield
573
+ set(key, new_val, ttl_or_default(ttl), req_options)
574
+ new_val
575
+ end
576
+
532
577
  ##
533
578
  # Uses the argument TTL or the client-wide default. Ensures
534
579
  # that the value is an integer
@@ -571,7 +616,12 @@ module Dalli
571
616
  key = @key_manager.validate_key(key)
572
617
 
573
618
  server = ring.server_for_key(key)
574
- server.request(op, key, *args)
619
+ Instrumentation.trace(op.to_s, {
620
+ 'db.operation' => op.to_s,
621
+ 'server.address' => server.name
622
+ }) do
623
+ server.request(op, key, *args)
624
+ end
575
625
  rescue NetworkError => e
576
626
  Dalli.logger.debug { e.inspect }
577
627
  Dalli.logger.debug { 'retrying request with new server' }
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ ##
5
+ # Instrumentation support for Dalli. Provides hooks for distributed tracing
6
+ # via OpenTelemetry when the SDK is available.
7
+ #
8
+ # When OpenTelemetry is loaded, Dalli automatically creates spans for cache operations.
9
+ # When OpenTelemetry is not available, all tracing methods are no-ops with zero overhead.
10
+ #
11
+ # == Span Attributes
12
+ #
13
+ # All spans include the following default attributes:
14
+ # - +db.system+ - Always "memcached"
15
+ #
16
+ # 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
+ #
20
+ # Multi-key operations (+get_multi+) add:
21
+ # - +db.operation+ - "get_multi"
22
+ # - +db.memcached.key_count+ - Number of keys requested
23
+ # - +db.memcached.hit_count+ - Number of keys found in cache
24
+ # - +db.memcached.miss_count+ - Number of keys not found
25
+ #
26
+ # Bulk write operations (+set_multi+, +delete_multi+) add:
27
+ # - +db.operation+ - The operation name
28
+ # - +db.memcached.key_count+ - Number of keys in the operation
29
+ #
30
+ # == Error Handling
31
+ #
32
+ # When an exception occurs during a traced operation:
33
+ # - The exception is recorded on the span via +record_exception+
34
+ # - The span status is set to error with the exception message
35
+ # - The exception is re-raised to the caller
36
+ #
37
+ # @example Checking if tracing is enabled
38
+ # Dalli::Instrumentation.enabled? # => true if OpenTelemetry is loaded
39
+ #
40
+ ##
41
+ module Instrumentation
42
+ # Default attributes included on all memcached spans.
43
+ # @return [Hash] frozen hash with 'db.system' => 'memcached'
44
+ DEFAULT_ATTRIBUTES = { 'db.system' => 'memcached' }.freeze
45
+
46
+ class << self
47
+ # Returns the OpenTelemetry tracer if available, nil otherwise.
48
+ #
49
+ # The tracer is cached after first lookup for performance.
50
+ # Uses the library name 'dalli' and current Dalli::VERSION.
51
+ #
52
+ # @return [OpenTelemetry::Trace::Tracer, nil] the tracer or nil if OTel unavailable
53
+ def tracer
54
+ return @tracer if defined?(@tracer)
55
+
56
+ @tracer = (OpenTelemetry.tracer_provider.tracer('dalli', Dalli::VERSION) if defined?(OpenTelemetry))
57
+ end
58
+
59
+ # Returns true if instrumentation is enabled (OpenTelemetry SDK is available).
60
+ #
61
+ # @return [Boolean] true if tracing is active, false otherwise
62
+ def enabled?
63
+ !tracer.nil?
64
+ end
65
+
66
+ # Wraps a block with a span if instrumentation is enabled.
67
+ #
68
+ # Creates a client span with the given name and attributes merged with
69
+ # DEFAULT_ATTRIBUTES. The block is executed within the span context.
70
+ # If an exception occurs, it is recorded on the span before re-raising.
71
+ #
72
+ # When tracing is disabled (OpenTelemetry not loaded), this method
73
+ # simply yields directly with zero overhead.
74
+ #
75
+ # @param name [String] the span name (e.g., 'get', 'set', 'delete')
76
+ # @param attributes [Hash] span attributes to merge with defaults.
77
+ # Common attributes include:
78
+ # - 'db.operation' - the operation name
79
+ # - 'server.address' - the target server
80
+ # - 'db.memcached.key_count' - number of keys (for multi operations)
81
+ # @yield the cache operation to trace
82
+ # @return [Object] the result of the block
83
+ # @raise [StandardError] re-raises any exception from the block
84
+ #
85
+ # @example Tracing a set operation
86
+ # trace('set', { 'db.operation' => 'set', 'server.address' => 'localhost:11211' }) do
87
+ # server.set(key, value, ttl)
88
+ # end
89
+ #
90
+ def trace(name, attributes = {})
91
+ return yield unless enabled?
92
+
93
+ tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client) do |span|
94
+ yield
95
+ rescue StandardError => e
96
+ span.record_exception(e)
97
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
98
+ raise
99
+ end
100
+ end
101
+
102
+ # Like trace, but yields the span to allow adding attributes after execution.
103
+ #
104
+ # This is useful for operations where metrics are only known after the
105
+ # operation completes, such as get_multi where hit/miss counts depend
106
+ # on the cache response.
107
+ #
108
+ # When tracing is disabled, yields nil as the span argument.
109
+ #
110
+ # @param name [String] the span name (e.g., 'get_multi')
111
+ # @param attributes [Hash] initial span attributes to merge with defaults
112
+ # @yield [OpenTelemetry::Trace::Span, nil] the span object, or nil if disabled
113
+ # @return [Object] the result of the block
114
+ # @raise [StandardError] re-raises any exception from the block
115
+ #
116
+ # @example Recording hit/miss metrics after get_multi
117
+ # trace_with_result('get_multi', { 'db.operation' => 'get_multi' }) do |span|
118
+ # results = fetch_from_cache(keys)
119
+ # if span
120
+ # span.set_attribute('db.memcached.hit_count', results.size)
121
+ # span.set_attribute('db.memcached.miss_count', keys.size - results.size)
122
+ # end
123
+ # results
124
+ # end
125
+ #
126
+ def trace_with_result(name, attributes = {})
127
+ return yield(nil) unless enabled?
128
+
129
+ tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client) do |span|
130
+ yield(span)
131
+ rescue StandardError => e
132
+ span.record_exception(e)
133
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
134
+ raise
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -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
@@ -1,10 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module Dalli
4
6
  ##
5
7
  # Contains logic for the pipelined gets implemented by the client.
6
8
  ##
7
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
+
8
17
  def initialize(ring, key_manager)
9
18
  @ring = ring
10
19
  @key_manager = key_manager
@@ -17,8 +26,14 @@ module Dalli
17
26
  return {} if keys.empty?
18
27
 
19
28
  @ring.lock do
29
+ # Stores partial results collected during interleaved send phase
30
+ @partial_results = {}
20
31
  servers = setup_requests(keys)
21
32
  start_time = Time.now
33
+
34
+ # First yield any partial results collected during interleaved send
35
+ yield_partial_results(&block)
36
+
22
37
  servers = fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
23
38
  end
24
39
  rescue NetworkError => e
@@ -27,6 +42,15 @@ module Dalli
27
42
  retry
28
43
  end
29
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
+
30
54
  def setup_requests(keys)
31
55
  groups = groups_for_keys(keys)
32
56
  make_getkq_requests(groups)
@@ -45,7 +69,14 @@ module Dalli
45
69
  ##
46
70
  def make_getkq_requests(groups)
47
71
  groups.each do |server, keys_for_server|
48
- 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
49
80
  rescue DalliError, NetworkError => e
50
81
  Dalli.logger.debug { e.inspect }
51
82
  Dalli.logger.debug { "unable to get keys for server #{server.name}" }
@@ -57,7 +88,7 @@ module Dalli
57
88
  # our set, sending the noop to terminate the set of queries.
58
89
  ##
59
90
  def finish_queries(servers)
60
- deleted = []
91
+ deleted = Set.new
61
92
 
62
93
  servers.each do |server|
63
94
  next unless server.connected?
@@ -67,7 +98,7 @@ module Dalli
67
98
  rescue Dalli::NetworkError
68
99
  raise
69
100
  rescue Dalli::DalliError
70
- deleted.append(server)
101
+ deleted << server
71
102
  end
72
103
  end
73
104
 
@@ -94,7 +125,7 @@ module Dalli
94
125
 
95
126
  def fetch_responses(servers, start_time, timeout, &block)
96
127
  # Remove any servers which are not connected
97
- servers.delete_if { |s| !s.connected? }
128
+ servers.select!(&:connected?)
98
129
  return [] if servers.empty?
99
130
 
100
131
  time_left = remaining_time(start_time, timeout)
@@ -23,10 +23,17 @@ module Dalli
23
23
  def initialize(attribs, client_options = {})
24
24
  hostname, port, socket_type, @weight, user_creds = ServerConfigParser.parse(attribs)
25
25
  @options = client_options.merge(user_creds)
26
- @value_marshaller = client_options[:raw] ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
26
+ @raw_mode = client_options[:raw]
27
+ @value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
27
28
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
28
29
  end
29
30
 
31
+ # Returns true if client is in raw mode (no serialization/compression).
32
+ # In raw mode, we can skip requesting bitflags from the server.
33
+ def raw_mode?
34
+ @raw_mode
35
+ end
36
+
30
37
  # Chokepoint method for error handling and ensuring liveness
31
38
  def request(opkey, *args)
32
39
  verify_state(opkey)
@@ -35,8 +42,8 @@ module Dalli
35
42
  @connection_manager.start_request!
36
43
  response = send(opkey, *args)
37
44
 
38
- # pipelined_get emit query but doesn't read the response(s)
39
- @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)
40
47
 
41
48
  response
42
49
  rescue Dalli::MarshalError => e
@@ -74,7 +81,9 @@ module Dalli
74
81
  def pipeline_response_setup
75
82
  verify_pipelined_state(:getkq)
76
83
  write_noop
77
- 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
78
87
  end
79
88
 
80
89
  # Attempt to receive and parse as many key/value pairs as possible
@@ -213,6 +222,11 @@ module Dalli
213
222
  end
214
223
 
215
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
+
216
230
  req = +''
217
231
  keys.each do |key|
218
232
  req << quiet_get_request(key)
@@ -221,6 +235,51 @@ module Dalli
221
235
  write(req)
222
236
  end
223
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
+
224
283
  def response_buffer
225
284
  @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
226
285
  end
@@ -22,6 +22,7 @@ module Dalli
22
22
  def get(key, options = nil)
23
23
  req = RequestFormatter.standard_request(opkey: :get, key: key)
24
24
  write(req)
25
+ @connection_manager.flush
25
26
  response_processor.get(cache_nils: cache_nils?(options))
26
27
  end
27
28
 
@@ -33,12 +34,14 @@ module Dalli
33
34
  ttl = TtlSanitizer.sanitize(ttl)
34
35
  req = RequestFormatter.standard_request(opkey: :gat, key: key, ttl: ttl)
35
36
  write(req)
37
+ @connection_manager.flush
36
38
  response_processor.get(cache_nils: cache_nils?(options))
37
39
  end
38
40
 
39
41
  def touch(key, ttl)
40
42
  ttl = TtlSanitizer.sanitize(ttl)
41
43
  write(RequestFormatter.standard_request(opkey: :touch, key: key, ttl: ttl))
44
+ @connection_manager.flush
42
45
  response_processor.generic_response
43
46
  end
44
47
 
@@ -47,6 +50,7 @@ module Dalli
47
50
  def cas(key)
48
51
  req = RequestFormatter.standard_request(opkey: :get, key: key)
49
52
  write(req)
53
+ @connection_manager.flush
50
54
  response_processor.data_cas_response
51
55
  end
52
56
 
@@ -81,6 +85,7 @@ module Dalli
81
85
  value: value, bitflags: bitflags,
82
86
  ttl: ttl, cas: cas)
83
87
  write(req)
88
+ @connection_manager.flush unless quiet?
84
89
  response_processor.storage_response unless quiet?
85
90
  end
86
91
  # rubocop:enable Metrics/ParameterLists
@@ -97,6 +102,7 @@ module Dalli
97
102
 
98
103
  def write_append_prepend(opkey, key, value)
99
104
  write(RequestFormatter.standard_request(opkey: opkey, key: key, value: value))
105
+ @connection_manager.flush unless quiet?
100
106
  response_processor.no_body_response unless quiet?
101
107
  end
102
108
 
@@ -105,6 +111,7 @@ module Dalli
105
111
  opkey = quiet? ? :deleteq : :delete
106
112
  req = RequestFormatter.standard_request(opkey: opkey, key: key, cas: cas)
107
113
  write(req)
114
+ @connection_manager.flush unless quiet?
108
115
  response_processor.delete unless quiet?
109
116
  end
110
117
 
@@ -139,6 +146,7 @@ module Dalli
139
146
  initial ||= 0
140
147
  write(RequestFormatter.decr_incr_request(opkey: opkey, key: key,
141
148
  count: count, initial: initial, expiry: expiry))
149
+ @connection_manager.flush unless quiet?
142
150
  response_processor.decr_incr unless quiet?
143
151
  end
144
152
 
@@ -146,6 +154,7 @@ module Dalli
146
154
  def flush(ttl = 0)
147
155
  opkey = quiet? ? :flushq : :flush
148
156
  write(RequestFormatter.standard_request(opkey: opkey, ttl: ttl))
157
+ @connection_manager.flush unless quiet?
149
158
  response_processor.no_body_response unless quiet?
150
159
  end
151
160
 
@@ -159,22 +168,26 @@ module Dalli
159
168
  def stats(info = '')
160
169
  req = RequestFormatter.standard_request(opkey: :stat, key: info)
161
170
  write(req)
171
+ @connection_manager.flush
162
172
  response_processor.stats
163
173
  end
164
174
 
165
175
  def reset_stats
166
176
  write(RequestFormatter.standard_request(opkey: :stat, key: 'reset'))
177
+ @connection_manager.flush
167
178
  response_processor.reset
168
179
  end
169
180
 
170
181
  def version
171
182
  write(RequestFormatter.standard_request(opkey: :version))
183
+ @connection_manager.flush
172
184
  response_processor.version
173
185
  end
174
186
 
175
187
  def write_noop
176
188
  req = RequestFormatter.standard_request(opkey: :noop)
177
189
  write(req)
190
+ @connection_manager.flush
178
191
  end
179
192
 
180
193
  require_relative 'binary/request_formatter'
@@ -53,6 +53,7 @@ module Dalli
53
53
  Dalli.logger.debug { "Dalli::Server#connect #{name}" }
54
54
 
55
55
  @sock = memcached_socket
56
+ @sock.sync = false # Enable buffered I/O for better performance
56
57
  @pid = PIDCache.pid
57
58
  @request_in_progress = false
58
59
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
@@ -166,6 +167,12 @@ module Dalli
166
167
  error_on_request!(e)
167
168
  end
168
169
 
170
+ def flush
171
+ @sock.flush
172
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS => e
173
+ error_on_request!(e)
174
+ end
175
+
169
176
  # Non-blocking read. Here to support the operation
170
177
  # of the get_multi operation
171
178
  def read_nonblock
@@ -37,9 +37,12 @@ module Dalli
37
37
  # - l<N>: Seconds since last access
38
38
  def self.meta_get(key:, value: true, return_cas: false, ttl: nil, base64: false, quiet: false,
39
39
  vivify_ttl: nil, recache_ttl: nil,
40
- return_hit_status: false, return_last_access: false, skip_lru_bump: false)
40
+ return_hit_status: false, return_last_access: false, skip_lru_bump: false,
41
+ skip_flags: false)
41
42
  cmd = "mg #{key}"
42
- cmd << ' v f' if value
43
+ # In raw mode (skip_flags: true), we don't request bitflags since they're not used.
44
+ # This saves 2 bytes per request and skips parsing on response.
45
+ cmd << (skip_flags ? ' v' : ' v f') if value
43
46
  cmd << ' c' if return_cas
44
47
  cmd << ' b' if base64
45
48
  cmd << " T#{ttl}" if ttl
@@ -25,21 +25,28 @@ module Dalli
25
25
  # Retrieval Commands
26
26
  def get(key, options = nil)
27
27
  encoded_key, base64 = KeyRegularizer.encode(key)
28
- req = RequestFormatter.meta_get(key: encoded_key, base64: base64)
28
+ # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
29
+ skip_flags = raw_mode? || (options && options[:raw])
30
+ req = RequestFormatter.meta_get(key: encoded_key, base64: base64, skip_flags: skip_flags)
29
31
  write(req)
32
+ @connection_manager.flush
30
33
  response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
31
34
  end
32
35
 
33
36
  def quiet_get_request(key)
34
37
  encoded_key, base64 = KeyRegularizer.encode(key)
35
- RequestFormatter.meta_get(key: encoded_key, return_cas: true, base64: base64, quiet: true)
38
+ # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
39
+ RequestFormatter.meta_get(key: encoded_key, return_cas: true, base64: base64, quiet: true,
40
+ skip_flags: raw_mode?)
36
41
  end
37
42
 
38
43
  def gat(key, ttl, options = nil)
39
44
  ttl = TtlSanitizer.sanitize(ttl)
40
45
  encoded_key, base64 = KeyRegularizer.encode(key)
41
- req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, base64: base64)
46
+ skip_flags = raw_mode? || (options && options[:raw])
47
+ req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, base64: base64, skip_flags: skip_flags)
42
48
  write(req)
49
+ @connection_manager.flush
43
50
  response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
44
51
  end
45
52
 
@@ -48,6 +55,7 @@ module Dalli
48
55
  encoded_key, base64 = KeyRegularizer.encode(key)
49
56
  req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, value: false, base64: base64)
50
57
  write(req)
58
+ @connection_manager.flush
51
59
  response_processor.meta_get_without_value
52
60
  end
53
61
 
@@ -57,6 +65,7 @@ module Dalli
57
65
  encoded_key, base64 = KeyRegularizer.encode(key)
58
66
  req = RequestFormatter.meta_get(key: encoded_key, value: true, return_cas: true, base64: base64)
59
67
  write(req)
68
+ @connection_manager.flush
60
69
  response_processor.meta_get_with_value_and_cas
61
70
  end
62
71
 
@@ -93,6 +102,7 @@ module Dalli
93
102
  return_last_access: options[:return_last_access], skip_lru_bump: options[:skip_lru_bump]
94
103
  )
95
104
  write(req)
105
+ @connection_manager.flush
96
106
  response_processor.meta_get_with_metadata(
97
107
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
98
108
  return_last_access: options[:return_last_access]
@@ -110,6 +120,7 @@ module Dalli
110
120
  encoded_key, base64 = KeyRegularizer.encode(key)
111
121
  req = RequestFormatter.meta_delete(key: encoded_key, cas: cas, base64: base64, stale: true)
112
122
  write(req)
123
+ @connection_manager.flush
113
124
  response_processor.meta_delete
114
125
  end
115
126
 
@@ -146,6 +157,7 @@ module Dalli
146
157
  write(req)
147
158
  write(value)
148
159
  write(TERMINATOR)
160
+ @connection_manager.flush unless quiet
149
161
  end
150
162
  # rubocop:enable Metrics/ParameterLists
151
163
 
@@ -168,6 +180,7 @@ module Dalli
168
180
  write(req)
169
181
  write(value)
170
182
  write(TERMINATOR)
183
+ @connection_manager.flush unless quiet?
171
184
  end
172
185
  # rubocop:enable Metrics/ParameterLists
173
186
 
@@ -177,6 +190,7 @@ module Dalli
177
190
  req = RequestFormatter.meta_delete(key: encoded_key, cas: cas,
178
191
  base64: base64, quiet: quiet?)
179
192
  write(req)
193
+ @connection_manager.flush unless quiet?
180
194
  response_processor.meta_delete unless quiet?
181
195
  end
182
196
 
@@ -202,12 +216,14 @@ module Dalli
202
216
  encoded_key, base64 = KeyRegularizer.encode(key)
203
217
  write(RequestFormatter.meta_arithmetic(key: encoded_key, delta: delta, initial: initial, incr: incr, ttl: ttl,
204
218
  quiet: quiet?, base64: base64))
219
+ @connection_manager.flush unless quiet?
205
220
  response_processor.decr_incr unless quiet?
206
221
  end
207
222
 
208
223
  # Other Commands
209
224
  def flush(delay = 0)
210
225
  write(RequestFormatter.flush(delay: delay))
226
+ @connection_manager.flush unless quiet?
211
227
  response_processor.flush unless quiet?
212
228
  end
213
229
 
@@ -220,21 +236,25 @@ module Dalli
220
236
 
221
237
  def stats(info = nil)
222
238
  write(RequestFormatter.stats(info))
239
+ @connection_manager.flush
223
240
  response_processor.stats
224
241
  end
225
242
 
226
243
  def reset_stats
227
244
  write(RequestFormatter.stats('reset'))
245
+ @connection_manager.flush
228
246
  response_processor.reset
229
247
  end
230
248
 
231
249
  def version
232
250
  write(RequestFormatter.version)
251
+ @connection_manager.flush
233
252
  response_processor.version
234
253
  end
235
254
 
236
255
  def write_noop
237
256
  write(RequestFormatter.meta_noop)
257
+ @connection_manager.flush
238
258
  end
239
259
 
240
260
  def authenticate_connection
@@ -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.1.0'
4
+ VERSION = '4.3.0'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.4'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -56,6 +56,7 @@ module Dalli
56
56
  end
57
57
 
58
58
  require_relative 'dalli/version'
59
+ require_relative 'dalli/instrumentation'
59
60
 
60
61
  require_relative 'dalli/compressor'
61
62
  require_relative 'dalli/protocol_deprecations'
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.1.0
4
+ version: 4.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -40,6 +40,7 @@ files:
40
40
  - lib/dalli/cas/client.rb
41
41
  - lib/dalli/client.rb
42
42
  - lib/dalli/compressor.rb
43
+ - lib/dalli/instrumentation.rb
43
44
  - lib/dalli/key_manager.rb
44
45
  - lib/dalli/options.rb
45
46
  - lib/dalli/pid_cache.rb