dalli 4.0.0 → 4.1.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: 86e39b2869b7c916472e88e6cbbfb6aa1f6533aa35c9e1f1c704cffcbc680f8a
4
- data.tar.gz: bf2e56610c6bae187d561ccf09105e6f5b4a29eed1308d089745ef22cf5b673b
3
+ metadata.gz: e342d39d58d552607783486a9c9f0ffa23d9c479079c62cb760ec84a97d064da
4
+ data.tar.gz: 1ae923ede204d0a3e82426404cfb4882f4414a12cb351cf3d04b9ef8653051ce
5
5
  SHA512:
6
- metadata.gz: e2faab814abb4b25a53d87048f7e4e81e49c609bd64725cb18607670110da3d5b8038544598c44575831b9038500f9d8220a56ec28c16b9517a21d442212d12b
7
- data.tar.gz: 3e5a7c326c908d72d2d8ce2211b27f67acebbbf97c279c5faf3ba2914ec7409df7f259a9082d82903cc9ae6b6716a137994aba849321546b8d3d3a35f8f7f5af
6
+ metadata.gz: aeb1bec84092f4e07db884b415d6b25375dc995fff04cc58a1764d9ad0937986ba7ff5db5a665a831bd8ab488b3fa4868fce79834e93a26848bc37ac1c9f9855
7
+ data.tar.gz: 8c5a4e21f4b0a86279bf28edc729c0a1981964438342117f3bc180d15d5cdfc93e395913267db7af66b3907416051be7629e687708c163f4d4015d4e2a768508
data/CHANGELOG.md CHANGED
@@ -1,6 +1,37 @@
1
1
  Dalli Changelog
2
2
  =====================
3
3
 
4
+ 4.1.0
5
+ ==========
6
+
7
+ New Features:
8
+
9
+ - Add `set_multi` for efficient bulk set operations using pipelined requests
10
+ - Add `delete_multi` for efficient bulk delete operations using pipelined requests
11
+ - Add `fetch_with_lock` for thundering herd protection using meta protocol's vivify/recache flags (requires memcached 1.6+)
12
+ - Add thundering herd protection support to meta protocol (requires memcached 1.6+):
13
+ - `N` (vivify) flag for creating stubs on cache miss
14
+ - `R` (recache) flag for winning recache race when TTL is below threshold
15
+ - Response flags `W` (won recache), `X` (stale), `Z` (lost race)
16
+ - `delete_stale` method for marking items as stale instead of deleting
17
+ - Add `get_with_metadata` for advanced cache operations with metadata retrieval (requires memcached 1.6+):
18
+ - Returns hash with `:value`, `:cas`, `:won_recache`, `:stale`, `:lost_recache`
19
+ - Optional `:return_hit_status` returns `:hit_before` (true/false for previous access)
20
+ - Optional `:return_last_access` returns `:last_access` (seconds since last access)
21
+ - Optional `:skip_lru_bump` prevents LRU update on access
22
+ - Optional `:vivify_ttl` and `:recache_ttl` for thundering herd protection
23
+
24
+ Deprecations:
25
+
26
+ - Binary protocol is deprecated and will be removed in Dalli 5.0. Use `protocol: :meta` instead (requires memcached 1.6+)
27
+ - SASL authentication is deprecated and will be removed in Dalli 5.0. Consider using network-level security or memcached's TLS support
28
+
29
+ 4.0.1
30
+ ==========
31
+
32
+ - Add `:raw` client option to skip serialization entirely, returning raw byte strings
33
+ - Handle `OpenSSL::SSL::SSLError` in connection manager
34
+
4
35
  4.0.0
5
36
  ==========
6
37
 
data/Gemfile CHANGED
@@ -5,11 +5,18 @@ source 'https://rubygems.org'
5
5
  gemspec
6
6
 
7
7
  group :development, :test do
8
+ gem 'benchmark'
8
9
  gem 'cgi'
9
10
  gem 'connection_pool'
10
11
  gem 'debug' unless RUBY_PLATFORM == 'java'
11
- gem 'minitest', '~> 5'
12
- gem 'rack', '~> 2.0', '>= 2.2.0'
12
+ if RUBY_VERSION >= '3.2'
13
+ gem 'minitest', '~> 6'
14
+ gem 'minitest-mock'
15
+ else
16
+ gem 'minitest', '~> 5'
17
+ end
18
+ gem 'rack', '~> 3'
19
+ gem 'rack-session'
13
20
  gem 'rake', '~> 13.0'
14
21
  gem 'rubocop'
15
22
  gem 'rubocop-minitest'
data/README.md CHANGED
@@ -14,6 +14,32 @@ Dalli supports:
14
14
 
15
15
  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
16
 
