dalli 3.2.8 → 5.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +305 -0
  3. data/Gemfile +16 -2
  4. data/README.md +96 -2
  5. data/lib/dalli/client.rb +289 -28
  6. data/lib/dalli/flags.rb +15 -0
  7. data/lib/dalli/instrumentation.rb +153 -0
  8. data/lib/dalli/key_manager.rb +23 -8
  9. data/lib/dalli/options.rb +1 -1
  10. data/lib/dalli/pid_cache.rb +1 -1
  11. data/lib/dalli/pipelined_deleter.rb +82 -0
  12. data/lib/dalli/pipelined_getter.rb +44 -20
  13. data/lib/dalli/pipelined_setter.rb +87 -0
  14. data/lib/dalli/protocol/base.rb +95 -25
  15. data/lib/dalli/protocol/connection_manager.rb +37 -14
  16. data/lib/dalli/protocol/{meta/key_regularizer.rb → key_regularizer.rb} +1 -1
  17. data/lib/dalli/protocol/meta.rb +185 -20
  18. data/lib/dalli/protocol/{meta/request_formatter.rb → request_formatter.rb} +49 -16
  19. data/lib/dalli/protocol/response_buffer.rb +36 -12
  20. data/lib/dalli/protocol/{meta/response_processor.rb → response_processor.rb} +93 -37
  21. data/lib/dalli/protocol/server_config_parser.rb +2 -2
  22. data/lib/dalli/protocol/string_marshaller.rb +65 -0
  23. data/lib/dalli/protocol/ttl_sanitizer.rb +1 -1
  24. data/lib/dalli/protocol/value_compressor.rb +3 -16
  25. data/lib/dalli/protocol/value_marshaller.rb +1 -1
  26. data/lib/dalli/protocol/value_serializer.rb +61 -44
  27. data/lib/dalli/protocol.rb +10 -0
  28. data/lib/dalli/ring.rb +2 -2
  29. data/lib/dalli/servers_arg_normalizer.rb +1 -1
  30. data/lib/dalli/socket.rb +79 -14
  31. data/lib/dalli/version.rb +2 -2
  32. data/lib/dalli.rb +13 -5
  33. data/lib/rack/session/dalli.rb +43 -8
  34. metadata +27 -17
  35. data/lib/dalli/protocol/binary/request_formatter.rb +0 -117
  36. data/lib/dalli/protocol/binary/response_header.rb +0 -36
  37. data/lib/dalli/protocol/binary/response_processor.rb +0 -239
  38. data/lib/dalli/protocol/binary/sasl_authentication.rb +0 -60
  39. data/lib/dalli/protocol/binary.rb +0 -173
  40. data/lib/dalli/server.rb +0 -6
@@ -25,21 +25,26 @@ module Dalli
25
25
  # Retrieval Commands
26
26
  def get(key, options = nil)
27
27
  encoded_key, base64 = KeyRegularizer.encode(key)
28
- req = RequestFormatter.meta_get(key: encoded_key, base64: base64)
29
- write(req)
28
+ # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
29
+ skip_flags = raw_mode? || (options && options[:raw])
30
+ req = RequestFormatter.meta_get(key: encoded_key, base64: base64, skip_flags: skip_flags)
31
+ flushed_write(req)
30
32
  response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
31
33
  end
32
34
 
33
35
  def quiet_get_request(key)
34
36
  encoded_key, base64 = KeyRegularizer.encode(key)
35
- RequestFormatter.meta_get(key: encoded_key, return_cas: true, base64: base64, quiet: true)
37
+ # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
38
+ RequestFormatter.meta_get(key: encoded_key, return_cas: true, base64: base64, quiet: true,
39
+ skip_flags: raw_mode?)
36
40
  end
37
41
 
38
42
  def gat(key, ttl, options = nil)
39
43
  ttl = TtlSanitizer.sanitize(ttl)
40
44
  encoded_key, base64 = KeyRegularizer.encode(key)
