dalli 3.0.1 → 3.0.5

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of dalli might be problematic. Click here for more details.

data/lib/dalli/client.rb CHANGED
@@ -1,17 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest/md5"
4
- require "set"
3
+ require 'digest/md5'
4
+ require 'set'
5
5
 
6
6
  # encoding: ascii
7
7
  module Dalli
8
+ ##
9
+ # Dalli::Client is the main class which developers will use to interact with
10
+ # Memcached.
11
+ ##
8
12
  class Client
9
13
  ##
10
14
  # Dalli::Client is the main class which developers will use to interact with
11
15
  # the memcached server. Usage:
12
16
  #
13
- # Dalli::Client.new(['localhost:11211:10', 'cache-2.example.com:11211:5', '192.168.0.1:22122:5', '/var/run/memcached/socket'],
14
- # :threadsafe => true, :failover => true, :expires_in => 300)
17
+ # Dalli::Client.new(['localhost:11211:10',
18
+ # 'cache-2.example.com:11211:5',
19
+ # '192.168.0.1:22122:5',
20
+ # '/var/run/memcached/socket'],
21
+ # failover: true, expires_in: 300)
15
22
  #
16
23
  # servers is an Array of "host:port:weight" where weight allows you to distribute cache unevenly.
17
24
  # Both weight and port are optional. If you pass in nil, Dalli will use the <tt>MEMCACHE_SERVERS</tt>
@@ -24,18 +31,25 @@ module Dalli
24
31
  # - :namespace - prepend each key with this value to provide simple namespacing.
25
32
  # - :failover - if a server is down, look for and store values on another server in the ring. Default: true.
26
33
  # - :threadsafe - ensure that only one thread is actively using a socket at a time. Default: true.
27
- # - :expires_in - default TTL in seconds if you do not pass TTL as a parameter to an individual operation, defaults to 0 or forever
28
- # - :compress - defaults to false, if true Dalli will compress values larger than 1024 bytes before sending them to memcached.
34
+ # - :expires_in - default TTL in seconds if you do not pass TTL as a parameter to an individual operation, defaults
35
+ # to 0 or forever.
36
+ # - :compress - if true Dalli will compress values larger than compression_min_size bytes before sending them
37
+ # to memcached. Default: true.
38
+ # - :compression_min_size - the minimum size (in bytes) for which Dalli will compress values sent to Memcached.
39
+ # Defaults to 4K.
29
40
  # - :serializer - defaults to Marshal
30
- # - :compressor - defaults to zlib
31
- # - :cache_nils - defaults to false, if true Dalli will not treat cached nil values as 'not found' for #fetch operations.
32
- # - :digest_class - defaults to Digest::MD5, allows you to pass in an object that responds to the hexdigest method, useful for injecting a FIPS compliant hash object.
33
- # - :protocol_implementation - defaults to Dalli::Protocol::Binary which uses the binary protocol. Allows you to pass an alternative implementation using another protocol.
41
+ # - :compressor - defaults to Dalli::Compressor, a Zlib-based implementation
42
+ # - :cache_nils - defaults to false, if true Dalli will not treat cached nil values as 'not found' for
43
+ # #fetch operations.
44
+ # - :digest_class - defaults to Digest::MD5, allows you to pass in an object that responds to the hexdigest method,
45
+ # useful for injecting a FIPS compliant hash object.
46
+ # - :protocol_implementation - defaults to Dalli::Protocol::Binary which uses the binary protocol. Allows you to
47
+ # pass an alternative implementation using another protocol.
34
48
  #
35
49
  def initialize(servers = nil, options = {})
36
- validate_servers_arg(servers)
37
- @servers = normalize_servers(servers || ENV["MEMCACHE_SERVERS"] || "127.0.0.1:11211")
50
+ @servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
38
51
  @options = normalize_options(options)
52
+ @key_manager = ::Dalli::KeyManager.new(options)
39
53
  @ring = nil
40
54
  end
41
55
 
