dalli 3.2.8 → 4.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7d8b3dd9e76a224d876f901dc44c3cf8016326209df3efc7fe522053a4a3c22
4
- data.tar.gz: 0ced058a6a170cadd4b74268de5417c4a1e700baf5767fc8f260db9477e2b735
3
+ metadata.gz: 57b78e30ee409a2d742fc47ee57ccdd482fe9c75f369366ae315b7ef8b649934
4
+ data.tar.gz: b8cad66f3cba53bbcb84b18f406eed60f22209c10dd4a312063a1e13189185ca
5
5
  SHA512:
6
- metadata.gz: 3e316a4d60c3327cecec46a0a34c52536130199124035c375bf1933676d85fbf09e99a985ae44faf4e27b0fb1f8b8faef1656a812e6bdfc387406c5e18874461
7
- data.tar.gz: bce3e0e41c280e99889bea6835c1b2f701cbcb5e4f398203ae2b4d6ebc05cdc505ee5955365715f8e00441d69701ceac84de37a98eac8581d003d1ab411a33e9
6
+ metadata.gz: 18b26e3c4aa30e5f8b4195d95307afca6c9240dd1154a36966d40d52047e638758850ec06e2a40fd1bd8e69fe150dee7409b1f4a565f0a80e397aaf115315463
7
+ data.tar.gz: b6db69ecbc1e6d34587d8ec29b861f6a71c863b36229d3c642e3d0e646ba6234e3bac04225d64ff174d12f35b728663e82e1d2b5f7e93dc9ac3e1ff33fd58db3
data/CHANGELOG.md CHANGED
@@ -1,9 +1,177 @@
1
1
  Dalli Changelog
2
2
  =====================
3
3
 
4
- Unreleased
4
+ 4.3.3
5
5
  ==========
6
6
 
