dalli 4.3.2 → 5.0.2

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.
@@ -257,13 +257,85 @@ module Dalli
257
257
  @connection_manager.flush
258
258
  end
259
259
 
260
- def authenticate_connection
261
- raise Dalli::DalliError, 'Authentication not supported for the meta protocol.'
260
+ # Single-server fast path for get_multi. Inlines request formatting and
261
+ # response parsing to minimize per-key overhead. Avoids the PipelinedGetter
262
+ # machinery (IO.select, response buffering, server grouping).
263
+ def read_multi_req(keys)
264
+ is_raw = raw_mode?
265
+ # Inline request formatting — avoids RequestFormatter.meta_get overhead per key.
266
+ # In raw mode: "mg <key> v k q s\r\n" (no f flag, key at index 2)
267
+ # Normal mode: "mg <key> v f k q s\r\n" (key at index 3)
268
+ post_get = is_raw ? " v k q s\r\n" : " v f k q s\r\n"
269
+ keys.each do |key|
270
+ encoded_key, base64 = KeyRegularizer.encode(key)
271
+ write(base64 ? "mg #{encoded_key} b#{post_get}" : "mg #{encoded_key}#{post_get}")
272
+ end
273
+ write("mn\r\n")
274
+ @connection_manager.flush
275
+
276
+ read_multi_get_responses(is_raw)
277
+ end
278
+
279
+ def read_multi_get_responses(is_raw)
280
+ hash = {}
281
+ key_index = is_raw ? 2 : 3
282
+ while (line = @connection_manager.read_line)
283
+ break if line.start_with?('MN')
284
+ next unless line.start_with?('VA ')
285
+
286
+ key, value = parse_multi_get_value(line, key_index, is_raw)
287
+ hash[key] = value if key
288
+ end
289
+ hash
290
+ end
291
+
292
+ def parse_multi_get_value(line, key_index, is_raw)
293
+ tokens = line.chomp!(TERMINATOR).split
294
+ value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
295
+ raw_key = tokens[key_index]
296
+ return unless raw_key
297
+
298
+ key = KeyRegularizer.decode(raw_key[1..], tokens.include?('b'))
299
+ bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
300
+ [key, @value_marshaller.retrieve(value, bitflags)]
301
+ end
302
+
303
+ # Single-server fast path for set_multi. Inlines request formatting to
304
+ # minimize per-key overhead. Avoids PipelinedSetter server grouping.
305
+ def write_multi_req(pairs, ttl, req_options)
306
+ ttl = TtlSanitizer.sanitize(ttl) if ttl
307
+ pairs.each do |key, raw_value|
308
+ (value, bitflags) = @value_marshaller.store(key, raw_value, req_options)
309
+ encoded_key, base64 = KeyRegularizer.encode(key)
310
+ # Inline format: "ms <key> <size> c [b] F<flags> T<ttl> MS q\r\n"
311
+ cmd = "ms #{encoded_key} #{value.bytesize} c"
312
+ cmd << ' b' if base64
313
+ cmd << " F#{bitflags}" if bitflags
314
+ cmd << " T#{ttl}" if ttl
315
+ cmd << " MS q\r\n"
316
+ write(cmd)
317
+ write(value)
318
+ write(TERMINATOR)
319
+ end
320
+ write_noop
321
+ response_processor.consume_all_responses_until_mn
322
+ end
323
+
324
+ # Single-server fast path for delete_multi. Writes all quiet delete requests
325
+ # terminated by a noop, then consumes all responses.
326
+ def delete_multi_req(keys)
327
+ keys.each do |key|
328
+ encoded_key, base64 = KeyRegularizer.encode(key)
329
+ # Inline format: "md <key> [b] q\r\n"
330
+ write(base64 ? "md #{encoded_key} b q\r\n" : "md #{encoded_key} q\r\n")
331
+ end
332
+ write_noop
333
+ response_processor.consume_all_responses_until_mn
262
334
  end
263
335
 
264
- require_relative 'meta/key_regularizer'
265
- require_relative 'meta/request_formatter'
266
- require_relative 'meta/response_processor'
336
+ require_relative 'key_regularizer'
337
+ require_relative 'request_formatter'
338
+ require_relative 'response_processor'
267
339
  end
268
340
  end
269
341
  end
@@ -7,38 +7,39 @@ module Dalli
7
7
  module Protocol
8
8
  ##
9
9
  # Manages the buffer for responses from memcached.
10
+ # Uses an offset-based approach to avoid string allocations
11
+ # when advancing through parsed responses.
10
12
  ##
