dalli 3.2.8 → 5.0.5

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +305 -0
  3. data/Gemfile +16 -2
  4. data/README.md +96 -2
  5. data/lib/dalli/client.rb +289 -28
  6. data/lib/dalli/flags.rb +15 -0
  7. data/lib/dalli/instrumentation.rb +153 -0
  8. data/lib/dalli/key_manager.rb +23 -8
  9. data/lib/dalli/options.rb +1 -1
  10. data/lib/dalli/pid_cache.rb +1 -1
  11. data/lib/dalli/pipelined_deleter.rb +82 -0
  12. data/lib/dalli/pipelined_getter.rb +44 -20
  13. data/lib/dalli/pipelined_setter.rb +87 -0
  14. data/lib/dalli/protocol/base.rb +95 -25
  15. data/lib/dalli/protocol/connection_manager.rb +37 -14
  16. data/lib/dalli/protocol/{meta/key_regularizer.rb → key_regularizer.rb} +1 -1
  17. data/lib/dalli/protocol/meta.rb +185 -20
  18. data/lib/dalli/protocol/{meta/request_formatter.rb → request_formatter.rb} +49 -16
  19. data/lib/dalli/protocol/response_buffer.rb +36 -12
  20. data/lib/dalli/protocol/{meta/response_processor.rb → response_processor.rb} +93 -37
  21. data/lib/dalli/protocol/server_config_parser.rb +2 -2
  22. data/lib/dalli/protocol/string_marshaller.rb +65 -0
  23. data/lib/dalli/protocol/ttl_sanitizer.rb +1 -1
  24. data/lib/dalli/protocol/value_compressor.rb +3 -16
  25. data/lib/dalli/protocol/value_marshaller.rb +1 -1
  26. data/lib/dalli/protocol/value_serializer.rb +61 -44
  27. data/lib/dalli/protocol.rb +10 -0
  28. data/lib/dalli/ring.rb +2 -2
  29. data/lib/dalli/servers_arg_normalizer.rb +1 -1
  30. data/lib/dalli/socket.rb +79 -14
  31. data/lib/dalli/version.rb +2 -2
  32. data/lib/dalli.rb +13 -5
  33. data/lib/rack/session/dalli.rb +43 -8
  34. metadata +27 -17
  35. data/lib/dalli/protocol/binary/request_formatter.rb +0 -117
  36. data/lib/dalli/protocol/binary/response_header.rb +0 -36
  37. data/lib/dalli/protocol/binary/response_processor.rb +0 -239
  38. data/lib/dalli/protocol/binary/sasl_authentication.rb +0 -60
  39. data/lib/dalli/protocol/binary.rb +0 -173
  40. data/lib/dalli/server.rb +0 -6
data/lib/dalli/client.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest/md5'
4
- require 'set'
5
4
 
6
5
  # encoding: ascii
7
6
  module Dalli
@@ -41,14 +40,22 @@ module Dalli
41
40
  # - :compressor - defaults to Dalli::Compressor, a Zlib-based implementation
42
41
  # - :cache_nils - defaults to false, if true Dalli will not treat cached nil values as 'not found' for
43
42
  # #fetch operations.
43
+ # - :raw - If set, disables serialization and compression entirely at the client level.
44
+ # Only String values are supported. This is useful when the caller handles its own
45
+ # serialization (e.g., Rails' ActiveSupport::Cache). Note: this is different from
46
+ # the per-request :raw option which converts values to strings but still uses the
47
+ # serialization pipeline.
44
48
  # - :digest_class - defaults to Digest::MD5, allows you to pass in an object that responds to the hexdigest method,
45
49
  # useful for injecting a FIPS compliant hash object.
46
- # - :protocol - one of either :binary or :meta, defaulting to :binary. This sets the protocol that Dalli uses
47
- # to communicate with memcached.
50
+ # - :otel_db_statement - controls the +db.query.text+ span attribute when OpenTelemetry is loaded.
51
+ # +:include+ logs the full operation and key(s), +:obfuscate+ replaces keys with "?",
52
+ # +nil+ (default) omits the attribute entirely.
53
+ # - :otel_peer_service - when set, adds a +peer.service+ span attribute with this value for logical service naming.
48
54
  #