17
+ ## Requirements
18
+
19
+ * Ruby 3.1 or later
20
+ * memcached 1.4 or later (1.6+ recommended for meta protocol support)
21
+
22
+ ## Protocol Options
23
+
24
+ Dalli supports two protocols for communicating with memcached:
25
+
26
+ * `:binary` (default) - Works with all memcached versions, supports SASL authentication
27
+ * `:meta` - Requires memcached 1.6+, better performance for some operations, no authentication support
28
+
29
+ ```ruby
30
+ Dalli::Client.new('localhost:11211', protocol: :meta)
31
+ ```
32
+
33
+ ## Security Note
34
+
35
+ By default, Dalli uses Ruby's Marshal for serialization. Deserializing untrusted data with Marshal can lead to remote code execution. If you cache user-controlled data, consider using a safer serializer:
36
+
37
+ ```ruby
38
+ Dalli::Client.new('localhost:11211', serializer: JSON)
39
+ ```
40
+
41
+ See the [4.0-Upgrade.md](4.0-Upgrade.md) guide for more information.
42
+
17
43
  ![Persistence of Memory](https://upload.wikimedia.org/wikipedia/en/d/dd/The_Persistence_of_Memory.jpg)
18
44
 
19
45
 
data/lib/dalli/client.rb CHANGED
@@ -41,6 +41,11 @@ module Dalli
41
41
  # - :compressor - defaults to Dalli::Compressor, a Zlib-based implementation
42
42
  # - :cache_nils - defaults to false, if true Dalli will not treat cached nil values as 'not found' for
43
43
  # #fetch operations.
44
+ # - :raw - If set, disables serialization and compression entirely at the client level.
45
+ # Only String values are supported. This is useful when the caller handles its own
46
+ # serialization (e.g., Rails' ActiveSupport::Cache). Note: this is different from
47
+ # the per-request :raw option which converts values to strings but still uses the
48
+ # serialization pipeline.
44
49
  # - :digest_class - defaults to Digest::MD5, allows you to pass in an object that responds to the hexdigest method,
45
50
  # useful for injecting a FIPS compliant hash object.
46
51
  # - :protocol - one of either :binary or :meta, defaulting to :binary. This sets the protocol that Dalli uses
@@ -51,6 +56,7 @@ module Dalli
51
56
  @options = normalize_options(options)
52
57
  @key_manager = ::Dalli::KeyManager.new(@options)
53
58
  @ring = nil
59
+ emit_deprecation_warnings
54
60
  end
55
61
 
56
62
  #
@@ -91,6 +97,51 @@ module Dalli
91
97
  yield value, cas
92
98
  end
93
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
+ server = ring.server_for_key(key)
138
+ server.request(:meta_get, key, options)
139
+ rescue NetworkError => e
140
+ Dalli.logger.debug { e.inspect }
141
+ Dalli.logger.debug { 'retrying get_with_metadata with new server' }
142
+ retry
143
+ end
144
+
94
145
  ##
95
146
  # Fetch multiple keys efficiently.
96
147
  # If a block is given, yields key/value pairs one at a time.
@@ -144,6 +195,69 @@ module Dalli
144
195
  new_val
145
196
  end
146
197
 
198
+ ##
199
+ # Fetch the value with thundering herd protection using the meta protocol's
200
+ # N (vivify) and R (recache) flags.
201
+ #
202
+ # This method prevents multiple clients from simultaneously regenerating the same
203
+ # cache entry (the "thundering herd" problem). Only one client wins the right to
204
+ # regenerate; other clients receive the stale value (if available) or wait.
205
+ #
206
+ # IMPORTANT: This method requires memcached 1.6+ and the meta protocol (protocol: :meta).
207
+ # It will raise an error if used with the binary protocol.
208
+ #
209
+ # @param key [String] the cache key
210
+ # @param ttl [Integer] time-to-live for the cached value in seconds
211
+ # @param lock_ttl [Integer] how long the lock/stub lives (default: 30 seconds)
212
+ # This is the maximum time other clients will return stale data while
213
+ # waiting for regeneration. Should be longer than your expected regeneration time.
214
+ # @param recache_threshold [Integer, nil] if set, win the recache race when the
215
+ # item's remaining TTL is below this threshold. Useful for proactive recaching.
216
+ # @param req_options [Hash] options passed to set operations (e.g., raw: true)
217
+ #
218
+ # @yield Block to regenerate the value (only called if this client won the race)
219
+ # @return [Object] the cached value (may be stale if another client is regenerating)
220
+ #
221
+ # @example Basic usage
222
+ # client.fetch_with_lock('expensive_key', ttl: 300, lock_ttl: 30) do
223
+ # expensive_database_query
224
+ # end
225
+ #
226
+ # @example With proactive recaching (recache before expiry)
227
+ # client.fetch_with_lock('key', ttl: 300, lock_ttl: 30, recache_threshold: 60) do
228
+ # expensive_operation
229
+ # end
230
+ #
231
+ def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil)
232
+ raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
233
+
234
+ raise_unless_meta_protocol!
235
+
236
+ key = key.to_s
237
+ key = @key_manager.validate_key(key)
238
+
239
+ server = ring.server_for_key(key)
240
+ result = server.request(:meta_get, key, {
241
+ vivify_ttl: lock_ttl,
242
+ recache_ttl: recache_threshold
243
+ })
244
+
245
+ if result[:won_recache]
246
+ # This client won the race - regenerate the value
247
+ new_val = yield
248
+ set(key, new_val, ttl_or_default(ttl), req_options)
249
+ new_val
250
+ else
251
+ # Another client is regenerating, or value exists and isn't stale
252
+ # Return the existing value
253
+ result[:value]
254
+ end
255
+ rescue NetworkError => e
256
+ Dalli.logger.debug { e.inspect }
257
+ Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
258
+ retry
259
+ end
260
+
147
261
  ##
