dalli 5.0.8 → 5.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.
@@ -36,28 +36,40 @@ module Dalli
36
36
  # - l<N>: Seconds since last access
37
37
  def meta_get(key:, value: true, return_cas: false, ttl: nil, quiet: false,
38
38
  vivify_ttl: nil, recache_ttl: nil,
39
- return_hit_status: false, return_last_access: false, skip_lru_bump: false,
40
- skip_flags: false)
39
+ return_hit_status: false, return_last_access: false, return_ttl_remaining: false,
40
+ skip_lru_bump: false, skip_flags: false, p_token: nil, l_token: nil)
41
41
  cmd = "mg #{encoded_key(key)}"
42
42
  # In raw mode (skip_flags: true), we don't request bitflags since they're not used.
43
43
  # This saves 2 bytes per request and skips parsing on response.
44
44
  cmd << (skip_flags ? ' v' : ' v f') if value
45
45
  cmd << ' c' if return_cas
46
- cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
46
+ cmd << " T#{ttl}" if ttl
47
+ cmd << routing_tokens(p_token: p_token, l_token: l_token)
47
48
  cmd << ' k q s' if quiet # Return the key in the response if quiet
48
- cmd << " N#{integer_flag(:vivify_ttl, vivify_ttl)}" if vivify_ttl # Thundering herd: vivify on miss
49
- # Thundering herd: win recache if TTL below threshold
50
- cmd << " R#{integer_flag(:recache_ttl, recache_ttl)}" if recache_ttl
49
+ cmd << " N#{vivify_ttl}" if vivify_ttl # Thundering herd: vivify on miss
50
+ cmd << " R#{recache_ttl}" if recache_ttl # Thundering herd: win recache if TTL below threshold
51
51
  cmd << ' h' if return_hit_status # Return hit status (0 or 1)
52
52
  cmd << ' l' if return_last_access # Return seconds since last access
53
+ cmd << ' t' if return_ttl_remaining # Return seconds of TTL remaining (-1 = no TTL)
53
54
  cmd << ' u' if skip_lru_bump # Don't bump LRU or update access stats
54
55
  cmd << TERMINATOR
55
56
  end
56
57
 
57
- def multi_meta_get(keys, skip_flags: false)
58
+ def multi_meta_get(keys, skip_flags: false, return_cas: false, p_token: nil, l_token: nil)
58
59
  # In raw mode: "mg <key> v k q s\r\n" (no f flag, key at index 2)
59
60
  # Normal mode: "mg <key> v f k q s\r\n" (key at index 3)
60
- post_get = skip_flags ? " v k q s\r\n" : " v f k q s\r\n"
61
+ # With return_cas a "c" flag follows, which shifts those indexes --
62
+ # callers of that variant locate tokens by flag rather than position.
63
+ # Routing tokens apply to every key in the batch, so the suffix is
64
+ # built once rather than per key.
65
+ post_get = if return_cas
66
+ skip_flags ? ' v c' : ' v f c'
67
+ else
68
+ skip_flags ? ' v' : ' v f'
69
+ end
70
+ post_get += routing_tokens(p_token: p_token, l_token: l_token)
71
+ post_get += " k q s#{TERMINATOR}"
72
+
61
73
  buffer = ''.b
62
74
  keys.each do |key|
63
75
  buffer << 'mg ' << encoded_key(key) << post_get
@@ -65,7 +77,8 @@ module Dalli
65
77
  buffer << 'mn' << TERMINATOR
66
78
  end
67
79
 
68
- def meta_set(key:, value:, bitflags: nil, cas: nil, ttl: nil, mode: :set, quiet: false)
80
+ def meta_set(key:, value:, bitflags: nil, cas: nil, ttl: nil, mode: :set, quiet: false,
81
+ p_token: nil, l_token: nil)
69
82
  base64 = KeyRegularizer.required?(key)
70
83
  key = KeyRegularizer.encode(key) if base64