49
55
  def initialize(servers = nil, options = {})
50
56
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
51
57
  @options = normalize_options(options)
58
+ warn_removed_options(@options)
52
59
  @key_manager = ::Dalli::KeyManager.new(@options)
53
60
  @ring = nil
54
61
  end
@@ -91,24 +98,65 @@ module Dalli
91
98
  yield value, cas
92
99
  end
93
100
 
101
+ ##
102
+ # Get value with extended metadata.
103
+ #
104
+ # @param key [String] the cache key
105
+ # @param options [Hash] options controlling what metadata to return
106
+ # - :return_cas [Boolean] return the CAS value (default: true)
107
+ # - :return_hit_status [Boolean] return whether item was previously accessed
108
+ # - :return_last_access [Boolean] return seconds since last access
109
+ # - :skip_lru_bump [Boolean] don't bump LRU or update access stats
110
+ #
111
+ # @return [Hash] containing:
112
+ # - :value - the cached value (or nil on miss)
113
+ # - :cas - the CAS value
114
+ # - :hit_before - true/false if previously accessed (only if return_hit_status: true)
115
+ # - :last_access - seconds since last access (only if return_last_access: true)
116
+ #
117
+ # @example Get with hit status
118
+ # result = client.get_with_metadata('key', return_hit_status: true)
119
+ # # => { value: "data", cas: 123, hit_before: true }
120
+ #
121
+ # @example Get with all metadata without affecting LRU
122
+ # result = client.get_with_metadata('key',
123
+ # return_hit_status: true,
124
+ # return_last_access: true,
125
+ # skip_lru_bump: true
126
+ # )
127
+ # # => { value: "data", cas: 123, hit_before: true, last_access: 42 }
128
+ #
129
+ def get_with_metadata(key, options = {})
130
+ key = key.to_s
131
+ key = @key_manager.validate_key(key)
132
+
133
+ server = ring.server_for_key(key)
134
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
135
+ server.request(:meta_get, key, options)
136
+ end
137
+ rescue NetworkError => e
138
+ Dalli.logger.debug { e.inspect }
139
+ Dalli.logger.debug { 'retrying get_with_metadata with new server' }
140
+ retry
141
+ end
142
+
94
143
  ##
95
144
  # Fetch multiple keys efficiently.
96
145
  # If a block is given, yields key/value pairs one at a time.
97
146
  # Otherwise returns a hash of { 'key' => 'value', 'key2' => 'value1' }
147
+ # rubocop:disable Style/ExplicitBlockArgument
98
148
  def get_multi(*keys)
99
149
  keys.flatten!
100
150
  keys.compact!
101
-
102
151
  return {} if keys.empty?
103
152
 
104
153
  if block_given?
105
- pipelined_getter.process(keys) { |k, data| yield k, data.first }
154
+ get_multi_yielding(keys) { |k, v| yield k, v }
106
155
  else
107
- {}.tap do |hash|
108
- pipelined_getter.process(keys) { |k, data| hash[k] = data.first }
109
- end
156
+ get_multi_hash(keys)
110
157
  end
111
158
  end
159
+ # rubocop:enable Style/ExplicitBlockArgument
112
160
 
113
161
  ##
114
162
  # Fetch multiple keys efficiently, including available metadata such as CAS.
@@ -144,6 +192,52 @@ module Dalli
144
192
  new_val
145
193
  end
146
194
 
