dalli 4.0.1 → 4.2.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: 5494b3c5f7ce6c73cb061511ab96c7dafc0ed1f5d10a865310a7adc79c8587c9
4
- data.tar.gz: 1326cd38aad6ba7c7c0c65c2b0305b4c4c566e3a0050df1f8ccee6aa42ee2bc7
3
+ metadata.gz: 7966ac393d9c0f41d61b244c6a5832fbcab5705ed4daa4189b6e1ffc92db600f
4
+ data.tar.gz: da320eac7a8d553f33fe81315750297f97edb75c6da79197ef1f974d47ac8081
5
5
  SHA512:
6
- metadata.gz: 5affa5731ead45a7c352628c4c3c471b1fc5c7499e9e63e58278e2c59c309e90fd550774a8808de78ff9daaa53913503810ded029d0879f3a6408f19a4bf67f2
7
- data.tar.gz: 244b909ac405e2f8757b3bfa3fdcea3fb17615f6d329527f63c3392a4921dd8298fa9d6eeaab368bd80112413c593d94f305062732fb28204072526b84eba3d4
6
+ metadata.gz: 5d29cfbe8d2ee6e2993e8a292238f2762ac1de29b8d8623f0a521f810edf866ffd9d631905d5b7cb939d44216fb7e8d6edabf4c09ba388162fef97a772254352
7
+ data.tar.gz: ef1c636e6aab12a630ddb7e4d7081c29b0e257203b97f78ccde207170802d1c3619db7057093d4140fcc9cbc23f979bf6f60be4a64011bde983062a724ea59fc
data/CHANGELOG.md CHANGED
@@ -1,6 +1,51 @@
1
1
  Dalli Changelog
2
2
  =====================
3
3
 
4
+ 4.2.0
5
+ ==========
6
+
7
+ Performance:
8
+
9
+ - Buffered I/O: Use `socket.sync = false` with explicit flush to reduce syscalls for pipelined operations
10
+ - get_multi optimizations: Use Set for O(1) server tracking lookups
11
+ - Raw mode optimization: Skip bitflags request in meta protocol when in raw mode (saves 2 bytes per request)
12
+
13
+ New Features:
14
+
15
+ - OpenTelemetry tracing support: Automatically instruments operations when OpenTelemetry SDK is present
16
+ - Zero overhead when OpenTelemetry is not loaded
17
+ - Traces `get`, `set`, `delete`, `get_multi`, `set_multi`, `delete_multi`, `get_with_metadata`, and `fetch_with_lock`
18
+ - Spans include `db.system: memcached` and `db.operation` attributes
19
+ - Single-key operations include `server.address` attribute
20
+ - Multi-key operations include `db.memcached.key_count` attribute
21
+ - `get_multi` spans include `db.memcached.hit_count` and `db.memcached.miss_count` for cache efficiency metrics
22
+ - Exceptions are automatically recorded on spans with error status
23
+
24
+ 4.1.0
25
+ ==========
26
+
27
+ New Features:
28
+
29
+ - Add `set_multi` for efficient bulk set operations using pipelined requests
30
+ - Add `delete_multi` for efficient bulk delete operations using pipelined requests
31
+ - Add `fetch_with_lock` for thundering herd protection using meta protocol's vivify/recache flags (requires memcached 1.6+)
32
+ - Add thundering herd protection support to meta protocol (requires memcached 1.6+):
33
+ - `N` (vivify) flag for creating stubs on cache miss
34
+ - `R` (recache) flag for winning recache race when TTL is below threshold
35
+ - Response flags `W` (won recache), `X` (stale), `Z` (lost race)
36
+ - `delete_stale` method for marking items as stale instead of deleting
37
+ - Add `get_with_metadata` for advanced cache operations with metadata retrieval (requires memcached 1.6+):
38
+ - Returns hash with `:value`, `:cas`, `:won_recache`, `:stale`, `:lost_recache`
39
+ - Optional `:return_hit_status` returns `:hit_before` (true/false for previous access)
40
+ - Optional `:return_last_access` returns `:last_access` (seconds since last access)
41
+ - Optional `:skip_lru_bump` prevents LRU update on access
42
+ - Optional `:vivify_ttl` and `:recache_ttl` for thundering herd protection
43
+
44
+ Deprecations:
45
+
46
+ - Binary protocol is deprecated and will be removed in Dalli 5.0. Use `protocol: :meta` instead (requires memcached 1.6+)
47
+ - SASL authentication is deprecated and will be removed in Dalli 5.0. Consider using network-level security or memcached's TLS support
48
+
4
49
  4.0.1