148
262
  # compare and swap values using optimistic locking.
149
263
  # Fetch the existing value for key.
@@ -201,6 +315,24 @@ module Dalli
201
315
  set_cas(key, value, 0, ttl, req_options)
202
316
  end
203
317
 
318
+ ##
319
+ # Set multiple keys and values efficiently using pipelining.
320
+ # This method is more efficient than calling set() in a loop because
321
+ # it batches requests by server and uses quiet mode.
322
+ #
323
+ # @param hash [Hash] key-value pairs to set
324
+ # @param ttl [Integer] time-to-live in seconds (optional, uses default if not provided)
325
+ # @param req_options [Hash] options passed to each set operation
326
+ # @return [void]
327
+ #
328
+ # Example:
329
+ # client.set_multi({ 'key1' => 'value1', 'key2' => 'value2' }, 300)
330
+ def set_multi(hash, ttl = nil, req_options = nil)
331
+ return if hash.empty?
332
+
333
+ pipelined_setter.process(hash, ttl_or_default(ttl), req_options)
334
+ end
335
+
204
336
  ##
205
337
  # Set the key-value pair, verifying existing CAS.
206
338
  # Returns the resulting CAS value if succeeded, and falsy otherwise.
@@ -240,6 +372,22 @@ module Dalli
240
372
  delete_cas(key, 0)
241
373
  end
242
374
 
375
+ ##
376
+ # Delete multiple keys efficiently using pipelining.
377
+ # This method is more efficient than calling delete() in a loop because
378
+ # it batches requests by server and uses quiet mode.
379
+ #
380
+ # @param keys [Array<String>] keys to delete
381
+ # @return [void]
382
+ #
383
+ # Example:
384
+ # client.delete_multi(['key1', 'key2', 'key3'])
385
+ def delete_multi(keys)
386
+ return if keys.empty?
387
+
388
+ pipelined_deleter.process(keys)
389
+ end
390
+
243
391
  ##
244
392
  # Append value to the value already stored on the server for 'key'.
245
393
  # Appending only works for values stored with :raw => true.
@@ -440,5 +588,23 @@ module Dalli
440
588
  def pipelined_getter
441
589
  PipelinedGetter.new(ring, @key_manager)
442
590
  end
591
+
592
+ def pipelined_setter
593
+ PipelinedSetter.new(ring, @key_manager)
594
+ end
595
+
596
+ def pipelined_deleter
597
+ PipelinedDeleter.new(ring, @key_manager)
598
+ end
599
+
600
+ def raise_unless_meta_protocol!
601
+ return if protocol_implementation == Dalli::Protocol::Meta
602
+
603
+ raise Dalli::DalliError,
604
+ 'This operation requires the meta protocol (memcached 1.6+). ' \
605
+ 'Use protocol: :meta when creating the client.'
606
+ end
607
+
608
+ include ProtocolDeprecations
443
609
  end