11
13
  class ResponseBuffer
14
+ # Compact the buffer when the consumed portion exceeds this
15
+ # threshold and represents more than half the buffer
16
+ COMPACT_THRESHOLD = 4096
17
+
12
18
  def initialize(io_source, response_processor)
13
19
  @io_source = io_source
14
20
  @response_processor = response_processor
15
21
  @buffer = nil
22
+ @offset = 0
16
23
  end
17
24
 
18
25
  def read
19
26
  @buffer << @io_source.read_nonblock
20
27
  end
21
28
 
22
- # Attempts to process a single response from the buffer. Starts
23
- # by advancing the buffer to the specified start position
29
+ # Attempts to process a single response from the buffer,
30
+ # advancing the offset past the consumed bytes.
24
31
  def process_single_getk_response
25
- bytes, status, cas, key, value = @response_processor.getk_response_from_buffer(@buffer)
26
- advance(bytes)
32
+ bytes, status, cas, key, value = @response_processor.getk_response_from_buffer(@buffer, @offset)
33
+ @offset += bytes
34
+ compact_if_needed
27
35
  [status, cas, key, value]
28
36
  end
29
37
 
30
- # Advances the internal response buffer by bytes_to_advance
31
- # bytes. The
32
- def advance(bytes_to_advance)
33
- return unless bytes_to_advance.positive?
34
-
35
- @buffer = @buffer.byteslice(bytes_to_advance..-1)
36
- end
37
-
38
38
  # Resets the internal buffer to an empty state,
39
39
  # so that we're ready to read pipelined responses
40
40
  def reset
41
41
  @buffer = ''.b
42
+ @offset = 0
42
43
  end
43
44
 
44
45
  # Ensures the buffer is initialized for reading without discarding
@@ -48,16 +49,30 @@ module Dalli
48
49
  return if in_progress?
49
50
 
50
51
  @buffer = ''.b
52
+ @offset = 0
51
53
  end
52
54
 
53
55
  # Clear the internal response buffer
54
56
  def clear
55
57
  @buffer = nil
58
+ @offset = 0
56
59
  end
57
60
 
58
61
  def in_progress?
59
62
  !@buffer.nil?
60
63
  end
64
+
65
+ private
66
+
67
+ # Only compact when we've consumed a significant portion of the buffer.
68
+ # This avoids per-response string allocation while preventing unbounded
69
+ # memory growth for large pipelines.
70
+ def compact_if_needed
71
+ return unless @offset > COMPACT_THRESHOLD && @offset > @buffer.bytesize / 2
72
+
73
+ @buffer = @buffer.byteslice(@offset..)
74
+ @offset = 0
75
+ end
61
76
  end
62
77
  end
63
78
  end
@@ -147,14 +147,6 @@ module Dalli
147
147
  true
148
148
  end
149
149
 
150
- def tokens_from_header_buffer(buf)
151
- header = header_from_buffer(buf)
152
- tokens = header.split
153
- header_len = header.bytesize + TERMINATOR.length
154
- body_len = body_len_from_tokens(tokens)
155
- [tokens, header_len, body_len]
156
- end
157
-
158
150
  def full_response_from_buffer(tokens, body, resp_size)
159
151
  value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
160
152
  [resp_size, tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value]
@@ -170,11 +162,15 @@ module Dalli
170
162
  # The remaining three values in the array are the ResponseHeader,
171
163
  # key, and value.
172
164
  ##
173
- def getk_response_from_buffer(buf)
174
- # There's no header in the buffer, so don't advance
175
- return [0, nil, nil, nil, nil] unless contains_header?(buf)
165
+ def getk_response_from_buffer(buf, offset = 0)
166
+ # Find the header terminator starting from offset
167
+ term_idx = buf.index(TERMINATOR, offset)
168
+ return [0, nil, nil, nil, nil] unless term_idx
176
169
 
177
- tokens, header_len, body_len = tokens_from_header_buffer(buf)
170
+ header = buf.byteslice(offset, term_idx - offset)
171
+ tokens = header.split
172
+ header_len = header.bytesize + TERMINATOR.length
173
+ body_len = body_len_from_tokens(tokens)
178
174
 
179
175
  # We have a complete response that has no body.
180
176
  # This is either the response to the terminating
@@ -185,22 +181,14 @@ module Dalli
185
181
  resp_size = header_len + body_len + TERMINATOR.length
186
182
  # The header is in the buffer, but the body is not. As we don't have
187
183
  # a complete response, don't advance the buffer