5
50
  ==========
6
51
 
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
 
@@ -40,6 +41,46 @@ Dalli::Client.new('localhost:11211', serializer: JSON)
40
41
 
41
42
  See the [4.0-Upgrade.md](4.0-Upgrade.md) guide for more information.
42
43
 
44
+ ## OpenTelemetry Tracing
45
+
46
+ 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:
47
+
48
+ ```ruby
49
+ # Gemfile
50
+ gem 'opentelemetry-sdk'
51
+ gem 'opentelemetry-exporter-otlp' # or your preferred exporter
52
+ ```
53
+
54
+ When OpenTelemetry is loaded, Dalli creates spans for:
55
+ - Single key operations: `get`, `set`, `delete`, `add`, `replace`, `incr`, `decr`, etc.
56
+ - Multi-key operations: `get_multi`, `set_multi`, `delete_multi`
57
+ - Advanced operations: `get_with_metadata`, `fetch_with_lock`
58
+
59
+ ### Span Attributes
60
+
61
+ All spans include:
62
+ - `db.system`: `memcached`
63
+ - `db.operation`: The operation name (e.g., `get`, `set_multi`)
64
+
65
+ Single-key operations also include:
66
+ - `server.address`: The memcached server that handled the request (e.g., `localhost:11211`)
67
+
68
+ Multi-key operations include cache efficiency metrics:
69
+ - `db.memcached.key_count`: Number of keys in the request
70
+ - `db.memcached.hit_count`: Number of keys found (for `get_multi`)
71
+ - `db.memcached.miss_count`: Number of keys not found (for `get_multi`)
72
+
73
+ ### Error Handling
74
+
75
+ Exceptions are automatically recorded on spans with error status. When an operation fails:
76
+ 1. The exception is recorded on the span via `span.record_exception(e)`
77
+ 2. The span status is set to error with the exception message
78
+ 3. The exception is re-raised to the caller
79
+
80
+ ### Zero Overhead
81
+
82
+ 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.
83
+
43
84
  ![Persistence of Memory](https://upload.wikimedia.org/wikipedia/en/d/dd/The_Persistence_of_Memory.jpg)
44
85
 
45
86
 
data/lib/dalli/client.rb CHANGED
@@ -56,6 +56,7 @@ module Dalli
56
56
  @options = normalize_options(options)
57
57
  @key_manager = ::Dalli::KeyManager.new(@options)
58
58
  @ring = nil
59
+ emit_deprecation_warnings
59
60
  end
60
61
 
61
62
  #
@@ -96,24 +97,70 @@ module Dalli
96
97
  yield value, cas
97
98
  end
98
99
 
100
+ ##
101
+ # Get value with extended metadata using the meta protocol.
102
+ #
103
+ # IMPORTANT: This method requires memcached 1.6+ and the meta protocol (protocol: :meta).
104
+ # It will raise an error if used with the binary protocol.
105
+ #
106
+ # @param key [String] the cache key
107
+ # @param options [Hash] options controlling what metadata to return
108
+ # - :return_cas [Boolean] return the CAS value (default: true)
109
+ # - :return_hit_status [Boolean] return whether item was previously accessed
110
+ # - :return_last_access [Boolean] return seconds since last access
111
+ # - :skip_lru_bump [Boolean] don't bump LRU or update access stats
112
+ #
113
+ # @return [Hash] containing:
114
+ # - :value - the cached value (or nil on miss)
115
+ # - :cas - the CAS value
116
+ # - :hit_before - true/false if previously accessed (only if return_hit_status: true)
117
+ # - :last_access - seconds since last access (only if return_last_access: true)
118
+ #
119
+ # @example Get with hit status
120
+ # result = client.get_with_metadata('key', return_hit_status: true)
121
+ # # => { value: "data", cas: 123, hit_before: true }
122
+ #
123
+ # @example Get with all metadata without affecting LRU
124
+ # result = client.get_with_metadata('key',
125
+ # return_hit_status: true,
126
+ # return_last_access: true,
127
+ # skip_lru_bump: true
128
+ # )
129
+ # # => { value: "data", cas: 123, hit_before: true, last_access: 42 }
130
+ #
131
+ def get_with_metadata(key, options = {})
132
+ raise_unless_meta_protocol!
133
+
134
+ key = key.to_s
135
+ key = @key_manager.validate_key(key)
136
+
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
141
+ rescue NetworkError => e
142
+ Dalli.logger.debug { e.inspect }
143
+ Dalli.logger.debug { 'retrying get_with_metadata with new server' }
144
+ retry
145
+ end
146
+
99
147
  ##
100
148
  # Fetch multiple keys efficiently.
101
149
  # If a block is given, yields key/value pairs one at a time.
102
150
  # Otherwise returns a hash of { 'key' => 'value', 'key2' => 'value1' }
151
+ # rubocop:disable Style/ExplicitBlockArgument
103
152
  def get_multi(*keys)
104
153
  keys.flatten!
105
154
  keys.compact!
106
-
107
155
  return {} if keys.empty?
108
156
 
109
157
  if block_given?
110
- pipelined_getter.process(keys) { |k, data| yield k, data.first }
158
+ get_multi_yielding(keys) { |k, v| yield k, v }
111
159
  else
112
- {}.tap do |hash|
113
- pipelined_getter.process(keys) { |k, data| hash[k] = data.first }
114
- end
160
+ get_multi_hash(keys)
115
161
  end
116
162
  end
163
+ # rubocop:enable Style/ExplicitBlockArgument
117
164
 
118
165
  ##
119
166
  # Fetch multiple keys efficiently, including available metadata such as CAS.
@@ -149,6 +196,56 @@ module Dalli
149
196
  new_val
150
197
  end
151
198
 
199
+ ##
200
+ # Fetch the value with thundering herd protection using the meta protocol's
201
+ # N (vivify) and R (recache) flags.
202
+ #
203
+ # This method prevents multiple clients from simultaneously regenerating the same
204
+ # cache entry (the "thundering herd" problem). Only one client wins the right to
205
+ # regenerate; other clients receive the stale value (if available) or wait.
206
+ #
207
+ # IMPORTANT: This method requires memcached 1.6+ and the meta protocol (protocol: :meta).
208
+ # It will raise an error if used with the binary protocol.
209
+ #
210
+ # @param key [String] the cache key
211
+ # @param ttl [Integer] time-to-live for the cached value in seconds
212
+ # @param lock_ttl [Integer] how long the lock/stub lives (default: 30 seconds)
213
+ # This is the maximum time other clients will return stale data while
214
+ # waiting for regeneration. Should be longer than your expected regeneration time.
215
+ # @param recache_threshold [Integer, nil] if set, win the recache race when the
216
+ # item's remaining TTL is below this threshold. Useful for proactive recaching.
217
+ # @param req_options [Hash] options passed to set operations (e.g., raw: true)
218
+ #
219
+ # @yield Block to regenerate the value (only called if this client won the race)
220
+ # @return [Object] the cached value (may be stale if another client is regenerating)
221
+ #
222
+ # @example Basic usage
223
+ # client.fetch_with_lock('expensive_key', ttl: 300, lock_ttl: 30) do
224
+ # expensive_database_query
225
+ # end
226
+ #
227
+ # @example With proactive recaching (recache before expiry)
228
+ # client.fetch_with_lock('key', ttl: 300, lock_ttl: 30, recache_threshold: 60) do
229
+ # expensive_operation
230
+ # end
231
+ #
232
+ def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil, &block)
233
+ raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
234
+
235
+ raise_unless_meta_protocol!
236
+
237
+ key = key.to_s
238
+ key = @key_manager.validate_key(key)
239
+
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)
242
+ end
243
+ rescue NetworkError => e
244
+ Dalli.logger.debug { e.inspect }
245
+ Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
246
+ retry
247
+ end
248
+
152
249
  ##