444
610
  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
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ ##
5
+ # Contains logic for the pipelined set operations implemented by the client.
6
+ # Efficiently writes multiple key-value pairs by grouping requests by server
7
+ # and using quiet mode to minimize round trips.
8
+ ##
9
+ class PipelinedSetter
10
+ def initialize(ring, key_manager)
11
+ @ring = ring
12
+ @key_manager = key_manager
13
+ end
14
+
15
+ ##
16
+ # Writes multiple key-value pairs to memcached.
17
+ # Raises an error if any server is unavailable.
18
+ #
19
+ # @param hash [Hash] key-value pairs to set
20
+ # @param ttl [Integer] time-to-live in seconds
21
+ # @param req_options [Hash] options passed to each set operation
22
+ # @return [void]
23
+ ##
24
+ def process(hash, ttl, req_options)
25
+ return if hash.empty?
26
+
27
+ @ring.lock do
28
+ servers = setup_requests(hash, ttl, req_options)
29
+ finish_requests(servers)
30
+ end
31
+ rescue NetworkError => e
32
+ Dalli.logger.debug { e.inspect }
33
+ Dalli.logger.debug { 'retrying pipelined sets because of network error' }
34
+ retry
35
+ end
36
+
37
+ private
38
+
39
+ def setup_requests(hash, ttl, req_options)
40
+ groups = groups_for_keys(hash.keys)
41
+ make_set_requests(groups, hash, ttl, req_options)
42
+ groups.keys
43
+ end
44
+
45
+ ##
46
+ # Loop through the server-grouped sets of keys, writing
47
+ # the corresponding quiet set requests to the appropriate servers
48
+ ##
49
+ def make_set_requests(groups, hash, ttl, req_options)
50
+ groups.each do |server, keys_for_server|
51
+ keys_for_server.each do |key|
52
+ original_key = @key_manager.key_without_namespace(key)
53
+ value = hash[original_key]
54
+ server.request(:pipelined_set, key, value, ttl, req_options)
55
+ rescue DalliError, NetworkError => e
56
+ Dalli.logger.debug { e.inspect }
57
+ Dalli.logger.debug { "unable to set key #{key} for server #{server.name}" }
58
+ end
59
+ end
60
+ end
61
+
62
+ ##
63
+ # Sends noop to each server to flush responses and ensure all writes complete.
64
+ ##
65
+ def finish_requests(servers)
66
+ servers.each do |server|
67
+ server.request(:noop)
68
+ rescue DalliError, NetworkError => e
69
+ Dalli.logger.debug { e.inspect }
70
+ Dalli.logger.debug { "unable to complete pipelined set on server #{server.name}" }
71
+ end
72
+ end
73
+
74
+ def groups_for_keys(keys)
75
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
76
+ groups = @ring.keys_grouped_by_server(validated_keys)
77
+
78
+ if (unfound_keys = groups.delete(nil))
79
+ Dalli.logger.debug do
80
+ "unable to set #{unfound_keys.length} keys because no matching server was found"
81
+ end
82
+ end
83
+
84
+ groups
85
+ end
86
+ end
87
+ end
@@ -23,7 +23,7 @@ module Dalli
23
23
  def initialize(attribs, client_options = {})
24
24
  hostname, port, socket_type, @weight, user_creds = ServerConfigParser.parse(attribs)
25
25
  @options = client_options.merge(user_creds)
26
- @value_marshaller = ValueMarshaller.new(@options)
26
+ @value_marshaller = client_options[:raw] ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
27
27
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
28
28
  end
29
29
 
@@ -106,7 +106,7 @@ module Dalli
106
106
  end
107
107
 
108
108
  values
109
- rescue SystemCallError, *TIMEOUT_ERRORS, EOFError => e
109
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
110
110
  @connection_manager.error_on_request!(e)
111
111
  end
112
112
 
@@ -56,6 +56,12 @@ module Dalli
56
56
  storage_req(opkey, key, value, ttl, cas, options)
57
57
  end
58
58
 
59
+ # Pipelined set - writes a quiet set request without reading response.
60
+ # Used by PipelinedSetter for bulk operations.
61
+ def pipelined_set(key, value, ttl, options)
62
+ storage_req(:setq, key, value, ttl, 0, options)
63
+ end
64
+
59
65
  def add(key, value, ttl, options)
60
66
  opkey = quiet? ? :addq : :add
61
67
  storage_req(opkey, key, value, ttl, 0, options)
@@ -102,6 +108,13 @@ module Dalli
102
108
  response_processor.delete unless quiet?
103
109
  end
104
110
 
111
+ # Pipelined delete - writes a quiet delete request without reading response.
112
+ # Used by PipelinedDeleter for bulk operations.
113
+ def pipelined_delete(key)
114
+ req = RequestFormatter.standard_request(opkey: :deleteq, key: key, cas: 0)
115
+ write(req)
116
+ end
117
+
105
118
  # Arithmetic Commands
106
119
  def decr(key, count, ttl, initial)
107
120
  opkey = quiet? ? :decrq : :decr
@@ -150,19 +150,19 @@ module Dalli
150
150
  data = @sock.gets("\r\n")
151
151
  error_on_request!('EOF in read_line') if data.nil?
152
152
  data
153
- rescue SystemCallError, *TIMEOUT_ERRORS, EOFError => e
153
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
154
154
  error_on_request!(e)
155
155
  end
156
156
 
157
157
  def read(count)
158
158
  @sock.readfull(count)
159
- rescue SystemCallError, *TIMEOUT_ERRORS, EOFError => e
159
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
160
160
  error_on_request!(e)
161
161
  end
162
162
 
163
163
  def write(bytes)
164
164
  @sock.write(bytes)
165
- rescue SystemCallError, *TIMEOUT_ERRORS => e
165
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS => e
166
166
  error_on_request!(e)
167
167
  end