195
+ ##
196
+ # Fetch the value with thundering herd protection using the meta protocol's
197
+ # N (vivify) and R (recache) flags.
198
+ #
199
+ # This method prevents multiple clients from simultaneously regenerating the same
200
+ # cache entry (the "thundering herd" problem). Only one client wins the right to
201
+ # regenerate; other clients receive the stale value (if available) or wait.
202
+ #
203
+ # @param key [String] the cache key
204
+ # @param ttl [Integer] time-to-live for the cached value in seconds
205
+ # @param lock_ttl [Integer] how long the lock/stub lives (default: 30 seconds)
206
+ # This is the maximum time other clients will return stale data while
207
+ # waiting for regeneration. Should be longer than your expected regeneration time.
208
+ # @param recache_threshold [Integer, nil] if set, win the recache race when the
209
+ # item's remaining TTL is below this threshold. Useful for proactive recaching.
210
+ # @param req_options [Hash] options passed to set operations (e.g., raw: true)
211
+ #
212
+ # @yield Block to regenerate the value (only called if this client won the race)
213
+ # @return [Object] the cached value (may be stale if another client is regenerating)
214
+ #
215
+ # @example Basic usage
216
+ # client.fetch_with_lock('expensive_key', ttl: 300, lock_ttl: 30) do
217
+ # expensive_database_query
218
+ # end
219
+ #
220
+ # @example With proactive recaching (recache before expiry)
221
+ # client.fetch_with_lock('key', ttl: 300, lock_ttl: 30, recache_threshold: 60) do
222
+ # expensive_operation
223
+ # end
224
+ #
225
+ def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil, &block)
226
+ raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
227
+
228
+ key = key.to_s
229
+ key = @key_manager.validate_key(key)
230
+
231
+ server = ring.server_for_key(key)
232
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
233
+ fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
234
+ end
235
+ rescue NetworkError => e
236
+ Dalli.logger.debug { e.inspect }
237
+ Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
238
+ retry
239
+ end
240
+
147
241
  ##
148
242
  # compare and swap values using optimistic locking.
149
243
  # Fetch the existing value for key.
@@ -155,8 +249,8 @@ module Dalli
155
249
  # - nil if the key did not exist.
156
250
  # - false if the value was changed by someone else.
157
251
  # - 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)
252
+ def cas(key, ttl = nil, req_options = nil, &)
253
+ cas_core(key, false, ttl, req_options, &)
160
254
  end
161
255
 
162
256
  ##
@@ -166,8 +260,8 @@ module Dalli
166
260
  # Returns:
167
261
  # - false if the value was changed by someone else.
168
262
  # - 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)
263
+ def cas!(key, ttl = nil, req_options = nil, &)
264
+ cas_core(key, true, ttl, req_options, &)
171
265
  end
172
266
 
173
267
  ##
@@ -201,6 +295,30 @@ module Dalli
201
295
  set_cas(key, value, 0, ttl, req_options)
202
296
  end
203
297
 
298
+ ##
299
+ # Set multiple keys and values efficiently using pipelining.
300
+ # This method is more efficient than calling set() in a loop because
301
+ # it batches requests by server and uses quiet mode.
302
+ #
303
+ # @param hash [Hash] key-value pairs to set
304
+ # @param ttl [Integer] time-to-live in seconds (optional, uses default if not provided)
305
+ # @param req_options [Hash] options passed to each set operation
306
+ # @return [void]
307
+ #
308
+ # Example:
309
+ # client.set_multi({ 'key1' => 'value1', 'key2' => 'value2' }, 300)
310
+ def set_multi(hash, ttl = nil, req_options = nil)
311
+ return if hash.empty?
312
+
313
+ Instrumentation.trace('set_multi', multi_trace_attrs('set_multi', hash.size, hash.keys)) do
314
+ if ring.servers.size == 1
315
+ single_server_set_multi(hash, ttl_or_default(ttl), req_options)
316
+ else
317
+ pipelined_setter.process(hash, ttl_or_default(ttl), req_options)
318
+ end
319
+ end
320
+ end
321
+
204
322
  ##
205
323
  # Set the key-value pair, verifying existing CAS.
206
324
  # Returns the resulting CAS value if succeeded, and falsy otherwise.
@@ -240,6 +358,28 @@ module Dalli
240
358
  delete_cas(key, 0)
241
359
  end
242
360
 