153
250
  # compare and swap values using optimistic locking.
154
251
  # Fetch the existing value for key.
@@ -206,6 +303,29 @@ module Dalli
206
303
  set_cas(key, value, 0, ttl, req_options)
207
304
  end
208
305
 
306
+ ##
307
+ # Set multiple keys and values efficiently using pipelining.
308
+ # This method is more efficient than calling set() in a loop because
309
+ # it batches requests by server and uses quiet mode.
310
+ #
311
+ # @param hash [Hash] key-value pairs to set
312
+ # @param ttl [Integer] time-to-live in seconds (optional, uses default if not provided)
313
+ # @param req_options [Hash] options passed to each set operation
314
+ # @return [void]
315
+ #
316
+ # Example:
317
+ # client.set_multi({ 'key1' => 'value1', 'key2' => 'value2' }, 300)
318
+ def set_multi(hash, ttl = nil, req_options = nil)
319
+ return if hash.empty?
320
+
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
327
+ end
328
+
209
329
  ##
210
330
  # Set the key-value pair, verifying existing CAS.
211
331
  # Returns the resulting CAS value if succeeded, and falsy otherwise.
@@ -245,6 +365,27 @@ module Dalli
245
365
  delete_cas(key, 0)
246
366
  end
247
367
 
368
+ ##
369
+ # Delete multiple keys efficiently using pipelining.
370
+ # This method is more efficient than calling delete() in a loop because
371
+ # it batches requests by server and uses quiet mode.
372
+ #
373
+ # @param keys [Array<String>] keys to delete
374
+ # @return [void]
375
+ #
376
+ # Example:
377
+ # client.delete_multi(['key1', 'key2', 'key3'])
378
+ def delete_multi(keys)
379
+ return if keys.empty?
380
+
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
387
+ end
388
+
248
389
  ##