71
84
  cmd = "ms #{key} #{value.bytesize}"
@@ -75,14 +88,18 @@ module Dalli
75
88
  cmd << ' b' if base64
76
89
  cmd << " F#{bitflags}" if bitflags
77
90
  cmd << cas_string(cas)
78
- cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
91
+ cmd << " T#{ttl}" if ttl
79
92
  cmd << " M#{mode_to_token(mode)}"
80
93
  cmd << ' q' if quiet
94
+ cmd << routing_tokens(p_token: p_token, l_token: l_token)
81
95
  cmd << TERMINATOR
82
96
  end
83
97
 
84
- def multi_meta_set(entries, ttl: nil)
85
- ttl = integer_flag(:ttl, ttl) if ttl
98
+ def multi_meta_set(entries, ttl: nil, p_token: nil, l_token: nil)
99
+ # Routing tokens apply to every entry in the batch, so the suffix is
100
+ # built once rather than per entry.
101
+ token_suffix = routing_tokens(p_token: p_token, l_token: l_token)
102
+
86
103
  buffer = ''.b
87
104
  entries.each do |key, pair|
88
105
  value, bitflags = pair
@@ -90,12 +107,12 @@ module Dalli
90
107
  base64 = KeyRegularizer.required?(key)
91
108
  key = KeyRegularizer.encode(key) if base64
92
109
 
93
- # Inline format: "ms <key> <size> c [b] F<flags> T<ttl> MS q\r\n"
110
+ # Inline format: "ms <key> <size> c [b] F<flags> T<ttl> MS q [P/L]\r\n"
94
111
  buffer << "ms #{key} #{value.bytesize} c"
95
112
  buffer << ' b' if base64
96
113
  buffer << " F#{bitflags}" if bitflags
97
114
  buffer << " T#{ttl}" if ttl
98
- buffer << ' MS q' << TERMINATOR << value << TERMINATOR
115
+ buffer << ' MS q' << token_suffix << TERMINATOR << value << TERMINATOR
99
116
  end
100
117
  buffer << META_NOOP
101
118
  end
@@ -103,34 +120,96 @@ module Dalli
103
120
  # Thundering herd protection flag:
104
121
  # - stale (I flag): Instead of deleting the item, mark it as stale. Other clients
105
122
  # using N/R flags will see the X flag and know the item is being regenerated.
106
- def meta_delete(key:, cas: nil, ttl: nil, quiet: false, stale: false)
123
+ # Tombstone flags:
124
+ # - stale (I flag): mark the item stale instead of removing it. Readers
125
+ # using N/R flags, or get_with_metadata, see the X flag and know the
126
+ # item is being regenerated.
127
+ # - ttl (T flag): how long the stale marker lives. memcached only honors
128
+ # T on a delete when it is paired with I, so this raises rather than
129
+ # emitting a request the server would apply differently than intended.
130
+ # - drop_value (x flag): remove the item's value but leave the item, so a
131
+ # tombstone can be left without retaining the old payload.
132
+ def meta_delete(key:, cas: nil, ttl: nil, quiet: false, stale: false, drop_value: false,
133
+ p_token: nil, l_token: nil)
134
+ # Message uses this method's own parameter names (ttl/stale), not the
135
+ # client-facing tombstone_ttl/invalidate names Dalli::Client validates
136
+ # against -- this guard is also reachable by internal callers (tests,
137
+ # direct RequestFormatter use) that never go through the client.
138
+ raise ArgumentError, 'ttl requires stale: true' if ttl && !stale
139
+
107
140
  cmd = "md #{encoded_key(key)}"
108
141
  cmd << cas_string(cas)
109
- cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
110
142
  cmd << ' I' if stale # Mark stale instead of deleting
143
+ cmd << " T#{Integer(ttl)}" if ttl
144
+ cmd << ' x' if drop_value # Drop the value but keep the item
111
145
  cmd << ' q' if quiet