361
+ ##
362
+ # Delete multiple keys efficiently using pipelining.
363
+ # This method is more efficient than calling delete() in a loop because
364
+ # it batches requests by server and uses quiet mode.
365
+ #
366
+ # @param keys [Array<String>] keys to delete
367
+ # @return [void]
368
+ #
369
+ # Example:
370
+ # client.delete_multi(['key1', 'key2', 'key3'])
371
+ def delete_multi(keys)
372
+ return if keys.empty?
373
+
374
+ Instrumentation.trace('delete_multi', multi_trace_attrs('delete_multi', keys.size, keys)) do
375
+ if ring.servers.size == 1
376
+ single_server_delete_multi(keys)
377
+ else
378
+ pipelined_deleter.process(keys)
379
+ end
380
+ end
381
+ end
382
+
243
383
  ##
244
384
  # Append value to the value already stored on the server for 'key'.
245
385
  # Appending only works for values stored with :raw => true.
@@ -369,6 +509,100 @@ module Dalli
369
509
 
370
510
  private
371
511
 
512
+ def record_hit_miss_metrics(span, key_count, hit_count)
513
+ return unless span
514
+
515
+ span.add_attributes('db.memcached.hit_count' => hit_count,
516
+ 'db.memcached.miss_count' => key_count - hit_count)
517
+ end
518
+
519
+ def get_multi_yielding(keys)
520
+ Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
521
+ hit_count = 0
522
+ pipelined_getter.process(keys) do |k, data|
523
+ hit_count += 1
524
+ yield k, data.first
525
+ end
526
+ record_hit_miss_metrics(span, keys.size, hit_count)
527
+ nil
528
+ end
529
+ end
530
+
531
+ def get_multi_hash(keys)
532
+ Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
533
+ hash = if ring.servers.size == 1
534
+ single_server_get_multi(keys)
535
+ else
536
+ {}.tap do |h|
537
+ pipelined_getter.process(keys) { |k, data| h[k] = data.first }
538
+ end
539
+ end
540
+ record_hit_miss_metrics(span, keys.size, hash.size)
541
+ hash
542
+ end
543
+ end
544
+
545
+ def single_server
546
+ server = ring.servers.first
547
+ server if server&.alive?
548
+ end
549
+
550
+ def single_server_get_multi(keys)
551
+ keys.map! { |k| @key_manager.validate_key(k.to_s) }
552
+ return {} unless (server = single_server)
553
+
554
+ result = server.request(:read_multi_req, keys)
555
+ result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
556
+ result
557
+ rescue Dalli::NetworkError
558
+ {}
559
+ end
560
+
561
+ def single_server_set_multi(hash, ttl, req_options)
562
+ pairs = hash.transform_keys { |k| @key_manager.validate_key(k.to_s) }
563
+ return unless (server = single_server)
564
+
565
+ server.request(:write_multi_req, pairs, ttl, req_options)
566
+ rescue Dalli::NetworkError
567
+ nil
568
+ end
569
+
570
+ def single_server_delete_multi(keys)
571
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
572
+ return unless (server = single_server)
573
+
574
+ server.request(:delete_multi_req, validated_keys)
575
+ rescue Dalli::NetworkError
576
+ nil
577
+ end
578
+
579
+ def get_multi_attributes(keys)
580
+ multi_trace_attrs('get_multi', keys.size, keys)
581
+ end
582
+
583
+ def trace_attrs(operation, key, server)
584
+ attrs = { 'db.operation.name' => operation, 'server.address' => server.hostname }
585
+ attrs['server.port'] = server.port if server.socket_type == :tcp
586
+ attrs['peer.service'] = @options[:otel_peer_service] if @options[:otel_peer_service]
587
+ add_query_text(attrs, operation, key)
588
+ end
589
+
590
+ def multi_trace_attrs(operation, key_count, keys)
591
+ attrs = { 'db.operation.name' => operation, 'db.memcached.key_count' => key_count }
592
+ attrs['peer.service'] = @options[:otel_peer_service] if @options[:otel_peer_service]
593
+ add_query_text(attrs, operation, keys)
594
+ end
595
+
596
+ def add_query_text(attrs, operation, key_or_keys)
597
+ case @options[:otel_db_statement]
598
+ when :include
599
+ attrs['db.query.text'] = "#{operation} #{Array(key_or_keys).join(' ')}"
600
+ when :obfuscate
601
+ attrs['db.query.text'] = "#{operation} ?"
602
+ end
603
+ attrs
604
+ end
605
+
372
606
  def check_positive!(amt)