@@ -49,9 +63,11 @@ module Dalli
49
63
  # pipelined as Dalli will use 'quiet' operations where possible.
50
64
  # Currently supports the set, add, replace and delete operations.
51
65
  def multi
52
- old, Thread.current[:dalli_multi] = Thread.current[:dalli_multi], true
66
+ old = Thread.current[:dalli_multi]
67
+ Thread.current[:dalli_multi] = true
53
68
  yield
54
69
  ensure
70
+ @ring&.flush_multi_responses
55
71
  Thread.current[:dalli_multi] = old
56
72
  end
57
73
 
@@ -71,6 +87,7 @@ module Dalli
71
87
  keys.compact!
72
88
 
73
89
  return {} if keys.empty?
90
+
74
91
  if block_given?
75
92
  get_multi_yielder(keys) { |k, data| yield k, data.first }
76
93
  else
@@ -80,7 +97,7 @@ module Dalli
80
97
  end
81
98
  end
82
99
 
83
- CACHE_NILS = {cache_nils: true}.freeze
100
+ CACHE_NILS = { cache_nils: true }.freeze
84
101
 
85
102
  # Fetch the value associated with the key.
86
103
  # If a value is found, then it is returned.
@@ -90,19 +107,24 @@ module Dalli
90
107
  # If a value is not found (or if the found value is nil and :cache_nils is false)
91
108
  # and a block is given, the block will be invoked and its return value
92
109
  # written to the cache and returned.
93
- def fetch(key, ttl = nil, options = nil)
94
- options = options.nil? ? CACHE_NILS : options.merge(CACHE_NILS) if @options[:cache_nils]
95
- val = get(key, options)
96
- not_found = @options[:cache_nils] ?
97
- val == Dalli::Protocol::NOT_FOUND :
98
- val.nil?
99
- if not_found && block_given?
110
+ def fetch(key, ttl = nil, req_options = nil)
111
+ req_options = req_options.nil? ? CACHE_NILS : req_options.merge(CACHE_NILS) if cache_nils
112
+ val = get(key, req_options)
113
+ if not_found?(val) && block_given?
100
114
  val = yield
101
- add(key, val, ttl_or_default(ttl), options)
115
+ add(key, val, ttl_or_default(ttl), req_options)
102
116
  end
103
117
  val
104
118
  end
105
119
 
120
+ def not_found?(val)
121
+ cache_nils ? val == ::Dalli::NOT_FOUND : val.nil?
122
+ end
123
+
124
+ def cache_nils
125
+ @options[:cache_nils]
126
+ end
127
+
106
128
  ##
107
129
  # compare and swap values using optimistic locking.
108
130
  # Fetch the existing value for key.
@@ -170,7 +192,7 @@ module Dalli
170
192
  ring.servers.map { |s| s.request(:flush, time += delay) }
171
193
  end
172
194
 
173
- alias_method :flush_all, :flush
195
+ alias flush_all flush
174
196
 
175
197
  ##
176
198
  # Incr adds the given amount to the counter on the memcached server.
@@ -184,7 +206,8 @@ module Dalli
184
206
  # exist. To increase an existing counter and update its TTL, use
185
207
  # #cas.
186
208
  def incr(key, amt = 1, ttl = nil, default = nil)
187
- raise ArgumentError, "Positive values only: #{amt}" if amt < 0
209
+ raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
210
+
188
211
  perform(:incr, key, amt.to_i, ttl_or_default(ttl), default)
189
212
  end
190
213
 
@@ -203,7 +226,8 @@ module Dalli
203
226
  # exist. To decrease an existing counter and update its TTL, use
204
227
  # #cas.
205
228
  def decr(key, amt = 1, ttl = nil, default = nil)
206
- raise ArgumentError, "Positive values only: #{amt}" if amt < 0
229
+ raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
230
+
207
231
  perform(:decr, key, amt.to_i, ttl_or_default(ttl), default)
208
232
  end
209
233
 