249
390
  # Append value to the value already stored on the server for 'key'.
250
391
  # Appending only works for values stored with :raw => true.
@@ -374,6 +515,42 @@ module Dalli
374
515
 
375
516
  private
376
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
+
377
554
  def check_positive!(amt)
378
555
  raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
379
556
  end
@@ -386,6 +563,17 @@ module Dalli
386
563
  perform(:set, key, newvalue, ttl_or_default(ttl), cas, req_options)
387
564
  end
388
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
+
389
577
  ##
390
578
  # Uses the argument TTL or the client-wide default. Ensures
391
579
  # that the value is an integer
@@ -428,7 +616,12 @@ module Dalli
428
616
  key = @key_manager.validate_key(key)
429
617
 
430
618
  server = ring.server_for_key(key)
431
- 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
432
625
  rescue NetworkError => e
433
626
  Dalli.logger.debug { e.inspect }
434
627
  Dalli.logger.debug { 'retrying request with new server' }
@@ -445,5 +638,23 @@ module Dalli
445
638
  def pipelined_getter
446
639
  PipelinedGetter.new(ring, @key_manager)
447
640
  end
641
+
642
+ def pipelined_setter
643
+ PipelinedSetter.new(ring, @key_manager)
644
+ end
645
+
646
+ def pipelined_deleter
647
+ PipelinedDeleter.new(ring, @key_manager)
648
+ end
649
+
650
+ def raise_unless_meta_protocol!
651
+ return if protocol_implementation == Dalli::Protocol::Meta
652
+
653
+ raise Dalli::DalliError,
654
+ 'This operation requires the meta protocol (memcached 1.6+). ' \
655
+ 'Use protocol: :meta when creating the client.'
656
+ end
657
+
658
+ include ProtocolDeprecations
448
659
  end