373
607
  raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
374
608
  end
@@ -381,6 +615,17 @@ module Dalli
381
615
  perform(:set, key, newvalue, ttl_or_default(ttl), cas, req_options)
382
616
  end
383
617
 
618
+ def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
619
+ server = ring.server_for_key(key)
620
+ result = server.request(:meta_get, key, { vivify_ttl: lock_ttl, recache_ttl: recache_threshold })
621
+
622
+ return result[:value] unless result[:won_recache]
623
+
624
+ new_val = yield
625
+ set(key, new_val, ttl_or_default(ttl), req_options)
626
+ new_val
627
+ end
628
+
384
629
  ##
385
630
  # Uses the argument TTL or the client-wide default. Ensures
386
631
  # that the value is an integer
@@ -392,16 +637,7 @@ module Dalli
392
637
  end
393
638
 
394
639
  def ring
395
- @ring ||= Dalli::Ring.new(@normalized_servers, protocol_implementation, @options)
396
- end
397
-
398
- def protocol_implementation
399
- @protocol_implementation ||= case @options[:protocol]&.to_s
400
- when 'meta'
401
- Dalli::Protocol::Meta
402
- else
403
- Dalli::Protocol::Binary
404
- end
640
+ @ring ||= Dalli::Ring.new(@normalized_servers, @options)
405
641
  end
406
642
 
407
643
  ##
@@ -412,19 +648,21 @@ module Dalli
412
648
  #
413
649
  # This method also forces retries on network errors - when
414
650
  # a particular memcached instance becomes unreachable, or the
415
- # operational times out.
651
+ # operation times out.
416
652
  ##
417
- def perform(*all_args)
653
+ # rubocop:disable Naming/MethodParameterName
654
+ def perform(op, key, *args)
655
+ # rubocop:enable Naming/MethodParameterName
418
656
  return yield if block_given?
419
657
 
420
- op, key, *args = all_args
421
-
422
658
  key = key.to_s
423
659
  key = @key_manager.validate_key(key)
424
660
 
425
661
  server = ring.server_for_key(key)
426
- server.request(op, key, *args)
427
- rescue NetworkError => e
662
+ Instrumentation.trace(op.to_s, trace_attrs(op.to_s, key, server)) do
663
+ server.request(op, key, *args)
664
+ end
665
+ rescue RetryableNetworkError => e
428
666
  Dalli.logger.debug { e.inspect }
429
667
  Dalli.logger.debug { 'retrying request with new server' }
430
668
  retry
@@ -437,8 +675,31 @@ module Dalli
437
675
  raise ArgumentError, "cannot convert :expires_in => #{opts[:expires_in].inspect} to an integer"
438
676
  end
439
677
 
678
+ REMOVED_OPTIONS = {
679
+ protocol: 'Dalli 5.0 only supports the meta protocol. The :protocol option has been removed.',
680
+ username: 'Dalli 5.0 removed SASL authentication support. The :username option is ignored.',
681
+ password: 'Dalli 5.0 removed SASL authentication support. The :password option is ignored.'
682
+ }.freeze
683
+ private_constant :REMOVED_OPTIONS
684
+
685
+ def warn_removed_options(opts)
686
+ REMOVED_OPTIONS.each do |key, message|
687
+ next unless opts.key?(key)
688
+
689
+ Dalli.logger.warn(message)
690
+ end
691
+ end
692
+
440
693
  def pipelined_getter
441
694
  PipelinedGetter.new(ring, @key_manager)
442
695
  end
696
+
697
+ def pipelined_setter
698
+ PipelinedSetter.new(ring, @key_manager)
699
+ end
700
+
701
+ def pipelined_deleter
702
+ PipelinedDeleter.new(ring, @key_manager)
703
+ end
443
704
  end