@@ -248,7 +272,7 @@ module Dalli
248
272
  ##
249
273
  ## Make sure memcache servers are alive, or raise an Dalli::RingError
250
274
  def alive!
251
- ring.server_for_key("")
275
+ ring.server_for_key('')
252
276
  end
253
277
 
254
278
  ##
@@ -266,7 +290,7 @@ module Dalli
266
290
  # value and CAS will be passed to the block.
267
291
  def get_cas(key)
268
292
  (value, cas) = perform(:cas, key)
269
- value = !value || value == "Not found" ? nil : value
293
+ value = nil if !value || value == 'Not found'
270
294
  if block_given?
271
295
  yield value, cas
272
296
  else
@@ -317,12 +341,12 @@ module Dalli
317
341
  # Close our connection to each server.
318
342
  # If you perform another operation after this, the connections will be re-established.
319
343
  def close
320
- if @ring
321
- @ring.servers.each { |s| s.close }
322
- @ring = nil
323
- end
344
+ return unless @ring
345
+
346
+ @ring.servers.each(&:close)
347
+ @ring = nil
324
348
  end
325
- alias_method :reset, :close
349
+ alias reset close
326
350
 
327
351
  # Stub method so a bare Dalli client can pretend to be a connection pool.
328
352
  def with
@@ -333,8 +357,9 @@ module Dalli
333
357
 
334
358
  def cas_core(key, always_set, ttl = nil, options = nil)
335
359
  (value, cas) = perform(:cas, key)
336
- value = !value || value == "Not found" ? nil : value
360
+ value = nil if !value || value == 'Not found'
337
361
  return if value.nil? && !always_set
362
+
338
363
  newvalue = yield(value)
339
364
  perform(:set, key, newvalue, ttl_or_default(ttl), cas, options)
340
365
  end
@@ -345,26 +370,90 @@ module Dalli
345
370
  raise ArgumentError, "Cannot convert ttl (#{ttl}) to an integer"
346
371
  end
347
372
 
348
- def groups_for_keys(*keys)
349
- keys.flatten!
350
- keys.map! { |a| validate_key(a.to_s) }
373
+ def ring
374
+ # TODO: This server initialization should probably be pushed down
375
+ # to the Ring
376
+ @ring ||= Dalli::Ring.new(
377
+ @servers.map do |s|
378
+ protocol_implementation.new(s, @options)
379
+ end, @options
380
+ )
381
+ end
351
382
 
352
- keys.group_by { |key|
353
- begin
354
- ring.server_for_key(key)
355
- rescue Dalli::RingError
356
- Dalli.logger.debug { "unable to get key #{key}" }
357
- nil
383
+ def protocol_implementation
384
+ @protocol_implementation ||= @options.fetch(:protocol_implementation, Dalli::Protocol::Binary)
385
+ end
386
+
387
+ # Chokepoint method for instrumentation
388
+ def perform(*all_args)
389
+ return yield if block_given?
390
+
391
+ op, key, *args = all_args
392
+
393
+ key = key.to_s
394
+ key = @key_manager.validate_key(key)
395
+
396
+ server = ring.server_for_key(key)
397
+ server.request(op, key, *args)
398
+ rescue NetworkError => e
399
+ Dalli.logger.debug { e.inspect }
400
+ Dalli.logger.debug { 'retrying request with new server' }
401
+ retry
402
+ end
403
+
404
+ def normalize_options(opts)
405
+ begin
406
+ opts[:expires_in] = opts[:expires_in].to_i if opts[:expires_in]
407
+ rescue NoMethodError
408
+ raise ArgumentError, "cannot convert :expires_in => #{opts[:expires_in].inspect} to an integer"
409
+ end
410
+ opts
411
+ end
412
+
413
+ # TODO: Look at extracting below into separate MultiYielder class
414
+
415
+ ##
416
+ # Yields, one at a time, keys and their values+attributes.
417
+ #
418
+ def get_multi_yielder(keys, &block)
419
+ return {} if keys.empty?
420
+
421
+ ring.lock do
422
+ groups = groups_for_keys(keys)
423
+ if (unfound_keys = groups.delete(nil))
424
+ Dalli.logger.debug do
425
+ "unable to get keys for #{unfound_keys.length} keys "\
426
+ 'because no matching server was found'
427
+ end
428
+ end
429
+ make_multi_get_requests(groups)
430
+
431
+ servers = groups.keys
432
+ return if servers.empty?
433
+
434
+ # TODO: How does this exit on a NetworkError
435
+ servers = perform_multi_response_start(servers)
436
+
437
+ timeout = servers.first.options[:socket_timeout]
438
+ start_time = Time.now
439
+ loop do
440
+ # remove any dead servers
441
+ # TODO: Is this well behaved in a multi-threaded environment?
442
+ # Accessing the server socket like this seems problematic
443
+ servers.delete_if { |s| s.sock.nil? }
444
+ break if servers.empty?
445
+
446
+ servers = multi_yielder_loop(servers, start_time, timeout, &block)
358
447
  end
359
- }
448
+ end
449
+ rescue NetworkError => e
450
+ Dalli.logger.debug { e.inspect }
451
+ Dalli.logger.debug { 'retrying multi yielder because of timeout' }
452
+ retry
360
453
  end