41
- req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, base64: base64)
42
- write(req)
45
+ skip_flags = raw_mode? || (options && options[:raw])
46
+ req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, base64: base64, skip_flags: skip_flags)
47
+ flushed_write(req)
43
48
  response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
44
49
  end
45
50
 
@@ -47,7 +52,7 @@ module Dalli
47
52
  ttl = TtlSanitizer.sanitize(ttl)
48
53
  encoded_key, base64 = KeyRegularizer.encode(key)
49
54
  req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, value: false, base64: base64)
50
- write(req)
55
+ flushed_write(req)
51
56
  response_processor.meta_get_without_value
52
57
  end
53
58
 
@@ -56,16 +61,75 @@ module Dalli
56
61
  def cas(key)
57
62
  encoded_key, base64 = KeyRegularizer.encode(key)
58
63
  req = RequestFormatter.meta_get(key: encoded_key, value: true, return_cas: true, base64: base64)
59
- write(req)
64
+ flushed_write(req)
60
65
  response_processor.meta_get_with_value_and_cas
61
66
  end
62
67
 
68
+ # Comprehensive meta get with support for all metadata flags.
69
+ # @note Requires memcached 1.6+ (meta protocol feature)
70
+ #
71
+ # This is the full-featured get method that supports:
72
+ # - Thundering herd protection (vivify_ttl, recache_ttl)
73
+ # - Item metadata (hit_status, last_access)
74
+ # - LRU control (skip_lru_bump)
75
+ #
76
+ # @param key [String] the key to retrieve
77
+ # @param options [Hash] options controlling what metadata to return
78
+ # - :vivify_ttl [Integer] creates a stub on miss with this TTL (N flag)
79
+ # - :recache_ttl [Integer] wins recache race if remaining TTL is below this (R flag)
80
+ # - :return_hit_status [Boolean] return whether item was previously accessed (h flag)
81
+ # - :return_last_access [Boolean] return seconds since last access (l flag)
82
+ # - :skip_lru_bump [Boolean] don't bump LRU or update access stats (u flag)
83
+ # - :cache_nils [Boolean] whether to cache nil values
84
+ # @return [Hash] containing:
85
+ # - :value - the cached value (or nil on miss)
86
+ # - :cas - the CAS value
87
+ # - :won_recache - true if client won recache race (W flag)
88
+ # - :stale - true if item is stale (X flag)
89
+ # - :lost_recache - true if another client is recaching (Z flag)
90
+ # - :hit_before - true/false if previously accessed (only if return_hit_status: true)
91
+ # - :last_access - seconds since last access (only if return_last_access: true)
92
+ def meta_get(key, options = {})
93
+ encoded_key, base64 = KeyRegularizer.encode(key)
94
+ req = RequestFormatter.meta_get(
95
+ key: encoded_key, value: true, return_cas: true, base64: base64,
96
+ vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
97
+ return_hit_status: options[:return_hit_status],
98
+ return_last_access: options[:return_last_access], skip_lru_bump: options[:skip_lru_bump]
99
+ )
100
+ flushed_write(req)
101
+ response_processor.meta_get_with_metadata(
102
+ cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
103
+ return_last_access: options[:return_last_access]
104
+ )
105
+ end
106
+
107
+ # Delete with stale invalidation instead of actual deletion.
108
+ # Used with thundering herd protection to mark items as stale rather than removing them.
109
+ # @note Requires memcached 1.6+ (meta protocol feature)
110
+ #
111
+ # @param key [String] the key to invalidate
112
+ # @param cas [Integer] optional CAS value for compare-and-swap
113
+ # @return [Boolean] true if successful
114
+ def delete_stale(key, cas = nil)
115
+ encoded_key, base64 = KeyRegularizer.encode(key)
116
+ req = RequestFormatter.meta_delete(key: encoded_key, cas: cas, base64: base64, stale: true)
117
+ flushed_write(req)
118
+ response_processor.meta_delete
119
+ end
120
+
63
121
  # Storage Commands