168
168
 
@@ -15,13 +15,40 @@ module Dalli
15
15
  # rubocop:disable Metrics/CyclomaticComplexity
16
16
  # rubocop:disable Metrics/ParameterLists
17
17
  # rubocop:disable Metrics/PerceivedComplexity
18
- def self.meta_get(key:, value: true, return_cas: false, ttl: nil, base64: false, quiet: false)
18
+ #
19
+ # Meta get flags:
20
+ #
21
+ # Thundering herd protection:
22
+ # - vivify_ttl (N flag): On miss, create a stub item and return W flag. The TTL
23
+ # specifies how long the stub lives. Other clients see X (stale) and Z (lost race).
24
+ # - recache_ttl (R flag): If item's remaining TTL is below this threshold, return W
25
+ # flag to indicate this client should recache. Other clients get Z (lost race).
26
+ #
27
+ # Metadata flags:
28
+ # - return_hit_status (h flag): Return whether item has been hit before (0 or 1)
29
+ # - return_last_access (l flag): Return seconds since item was last accessed
30
+ # - skip_lru_bump (u flag): Don't bump item in LRU, don't update hit status or last access
31
+ #
32
+ # Response flags (parsed by response processor):
33
+ # - W: Client won the right to recache this item
34
+ # - X: Item is stale (another client is regenerating)
35
+ # - Z: Client lost the recache race (another client is already regenerating)
36
+ # - h0/h1: Hit status (0 = first access, 1 = previously accessed)
37
+ # - l<N>: Seconds since last access
38
+ def self.meta_get(key:, value: true, return_cas: false, ttl: nil, base64: false, quiet: false,
39
+ vivify_ttl: nil, recache_ttl: nil,
40
+ return_hit_status: false, return_last_access: false, skip_lru_bump: false)
19
41
  cmd = "mg #{key}"
20
42
  cmd << ' v f' if value
21
43
  cmd << ' c' if return_cas
22
44
  cmd << ' b' if base64
23
45
  cmd << " T#{ttl}" if ttl
24
46
  cmd << ' k q s' if quiet # Return the key in the response if quiet
47
+ cmd << " N#{vivify_ttl}" if vivify_ttl # Thundering herd: vivify on miss
48
+ cmd << " R#{recache_ttl}" if recache_ttl # Thundering herd: win recache if TTL below threshold
49
+ cmd << ' h' if return_hit_status # Return hit status (0 or 1)
50
+ cmd << ' l' if return_last_access # Return seconds since last access
51
+ cmd << ' u' if skip_lru_bump # Don't bump LRU or update access stats
25
52
  cmd + TERMINATOR
26
53
  end
27
54
 
@@ -37,11 +64,15 @@ module Dalli
37
64
  cmd << TERMINATOR
38
65
  end
39
66
 
40
- def self.meta_delete(key:, cas: nil, ttl: nil, base64: false, quiet: false)
67
+ # Thundering herd protection flag:
68
+ # - stale (I flag): Instead of deleting the item, mark it as stale. Other clients
69
+ # using N/R flags will see the X flag and know the item is being regenerated.
70
+ def self.meta_delete(key:, cas: nil, ttl: nil, base64: false, quiet: false, stale: false)
41
71
  cmd = "md #{key}"
42
72
  cmd << ' b' if base64
43
73
  cmd << cas_string(cas)
44
74
  cmd << " T#{ttl}" if ttl
75
+ cmd << ' I' if stale # Mark stale instead of deleting
45
76
  cmd << ' q' if quiet
46
77
  cmd + TERMINATOR
47
78
  end
@@ -51,6 +51,41 @@ module Dalli
51
51
  tokens.first == EN ? nil : true
52
52
  end
53
53
 
54
+ # Returns a hash with all requested metadata:
55
+ # - :value - the cached value (or nil if miss)
56
+ # - :cas - the CAS value (if return_cas was requested)
57
+ # - :won_recache - true if client won the right to recache (W flag)
58
+ # - :stale - true if the item is stale (X flag)
59
+ # - :lost_recache - true if another client is already recaching (Z flag)
60
+ # - :hit_before - true/false if item was previously accessed (h flag, if requested)
61
+ # - :last_access - seconds since last access (l flag, if requested)
62
+ #
63
+ # Used by meta_get for comprehensive metadata retrieval.
64
+ # Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
65
+ def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false)
66
+ tokens = error_on_unexpected!([VA, EN, HD])
67
+ result = build_metadata_result(tokens)
68
+ result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
69
+ result[:last_access] = last_access_from_tokens(tokens) if return_last_access
70
+ result[:value] = parse_value_from_tokens(tokens, cache_nils)
71
+ result
72
+ end
73
+
74
+ def build_metadata_result(tokens)
75
+ {
76
+ value: nil, cas: cas_from_tokens(tokens),
77
+ won_recache: tokens.include?('W'), stale: tokens.include?('X'),
78
+ lost_recache: tokens.include?('Z')
79
+ }
80
+ end
81
+
82
+ def parse_value_from_tokens(tokens, cache_nils)
83
+ return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
84
+ return unless tokens.first == VA
85
+
86
+ @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
87
+ end
88
+
54
89
  def meta_set_with_cas