361
454
 
362
455
  def make_multi_get_requests(groups)
363
456
  groups.each do |server, keys_for_server|
364
- # TODO: do this with the perform chokepoint?
365
- # But given the fact that fetching the response doesn't take place
366
- # in that slot it's misleading anyway. Need to move all of this method
367
- # into perform to be meaningful
368
457
  server.request(:send_multiget, keys_for_server)
369
458
  rescue DalliError, NetworkError => e
370
459
  Dalli.logger.debug { e.inspect }
@@ -372,175 +461,86 @@ module Dalli
372
461
  end
373
462
  end
374
463
 
464
+ # raises Dalli::NetworkError
375
465
  def perform_multi_response_start(servers)
466
+ deleted = []
467
+
376
468
  servers.each do |server|
377
469
  next unless server.alive?
470
+
378
471
  begin
379
472
  server.multi_response_start
380
- rescue DalliError, NetworkError => e
473
+ rescue Dalli::NetworkError
474
+ abort_multi_response(servers)
475
+ raise
476
+ rescue Dalli::DalliError => e
381
477
  Dalli.logger.debug { e.inspect }
382
- Dalli.logger.debug { "results from this server will be missing" }
383
- servers.delete(server)
478
+ Dalli.logger.debug { 'results from this server will be missing' }
479
+ deleted.append(server)
384
480
  end
385
481
  end
386
- servers
387
- end
388
482
 
389
- ##
390
- # Ensures that the servers arg is either an array or a string.
391
- def validate_servers_arg(servers)
392
- return if servers.nil?
393
- return if servers.is_a?(Array)
394
- return if servers.is_a?(String)
483
+ servers.delete_if { |server| deleted.include?(server) }
484
+ end
395
485
 
396
- raise ArgumentError, "An explicit servers argument must be a comma separated string or an array containing strings."
486
+ # Swallows Dalli::NetworkError
487
+ def abort_multi_response(servers)
488
+ servers.each(&:multi_response_abort)
397
489
  end
398
490
 
399
- ##
400
- # Normalizes the argument into an array of servers.
401
- # If the argument is a string, or an array containing strings, it's expected that the URIs are comma separated e.g.
402
- # "memcache1.example.com:11211,memcache2.example.com:11211,memcache3.example.com:11211"
403
- def normalize_servers(servers)
404
- Array(servers).flat_map do |server|
405
- if server.is_a? String
406
- server.split(",")
407
- else
408
- server
409
- end
491
+ def multi_yielder_loop(servers, start_time, timeout, &block)
492
+ time_left = remaining_time(start_time, timeout)
493
+ readable_servers = servers_with_data(servers, time_left)
494
+ if readable_servers.empty?
495
+ abort_multi_connections_w_timeout(servers)
496
+ return readable_servers
410
497
  end