64
122
  def set(key, value, ttl, cas, options)
65
123
  write_storage_req(:set, key, value, ttl, cas, options)
66
124
  response_processor.meta_set_with_cas unless quiet?
67
125
  end
68
126
 
127
+ # Pipelined set - writes a quiet set request without reading response.
128
+ # Used by PipelinedSetter for bulk operations.
129
+ def pipelined_set(key, value, ttl, options)
130
+ write_storage_req(:set, key, value, ttl, nil, options, quiet: true)
131
+ end
132
+
69
133
  def add(key, value, ttl, options)
70
134
  write_storage_req(:add, key, value, ttl, nil, options)
71
135
  response_processor.meta_set_with_cas unless quiet?
@@ -77,14 +141,15 @@ module Dalli
77
141
  end
78
142
 
79
143
  # rubocop:disable Metrics/ParameterLists
80
- def write_storage_req(mode, key, raw_value, ttl = nil, cas = nil, options = {})
144
+ def write_storage_req(mode, key, raw_value, ttl = nil, cas = nil, options = {}, quiet: quiet?)
81
145
  (value, bitflags) = @value_marshaller.store(key, raw_value, options)
82
146
  ttl = TtlSanitizer.sanitize(ttl) if ttl
83
147
  encoded_key, base64 = KeyRegularizer.encode(key)
84
148
  req = RequestFormatter.meta_set(key: encoded_key, value: value,
85
149
  bitflags: bitflags, cas: cas,
86
- ttl: ttl, mode: mode, quiet: quiet?, base64: base64)
87
- write(req)
150
+ ttl: ttl, mode: mode, quiet: quiet, base64: base64)
151
+ write("#{req}#{value}#{TERMINATOR}")
152
+ @connection_manager.flush unless quiet
88
153
  end
89
154
  # rubocop:enable Metrics/ParameterLists
90
155
 
@@ -104,7 +169,8 @@ module Dalli
104
169
  encoded_key, base64 = KeyRegularizer.encode(key)
105
170
  req = RequestFormatter.meta_set(key: encoded_key, value: value, base64: base64,
106
171
  cas: cas, ttl: ttl, mode: mode, quiet: quiet?)
107
- write(req)
172
+ write("#{req}#{value}#{TERMINATOR}")
173
+ @connection_manager.flush unless quiet?
108
174
  end
109
175
  # rubocop:enable Metrics/ParameterLists
110
176
 
@@ -114,9 +180,18 @@ module Dalli
114
180
  req = RequestFormatter.meta_delete(key: encoded_key, cas: cas,
115
181
  base64: base64, quiet: quiet?)
116
182
  write(req)
183
+ @connection_manager.flush unless quiet?
117
184
  response_processor.meta_delete unless quiet?
118
185
  end
119
186
 
187
+ # Pipelined delete - writes a quiet delete request without reading response.
188
+ # Used by PipelinedDeleter for bulk operations.
189
+ def pipelined_delete(key)
190
+ encoded_key, base64 = KeyRegularizer.encode(key)
191
+ req = RequestFormatter.meta_delete(key: encoded_key, base64: base64, quiet: true)
192
+ write(req)
193
+ end
194
+
120
195
  # Arithmetic Commands
121
196
  def decr(key, count, ttl, initial)
122
197
  decr_incr false, key, count, ttl, initial
@@ -131,12 +206,14 @@ module Dalli
131
206
  encoded_key, base64 = KeyRegularizer.encode(key)
132
207
  write(RequestFormatter.meta_arithmetic(key: encoded_key, delta: delta, initial: initial, incr: incr, ttl: ttl,
133
208
  quiet: quiet?, base64: base64))
209
+ @connection_manager.flush unless quiet?
134
210
  response_processor.decr_incr unless quiet?
135
211
  end
136
212
 