7
+ Performance:
8
+
9
+ - Reduce object allocations in pipelined get response processing (#1072)
10
+ - Offset-based `ResponseBuffer`: track a read offset instead of slicing a new string after every parsed response; compact only when the consumed portion exceeds 4KB and more than half the buffer
11
+ - Inline response processor parsing: avoid intermediate array allocations from `split`-based header parsing in both binary and meta protocols
12
+ - Block-based `pipeline_next_responses`: yield `(key, value, cas)` directly when a block is given, avoiding per-call Hash allocation
13
+ - `PipelinedGetter`: replace Hash-based socket-to-server mapping with linear scan (faster for typical 1-5 server counts); use `Process.clock_gettime(CLOCK_MONOTONIC)` instead of `Time.now`
14
+ - Add cross-version benchmark script (`bin/compare_versions`) for reproducible performance comparisons across Dalli versions
15
+
16
+ Bug Fixes:
17
+
18
+ - Skip OTel integration tests when meta protocol is unavailable (#1072)
19
+
20
+ 4.3.2
21
+ ==========
22
+
23
+ OpenTelemetry:
24
+
25
+ - Migrate to stable OTel semantic conventions
26
+ - `db.system` renamed to `db.system.name`
27
+ - `db.operation` renamed to `db.operation.name`
28
+ - `server.address` now contains hostname only; `server.port` is a separate integer attribute
29
+ - `get_with_metadata` and `fetch_with_lock` now include `server.address`/`server.port`
30
+ - Add `db.query.text` span attribute with configurable modes
31
+ - `:otel_db_statement` option: `:include`, `:obfuscate`, or `nil` (default: omitted)
32
+ - Add `peer.service` span attribute
33
+ - `:otel_peer_service` option for logical service naming
34
+
35
+ 4.3.1
36
+ ==========
37
+
38
+ Bug Fixes:
39
+
40
+ - Fix socket compatibility with gems that monkey-patch TCPSocket (#996, #1012)
41
+ - Gems like `socksify` and `resolv-replace` modify `TCPSocket#initialize`, breaking Ruby 3.0+'s `connect_timeout:` keyword argument
42
+ - Detection now uses parameter signature checking instead of gem-specific method detection
43
+ - Falls back to `Timeout.timeout` when monkey-patching is detected
44
+ - Detection result is cached for performance
45
+
46
+ - Fix network retry bug with `socket_max_failures: 0` (#1065)
47
+ - Previously, setting `socket_max_failures: 0` could still cause retries due to error handling
48
+ - Introduced `RetryableNetworkError` subclass to distinguish retryable vs non-retryable errors
49
+ - `down!` now raises non-retryable `NetworkError`, `reconnect!` raises `RetryableNetworkError`
50
+ - Thanks to Graham Cooper (Shopify) for this fix
51
+
52
+ - Fix "character class has duplicated range" Ruby warning (#1067)
53
+ - Fixed regex in `KeyManager::VALID_NAMESPACE_SEPARATORS` that caused warnings on newer Ruby versions
54
+ - Thanks to Hartley McGuire for this fix
55
+
56
+ Improvements:
57
+
58
+ - Add StrictWarnings test helper to catch Ruby warnings early (#1067)
59
+
60
+ - Use bulk attribute setter for OpenTelemetry spans (#1068)
61
+ - Reduces lock acquisitions when setting span attributes
62
+ - Thanks to Robert Laurin (Shopify) for this optimization
63
+
64
+ - Fix double recording of exceptions on OpenTelemetry spans (#1069)
65
+ - OpenTelemetry's `in_span` method already records exceptions and sets error status automatically
66
+ - Removed redundant explicit exception recording that caused exceptions to appear twice in traces
67
+ - Thanks to Robert Laurin (Shopify) for this fix
68
+
69
+ 4.3.0
70
+ ==========
71
+
72
+ New Features:
73
+
74
+ - Add `namespace_separator` option to customize the separator between namespace and key (#1019)
75
+ - Default is `:` for backward compatibility
76
+ - Must be a single non-alphanumeric character (e.g., `:`, `/`, `|`, `.`)
77
+ - Example: `Dalli::Client.new(servers, namespace: 'myapp', namespace_separator: '/')`
78
+
79
+ Bug Fixes:
80
+
81
+ - Fix architecture-dependent struct timeval packing for socket timeouts (#1034)
82
+ - Detects correct pack format for time_t and suseconds_t on each platform
83
+ - Fixes timeout issues on architectures with 64-bit time_t
84
+
85
+ - Fix get_multi hanging with large key counts (#776, #941)
86
+ - Add interleaved read/write for pipelined gets to prevent socket buffer deadlock
87
+ - For batches over 10,000 keys per server, requests are now sent in chunks
88
+
89
+ - **Breaking:** Enforce string-only values in raw mode (#1022)
90
+ - `set(key, nil, raw: true)` now raises `MarshalError` instead of storing `""`
91
+ - `set(key, 123, raw: true)` now raises `MarshalError` instead of storing `"123"`
92
+ - This matches the behavior of client-level `raw: true` mode
93
+ - To store counters, use string values: `set('counter', '0', raw: true)`
94
+
95
+ CI:
96
+
97
+ - Add TruffleRuby to CI test matrix (#988)
98
+
99
+ 4.2.0
100
+ ==========
101
+
102
+ Performance:
103
+
104
+ - Buffered I/O: Use `socket.sync = false` with explicit flush to reduce syscalls for pipelined operations
105
+ - get_multi optimizations: Use Set for O(1) server tracking lookups
106
+ - Raw mode optimization: Skip bitflags request in meta protocol when in raw mode (saves 2 bytes per request)
107
+
108
+ New Features:
109
+
110
+ - OpenTelemetry tracing support: Automatically instruments operations when OpenTelemetry SDK is present
111
+ - Zero overhead when OpenTelemetry is not loaded
112
+ - Traces `get`, `set`, `delete`, `get_multi`, `set_multi`, `delete_multi`, `get_with_metadata`, and `fetch_with_lock`
113
+ - Spans include `db.system: memcached` and `db.operation` attributes
114
+ - Single-key operations include `server.address` attribute
115
+ - Multi-key operations include `db.memcached.key_count` attribute
116
+ - `get_multi` spans include `db.memcached.hit_count` and `db.memcached.miss_count` for cache efficiency metrics
117
+ - Exceptions are automatically recorded on spans with error status
118
+
119
+ 4.1.0
120
+ ==========
121
+
122
+ New Features:
123
+
124
+ - Add `set_multi` for efficient bulk set operations using pipelined requests
125
+ - Add `delete_multi` for efficient bulk delete operations using pipelined requests
126
+ - Add `fetch_with_lock` for thundering herd protection using meta protocol's vivify/recache flags (requires memcached 1.6+)
127
+ - Add thundering herd protection support to meta protocol (requires memcached 1.6+):
128
+ - `N` (vivify) flag for creating stubs on cache miss
129
+ - `R` (recache) flag for winning recache race when TTL is below threshold
130
+ - Response flags `W` (won recache), `X` (stale), `Z` (lost race)
131
+ - `delete_stale` method for marking items as stale instead of deleting
132
+ - Add `get_with_metadata` for advanced cache operations with metadata retrieval (requires memcached 1.6+):
133
+ - Returns hash with `:value`, `:cas`, `:won_recache`, `:stale`, `:lost_recache`
134
+ - Optional `:return_hit_status` returns `:hit_before` (true/false for previous access)
135
+ - Optional `:return_last_access` returns `:last_access` (seconds since last access)
136
+ - Optional `:skip_lru_bump` prevents LRU update on access
137
+ - Optional `:vivify_ttl` and `:recache_ttl` for thundering herd protection
138
+
139
+ Deprecations:
140
+
141
+ - Binary protocol is deprecated and will be removed in Dalli 5.0. Use `protocol: :meta` instead (requires memcached 1.6+)
142
+ - SASL authentication is deprecated and will be removed in Dalli 5.0. Consider using network-level security or memcached's TLS support
143
+
144
+ 4.0.1
145
+ ==========
146
+
147
+ - Add `:raw` client option to skip serialization entirely, returning raw byte strings
148
+ - Handle `OpenSSL::SSL::SSLError` in connection manager
149
+
150
+ 4.0.0
151
+ ==========
152
+
153
+ BREAKING CHANGES:
154
+
155
+ - Require Ruby 3.1+ (dropped support for Ruby 2.6, 2.7, and 3.0)
156
+ - Removed `Dalli::Server` deprecated alias - use `Dalli::Protocol::Binary` instead
157
+ - Removed `:compression` option - use `:compress` instead
158
+ - Removed `close_on_fork` method - use `reconnect_on_fork` instead
159
+
160
+ Other changes:
161
+
162
+ - Add security warning when using default Marshal serializer (silence with `silence_marshal_warning: true`)
163
+ - Add defense-in-depth input validation for stats command arguments
164
+ - Add `string_fastpath` option to skip serialization for simple strings (byroot)
165
+ - Meta protocol set performance improvement (danmayer)
166
+ - Fix connection_pool 3.0 compatibility for Rack session store
167
+ - Fix session recovery after deletion (stengineering0)
168
+ - Fix cannot read response data included terminator `\r\n` when use meta protocol (matsubara0507)
169
+ - Support SERVER_ERROR response from Memcached as per the [memcached spec](https://github.com/memcached/memcached/blob/e43364402195c8e822bb8f88755a60ab8bbed62a/doc/protocol.txt#L172) (grcooper)
170
+ - Update Socket timeout handling to use Socket#timeout= when available (nickamorim)
171
+ - Serializer: reraise all .load errors as UnmarshalError (olleolleolle)
172
+ - Reconnect gracefully when a fork is detected instead of crashing (PatrickTulskie)
173
+ - Update CI to test against memcached 1.6.40
174
+
7
175
  3.2.8
8
176
  ==========
9
177
 
data/Gemfile CHANGED
@@ -5,9 +5,18 @@ source 'https://rubygems.org'
5
5
  gemspec
6
6
 
7
7
  group :development, :test do
8
+ gem 'benchmark'
9
+ gem 'cgi'
8
10
  gem 'connection_pool'
9
- gem 'minitest', '~> 5'
10
- gem 'rack', '~> 2.0', '>= 2.2.0'
11
+ gem 'debug' unless RUBY_PLATFORM == 'java'
12
+ if RUBY_VERSION >= '3.2'
13
+ gem 'minitest', '~> 6'
14
+ gem 'minitest-mock'
15
+ else
16
+ gem 'minitest', '~> 5'
17
+ end
18
+ gem 'rack', '~> 3'
19
+ gem 'rack-session'
11
20
  gem 'rake', '~> 13.0'
12
21
  gem 'rubocop'
13
22
  gem 'rubocop-minitest'
@@ -18,4 +27,8 @@ end
18
27
 
19
28
  group :test do
20
29
  gem 'ruby-prof', platform: :mri
30
+
31
+ # For socket compatibility testing (these gems monkey-patch TCPSocket)
32
+ gem 'resolv-replace', require: false
33
+ gem 'socksify', require: false
21
34
  end
data/README.md CHANGED
@@ -11,9 +11,101 @@ 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
 
18
+ ## Requirements
19
+
20
+ * Ruby 3.1 or later
21
+ * memcached 1.4 or later (1.6+ recommended for meta protocol support)
22
+
23
+ ## Protocol Options
24
+
25
+ Dalli supports two protocols for communicating with memcached:
26
+
27
+ * `:binary` (default) - Works with all memcached versions, supports SASL authentication
28
+ * `:meta` - Requires memcached 1.6+, better performance for some operations, no authentication support
29
+
30
+ ```ruby
31
+ Dalli::Client.new('localhost:11211', protocol: :meta)
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
+
59
+ ## Security Note
60
+
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:
62
+
63
+ ```ruby
64
+ Dalli::Client.new('localhost:11211', serializer: JSON)
65
+ ```
66
+
67
+ See the [4.0-Upgrade.md](4.0-Upgrade.md) guide for more information.
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
+
17
109
  ![Persistence of Memory](https://upload.wikimedia.org/wikipedia/en/d/dd/The_Persistence_of_Memory.jpg)
18
110
 
19
111
 
data/lib/dalli/client.rb CHANGED
@@ -41,16 +41,26 @@ module Dalli
41
41
  # - :compressor - defaults to Dalli::Compressor, a Zlib-based implementation
42
42
  # - :cache_nils - defaults to false, if true Dalli will not treat cached nil values as 'not found' for
43
43
  # #fetch operations.
44
+ # - :raw - If set, disables serialization and compression entirely at the client level.
45
+ # Only String values are supported. This is useful when the caller handles its own
46
+ # serialization (e.g., Rails' ActiveSupport::Cache). Note: this is different from
47
+ # the per-request :raw option which converts values to strings but still uses the
48
+ # serialization pipeline.
44
49
  # - :digest_class - defaults to Digest::MD5, allows you to pass in an object that responds to the hexdigest method,
45
50
  # useful for injecting a FIPS compliant hash object.
46
51
  # - :protocol - one of either :binary or :meta, defaulting to :binary. This sets the protocol that Dalli uses
47
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.
48
57
  #
49
58
  def initialize(servers = nil, options = {})
50
59
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
51
60
  @options = normalize_options(options)
52
61
  @key_manager = ::Dalli::KeyManager.new(@options)
53
62
  @ring = nil
63
+ emit_deprecation_warnings
54
64
  end
55
65
 
56
66
  #
@@ -91,24 +101,70 @@ module Dalli
91
101
  yield value, cas
92
102
  end
93
103
 
104
+ ##
105
+ # Get value with extended metadata using the meta protocol.
106
+ #
107
+ # IMPORTANT: This method requires memcached 1.6+ and the meta protocol (protocol: :meta).
108
+ # It will raise an error if used with the binary protocol.
109
+ #
110
+ # @param key [String] the cache key
111
+ # @param options [Hash] options controlling what metadata to return
112
+ # - :return_cas [Boolean] return the CAS value (default: true)
113
+ # - :return_hit_status [Boolean] return whether item was previously accessed
114
+ # - :return_last_access [Boolean] return seconds since last access
115
+ # - :skip_lru_bump [Boolean] don't bump LRU or update access stats
116
+ #
117
+ # @return [Hash] containing:
118
+ # - :value - the cached value (or nil on miss)
119
+ # - :cas - the CAS value
120
+ # - :hit_before - true/false if previously accessed (only if return_hit_status: true)
121
+ # - :last_access - seconds since last access (only if return_last_access: true)
122
+ #
123
+ # @example Get with hit status
124
+ # result = client.get_with_metadata('key', return_hit_status: true)
125
+ # # => { value: "data", cas: 123, hit_before: true }
126
+ #
127
+ # @example Get with all metadata without affecting LRU
128
+ # result = client.get_with_metadata('key',
129
+ # return_hit_status: true,
130
+ # return_last_access: true,
131
+ # skip_lru_bump: true
132
+ # )
133
+ # # => { value: "data", cas: 123, hit_before: true, last_access: 42 }
134
+ #
135
+ def get_with_metadata(key, options = {})
136
+ raise_unless_meta_protocol!
137
+
138
+ key = key.to_s
139
+ key = @key_manager.validate_key(key)
140
+
141
+ server = ring.server_for_key(key)
142
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
143
+ server.request(:meta_get, key, options)
144
+ end
145
+ rescue NetworkError => e
146
+ Dalli.logger.debug { e.inspect }
147
+ Dalli.logger.debug { 'retrying get_with_metadata with new server' }
148
+ retry
149
+ end
150
+
94
151
  ##
95
152
  # Fetch multiple keys efficiently.
96
153
  # If a block is given, yields key/value pairs one at a time.
97
154
  # Otherwise returns a hash of { 'key' => 'value', 'key2' => 'value1' }
155
+ # rubocop:disable Style/ExplicitBlockArgument
98
156
  def get_multi(*keys)
99
157
  keys.flatten!
100
158
  keys.compact!
101
-
102
159
  return {} if keys.empty?
103
160
 
104
161
  if block_given?
105
- pipelined_getter.process(keys) { |k, data| yield k, data.first }
162
+ get_multi_yielding(keys) { |k, v| yield k, v }
106
163
  else
107
- {}.tap do |hash|
108
- pipelined_getter.process(keys) { |k, data| hash[k] = data.first }
109
- end
164
+ get_multi_hash(keys)
110
165
  end
111
166
  end
167
+ # rubocop:enable Style/ExplicitBlockArgument
112
168
 
113
169
  ##
114
170
  # Fetch multiple keys efficiently, including available metadata such as CAS.
@@ -144,6 +200,57 @@ module Dalli
144
200
  new_val
145
201
  end
146
202
 
203
+ ##
204
+ # Fetch the value with thundering herd protection using the meta protocol's
205
+ # N (vivify) and R (recache) flags.
206
+ #
207
+ # This method prevents multiple clients from simultaneously regenerating the same
208
+ # cache entry (the "thundering herd" problem). Only one client wins the right to
209
+ # regenerate; other clients receive the stale value (if available) or wait.
210
+ #
211
+ # IMPORTANT: This method requires memcached 1.6+ and the meta protocol (protocol: :meta).
212
+ # It will raise an error if used with the binary protocol.
213
+ #
214
+ # @param key [String] the cache key
215
+ # @param ttl [Integer] time-to-live for the cached value in seconds
216
+ # @param lock_ttl [Integer] how long the lock/stub lives (default: 30 seconds)
217
+ # This is the maximum time other clients will return stale data while
218
+ # waiting for regeneration. Should be longer than your expected regeneration time.
219
+ # @param recache_threshold [Integer, nil] if set, win the recache race when the
220
+ # item's remaining TTL is below this threshold. Useful for proactive recaching.
221
+ # @param req_options [Hash] options passed to set operations (e.g., raw: true)
222
+ #
223
+ # @yield Block to regenerate the value (only called if this client won the race)
224
+ # @return [Object] the cached value (may be stale if another client is regenerating)
225
+ #
226
+ # @example Basic usage
227
+ # client.fetch_with_lock('expensive_key', ttl: 300, lock_ttl: 30) do
228
+ # expensive_database_query
229
+ # end
230
+ #
231
+ # @example With proactive recaching (recache before expiry)
232
+ # client.fetch_with_lock('key', ttl: 300, lock_ttl: 30, recache_threshold: 60) do
233
+ # expensive_operation
234
+ # end
235
+ #
236
+ def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil, &block)
237
+ raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
238
+
239
+ raise_unless_meta_protocol!
240
+
241
+ key = key.to_s
242
+ key = @key_manager.validate_key(key)
243
+
244
+ server = ring.server_for_key(key)
245
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
246
+ fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
247
+ end
248
+ rescue NetworkError => e
249
+ Dalli.logger.debug { e.inspect }
250
+ Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
251
+ retry
252
+ end
253
+
147
254
  ##
148
255
  # compare and swap values using optimistic locking.
149
256
  # Fetch the existing value for key.
@@ -155,8 +262,8 @@ module Dalli
155
262
  # - nil if the key did not exist.
156
263
  # - false if the value was changed by someone else.
157
264
  # - true if the value was successfully updated.
158
- def cas(key, ttl = nil, req_options = nil, &block)
159
- cas_core(key, false, ttl, req_options, &block)
265
+ def cas(key, ttl = nil, req_options = nil, &)
266
+ cas_core(key, false, ttl, req_options, &)
160
267
  end
161
268
 
162
269
  ##
@@ -166,8 +273,8 @@ module Dalli
166
273
  # Returns:
167
274
  # - false if the value was changed by someone else.
168
275
  # - true if the value was successfully updated.
169
- def cas!(key, ttl = nil, req_options = nil, &block)
170
- cas_core(key, true, ttl, req_options, &block)
276
+ def cas!(key, ttl = nil, req_options = nil, &)
277
+ cas_core(key, true, ttl, req_options, &)
171
278
  end
172
279
 
173
280
  ##
@@ -201,6 +308,26 @@ module Dalli
201
308
  set_cas(key, value, 0, ttl, req_options)
202
309
  end
203
310
 
311
+ ##
312
+ # Set multiple keys and values efficiently using pipelining.
313
+ # This method is more efficient than calling set() in a loop because
314
+ # it batches requests by server and uses quiet mode.
315
+ #
316
+ # @param hash [Hash] key-value pairs to set
317
+ # @param ttl [Integer] time-to-live in seconds (optional, uses default if not provided)
318
+ # @param req_options [Hash] options passed to each set operation
319
+ # @return [void]
320
+ #
321
+ # Example:
322
+ # client.set_multi({ 'key1' => 'value1', 'key2' => 'value2' }, 300)
323
+ def set_multi(hash, ttl = nil, req_options = nil)
324
+ return if hash.empty?
325
+
326
+ Instrumentation.trace('set_multi', multi_trace_attrs('set_multi', hash.size, hash.keys)) do
327
+ pipelined_setter.process(hash, ttl_or_default(ttl), req_options)
328
+ end
329
+ end
330
+
204
331
  ##
205
332
  # Set the key-value pair, verifying existing CAS.
206
333
  # Returns the resulting CAS value if succeeded, and falsy otherwise.
@@ -240,6 +367,24 @@ module Dalli
240
367
  delete_cas(key, 0)
241
368
  end
242
369
 
370
+ ##
371
+ # Delete multiple keys efficiently using pipelining.
372
+ # This method is more efficient than calling delete() in a loop because
373
+ # it batches requests by server and uses quiet mode.
374
+ #
375
+ # @param keys [Array<String>] keys to delete
376
+ # @return [void]
377
+ #
378
+ # Example:
379
+ # client.delete_multi(['key1', 'key2', 'key3'])
380
+ def delete_multi(keys)
381
+ return if keys.empty?
382
+
383
+ Instrumentation.trace('delete_multi', multi_trace_attrs('delete_multi', keys.size, keys)) do
384
+ pipelined_deleter.process(keys)
385
+ end
386
+ end
387
+
243
388
  ##
244
389
  # Append value to the value already stored on the server for 'key'.
245
390
  # Appending only works for values stored with :raw => true.
@@ -369,6 +514,65 @@ module Dalli
369
514
 
370
515
  private
371
516
 
517
+ # Records hit/miss metrics on a span for cache observability.
518
+ # @param span [OpenTelemetry::Trace::Span, nil] the span to record on
519
+ # @param key_count [Integer] total keys requested
520
+ # @param hit_count [Integer] keys found in cache
521
+ def record_hit_miss_metrics(span, key_count, hit_count)
522
+ return unless span
523
+
524
+ span.add_attributes('db.memcached.hit_count' => hit_count,
525
+ 'db.memcached.miss_count' => key_count - hit_count)
526
+ end
527
+
528
+ def get_multi_yielding(keys)
529
+ Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
530
+ hit_count = 0
531
+ pipelined_getter.process(keys) do |k, data|
532
+ hit_count += 1
533
+ yield k, data.first
534
+ end
535
+ record_hit_miss_metrics(span, keys.size, hit_count)
536
+ nil
537
+ end
538
+ end
539
+
540
+ def get_multi_hash(keys)
541
+ Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
542
+ {}.tap do |hash|
543
+ pipelined_getter.process(keys) { |k, data| hash[k] = data.first }
544
+ record_hit_miss_metrics(span, keys.size, hash.size)
545
+ end
546
+ end
547
+ end
548
+
549
+ 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
574
+ end
575
+
372
576
  def check_positive!(amt)
373
577
  raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
374
578
  end
@@ -381,6 +585,17 @@ module Dalli
381
585
  perform(:set, key, newvalue, ttl_or_default(ttl), cas, req_options)
382
586
  end
383
587
 
588
+ def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
589
+ server = ring.server_for_key(key)
590
+ result = server.request(:meta_get, key, { vivify_ttl: lock_ttl, recache_ttl: recache_threshold })
591
+
592
+ return result[:value] unless result[:won_recache]
593
+
594
+ new_val = yield
595
+ set(key, new_val, ttl_or_default(ttl), req_options)
596
+ new_val
597
+ end
598
+
384
599
  ##
385
600
  # Uses the argument TTL or the client-wide default. Ensures
386
601
  # that the value is an integer
@@ -423,8 +638,10 @@ module Dalli
423
638
  key = @key_manager.validate_key(key)
424
639
 
425
640
  server = ring.server_for_key(key)
426
- server.request(op, key, *args)
427
- rescue NetworkError => e
641
+ Instrumentation.trace(op.to_s, trace_attrs(op.to_s, key, server)) do
642
+ server.request(op, key, *args)
643
+ end
644
+ rescue RetryableNetworkError => e
428
645
  Dalli.logger.debug { e.inspect }
429
646
  Dalli.logger.debug { 'retrying request with new server' }
430
647
  retry
@@ -440,5 +657,23 @@ module Dalli
440
657
  def pipelined_getter
441
658
  PipelinedGetter.new(ring, @key_manager)
442
659
  end
660
+
661
+ def pipelined_setter
662
+ PipelinedSetter.new(ring, @key_manager)
663
+ end
664
+
665
+ def pipelined_deleter
666
+ PipelinedDeleter.new(ring, @key_manager)
667
+ end
668
+
669
+ def raise_unless_meta_protocol!
670
+ return if protocol_implementation == Dalli::Protocol::Meta
671
+
672
+ raise Dalli::DalliError,
673
+ 'This operation requires the meta protocol (memcached 1.6+). ' \
674
+ 'Use protocol: :meta when creating the client.'
675
+ end
676
+
677
+ include ProtocolDeprecations
443
678
  end
444
679
  end