411
- end
412
498
 
413
- def ring
414
- @ring ||= Dalli::Ring.new(
415
- @servers.map { |s|
416
- server_options = {}
417
- if s.start_with?("memcached://")
418
- uri = URI.parse(s)
419
- server_options[:username] = uri.user
420
- server_options[:password] = uri.password
421
- s = "#{uri.host}:#{uri.port}"
422
- end
423
- @options.fetch(:protocol_implementation, Dalli::Protocol::Binary).new(s, @options.merge(server_options))
424
- }, @options
425
- )
499
+ readable_servers.each do |server|
500
+ servers.delete(server) if respond_to_readable_server(server, &block)
501
+ end
502
+ servers
503
+ rescue NetworkError
504
+ abort_multi_response(servers)
505
+ raise
426
506
  end
427
507
 
428
- # Chokepoint method for instrumentation
429
- def perform(*all_args)
430
- return yield if block_given?
431
- op, key, *args = all_args
508
+ def remaining_time(start, timeout)
509
+ elapsed = Time.now - start
510
+ return 0 if elapsed > timeout
432
511
 
433
- key = key.to_s
434
- key = validate_key(key)
435
- begin
436
- server = ring.server_for_key(key)
437
- server.request(op, key, *args)
438
- rescue NetworkError => e
439
- Dalli.logger.debug { e.inspect }
440
- Dalli.logger.debug { "retrying request with new server" }
441
- retry
442
- end
512
+ timeout - elapsed
443
513
  end
444
514
 
445
- def validate_key(key)
446
- raise ArgumentError, "key cannot be blank" if !key || key.length == 0
447
- key = key_with_namespace(key)
448
- if key.length > 250
449
- digest_class = @options[:digest_class] || ::Digest::MD5
450
- max_length_before_namespace = 212 - (namespace || "").size
451
- key = "#{key[0, max_length_before_namespace]}:md5:#{digest_class.hexdigest(key)}"
515
+ # Swallows Dalli::NetworkError
516
+ def abort_multi_connections_w_timeout(servers)
517
+ abort_multi_response(servers)
518
+ servers.each do |server|
519
+ Dalli.logger.debug { "memcached at #{server.name} did not response within timeout" }
452
520
  end
453
- key
454
- end
455
521
 
456
- def key_with_namespace(key)
457
- (ns = namespace) ? "#{ns}:#{key}" : key
522
+ true # Required to simplify caller
458
523
  end
459
524
 