146
+ cmd << routing_tokens(p_token: p_token, l_token: l_token)
112
147
  cmd << TERMINATOR
113
148
  end
114
149
 
115
- def multi_meta_delete(keys)
150
+ # Tombstone and routing-token flags apply to every key in the batch;
151
+ # see meta_delete.
152
+ def multi_meta_delete(keys, stale: false, ttl: nil, drop_value: false, p_token: nil, l_token: nil)
153
+ raise ArgumentError, 'tombstone_ttl requires invalidate: true' if ttl && !stale
154
+
155
+ suffix = +''
156
+ suffix << ' I' if stale
157
+ suffix << " T#{Integer(ttl)}" if ttl
158
+ suffix << ' x' if drop_value
159
+ suffix << ' q'
160
+ suffix << routing_tokens(p_token: p_token, l_token: l_token)
161
+ suffix << TERMINATOR
162
+
116
163
  buffer = ''.b
117
164
  keys.each do |key|
118
- buffer << 'md ' << encoded_key(key) << ' q' << TERMINATOR
165
+ buffer << 'md ' << encoded_key(key) << suffix
119
166
  end
120
167
  buffer << META_NOOP
121
168
  end
122
169
 
123
- def meta_arithmetic(key:, delta:, initial:, incr: true, cas: nil, ttl: nil, quiet: false)
170
+ def meta_arithmetic(key:, delta:, initial:, incr: true, cas: nil, ttl: nil, quiet: false,
171
+ p_token: nil, l_token: nil)
124
172
  cmd = "ma #{encoded_key(key)} v"
125
- cmd << " D#{integer_flag(:delta, delta)}" if delta
126
- cmd << " J#{integer_flag(:initial, initial)}" if initial
173
+ cmd << " D#{delta}" if delta
174
+ cmd << " J#{initial}" if initial
127
175
  # Always set a TTL if an initial value is specified
128
- cmd << " N#{ttl ? integer_flag(:ttl, ttl) : 0}" if ttl || initial
176
+ cmd << " N#{ttl || 0}" if ttl || initial
129
177
  cmd << cas_string(cas)
130
178
  cmd << ' q' if quiet
131
179
  cmd << " M#{incr ? 'I' : 'D'}"
180
+ cmd << routing_tokens(p_token: p_token, l_token: l_token)
132
181
  cmd << TERMINATOR
133
182
  end
183
+
184
+ # Builds the wire-format suffix for opaque routing tokens (P and L).
185
+ # memcached itself ignores these; they exist as hints for a proxy or
186
+ # router sitting between the client and memcached. See protocol.txt:
187
+ # "All commands accept tokens 'P' and 'L' which are completely ignored.
188
+ # The arguments to 'P' and 'L' can be used as hints or path
189
+ # specifications to a proxy or router inbetween a client and a
190
+ # memcached daemon."
191
+ #
192
+ # Empty / nil tokens are treated as no-ops. CRLF and null bytes are
193
+ # rejected with ArgumentError to prevent the token from being used as a
194
+ # wire-protocol injection vector (e.g. "foo\r\nflush_all\r\n" would
195
+ # otherwise be parsed as a second command by memcached or any
196
+ # intermediate proxy/LB).
197
+ def routing_tokens(p_token: nil, l_token: nil)
198
+ # Only an empty *String* is a no-op. Checking respond_to?(:empty?)
199
+ # instead would also swallow p_token: [] / {} before the type check
200
+ # below ever runs, silently dropping caller mistakes that should
201
+ # raise "must be a String".
202
+ p_token = nil if p_token.is_a?(String) && p_token.empty?
203
+ l_token = nil if l_token.is_a?(String) && l_token.empty?
204
+ validate_routing_token!('p_token', p_token)
205
+ validate_routing_token!('l_token', l_token)
206
+ return '' unless p_token || l_token
207
+
208
+ s = +''
209
+ s << " P#{p_token}" if p_token
210
+ s << " L#{l_token}" if l_token
211
+ s
212
+ end
134
213
  # rubocop:enable Metrics/CyclomaticComplexity
