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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +305 -0
- data/Gemfile +16 -2
- data/README.md +96 -2
- data/lib/dalli/client.rb +289 -28
- data/lib/dalli/flags.rb +15 -0
- data/lib/dalli/instrumentation.rb +153 -0
- data/lib/dalli/key_manager.rb +23 -8
- data/lib/dalli/options.rb +1 -1
- data/lib/dalli/pid_cache.rb +1 -1
- data/lib/dalli/pipelined_deleter.rb +82 -0
- data/lib/dalli/pipelined_getter.rb +44 -20
- data/lib/dalli/pipelined_setter.rb +87 -0
- data/lib/dalli/protocol/base.rb +95 -25
- data/lib/dalli/protocol/connection_manager.rb +37 -14
- data/lib/dalli/protocol/{meta/key_regularizer.rb → key_regularizer.rb} +1 -1
- data/lib/dalli/protocol/meta.rb +185 -20
- data/lib/dalli/protocol/{meta/request_formatter.rb → request_formatter.rb} +49 -16
- data/lib/dalli/protocol/response_buffer.rb +36 -12
- data/lib/dalli/protocol/{meta/response_processor.rb → response_processor.rb} +93 -37
- data/lib/dalli/protocol/server_config_parser.rb +2 -2
- data/lib/dalli/protocol/string_marshaller.rb +65 -0
- data/lib/dalli/protocol/ttl_sanitizer.rb +1 -1
- data/lib/dalli/protocol/value_compressor.rb +3 -16
- data/lib/dalli/protocol/value_marshaller.rb +1 -1
- data/lib/dalli/protocol/value_serializer.rb +61 -44
- data/lib/dalli/protocol.rb +10 -0
- data/lib/dalli/ring.rb +2 -2
- data/lib/dalli/servers_arg_normalizer.rb +1 -1
- data/lib/dalli/socket.rb +79 -14
- data/lib/dalli/version.rb +2 -2
- data/lib/dalli.rb +13 -5
- data/lib/rack/session/dalli.rb +43 -8
- metadata +27 -17
- data/lib/dalli/protocol/binary/request_formatter.rb +0 -117
- data/lib/dalli/protocol/binary/response_header.rb +0 -36
- data/lib/dalli/protocol/binary/response_processor.rb +0 -239
- data/lib/dalli/protocol/binary/sasl_authentication.rb +0 -60
- data/lib/dalli/protocol/binary.rb +0 -173
- 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
|
-
# - :
|
|
47
|
-
#
|
|
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
|
-
|
|
154
|
+
get_multi_yielding(keys) { |k, v| yield k, v }
|
|
106
155
|
else
|
|
107
|
-
|
|
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, &
|
|
159
|
-
cas_core(key, false, ttl, req_options, &
|
|
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, &
|
|
170
|
-
cas_core(key, true, ttl, req_options, &
|
|
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,
|
|
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
|
-
#
|
|
651
|
+
# operation times out.
|
|
416
652
|
##
|
|
417
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
data/lib/dalli/flags.rb
ADDED
|
@@ -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
|
data/lib/dalli/key_manager.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Dalli
|
|
|
12
12
|
class KeyManager
|
|
13
13
|
MAX_KEY_LENGTH = 250
|
|
14
14
|
|
|
15
|
-
|
|
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.
|
|
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}#{
|
|
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)}
|
|
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)}
|
|
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