460
- def key_without_namespace(key)
461
- (ns = namespace) ? key.sub(%r{\A#{Regexp.escape ns}:}, "") : key
462
- end
525
+ def respond_to_readable_server(server)
526
+ server.multi_response_nonblock.each_pair do |key, value_list|
527
+ yield @key_manager.key_without_namespace(key), value_list
528
+ end
463
529
 
464
- def namespace
465
- return nil unless @options[:namespace]
466
- @options[:namespace].is_a?(Proc) ? @options[:namespace].call.to_s : @options[:namespace].to_s
530
+ server.multi_response_completed?
467
531
  end
468
532
 
469
- def normalize_options(opts)
470
- if opts[:compression]
471
- Dalli.logger.warn "DEPRECATED: Dalli's :compression option is now just :compress => true. Please update your configuration."
472
- opts[:compress] = opts.delete(:compression)
473
- end
474
- begin
475
- opts[:expires_in] = opts[:expires_in].to_i if opts[:expires_in]
476
- rescue NoMethodError
477
- raise ArgumentError, "cannot convert :expires_in => #{opts[:expires_in].inspect} to an integer"
478
- end
479
- if opts[:digest_class] && !opts[:digest_class].respond_to?(:hexdigest)
480
- raise ArgumentError, "The digest_class object must respond to the hexdigest method"
481
- end
482
- opts
483
- end
533
+ def servers_with_data(servers, timeout)
534
+ readable, = IO.select(servers.map(&:sock), nil, nil, timeout)
535
+ return [] if readable.nil?
484
536
 
485
- ##
486
- # Yields, one at a time, keys and their values+attributes.
487
- def get_multi_yielder(keys)
488
- perform do
489
- return {} if keys.empty?
490
- ring.lock do
491
- groups = groups_for_keys(keys)
492
- if (unfound_keys = groups.delete(nil))
493
- Dalli.logger.debug { "unable to get keys for #{unfound_keys.length} keys because no matching server was found" }
494
- end
495
- make_multi_get_requests(groups)
496
-
497
- servers = groups.keys
498
- return if servers.empty?
499
- servers = perform_multi_response_start(servers)
500
-
501
- start = Time.now
502
- loop do
503
- # remove any dead servers
504
- servers.delete_if { |s| s.sock.nil? }
505
- break if servers.empty?
506
-
507
- # calculate remaining timeout
508
- elapsed = Time.now - start
509
- timeout = servers.first.options[:socket_timeout]
510
- time_left = elapsed > timeout ? 0 : timeout - elapsed
511
-
512
- sockets = servers.map(&:sock)
513
- readable, _ = IO.select(sockets, nil, nil, time_left)
514
-
515
- if readable.nil?
516
- # no response within timeout; abort pending connections
517
- servers.each do |server|
518
- Dalli.logger.debug { "memcached at #{server.name} did not response within timeout" }
519
- server.multi_response_abort
520
- end
521
- break
522
-
523
- else
524
- readable.each do |sock|
525
- server = sock.server
526
-
527
- begin
528
- server.multi_response_nonblock.each_pair do |key, value_list|
529
- yield key_without_namespace(key), value_list
530
- end
531
-
532
- if server.multi_response_completed?
533
- servers.delete(server)
534
- end
535
- rescue NetworkError
536
- servers.delete(server)
537
- end
538
- end
539
- end
540
- end
541
- end
542
- end
537
+ readable.map(&:server)
543
538
  end
544
539
 
540
+ def groups_for_keys(*keys)
541
+ keys.flatten!
542
+ keys.map! { |a| @key_manager.validate_key(a.to_s) }
543
+ ring.keys_grouped_by_server(keys)
544
+ end
545
545
  end
546
546
  end
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "zlib"
4
- require "stringio"
3
+ require 'zlib'
4
+ require 'stringio'
5
5
 
6
6
  module Dalli
7
+ ##
8
+ # Default compressor used by Dalli, that uses
9
+ # Zlib DEFLATE to compress data.
10
+ ##
7
11
  class Compressor
8
12
  def self.compress(data)
9
13
  Zlib::Deflate.deflate(data)
@@ -14,9 +18,14 @@ module Dalli
14
18
  end
15
19
  end
16
20
 
21
+ ##
22
+ # Alternate compressor for Dalli, that uses
23
+ # Gzip. Gzip adds a checksum to each compressed
24
+ # entry.
25
+ ##
17
26
  class GzipCompressor
18
27
  def self.compress(data)
19
- io = StringIO.new(+"", "w")
28
+ io = StringIO.new(+'', 'w')
20
29
  gz = Zlib::GzipWriter.new(io)
21
30
  gz.write(data)
22
31
  gz.close
@@ -24,7 +33,7 @@ module Dalli
24
33
  end
25
34
 
26
35
  def self.decompress(data)
27
- io = StringIO.new(data, "rb")
36
+ io = StringIO.new(data, 'rb')
28
37
  Zlib::GzipReader.new(io).read
29
38
  end
30
39
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/md5'
4
+
5
+ module Dalli
6
+ ##
7
+ # This class manages and validates keys sent to Memcached, ensuring
8
+ # that they meet Memcached key length requirements, and supporting
9
+ # the implementation of optional namespaces on a per-Dalli client
10
+ # basis.
11
+ ##
12
+ class KeyManager
13
+ MAX_KEY_LENGTH = 250
14
+
15
+ NAMESPACE_SEPARATOR = ':'
16
+
17
+ # This is a hard coded md5 for historical reasons
18
+ TRUNCATED_KEY_SEPARATOR = ':md5:'
19
+
20
+ # This is 249 for historical reasons
21
+ TRUNCATED_KEY_TARGET_SIZE = 249
22
+
23
+ DEFAULTS = {
24
+ digest_class: ::Digest::MD5
25
+ }.freeze
26
+
27
+ OPTIONS = %i[digest_class namespace].freeze
28
+
29
+ attr_reader :namespace
30
+
31
+ def initialize(client_options)
32
+ @key_options =
33
+ DEFAULTS.merge(client_options.select { |k, _| OPTIONS.include?(k) })
34
+ validate_digest_class_option(@key_options)
35
+
36
+ @namespace = namespace_from_options
37
+ end
38
+
39
+ ##
40
+ # Validates the key, and transforms as needed.
41
+ #
42
+ # If the key is nil or empty, raises ArgumentError. Whitespace
43
+ # characters are allowed for historical reasons, but likely shouldn't
44
+ # be used.
45
+ # If the key (with namespace) is shorter than the memcached maximum
46
+ # allowed key length, just returns the argument key
47
+ # Otherwise computes a "truncated" key that uses a truncated prefix
48
+ # combined with a 32-byte hex digest of the whole key.
49
+ ##
50
+ def validate_key(key)
51
+ raise ArgumentError, 'key cannot be blank' unless key&.length&.positive?
52
+
53
+ key = key_with_namespace(key)
54
+ key.length > MAX_KEY_LENGTH ? truncated_key(key) : key
55
+ end
56
+
57
+ ##
58
+ # Returns the key with the namespace prefixed, if a namespace is
59
+ # defined. Otherwise just returns the key
60
+ ##
61
+ def key_with_namespace(key)
62
+ return key if namespace.nil?
63
+
64
+ "#{namespace}#{NAMESPACE_SEPARATOR}#{key}"
65
+ end
66
+
67
+ def key_without_namespace(key)
68
+ return key if namespace.nil?
69
+
70
+ key.sub(namespace_regexp, '')
71
+ end
72
+
73
+ def digest_class
74
+ @digest_class ||= @key_options[:digest_class]
75
+ end
76
+
77
+ def namespace_regexp
78
+ @namespace_regexp ||= /\A#{Regexp.escape(namespace)}:/.freeze unless namespace.nil?
79
+ end
80
+
81
+ def validate_digest_class_option(opts)
82
+ return if opts[:digest_class].respond_to?(:hexdigest)
83
+
84
+ raise ArgumentError, 'The digest_class object must respond to the hexdigest method'
85
+ end
86
+
87
+ def namespace_from_options
88
+ raw_namespace = @key_options[:namespace]
89
+ return nil unless raw_namespace
90
+ return raw_namespace.call.to_s if raw_namespace.is_a?(Proc)
91
+
92
+ raw_namespace.to_s
93
+ end
94
+
95
+ ##
96
+ # Produces a truncated key, if the raw key is longer than the maximum allowed
97
+ # length. The truncated key is produced by generating a hex digest
98
+ # of the key, and appending that to a truncated section of the key.
99
+ ##
100
+ def truncated_key(key)
101
+ digest = digest_class.hexdigest(key)
102
+ "#{key[0, prefix_length(digest)]}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
103
+ end
104
+
105
+ def prefix_length(digest)
106
+ return TRUNCATED_KEY_TARGET_SIZE - (TRUNCATED_KEY_SEPARATOR.length + digest.length) if namespace.nil?
107
+
108
+ # For historical reasons, truncated keys with namespaces had a length of 250 rather
109
+ # than 249
110
+ TRUNCATED_KEY_TARGET_SIZE + 1 - (TRUNCATED_KEY_SEPARATOR.length + digest.length)
111
+ end
112
+ end
113
+ end