55
90
  tokens = error_on_unexpected!([HD, NS, NF, EX])
56
91
  return false unless tokens.first == HD
@@ -190,6 +225,21 @@ module Dalli
190
225
  KeyRegularizer.decode(encoded_key, base64_encoded)
191
226
  end
192
227
 
228
+ # Returns true if item was previously hit, false if first access, nil if not requested
229
+ # The h flag returns h0 (first access) or h1 (previously accessed)
230
+ def hit_status_from_tokens(tokens)
231
+ hit_token = tokens.find { |t| t.start_with?('h') && t.length == 2 }
232
+ return nil unless hit_token
233
+
234
+ hit_token[1] == '1'
235
+ end
236
+
237
+ # Returns seconds since last access, or nil if not requested
238
+ # The l flag returns l<seconds>
239
+ def last_access_from_tokens(tokens)
240
+ value_from_tokens(tokens, 'l')&.to_i
241
+ end
242
+
193
243
  def body_len_from_tokens(tokens)
194
244
  value_from_tokens(tokens, 's')&.to_i
195
245
  end
@@ -60,12 +60,71 @@ module Dalli
60
60
  response_processor.meta_get_with_value_and_cas
61
61
  end
62
62
 
63
+ # Comprehensive meta get with support for all metadata flags.
64
+ # @note Requires memcached 1.6+ (meta protocol feature)
65
+ #
66
+ # This is the full-featured get method that supports:
67
+ # - Thundering herd protection (vivify_ttl, recache_ttl)
68
+ # - Item metadata (hit_status, last_access)
69
+ # - LRU control (skip_lru_bump)
70
+ #
71
+ # @param key [String] the key to retrieve
72
+ # @param options [Hash] options controlling what metadata to return
73
+ # - :vivify_ttl [Integer] creates a stub on miss with this TTL (N flag)
74
+ # - :recache_ttl [Integer] wins recache race if remaining TTL is below this (R flag)
75
+ # - :return_hit_status [Boolean] return whether item was previously accessed (h flag)
76
+ # - :return_last_access [Boolean] return seconds since last access (l flag)
77
+ # - :skip_lru_bump [Boolean] don't bump LRU or update access stats (u flag)
78
+ # - :cache_nils [Boolean] whether to cache nil values
79
+ # @return [Hash] containing:
80
+ # - :value - the cached value (or nil on miss)
81
+ # - :cas - the CAS value
82
+ # - :won_recache - true if client won recache race (W flag)
83
+ # - :stale - true if item is stale (X flag)
84
+ # - :lost_recache - true if another client is recaching (Z flag)
85
+ # - :hit_before - true/false if previously accessed (only if return_hit_status: true)
86
+ # - :last_access - seconds since last access (only if return_last_access: true)
87
+ def meta_get(key, options = {})
88
+ encoded_key, base64 = KeyRegularizer.encode(key)
89
+ req = RequestFormatter.meta_get(
90
+ key: encoded_key, value: true, return_cas: true, base64: base64,
91
+ vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
92
+ return_hit_status: options[:return_hit_status],
93
+ return_last_access: options[:return_last_access], skip_lru_bump: options[:skip_lru_bump]
94
+ )
95
+ write(req)
96
+ response_processor.meta_get_with_metadata(
97
+ cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
98
+ return_last_access: options[:return_last_access]
99
+ )
100
+ end
101
+
102
+ # Delete with stale invalidation instead of actual deletion.
103
+ # Used with thundering herd protection to mark items as stale rather than removing them.
104
+ # @note Requires memcached 1.6+ (meta protocol feature)
105
+ #
106
+ # @param key [String] the key to invalidate
107
+ # @param cas [Integer] optional CAS value for compare-and-swap
108
+ # @return [Boolean] true if successful
109
+ def delete_stale(key, cas = nil)
110
+ encoded_key, base64 = KeyRegularizer.encode(key)
111
+ req = RequestFormatter.meta_delete(key: encoded_key, cas: cas, base64: base64, stale: true)
112
+ write(req)
113
+ response_processor.meta_delete
114
+ end
115
+
63
116
  # Storage Commands
64
117
  def set(key, value, ttl, cas, options)
65
118
  write_storage_req(:set, key, value, ttl, cas, options)
66
119
  response_processor.meta_set_with_cas unless quiet?
67
120
  end
68
121
 