137
213
  # Other Commands
138
214
  def flush(delay = 0)
139
215
  write(RequestFormatter.flush(delay: delay))
216
+ @connection_manager.flush unless quiet?
140
217
  response_processor.flush unless quiet?
141
218
  end
142
219
 
@@ -148,31 +225,119 @@ module Dalli
148
225
  end
149
226
 
150
227
  def stats(info = nil)
151
- write(RequestFormatter.stats(info))
228
+ flushed_write(RequestFormatter.stats(info))
152
229
  response_processor.stats
153
230
  end
154
231
 
155
232
  def reset_stats
156
- write(RequestFormatter.stats('reset'))
233
+ flushed_write(RequestFormatter.stats('reset'))
157
234
  response_processor.reset
158
235
  end
159
236
 
160
237
  def version
161
- write(RequestFormatter.version)
238
+ flushed_write(RequestFormatter.version)
162
239
  response_processor.version
163
240
  end
164
241
 
165
242
  def write_noop
166
- write(RequestFormatter.meta_noop)
243
+ flushed_write(RequestFormatter.meta_noop)
244
+ end
245
+
246
+ # Single-server fast path for get_multi. Inlines request formatting and
247
+ # response parsing to minimize per-key overhead. Avoids the PipelinedGetter
248
+ # machinery (IO.select, response buffering, server grouping).
249
+ def read_multi_req(keys)
250
+ is_raw = raw_mode?
251
+ # Inline request formatting — avoids RequestFormatter.meta_get overhead per key.
252
+ # In raw mode: "mg <key> v k q s\r\n" (no f flag, key at index 2)
253
+ # Normal mode: "mg <key> v f k q s\r\n" (key at index 3)
254
+ post_get = is_raw ? " v k q s\r\n" : " v f k q s\r\n"
255
+ buffer = ''.b
256
+ keys.each do |key|
257
+ encoded_key, base64 = KeyRegularizer.encode(key)
258
+ if base64
259
+ buffer << 'mg ' << encoded_key << ' b' << post_get
260
+ else
261
+ buffer << 'mg ' << encoded_key << post_get
262
+ end
263
+ end
264
+ buffer << 'mn' << TERMINATOR
265
+ flushed_write(buffer)
266
+ buffer.clear
267
+
268
+ read_multi_get_responses(is_raw)
269
+ end
270
+
271
+ def read_multi_get_responses(is_raw)
272
+ hash = {}
273
+ key_index = is_raw ? 2 : 3
274
+ while (line = @connection_manager.read_line)
275
+ break if line.start_with?('MN')
276
+ next unless line.start_with?('VA ')
277
+
278
+ key, value = parse_multi_get_value(line, key_index, is_raw)
279
+ hash[key] = value if key
280
+ end
281
+ hash
282
+ end
283
+
284
+ def parse_multi_get_value(line, key_index, is_raw)
285
+ tokens = line.chomp!(TERMINATOR).split
286
+ value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
287
+ raw_key = tokens[key_index]
288
+ return unless raw_key
289
+
290
+ key = KeyRegularizer.decode(raw_key[1..], tokens.include?('b'))
291
+ bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
292
+ [key, @value_marshaller.retrieve(value, bitflags)]
293
+ end
294
+
295
+ # rubocop:disable Metrics/AbcSize
296
+
297
+ # Single-server fast path for set_multi. Inlines request formatting to
298
+ # minimize per-key overhead. Avoids PipelinedSetter server grouping.
299
+ def write_multi_req(pairs, ttl, req_options)
300
+ ttl = TtlSanitizer.sanitize(ttl) if ttl
301
+ buffer = ''.b
302
+ pairs.each do |key, raw_value|
303
+ (value, bitflags) = @value_marshaller.store(key, raw_value, req_options)
304
+ encoded_key, base64 = KeyRegularizer.encode(key)
305
+ # Inline format: "ms <key> <size> c [b] F<flags> T<ttl> MS q\r\n"
306
+ buffer << "ms #{encoded_key} #{value.bytesize} c"
307
+ buffer << ' b' if base64
308
+ buffer << " F#{bitflags}" if bitflags
309
+ buffer << " T#{ttl}" if ttl
310
+ buffer << ' MS q' << TERMINATOR << value << TERMINATOR
311
+ end
312
+ buffer << RequestFormatter.meta_noop
313
+ flushed_write(buffer)
314
+ buffer.clear
315
+ response_processor.consume_all_responses_until_mn
167
316
  end