188
- return [0, nil, nil, nil, nil] unless buf.bytesize >= resp_size
184
+ return [0, nil, nil, nil, nil] unless buf.bytesize >= offset + resp_size
189
185
 
190
186
  # The full response is in our buffer, so parse it and return
191
187
  # the values
192
- body = buf.slice(header_len, body_len)
188
+ body = buf.byteslice(offset + header_len, body_len)
193
189
  full_response_from_buffer(tokens, body, resp_size)
194
190
  end
195
191
 
196
- def contains_header?(buf)
197
- buf.include?(TERMINATOR)
198
- end
199
-
200
- def header_from_buffer(buf)
201
- buf.split(TERMINATOR, 2).first
202
- end
203
-
204
192
  def error_on_unexpected!(expected_codes)
205
193
  tokens = next_line_to_tokens
206
194
 
@@ -6,7 +6,7 @@ module Dalli
6
6
  module Protocol
7
7
  ##
8
8
  # Dalli::Protocol::ServerConfigParser parses a server string passed to
9
- # a Dalli::Protocol::Binary instance into the hostname, port, weight, and
9
+ # a Dalli::Protocol::Meta instance into the hostname, port, weight, and
10
10
  # socket_type.
11
11
  ##
12
12
  class ServerConfigParser
data/lib/dalli/ring.rb CHANGED
@@ -23,9 +23,9 @@ module Dalli
23
23
 
24
24
  attr_accessor :servers, :continuum
25
25
 
26
- def initialize(servers_arg, protocol_implementation, options)
26
+ def initialize(servers_arg, options)
27
27
  @servers = servers_arg.map do |s|
28
- protocol_implementation.new(s, options)
28
+ Dalli::Protocol::Meta.new(s, options)
29
29
  end
30
30
  @continuum = nil
31
31
  @continuum = build_continuum(servers) if servers.size > 1
@@ -16,7 +16,7 @@ module Dalli
16
16
  # weight are optional (e.g. 'localhost', 'abc.com:12345', 'example.org:22222:3')
17
17
  # * A colon separated string of (UNIX socket, weight) where the weight is optional
18
18
  # (e.g. '/var/run/memcached/socket', '/tmp/xyz:3') (not supported on Windows)
19
- # * A URI with a 'memcached' protocol, which will typically include a username/password
19
+ # * A URI with a 'memcached' protocol (e.g. 'memcached://localhost:11211')
20
20
  #
21
21
  # The methods in this module do not validate the format of individual server strings, but
22
22
  # rather normalize the argument into a compact array, wherein each array entry corresponds
data/lib/dalli/socket.rb CHANGED
@@ -108,12 +108,14 @@ module Dalli
108
108
  # Detect and cache whether TCPSocket supports the connect_timeout: keyword argument.
109
109
  # Returns false if TCPSocket#initialize has been monkey-patched by gems like
110
110
  # socksify or resolv-replace, which don't support keyword arguments.
111
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
111
112
  def self.supports_connect_timeout?
112
113
  return @supports_connect_timeout if defined?(@supports_connect_timeout)
113
114
 
114
115
  @supports_connect_timeout = RUBY_VERSION >= '3.0' &&
115
116
  ::TCPSocket.instance_method(:initialize).parameters == TCPSOCKET_NATIVE_PARAMETERS
116
117
  end
118
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
117
119
 
118
120
  def self.create_socket_with_timeout(host, port, options)
119
121
  if supports_connect_timeout?
@@ -170,12 +172,14 @@ module Dalli
170
172
 
171
173
  # Detect and cache the correct pack format for struct timeval on this platform.
172
174
  # Different architectures have different sizes for time_t and suseconds_t.
175
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
173
176
  def self.timeval_pack_format(sock)
174
177
  @timeval_pack_format ||= begin
175
178
  expected_size = sock.getsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO).data.bytesize
176
179
  TIMEVAL_PACK_FORMATS.find { |fmt| TIMEVAL_TEST_VALUES.pack(fmt).bytesize == expected_size } || 'll'
177
180
  end
178
181
  end
182
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
179
183
 
180
184
  def self.pack_timeval(sock, seconds, microseconds)