135
214
  # rubocop:enable Metrics/ParameterLists
136
215
  # rubocop:enable Metrics/PerceivedComplexity
@@ -171,16 +250,15 @@ module Dalli
171
250
 
172
251
  private
173
252
 
174
- # Numeric flag values are written straight into the command line, so a
175
- # value that isn't an integer -- e.g. a String carrying CRLF -- would let
176
- # the caller inject further memcached commands (GHSA-6wmv-xq9m-fmp7).
177
- # Converting to Integer means only digits ever reach the wire. Strings
178
- # are parsed as base 10 so "010" means 10, as memcached would read it,
179
- # rather than octal 8.
180
- def integer_flag(name, value)
181
- value.is_a?(String) ? Integer(value, 10) : Integer(value)
182
- rescue ArgumentError, TypeError, FloatDomainError
183
- raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
253
+ # Disallowed bytes: CR, LF, NUL. Any of these embedded in a routing
254
+ # token would let the caller inject a second wire-protocol command.
255
+ ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
256
+ private_constant :ROUTING_TOKEN_FORBIDDEN
257
+
258
+ def validate_routing_token!(name, value)
259
+ return if value.nil?
260
+ raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
261
+ raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
184
262
  end
185
263
 
186
264
  def mode_to_token(mode)
@@ -72,11 +72,13 @@ module Dalli
72
72
  #
73
73
  # Used by meta_get for comprehensive metadata retrieval.
74
74
  # Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
75
- def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false)
75
+ def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
76
+ return_ttl_remaining: false)
76
77
  tokens = error_on_unexpected!(T_VA_EN_HD)
77
78
  result = build_metadata_result(tokens)
78
79
  result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
79
80
  result[:last_access] = last_access_from_tokens(tokens) if return_last_access
81
+ result[:ttl_remaining] = ttl_remaining_from_tokens(tokens) if return_ttl_remaining
80
82
  result[:value] = parse_value_from_tokens(tokens, cache_nils)
81
83
  result
82
84
  end
@@ -85,7 +87,12 @@ module Dalli
85
87
  {
86
88
  value: nil, cas: cas_from_tokens(tokens),
87
89
  won_recache: tokens.include?('W'), stale: tokens.include?('X'),
88
- lost_recache: tokens.include?('Z')
90
+ lost_recache: tokens.include?('Z'),
91
+ # Explicit miss marker: EN means the key does not exist. A tombstoned
92
+ # item is NOT a miss -- it answers VA/HD with the X flag set -- and a
93
+ # stored nil under cache_nils is not one either, so neither can be
94
+ # inferred from value or cas alone.
95
+ miss: tokens.first == EN
89
96
  }
90
97
  end
91
98
 
@@ -204,10 +211,8 @@ module Dalli
204
211
  # We have a complete response that has no body.
205
212
  # This is either the response to the terminating
206
213
  # noop or, if the status is not MN, an intermediate
207
- # error response that needs to be discarded. A hit
208
- # on an empty value (VA 0) still has a body -- just
209
- # its terminator -- so it's parsed below as a value.
210
- return [true, header_len] if body_len.zero? && tokens.first != VA
214
+ # error response that needs to be discarded.
215
+ return [true, header_len] if body_len.zero?
211
216
 
212
217
  resp_size = header_len + body_len + TERMINATOR.length
213
218
  # The header is in the buffer, but the body is not. As we don't have
@@ -238,6 +243,14 @@ module Dalli
238
243
  value_from_tokens(tokens, 'c').to_i
239
244
  end
240
245
 