449
660
  end
@@ -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
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ ##
5
+ # Contains logic for the pipelined delete operations implemented by the client.
6
+ # Efficiently deletes multiple keys by grouping requests by server
7
+ # and using quiet mode to minimize round trips.
8
+ ##
9
+ class PipelinedDeleter
10
+ def initialize(ring, key_manager)
11
+ @ring = ring
12
+ @key_manager = key_manager
13
+ end
14
+
15
+ ##
16
+ # Deletes multiple keys from memcached.
17
+ #
18
+ # @param keys [Array<String>] keys to delete
19
+ # @return [void]
20
+ ##
21
+ def process(keys)
22
+ return if keys.empty?
23
+
24
+ @ring.lock do
25
+ servers = setup_requests(keys)
26
+ finish_requests(servers)
27
+ end
28
+ rescue NetworkError => e
29
+ Dalli.logger.debug { e.inspect }
30
+ Dalli.logger.debug { 'retrying pipelined deletes because of network error' }
31
+ retry
32
+ end
33
+
34
+ private
35
+
36
+ def setup_requests(keys)
37
+ groups = groups_for_keys(keys)
38
+ make_delete_requests(groups)
39
+ groups.keys
40
+ end
41
+
42
+ ##
43
+ # Loop through the server-grouped sets of keys, writing
44
+ # the corresponding quiet delete requests to the appropriate servers
45
+ ##
46
+ def make_delete_requests(groups)
47
+ groups.each do |server, keys_for_server|
48
+ keys_for_server.each do |key|
49
+ server.request(:pipelined_delete, key)
50
+ rescue DalliError, NetworkError => e
51
+ Dalli.logger.debug { e.inspect }
52
+ Dalli.logger.debug { "unable to delete key #{key} for server #{server.name}" }
53
+ end
54
+ end
55
+ end
56
+
57
+ ##
58
+ # Sends noop to each server to flush responses and ensure all deletes complete.
59
+ ##
60
+ def finish_requests(servers)
61
+ servers.each do |server|
62
+ server.request(:noop)
63
+ rescue DalliError, NetworkError => e
64
+ Dalli.logger.debug { e.inspect }
65
+ Dalli.logger.debug { "unable to complete pipelined delete on server #{server.name}" }
66
+ end
67
+ end
68
+
69
+ def groups_for_keys(keys)
70
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
71
+ groups = @ring.keys_grouped_by_server(validated_keys)
72
+
73
+ if (unfound_keys = groups.delete(nil))
74
+ Dalli.logger.debug do
75
+ "unable to delete #{unfound_keys.length} keys because no matching server was found"
76
+ end
77
+ end
78
+
79
+ groups
80
+ end
81
+ end
82
+ end
@@ -1,5 +1,7 @@
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.
@@ -57,7 +59,7 @@ module Dalli
57
59
  # our set, sending the noop to terminate the set of queries.
58
60
  ##
59
61
  def finish_queries(servers)
60
- deleted = []
62
+ deleted = Set.new
61
63
 
62
64
  servers.each do |server|
63
65
  next unless server.connected?
@@ -67,7 +69,7 @@ module Dalli
67
69
  rescue Dalli::NetworkError
68
70
  raise
69
71
  rescue Dalli::DalliError
70
- deleted.append(server)
72
+ deleted << server
71
73
  end
72
74
  end
73
75
 
@@ -94,7 +96,7 @@ module Dalli
94
96
 
95
97
  def fetch_responses(servers, start_time, timeout, &block)
96
98
  # Remove any servers which are not connected
97
- servers.delete_if { |s| !s.connected? }
99
+ servers.select!(&:connected?)
98
100
  return [] if servers.empty?
99
101
 
100
102
  time_left = remaining_time(start_time, timeout)