122
+ # Pipelined set - writes a quiet set request without reading response.
123
+ # Used by PipelinedSetter for bulk operations.
124
+ def pipelined_set(key, value, ttl, options)
125
+ write_storage_req(:set, key, value, ttl, nil, options, quiet: true)
126
+ end
127
+
69
128
  def add(key, value, ttl, options)
70
129
  write_storage_req(:add, key, value, ttl, nil, options)
71
130
  response_processor.meta_set_with_cas unless quiet?
@@ -77,13 +136,13 @@ module Dalli
77
136
  end
78
137
 
79
138
  # rubocop:disable Metrics/ParameterLists
80
- def write_storage_req(mode, key, raw_value, ttl = nil, cas = nil, options = {})
139
+ def write_storage_req(mode, key, raw_value, ttl = nil, cas = nil, options = {}, quiet: quiet?)
81
140
  (value, bitflags) = @value_marshaller.store(key, raw_value, options)
82
141
  ttl = TtlSanitizer.sanitize(ttl) if ttl
83
142
  encoded_key, base64 = KeyRegularizer.encode(key)
84
143
  req = RequestFormatter.meta_set(key: encoded_key, value: value,
85
144
  bitflags: bitflags, cas: cas,
86
- ttl: ttl, mode: mode, quiet: quiet?, base64: base64)
145
+ ttl: ttl, mode: mode, quiet: quiet, base64: base64)
87
146
  write(req)
88
147
  write(value)
89
148
  write(TERMINATOR)
@@ -121,6 +180,14 @@ module Dalli
121
180
  response_processor.meta_delete unless quiet?
122
181
  end
123
182
 
183
+ # Pipelined delete - writes a quiet delete request without reading response.
184
+ # Used by PipelinedDeleter for bulk operations.
185
+ def pipelined_delete(key)
186
+ encoded_key, base64 = KeyRegularizer.encode(key)
187
+ req = RequestFormatter.meta_delete(key: encoded_key, base64: base64, quiet: true)
188
+ write(req)
189
+ end
190
+
124
191
  # Arithmetic Commands
125
192
  def decr(key, count, ttl, initial)
126
193
  decr_incr false, key, count, ttl, initial
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ module Protocol
5
+ ##
6
+ # Dalli::Protocol::StringMarshaller is a pass-through marshaller for use with
7
+ # the :raw client option. It bypasses serialization and compression entirely,
8
+ # expecting values to already be strings (e.g., pre-serialized by Rails'
9
+ # ActiveSupport::Cache). It still enforces the maximum value size limit.
10
+ ##
11
+ class StringMarshaller
12
+ DEFAULTS = {
13
+ # max size of value in bytes (default is 1 MB, can be overriden with "memcached -I <size>")
14
+ value_max_bytes: 1024 * 1024
15
+ }.freeze
16
+
17
+ attr_reader :value_max_bytes
18
+
19
+ def initialize(client_options)
20
+ @value_max_bytes = client_options.fetch(:value_max_bytes) do
21
+ DEFAULTS.fetch(:value_max_bytes)
22
+ end.to_i
23
+ end
24
+
25
+ def store(key, value, _options = nil)
26
+ raise MarshalError, "Dalli in :raw mode only supports strings, got: #{value.class}" unless value.is_a?(String)
27
+
28
+ error_if_over_max_value_bytes(key, value)
29
+ [value, 0]
30
+ end
31
+
32
+ def retrieve(value, _flags)
33
+ value
34
+ end
35
+
36
+ # Interface compatibility methods - these return nil since
37
+ # StringMarshaller bypasses serialization and compression entirely.
38
+
39
+ def serializer
40
+ nil
41
+ end
42
+
43
+ def compressor
44
+ nil
45
+ end
46
+
47
+ def compression_min_size
48
+ nil
49
+ end
50
+
51
+ def compress_by_default?
52
+ false
53
+ end
54
+
55
+ private
56
+
57
+ def error_if_over_max_value_bytes(key, value)
58
+ return if value.bytesize <= value_max_bytes
59
+
60
+ message = "Value for #{key} over max size: #{value_max_bytes} <= #{value.bytesize}"
61
+ raise Dalli::ValueOverMaxSize, message
62
+ end
63
+ end
64
+ end
65
+ end
@@ -15,5 +15,15 @@ module Dalli
15
15
  else
16
16
  [Timeout::Error]
17
17
  end
18
+
19
+ # SSL errors that occur during read/write operations (not during initial
20
+ # handshake) should trigger reconnection. These indicate transient network
21
+ # issues, not configuration problems.
22
+ SSL_ERRORS =
23
+ if defined?(OpenSSL::SSL::SSLError)
24
+ [OpenSSL::SSL::SSLError]
25
+ else
26
+ []
27
+ end
18
28
  end