246
+ # Detects the X presence flag, set when an item has been marked stale by a
247
+ # prior `md key I`. Uses strict equality (Array#any? with a String pattern
248
+ # compares with ==) so a future value-bearing flag beginning with X cannot
249
+ # be mistaken for it.
250
+ def stale_from_tokens(tokens)
251
+ tokens.any?('X')
252
+ end
253
+
241
254
  def key_from_tokens(tokens)
242
255
  encoded_key = value_from_tokens(tokens, 'k')
243
256
  if tokens.delete('b')
@@ -262,6 +275,12 @@ module Dalli
262
275
  value_from_tokens(tokens, 'l').to_i
263
276
  end
264
277
 
278
+ # Returns seconds of TTL remaining; -1 when the item has no expiry.
279
+ # The t flag returns t<seconds>.
280
+ def ttl_remaining_from_tokens(tokens)
281
+ value_from_tokens(tokens, 't').to_i
282
+ end
283
+
265
284
  def body_len_from_tokens(tokens)
266
285
  value_from_tokens(tokens, 's').to_i
267
286
  end
data/lib/dalli/socket.rb CHANGED
@@ -124,7 +124,7 @@ module Dalli
124
124
  # Returns true for an unmodified TCPSocket on Ruby 3.0+, or for resolv-replace >= 0.2.0
125
125
  # which forwards keyword arguments through its patch.
126
126
  # Returns false when monkey-patched by gems like socksify or resolv-replace < 0.2.0.
127
- # rubocop:disable-next ThreadSafety/ClassInstanceVariable
127
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
128
128
  def self.supports_connect_timeout?
129
129
  return @supports_connect_timeout if defined?(@supports_connect_timeout)
130
130
 
@@ -135,6 +135,7 @@ module Dalli
135
135
  end
136
136
  end
137
137
  end
138
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
138
139
 
139
140
  def self.create_socket_with_timeout(host, port, options)
140
141
  if supports_connect_timeout?
@@ -191,13 +192,14 @@ module Dalli
191
192
 
192
193
  # Detect and cache the correct pack format for struct timeval on this platform.
193
194
  # Different architectures have different sizes for time_t and suseconds_t.
194
- # rubocop:disable-next ThreadSafety/ClassInstanceVariable
195
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
195
196
  def self.timeval_pack_format(sock)
196
197
  @timeval_pack_format ||= begin
197
198
  expected_size = sock.getsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO).data.bytesize
198
199
  TIMEVAL_PACK_FORMATS.find { |fmt| TIMEVAL_TEST_VALUES.pack(fmt).bytesize == expected_size } || 'll'
199
200
  end
200
201
  end
202
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
201
203
 
202
204
  def self.pack_timeval(sock, seconds, microseconds)
203
205
  [seconds, microseconds].pack(timeval_pack_format(sock))
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '5.0.8'
4
+ VERSION = '5.1.0'
5
5
 
6
- MIN_SUPPORTED_MEMCACHED_VERSION = '1.6'
6
+ MIN_SUPPORTED_MEMCACHED_VERSION = '1.6.27'
7
7
  end
@@ -16,10 +16,11 @@ module Rack
16
16
  attr_reader :data
17
17
 
18
18
  # Don't freeze this until we fix the specs/implementation
19
- # rubocop:disable-next Style/MutableConstant
19
+ # rubocop:disable Style/MutableConstant
20
20
  DEFAULT_DALLI_OPTIONS = {
21
21
  namespace: 'rack:session'
22
22
  }
23
+ # rubocop:enable Style/MutableConstant
23
24
 
24
25
  # Brings in a new Rack::Session::Dalli middleware with the given
25
26
  # `:memcache_server`. The server is either a hostname, or a
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: 5.0.8
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -87,7 +87,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
87
87
  - !ruby/object:Gem::Version
88
88
  version: '0'
89
89
  requirements: []
90
- rubygems_version: 4.0.21
90
+ rubygems_version: 4.0.18
91
91
  specification_version: 4
92
92
  summary: High performance memcached client for Ruby
93
93
  test_files: []