317
+ # rubocop:enable Metrics/AbcSize
168
318
 
169
- def authenticate_connection
170
- raise Dalli::DalliError, 'Authentication not supported for the meta protocol.'
319
+ # Single-server fast path for delete_multi. Writes all quiet delete requests
320
+ # terminated by a noop, then consumes all responses.
321
+ def delete_multi_req(keys)
322
+ buffer = ''.b
323
+ keys.each do |key|
324
+ encoded_key, base64 = KeyRegularizer.encode(key)
325
+ # Inline format: "md <key> [b] q\r\n"
326
+ if base64
327
+ buffer << 'md ' << encoded_key << ' b q' << TERMINATOR
328
+ else
329
+ buffer << 'md ' << encoded_key << ' q' << TERMINATOR
330
+ end
331
+ end
332
+ buffer << RequestFormatter.meta_noop
333
+ flushed_write(buffer)
334
+ buffer.clear
335
+ response_processor.consume_all_responses_until_mn
171
336
  end
172
337
 
173
- require_relative 'meta/key_regularizer'
174
- require_relative 'meta/request_formatter'
175
- require_relative 'meta/response_processor'
338
+ require_relative 'key_regularizer'
339
+ require_relative 'request_formatter'
340
+ require_relative 'response_processor'
176
341
  end
177
342
  end
178
343
  end
@@ -13,17 +13,46 @@ module Dalli
13
13
  # and introducing an intermediate object seems like overkill.
14
14
  #
15
15
  # rubocop:disable Metrics/CyclomaticComplexity
16
- # rubocop:disable Metrics/MethodLength
17
16
  # rubocop:disable Metrics/ParameterLists
18
17
  # rubocop:disable Metrics/PerceivedComplexity
19
- 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,
41
+ skip_flags: false)
20
42
  cmd = "mg #{key}"
21
- cmd << ' v f' if value
43
+ # In raw mode (skip_flags: true), we don't request bitflags since they're not used.
44
+ # This saves 2 bytes per request and skips parsing on response.
45
+ cmd << (skip_flags ? ' v' : ' v f') if value
22
46
  cmd << ' c' if return_cas
23
47
  cmd << ' b' if base64
24
48
  cmd << " T#{ttl}" if ttl
25
49
  cmd << ' k q s' if quiet # Return the key in the response if quiet
26
- cmd + TERMINATOR
50
+ cmd << " N#{vivify_ttl}" if vivify_ttl # Thundering herd: vivify on miss
51
+ cmd << " R#{recache_ttl}" if recache_ttl # Thundering herd: win recache if TTL below threshold
52
+ cmd << ' h' if return_hit_status # Return hit status (0 or 1)
53
+ cmd << ' l' if return_last_access # Return seconds since last access
54
+ cmd << ' u' if skip_lru_bump # Don't bump LRU or update access stats
55
+ cmd << TERMINATOR
27
56
  end
28
57
 
29
58
  def self.meta_set(key:, value:, bitflags: nil, cas: nil, ttl: nil, mode: :set, base64: false, quiet: false)
@@ -36,17 +65,19 @@ module Dalli
36
65
  cmd << " M#{mode_to_token(mode)}"
37
66
  cmd << ' q' if quiet
38
67
  cmd << TERMINATOR
39
- cmd << value
40
- cmd + TERMINATOR
41
68
  end
42
69
 