19
29
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ ##
5
+ # Handles deprecation warnings for protocol and authentication features
6
+ # that will be removed in Dalli 5.0.
7
+ ##
8
+ module ProtocolDeprecations
9
+ BINARY_PROTOCOL_DEPRECATION_MESSAGE = <<~MSG.chomp
10
+ [DEPRECATION] The binary protocol is deprecated and will be removed in Dalli 5.0. \
11
+ Please use `protocol: :meta` instead. The meta protocol requires memcached 1.6+. \
12
+ See https://github.com/petergoldstein/dalli for migration details.
13
+ MSG
14
+
15
+ SASL_AUTH_DEPRECATION_MESSAGE = <<~MSG.chomp
16
+ [DEPRECATION] SASL authentication is deprecated and will be removed in Dalli 5.0. \
17
+ SASL is only supported by the binary protocol, which is being removed. \
18
+ Consider using network-level security (firewall rules, VPN) or memcached's TLS support instead.
19
+ MSG
20
+
21
+ private
22
+
23
+ def emit_deprecation_warnings
24
+ emit_binary_protocol_deprecation_warning
25
+ emit_sasl_auth_deprecation_warning
26
+ end
27
+
28
+ def emit_binary_protocol_deprecation_warning
29
+ protocol = @options[:protocol]
30
+ # Binary is used when protocol is nil, :binary, or 'binary'
31
+ return if protocol.to_s == 'meta'
32
+
33
+ warn BINARY_PROTOCOL_DEPRECATION_MESSAGE
34
+ Dalli.logger.warn(BINARY_PROTOCOL_DEPRECATION_MESSAGE)
35
+ end
36
+
37
+ def emit_sasl_auth_deprecation_warning
38
+ username = @options[:username] || ENV.fetch('MEMCACHE_USERNAME', nil)
39
+ return unless username
40
+
41
+ warn SASL_AUTH_DEPRECATION_MESSAGE
42
+ Dalli.logger.warn(SASL_AUTH_DEPRECATION_MESSAGE)
43
+ end
44
+ end
45
+ end
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '4.0.0'
4
+ VERSION = '4.1.0'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.4'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -58,9 +58,12 @@ end
58
58
  require_relative 'dalli/version'
59
59
 
60
60
  require_relative 'dalli/compressor'
61
+ require_relative 'dalli/protocol_deprecations'
61
62
  require_relative 'dalli/client'
62
63
  require_relative 'dalli/key_manager'
63
64
  require_relative 'dalli/pipelined_getter'
65
+ require_relative 'dalli/pipelined_setter'
66
+ require_relative 'dalli/pipelined_deleter'
64
67
  require_relative 'dalli/ring'
65
68
  require_relative 'dalli/protocol'
66
69
  require_relative 'dalli/protocol/base'
@@ -72,6 +75,7 @@ require_relative 'dalli/protocol/server_config_parser'
72
75
  require_relative 'dalli/protocol/ttl_sanitizer'
73
76
  require_relative 'dalli/protocol/value_compressor'
74
77
  require_relative 'dalli/protocol/value_marshaller'
78
+ require_relative 'dalli/protocol/string_marshaller'
75
79
  require_relative 'dalli/protocol/value_serializer'
76
80
  require_relative 'dalli/servers_arg_normalizer'
77
81
  require_relative 'dalli/socket'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dalli
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -43,7 +43,9 @@ files:
43
43
  - lib/dalli/key_manager.rb
44
44
  - lib/dalli/options.rb
45
45
  - lib/dalli/pid_cache.rb
46
+ - lib/dalli/pipelined_deleter.rb
46
47
  - lib/dalli/pipelined_getter.rb
48
+ - lib/dalli/pipelined_setter.rb
47
49
  - lib/dalli/protocol.rb
48
50
  - lib/dalli/protocol/base.rb
49
51
  - lib/dalli/protocol/binary.rb
@@ -58,10 +60,12 @@ files:
58
60
  - lib/dalli/protocol/meta/response_processor.rb
59
61
  - lib/dalli/protocol/response_buffer.rb
60
62
  - lib/dalli/protocol/server_config_parser.rb
63
+ - lib/dalli/protocol/string_marshaller.rb
61
64
  - lib/dalli/protocol/ttl_sanitizer.rb
62
65
  - lib/dalli/protocol/value_compressor.rb
63
66
  - lib/dalli/protocol/value_marshaller.rb
64
67
  - lib/dalli/protocol/value_serializer.rb
68
+ - lib/dalli/protocol_deprecations.rb
65
69
  - lib/dalli/ring.rb
66
70
  - lib/dalli/servers_arg_normalizer.rb
67
71
  - lib/dalli/socket.rb
@@ -88,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
88
92
  - !ruby/object:Gem::Version
89
93
  version: '0'
90
94
  requirements: []
91
- rubygems_version: 4.0.3
95
+ rubygems_version: 4.0.4
92
96
  specification_version: 4
93
97
  summary: High performance memcached client for Ruby
94
98
  test_files: []