444
705
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ module Flags
5
+ # https://www.hjp.at/zettel/m/memcached_flags.rxml
6
+ # Looks like most clients use bit 0 to indicate native language serialization
7
+ SERIALIZED = 0x1
8
+
9
+ # https://www.hjp.at/zettel/m/memcached_flags.rxml
10
+ # Looks like most clients use bit 1 to indicate gzip compression.
11
+ COMPRESSED = 0x2
12
+
13
+ UTF8 = 0x4
14
+ end
15
+ end
@@ -0,0 +1,153 @@
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
+ # Dalli 5.0 uses the stable OTel semantic conventions for database spans.
12
+ #
13
+ # == Span Attributes
14
+ #
15
+ # All spans include the following default attributes:
16
+ # - +db.system.name+ - Always "memcached"
17
+ #
18
+ # Single-key operations (+get+, +set+, +delete+, +incr+, +decr+, etc.) add:
19
+ # - +db.operation.name+ - The operation name (e.g., "get", "set")
20
+ # - +server.address+ - The server hostname (e.g., "localhost")
21
+ # - +server.port+ - The server port as an integer (e.g., 11211); omitted for Unix sockets
22
+ #
23
+ # Multi-key operations (+get_multi+) add:
24
+ # - +db.operation.name+ - "get_multi"
25
+ # - +db.memcached.key_count+ - Number of keys requested
26
+ # - +db.memcached.hit_count+ - Number of keys found in cache
27
+ # - +db.memcached.miss_count+ - Number of keys not found
28
+ #
29
+ # Bulk write operations (+set_multi+, +delete_multi+) add:
30
+ # - +db.operation.name+ - The operation name
31
+ # - +db.memcached.key_count+ - Number of keys in the operation
32
+ #
33
+ # == Optional Attributes
34
+ #
35
+ # - +db.query.text+ - The operation and key(s), controlled by the +:otel_db_statement+ client option:
36
+ # - +:include+ - Full text (e.g., "get mykey")
37
+ # - +:obfuscate+ - Obfuscated (e.g., "get ?")
38
+ # - +nil+ (default) - Attribute omitted
39
+ # - +peer.service+ - Logical service name, set via the +:otel_peer_service+ client option
40
+ #
41
+ # == Error Handling
42
+ #
43
+ # When an exception occurs during a traced operation:
44
+ # - The exception is recorded on the span via +record_exception+
45
+ # - The span status is set to error with the exception message
46
+ # - The exception is re-raised to the caller
47
+ #
48
+ # @example Checking if tracing is enabled
49
+ # Dalli::Instrumentation.enabled? # => true if OpenTelemetry is loaded
50
+ #
51
+ ##
52
+ module Instrumentation
53
+ # Default attributes included on all memcached spans.
54
+ # @return [Hash] frozen hash with 'db.system.name' => 'memcached'
55
+ DEFAULT_ATTRIBUTES = { 'db.system.name' => 'memcached' }.freeze
56
+
57
+ class << self
58
+ # Returns the OpenTelemetry tracer if available, nil otherwise.
59
+ #
60
+ # The tracer is cached after first lookup for performance.
61
+ # Uses the library name 'dalli' and current Dalli::VERSION.
62
+ #
63
+ # @return [OpenTelemetry::Trace::Tracer, nil] the tracer or nil if OTel unavailable
64
+ # rubocop:disable ThreadSafety/ClassInstanceVariable, ThreadSafety/ClassAndModuleAttributes
65
+ def tracer
66
+ return @tracer if defined?(@tracer)
67
+
68
+ @tracer = (OpenTelemetry.tracer_provider.tracer('dalli', Dalli::VERSION) if defined?(OpenTelemetry))
69
+ end
70
+
71
+ attr_writer :tracer
72
+
73
+ # Returns true if instrumentation is enabled (OpenTelemetry SDK is available).
74
+ #
75
+ # @return [Boolean] true if tracing is active, false otherwise
76
+ def enabled?
77
+ !tracer.nil?
78
+ end
79
+
80
+ # Disable instrumentation.
81
+ #
82
+ # @return [nil]
83
+ def disable!
84
+ @tracer = nil
85
+ end
86
+
87
+ # rubocop:enable ThreadSafety/ClassInstanceVariable, ThreadSafety/ClassAndModuleAttributes
88
+
89
+ # Wraps a block with a span if instrumentation is enabled.
90
+ #
91
+ # Creates a client span with the given name and attributes merged with
92
+ # DEFAULT_ATTRIBUTES. The block is executed within the span context.
93
+ # If an exception occurs, it is recorded on the span before re-raising.
94
+ #
95
+ # When tracing is disabled (OpenTelemetry not loaded), this method
96
+ # simply yields directly with zero overhead.
97
+ #
98
+ # @param name [String] the span name (e.g., 'get', 'set', 'delete')
99
+ # @param attributes [Hash] span attributes to merge with defaults.
100
+ # Common attributes include:
101
+ # - 'db.operation.name' - the operation name
102
+ # - 'server.address' - the server hostname
103
+ # - 'server.port' - the server port (integer)
104
+ # - 'db.memcached.key_count' - number of keys (for multi operations)
105
+ # @yield the cache operation to trace
106
+ # @return [Object] the result of the block
107
+ # @raise [StandardError] re-raises any exception from the block
108
+ #
109
+ # @example Tracing a set operation
110
+ # trace('set', { 'db.operation.name' => 'set', 'server.address' => 'localhost', 'server.port' => 11211 }) do
111
+ # server.set(key, value, ttl)
112
+ # end
113
+ #
114
+ def trace(name, attributes = {})
115
+ return yield unless enabled?
116
+
117
+ tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client) do |_span|
118
+ yield
119
+ end
120
+ end
121
+
122
+ # Like trace, but yields the span to allow adding attributes after execution.
123
+ #
124
+ # This is useful for operations where metrics are only known after the
125
+ # operation completes, such as get_multi where hit/miss counts depend
126
+ # on the cache response.
127
+ #
128
+ # When tracing is disabled, yields nil as the span argument.
129
+ #
130
+ # @param name [String] the span name (e.g., 'get_multi')
131
+ # @param attributes [Hash] initial span attributes to merge with defaults
132
+ # @yield [OpenTelemetry::Trace::Span, nil] the span object, or nil if disabled
133
+ # @return [Object] the result of the block
134
+ # @raise [StandardError] re-raises any exception from the block
135
+ #
136
+ # @example Recording hit/miss metrics after get_multi
137
+ # trace_with_result('get_multi', { 'db.operation.name' => 'get_multi' }) do |span|
138
+ # results = fetch_from_cache(keys)
139
+ # if span
140
+ # span.set_attribute('db.memcached.hit_count', results.size)
141
+ # span.set_attribute('db.memcached.miss_count', keys.size - results.size)
142
+ # end
143
+ # results
144
+ # end
145
+ #
146
+ def trace_with_result(name, attributes = {}, &)
147
+ return yield(nil) unless enabled?
148
+
149
+ tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client, &)
150
+ end
151
+ end
152
+ end
153
+ 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 \x00-\x1F\x7F]\z/
30
35
 
31
36
  def initialize(client_options)
32
37
  @key_options =
33
- DEFAULTS.merge(client_options.select { |k, _| OPTIONS.include?(k) })
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)}:/.freeze 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
data/lib/dalli/options.rb CHANGED
@@ -6,7 +6,7 @@ module Dalli
6
6
  # Make Dalli threadsafe by using a lock around all
7
7
  # public server methods.
8
8
  #
9
- # Dalli::Protocol::Binary.extend(Dalli::Threadsafe)
9
+ # Dalli::Protocol::Meta.extend(Dalli::Threadsafe)
10
10
  #
11
11
  module Threadsafe
12
12
  def self.extended(obj)
@@ -13,7 +13,7 @@ module Dalli
13
13
  attr_reader :pid
14
14
 
15
15
  def update!
16
- @pid = Process.pid
16
+ @pid = Process.pid # rubocop:disable ThreadSafety/ClassInstanceVariable
17
17
  end
18
18
  end
19
19
  update!