181
185
  [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 = '4.3.2'
4
+ VERSION = '5.0.2'
5
5
 
6
- MIN_SUPPORTED_MEMCACHED_VERSION = '1.4'
6
+ MIN_SUPPORTED_MEMCACHED_VERSION = '1.6'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -38,7 +38,7 @@ module Dalli
38
38
  QUIET = :dalli_multi
39
39
 
40
40
  def self.logger
41
- @logger ||= rails_logger || default_logger
41
+ @logger ||= rails_logger || default_logger # rubocop:disable ThreadSafety/ClassInstanceVariable
42
42
  end
43
43
 
44
44
  def self.rails_logger
@@ -54,7 +54,7 @@ module Dalli
54
54
  end
55
55
 
56
56
  def self.logger=(logger)
57
- @logger = logger
57
+ @logger = logger # rubocop:disable ThreadSafety/ClassInstanceVariable
58
58
  end
59
59
  end
60
60
 
@@ -62,7 +62,6 @@ require_relative 'dalli/version'
62
62
  require_relative 'dalli/instrumentation'
63
63
 
64
64
  require_relative 'dalli/compressor'
65
- require_relative 'dalli/protocol_deprecations'
66
65
  require_relative 'dalli/client'
67
66
  require_relative 'dalli/key_manager'
68
67
  require_relative 'dalli/pipelined_getter'
@@ -71,7 +70,6 @@ require_relative 'dalli/pipelined_deleter'
71
70
  require_relative 'dalli/ring'
72
71
  require_relative 'dalli/protocol'
73
72
  require_relative 'dalli/protocol/base'
74
- require_relative 'dalli/protocol/binary'
75
73
  require_relative 'dalli/protocol/connection_manager'
76
74
  require_relative 'dalli/protocol/meta'
77
75
  require_relative 'dalli/protocol/response_buffer'
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.3.2
4
+ version: 5.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -49,24 +49,18 @@ files:
49
49
  - lib/dalli/pipelined_setter.rb
50
50
  - lib/dalli/protocol.rb
51
51
  - lib/dalli/protocol/base.rb
52
- - lib/dalli/protocol/binary.rb
53
- - lib/dalli/protocol/binary/request_formatter.rb
54
- - lib/dalli/protocol/binary/response_header.rb
55
- - lib/dalli/protocol/binary/response_processor.rb
56
- - lib/dalli/protocol/binary/sasl_authentication.rb
57
52
  - lib/dalli/protocol/connection_manager.rb
53
+ - lib/dalli/protocol/key_regularizer.rb
58
54
  - lib/dalli/protocol/meta.rb
59
- - lib/dalli/protocol/meta/key_regularizer.rb
60
- - lib/dalli/protocol/meta/request_formatter.rb
61
- - lib/dalli/protocol/meta/response_processor.rb
55
+ - lib/dalli/protocol/request_formatter.rb
62
56
  - lib/dalli/protocol/response_buffer.rb
57
+ - lib/dalli/protocol/response_processor.rb
63
58
  - lib/dalli/protocol/server_config_parser.rb
64
59
  - lib/dalli/protocol/string_marshaller.rb
65
60
  - lib/dalli/protocol/ttl_sanitizer.rb
66
61
  - lib/dalli/protocol/value_compressor.rb
67
62
  - lib/dalli/protocol/value_marshaller.rb
68
63
  - lib/dalli/protocol/value_serializer.rb
69
- - lib/dalli/protocol_deprecations.rb
70
64
  - lib/dalli/ring.rb
71
65
  - lib/dalli/servers_arg_normalizer.rb
72
66
  - lib/dalli/socket.rb
@@ -86,7 +80,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
86
80
  requirements:
87
81
  - - ">="
88
82
  - !ruby/object:Gem::Version
89
- version: '3.1'
83
+ version: '3.3'
90
84
  required_rubygems_version: !ruby/object:Gem::Requirement
91
85
  requirements:
92
86
  - - ">="
@@ -1,117 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Dalli
4
- module Protocol
5
- class Binary
6
- ##
7
- # Class that encapsulates logic for formatting binary protocol requests
8
- # to memcached.
9
- ##
10
- class RequestFormatter
11
- REQUEST = 0x80
12
-
13
- OPCODES = {
14
- get: 0x00,
15
- set: 0x01,
16
- add: 0x02,
17
- replace: 0x03,
18
- delete: 0x04,
19
- incr: 0x05,
20
- decr: 0x06,
21
- flush: 0x08,
22
- noop: 0x0A,
23
- version: 0x0B,
24
- getkq: 0x0D,
25
- append: 0x0E,
26
- prepend: 0x0F,
27
- stat: 0x10,
28
- setq: 0x11,
29
- addq: 0x12,
30
- replaceq: 0x13,
31
- deleteq: 0x14,
32
- incrq: 0x15,
33
- decrq: 0x16,
34
- flushq: 0x18,
35
- appendq: 0x19,
36
- prependq: 0x1A,
37
- touch: 0x1C,
38
- gat: 0x1D,
39
- auth_negotiation: 0x20,
40
- auth_request: 0x21,
41
- auth_continue: 0x22
42
- }.freeze
43
-
44
- REQ_HEADER_FORMAT = 'CCnCCnNNQ'
45
-
46
- KEY_ONLY = 'a*'
47
- TTL_AND_KEY = 'Na*'
48
- KEY_AND_VALUE = 'a*a*'
49
- INCR_DECR = 'NNNNNa*'
50
- TTL_ONLY = 'N'
51
- NO_BODY = ''
52
-
53
- BODY_FORMATS = {
54
- get: KEY_ONLY,
55
- getkq: KEY_ONLY,
56
- delete: KEY_ONLY,
57
- deleteq: KEY_ONLY,
58
- stat: KEY_ONLY,
59
-
60
- append: KEY_AND_VALUE,
61
- prepend: KEY_AND_VALUE,
62
- appendq: KEY_AND_VALUE,
63
- prependq: KEY_AND_VALUE,
64
- auth_request: KEY_AND_VALUE,
65
- auth_continue: KEY_AND_VALUE,
66
-
67
- set: 'NNa*a*',
68
- setq: 'NNa*a*',
69
- add: 'NNa*a*',
70
- addq: 'NNa*a*',
71
- replace: 'NNa*a*',
72
- replaceq: 'NNa*a*',
73
-
74
- incr: INCR_DECR,
75
- decr: INCR_DECR,
76
- incrq: INCR_DECR,
77
- decrq: INCR_DECR,
78
-
79
- flush: TTL_ONLY,
80
- flushq: TTL_ONLY,
81
-
82
- noop: NO_BODY,
83
- auth_negotiation: NO_BODY,
84
- version: NO_BODY,
85
-
86
- touch: TTL_AND_KEY,
87
- gat: TTL_AND_KEY
88
- }.freeze
89
- FORMAT = BODY_FORMATS.transform_values { |v| REQ_HEADER_FORMAT + v }
90
-
91
- # rubocop:disable Metrics/ParameterLists
92
- def self.standard_request(opkey:, key: nil, value: nil, opaque: 0, cas: 0, bitflags: nil, ttl: nil)
93
- extra_len = (bitflags.nil? ? 0 : 4) + (ttl.nil? ? 0 : 4)
94
- key_len = key.nil? ? 0 : key.bytesize
95
- value_len = value.nil? ? 0 : value.bytesize
96
- header = [REQUEST, OPCODES[opkey], key_len, extra_len, 0, 0, extra_len + key_len + value_len, opaque, cas]
97
- body = [bitflags, ttl, key, value].compact
98
- (header + body).pack(FORMAT[opkey])
99
- end
100
- # rubocop:enable Metrics/ParameterLists
101
-
102
- def self.decr_incr_request(opkey:, key: nil, count: nil, initial: nil, expiry: nil)
103
- extra_len = 20
104
- (h, l) = as_8byte_uint(count)
105
- (dh, dl) = as_8byte_uint(initial)
106
- header = [REQUEST, OPCODES[opkey], key.bytesize, extra_len, 0, 0, key.bytesize + extra_len, 0, 0]
107
- body = [h, l, dh, dl, expiry, key]
108
- (header + body).pack(FORMAT[opkey])
109
- end
110
-
111
- def self.as_8byte_uint(val)
112
- [val >> 32, val & 0xFFFFFFFF]
113
- end
114
- end
115
- end
116
- end
117
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Dalli
4
- module Protocol
5
- class Binary
6
- ##
7
- # Class that encapsulates data parsed from a memcached response header.
8
- ##
9
- class ResponseHeader
10
- SIZE = 24
11
- FMT = '@2nCCnNNQ'
12
-
13
- attr_reader :key_len, :extra_len, :data_type, :status, :body_len, :opaque, :cas
14
-
15
- def initialize(buf)
16
- raise ArgumentError, "Response buffer must be at least #{SIZE} bytes" unless buf.bytesize >= SIZE
17
-
18
- @key_len, @extra_len, @data_type, @status, @body_len, @opaque, @cas = buf.unpack(FMT)
19
- end
20
-
21
- def ok?
22
- status.zero?
23
- end
24
-
25
- def not_found?
26
- status == 1
27
- end
28
-
29
- NOT_STORED_STATUSES = [2, 5].freeze
30
- def not_stored?
31
- NOT_STORED_STATUSES.include?(status)
32
- end
33
- end
34
- end
35
- end
36
- end