43
- def self.meta_delete(key:, cas: nil, ttl: nil, base64: false, quiet: false)
70
+ # Thundering herd protection flag:
71
+ # - stale (I flag): Instead of deleting the item, mark it as stale. Other clients
72
+ # using N/R flags will see the X flag and know the item is being regenerated.
73
+ def self.meta_delete(key:, cas: nil, ttl: nil, base64: false, quiet: false, stale: false)
44
74
  cmd = "md #{key}"
45
75
  cmd << ' b' if base64
46
76
  cmd << cas_string(cas)
47
77
  cmd << " T#{ttl}" if ttl
78
+ cmd << ' I' if stale # Mark stale instead of deleting
48
79
  cmd << ' q' if quiet
49
- cmd + TERMINATOR
80
+ cmd << TERMINATOR
50
81
  end
51
82
 
52
83
  def self.meta_arithmetic(key:, delta:, initial:, incr: true, cas: nil, ttl: nil, base64: false, quiet: false)
@@ -59,15 +90,15 @@ module Dalli
59
90
  cmd << cas_string(cas)
60
91
  cmd << ' q' if quiet
61
92
  cmd << " M#{incr ? 'I' : 'D'}"
62
- cmd + TERMINATOR
93
+ cmd << TERMINATOR
63
94
  end
64
95
  # rubocop:enable Metrics/CyclomaticComplexity
65
- # rubocop:enable Metrics/MethodLength
66
96
  # rubocop:enable Metrics/ParameterLists
67
97
  # rubocop:enable Metrics/PerceivedComplexity
68
98
 
99
+ META_NOOP = "mn#{TERMINATOR}".freeze
69
100
  def self.meta_noop
70
- "mn#{TERMINATOR}"
101
+ META_NOOP
71
102
  end
72
103
 
73
104
  def self.version
@@ -78,16 +109,19 @@ module Dalli
78
109
  cmd = +'flush_all'
79
110
  cmd << " #{parse_to_64_bit_int(delay, 0)}" if delay
80
111
  cmd << ' noreply' if quiet
81
- cmd + TERMINATOR
112
+ cmd << TERMINATOR
82
113
  end
83
114
 
115
+ ALLOWED_STATS_ARGS = [nil, '', 'items', 'slabs', 'settings', 'reset'].freeze
116
+
84
117
  def self.stats(arg = nil)
118
+ raise ArgumentError, "Invalid stats argument: #{arg.inspect}" unless ALLOWED_STATS_ARGS.include?(arg)
119
+
85
120
  cmd = +'stats'
86
- cmd << " #{arg}" if arg
87
- cmd + TERMINATOR
121
+ cmd << " #{arg}" if arg && !arg.empty?
122
+ cmd << TERMINATOR
88
123
  end
89
124
 
90
- # rubocop:disable Metrics/MethodLength
91
125
  def self.mode_to_token(mode)
92
126
  case mode
93
127
  when :add
@@ -102,7 +136,6 @@ module Dalli
102
136
  'S'
103
137
  end
104
138
  end
105
- # rubocop:enable Metrics/MethodLength
106
139
 
107
140
  def self.cas_string(cas)
108
141
  cas = parse_to_64_bit_int(cas, nil)
@@ -7,48 +7,72 @@ 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
43
+ end
44
+
45
+ # Ensures the buffer is initialized for reading without discarding
46
+ # existing data. Used by interleaved pipelined get which may have
47
+ # already buffered partial responses during the send phase.
48
+ def ensure_ready
49
+ return if in_progress?
50
+
51
+ @buffer = ''.b
52
+ @offset = 0
42
53
  end
43
54
 
44
55
  # Clear the internal response buffer
45
56
  def clear
46
57
  @buffer = nil
58
+ @offset = 0
47
59
  end
48
60
 
49
61
  def in_progress?
50
62
  !@buffer.nil?
51
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
52
76
  end
53
77
  end
54
78
  end