dalli 3.0.4 → 3.1.0

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

Potentially problematic release.


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

@@ -1,17 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "socket"
4
- require "timeout"
3
+ require 'English'
4
+ require 'forwardable'
5
+ require 'socket'
6
+ require 'timeout'
7
+
8
+ require_relative 'binary/request_formatter'
9
+ require_relative 'binary/response_header'
10
+ require_relative 'binary/response_processor'
11
+ require_relative 'binary/sasl_authentication'
5
12
 
6
13
  module Dalli
7
14
  module Protocol
15
+ ##
16
+ # Access point for a single Memcached server, accessed via Memcached's binary
17
+ # protocol. Contains logic for managing connection state to the server (retries, etc),
18
+ # formatting requests to the server, and unpacking responses.
19
+ ##
8
20
  class Binary
9
- attr_accessor :hostname
10
- attr_accessor :port
11
- attr_accessor :weight
12
- attr_accessor :options
13
- attr_reader :sock
14
- attr_reader :socket_type # possible values: :unix, :tcp
21
+ extend Forwardable
22
+
23
+ attr_accessor :hostname, :port, :weight, :options
24
+ attr_reader :sock, :socket_type
25
+
26
+ def_delegators :@value_marshaller, :serializer, :compressor, :compression_min_size, :compress_by_default?
15
27
 
16
28
  DEFAULTS = {
17
29
  # seconds between trying to contact a remote server
@@ -21,31 +33,24 @@ module Dalli
21
33
  # times a socket operation may fail before considering the server dead
22
34
  socket_max_failures: 2,
23
35
  # amount of time to sleep between retries when a failure occurs
24
- socket_failure_delay: 0.1,
25
- # max size of value in bytes (default is 1 MB, can be overriden with "memcached -I <size>")
26
- value_max_bytes: 1024 * 1024,
27
- serializer: Marshal,
28
- username: nil,
29
- password: nil,
30
- keepalive: true,
31
- # max byte size for SO_SNDBUF
32
- sndbuf: nil,
33
- # max byte size for SO_RCVBUF
34
- rcvbuf: nil
35
- }
36
+ socket_failure_delay: 0.1
37
+ }.freeze
36
38
 
37
39
  def initialize(attribs, options = {})
38
- @hostname, @port, @weight, @socket_type = ServerConfigParser.parse(attribs)
39
- @fail_count = 0
40
- @down_at = nil
41
- @last_down_at = nil
40
+ @hostname, @port, @weight, @socket_type, options = ServerConfigParser.parse(attribs, options)
42
41
  @options = DEFAULTS.merge(options)
43
- @value_compressor = ValueCompressor.new(@options)
42
+ @value_marshaller = ValueMarshaller.new(@options)
43
+ @response_processor = ResponseProcessor.new(self, @value_marshaller)
44
+ @response_buffer = ResponseBuffer.new(self, @response_processor)
45
+
46
+ reset_down_info
44
47
  @sock = nil
45
- @msg = nil
46
- @error = nil
47
48
  @pid = nil
48
- @inprogress = nil
49
+ @request_in_progress = false
50
+ end
51
+
52
+ def response_buffer
53
+ @response_buffer ||= ResponseBuffer.new(self, @response_processor)
49
54
  end
50
55
 
51
56
  def name
@@ -56,33 +61,50 @@ module Dalli
56
61
  end
57
62
  end
58
63
 
59
- # Chokepoint method for instrumentation
60
- def request(op, *args)
61
- verify_state
62
- raise Dalli::NetworkError, "#{name} is down: #{@error} #{@msg}. If you are sure it is running, ensure memcached version is > 1.4." unless alive?
64
+ # Chokepoint method for error handling and ensuring liveness
65
+ def request(opkey, *args)
66
+ verify_state(opkey)
67
+ # The alive? call has the side effect of connecting the underlying
68
+ # socket if it is not connected, or there's been a disconnect
69
+ # because of timeout or other error. Method raises an error
70
+ # if it can't connect
71
+ raise_memcached_down_err unless alive?
72
+
63
73
  begin
64
- send(op, *args)
65
- rescue Dalli::MarshalError => ex
66
- Dalli.logger.error "Marshalling error for key '#{args.first}': #{ex.message}"
67
- Dalli.logger.error "You are trying to cache a Ruby object which cannot be serialized to memcached."
74
+ send(opkey, *args)
75
+ rescue Dalli::MarshalError => e
76
+ log_marshall_err(args.first, e)
68
77
  raise
69
78
  rescue Dalli::DalliError, Dalli::NetworkError, Dalli::ValueOverMaxSize, Timeout::Error
70
79
  raise
71
- rescue => ex
72
- Dalli.logger.error "Unexpected exception during Dalli request: #{ex.class.name}: #{ex.message}"
73
- Dalli.logger.error ex.backtrace.join("\n\t")
80
+ rescue StandardError => e
81
+ log_unexpected_err(e)
74
82
  down!
75
83
  end
76
84
  end
77
85
 
86
+ def raise_memcached_down_err
87
+ raise Dalli::NetworkError,
88
+ "#{name} is down: #{@error} #{@msg}. If you are sure it is running, "\
89
+ "ensure memcached version is > #{::Dalli::MIN_SUPPORTED_MEMCACHED_VERSION}."
90
+ end
91
+
92
+ def log_marshall_err(key, err)
93
+ Dalli.logger.error "Marshalling error for key '#{key}': #{err.message}"
94
+ Dalli.logger.error 'You are trying to cache a Ruby object which cannot be serialized to memcached.'
95
+ end
96
+
97
+ def log_unexpected_err(err)
98
+ Dalli.logger.error "Unexpected exception during Dalli request: #{err.class.name}: #{err.message}"
99
+ Dalli.logger.error err.backtrace.join("\n\t")
100
+ end
101
+
102
+ # The socket connection to the underlying server is initialized as a side
103
+ # effect of this call. In fact, this is the ONLY place where that
104
+ # socket connection is initialized.
78
105
  def alive?
79
106
  return true if @sock
80
-
81
- if @last_down_at && @last_down_at + options[:down_retry_delay] >= Time.now
82
- time = @last_down_at + options[:down_retry_delay] - Time.now
83
- Dalli.logger.debug { "down_retry_delay not reached for #{name} (%.3f seconds left)" % time }
84
- return false
85
- end
107
+ return false unless reconnect_down_server?
86
108
 
87
109
  connect
88
110
  !!@sock
@@ -90,128 +112,203 @@ module Dalli
90
112
  false
91
113
  end
92
114
 
115
+ def reconnect_down_server?
116
+ return true unless @last_down_at
117
+
118
+ time_to_next_reconnect = @last_down_at + options[:down_retry_delay] - Time.now
119
+ return true unless time_to_next_reconnect.positive?
120
+
121
+ Dalli.logger.debug do
122
+ format('down_retry_delay not reached for %<name>s (%<time>.3f seconds left)', name: name,
123
+ time: time_to_next_reconnect)
124
+ end
125
+ false
126
+ end
127
+
128
+ # Closes the underlying socket and cleans up
129
+ # socket state.
93
130
  def close
94
131
  return unless @sock
132
+
95
133
  begin
96
134
  @sock.close
97
- rescue
135
+ rescue StandardError
98
136
  nil
99
137
  end
100
138
  @sock = nil
101
139
  @pid = nil
102
- @inprogress = false
140
+ abort_request!
103
141
  end
104
142
 
105
- def lock!
106
- end
143
+ def lock!; end
107
144
 
108
- def unlock!
109
- end
110
-
111
- def serializer
112
- @options[:serializer]
113
- end
145
+ def unlock!; end
114
146
 
115
147
  # Start reading key/value pairs from this connection. This is usually called
116
148
  # after a series of GETKQ commands. A NOOP is sent, and the server begins
117
149
  # flushing responses for kv pairs that were found.
118
150
  #
119
151
  # Returns nothing.
120
- def multi_response_start
121
- verify_state
152
+ def pipeline_response_start
153
+ verify_state(:getkq)
122
154
  write_noop
123
- @multi_buffer = +""
124
- @position = 0
125
- @inprogress = true
155
+ response_buffer.reset
156
+ start_request!
157
+ end
158
+
159
+ # Did the last call to #pipeline_response_start complete successfully?
160
+ def pipeline_response_completed?
161
+ response_buffer.completed?
126
162
  end
127
163
 
128
- # Did the last call to #multi_response_start complete successfully?
129
- def multi_response_completed?
130
- @multi_buffer.nil?
164
+ def pipeline_response(bytes_to_advance = 0)
165
+ response_buffer.process_single_response(bytes_to_advance)
166
+ end
167
+
168
+ def reconnect_on_pipeline_complete!
169
+ reconnect! 'multi_response has completed' if pipeline_response_completed?
131
170
  end
132
171
 
133
172
  # Attempt to receive and parse as many key/value pairs as possible
134
- # from this server. After #multi_response_start, this should be invoked
173
+ # from this server. After #pipeline_response_start, this should be invoked
135
174
  # repeatedly whenever this server's socket is readable until
136
- # #multi_response_completed?.
175
+ # #pipeline_response_completed?.
137
176
  #
138
177
  # Returns a Hash of kv pairs received.
139
- def multi_response_nonblock
140
- reconnect! "multi_response has completed" if @multi_buffer.nil?
141
-
142
- @multi_buffer << @sock.read_available
143
- buf = @multi_buffer
144
- pos = @position
178
+ def process_outstanding_pipeline_requests
179
+ reconnect_on_pipeline_complete!
145
180
  values = {}
146
181
 
147
- while buf.bytesize - pos >= 24
148
- header = buf.slice(pos, 24)
149
- (key_length, _, body_length, cas) = header.unpack(KV_HEADER)
182
+ response_buffer.read
150
183
 
151
- if key_length == 0
152
- # all done!
153
- @multi_buffer = nil
154
- @position = nil
155
- @inprogress = false
184
+ bytes_to_advance, status, key, value, cas = pipeline_response
185
+ # Loop while we have at least a complete header in the buffer
186
+ while bytes_to_advance.positive?
187
+ # If the status and key length are both zero, then this is the response
188
+ # to the noop at the end of the pipeline
189
+ if status.zero? && key.nil?
190
+ finish_pipeline
156
191
  break
192
+ end
157
193
 
158
- elsif buf.bytesize - pos >= 24 + body_length
159
- flags = buf.slice(pos + 24, 4).unpack1("N")
160
- key = buf.slice(pos + 24 + 4, key_length)
161
- value = buf.slice(pos + 24 + 4 + key_length, body_length - key_length - 4) if body_length - key_length - 4 > 0
162
-
163
- pos = pos + 24 + body_length
164
-
165
- begin
166
- values[key] = [deserialize(value, flags), cas]
167
- rescue DalliError
168
- end
194
+ # If the status is zero and the key len is positive, then this is a
195
+ # getkq response with a value that we want to set in the response hash
196
+ values[key] = [value, cas] unless key.nil?
169
197
 
170
- else
171
- # not enough data yet, wait for more
172
- break
173
- end
198
+ # Get the next set of bytes from the buffer
199
+ bytes_to_advance, status, key, value, cas = pipeline_response(bytes_to_advance)
174
200
  end
175
- @position = pos
176
201
 
177
202
  values
178
203
  rescue SystemCallError, Timeout::Error, EOFError => e
179
204
  failure!(e)
180
205
  end
181
206
 
182
- # Abort an earlier #multi_response_start. Used to signal an external
207
+ def read_nonblock
208
+ @sock.read_available
209
+ end
210
+
211
+ # Called after the noop response is received at the end of a set
212
+ # of pipelined gets
213
+ def finish_pipeline
214
+ response_buffer.clear
215
+ finish_request!
216
+ end
217
+
218
+ # Abort an earlier #pipeline_response_start. Used to signal an external
183
219
  # timeout. The underlying socket is disconnected, and the exception is
184
220
  # swallowed.
185
221
  #
186
222
  # Returns nothing.
187
- def multi_response_abort
188
- @multi_buffer = nil
189
- @position = nil
190
- @inprogress = false
191
- failure!(RuntimeError.new("External timeout"))
223
+ def pipeline_response_abort
224
+ response_buffer.clear
225
+ abort_request!
226
+ return true unless @sock
227
+
228
+ failure!(RuntimeError.new('External timeout'))
192
229
  rescue NetworkError
193
230
  true
194
231
  end
195
232
 
233
+ def read(count)
234
+ start_request!
235
+ data = @sock.readfull(count)
236
+ finish_request!
237
+ data
238
+ rescue SystemCallError, Timeout::Error, EOFError => e
239
+ failure!(e)
240
+ end
241
+
242
+ def write(bytes)
243
+ start_request!
244
+ result = @sock.write(bytes)
245
+ finish_request!
246
+ result
247
+ rescue SystemCallError, Timeout::Error => e
248
+ failure!(e)
249
+ end
250
+
251
+ def connected?
252
+ !@sock.nil?
253
+ end
254
+
255
+ def socket_timeout
256
+ @socket_timeout ||= @options[:socket_timeout]
257
+ end
258
+
196
259
  # NOTE: Additional public methods should be overridden in Dalli::Threadsafe
197
260
 
198
261
  private
199
262
 
200
- def verify_state
201
- failure!(RuntimeError.new("Already writing to socket")) if @inprogress
202
- if @pid && @pid != Process.pid
203
- message = "Fork detected, re-connecting child process..."
204
- Dalli.logger.info { message }
205
- reconnect! message
206
- end
263
+ def request_in_progress?
264
+ @request_in_progress
265
+ end
266
+
267
+ def start_request!
268
+ @request_in_progress = true
269
+ end
270
+
271
+ def finish_request!
272
+ @request_in_progress = false
273
+ end
274
+
275
+ def abort_request!
276
+ @request_in_progress = false
277
+ end
278
+
279
+ def verify_state(opkey)
280
+ failure!(RuntimeError.new('Already writing to socket')) if request_in_progress?
281
+ reconnect_on_fork if fork_detected?
282
+ verify_allowed_multi!(opkey) if multi?
283
+ end
284
+
285
+ def fork_detected?
286
+ @pid && @pid != Process.pid
207
287
  end
208
288
 
289
+ ALLOWED_MULTI_OPS = %i[add addq delete deleteq replace replaceq set setq noop].freeze
290
+ def verify_allowed_multi!(opkey)
291
+ return if ALLOWED_MULTI_OPS.include?(opkey)
292
+
293
+ raise Dalli::NotPermittedMultiOpError, "The operation #{opkey} is not allowed in a multi block."
294
+ end
295
+
296
+ def reconnect_on_fork
297
+ message = 'Fork detected, re-connecting child process...'
298
+ Dalli.logger.info { message }
299
+ reconnect! message
300
+ end
301
+
302
+ # Marks the server instance as needing reconnect. Raises a
303
+ # Dalli::NetworkError with the specified message. Calls close
304
+ # to clean up socket state
209
305
  def reconnect!(message)
210
306
  close
211
307
  sleep(options[:socket_failure_delay]) if options[:socket_failure_delay]
212
308
  raise Dalli::NetworkError, message
213
309
  end
214
310
 
311
+ # Raises Dalli::NetworkError
215
312
  def failure!(exception)
216
313
  message = "#{name} failed (count: #{@fail_count}) #{exception.class}: #{exception.message}"
217
314
  Dalli.logger.warn { message }
@@ -220,34 +317,47 @@ module Dalli
220
317
  if @fail_count >= options[:socket_max_failures]
221
318
  down!
222
319
  else
223
- reconnect! "Socket operation failed, retrying..."
320
+ reconnect! 'Socket operation failed, retrying...'
224
321
  end
225
322
  end
226
323
 
324
+ # Marks the server instance as down. Updates the down_at state
325
+ # and raises an Dalli::NetworkError that includes the underlying
326
+ # error in the message. Calls close to clean up socket state
227
327
  def down!
228
328
  close
329
+ log_down_detected
229
330
 
331
+ @error = $ERROR_INFO&.class&.name
332
+ @msg ||= $ERROR_INFO&.message
333
+ raise Dalli::NetworkError, "#{name} is down: #{@error} #{@msg}"
334
+ end
335
+
336
+ def log_down_detected
230
337
  @last_down_at = Time.now
231
338
 
232
339
  if @down_at
233
340
  time = Time.now - @down_at
234
- Dalli.logger.debug { "#{name} is still down (for %.3f seconds now)" % time }
341
+ Dalli.logger.debug { format('%<name>s is still down (for %<time>.3f seconds now)', name: name, time: time) }
235
342
  else
236
343
  @down_at = @last_down_at
237
- Dalli.logger.warn { "#{name} is down" }
344
+ Dalli.logger.warn("#{name} is down")
238
345
  end
346
+ end
239
347
 
240
- @error = $!&.class&.name
241
- @msg ||= $!&.message
242
- raise Dalli::NetworkError, "#{name} is down: #{@error} #{@msg}"
348
+ def log_up_detected
349
+ return unless @down_at
350
+
351
+ time = Time.now - @down_at
352
+ Dalli.logger.warn { format('%<name>s is back (downtime was %<time>.3f seconds)', name: name, time: time) }
243
353
  end
244
354
 
245
355
  def up!
246
- if @down_at
247
- time = Time.now - @down_at
248
- Dalli.logger.warn { "#{name} is back (downtime was %.3f seconds)" % time }
249
- end
356
+ log_up_detected
357
+ reset_down_info
358
+ end
250
359
 
360
+ def reset_down_info
251
361
  @fail_count = 0
252
362
  @down_at = nil
253
363
  @last_down_at = nil
@@ -256,99 +366,106 @@ module Dalli
256
366
  end
257
367
 
258
368
  def multi?
259
- Thread.current[:dalli_multi]
369
+ Thread.current[::Dalli::MULTI_KEY]
370
+ end
371
+
372
+ def cache_nils?(opts)
373
+ return false unless opts.is_a?(Hash)
374
+
375
+ opts[:cache_nils] ? true : false
260
376
  end
261
377
 
262
378
  def get(key, options = nil)
263
- req = [REQUEST, OPCODES[:get], key.bytesize, 0, 0, 0, key.bytesize, 0, 0, key].pack(FORMAT[:get])
379
+ req = RequestFormatter.standard_request(opkey: :get, key: key)
264
380
  write(req)
265
- generic_response(true, !!(options && options.is_a?(Hash) && options[:cache_nils]))
381
+ @response_processor.generic_response(unpack: true, cache_nils: cache_nils?(options))
266
382
  end
267
383
 
268
- def send_multiget(keys)
269
- req = +""
384
+ def pipelined_get(keys)
385
+ req = +''
270
386
  keys.each do |key|
271
- req << [REQUEST, OPCODES[:getkq], key.bytesize, 0, 0, 0, key.bytesize, 0, 0, key].pack(FORMAT[:getkq])
387
+ req << RequestFormatter.standard_request(opkey: :getkq, key: key)
272
388
  end
273
- # Could send noop here instead of in multi_response_start
389
+ # Could send noop here instead of in pipeline_response_start
274
390
  write(req)
275
391
  end
276
392
 
277
393
  def set(key, value, ttl, cas, options)
278
- (value, flags) = serialize(key, value, options)
279
- ttl = TtlSanitizer.sanitize(ttl)
280
-
281
- guard_max_value(key, value)
282
-
283
- req = [REQUEST, OPCODES[multi? ? :setq : :set], key.bytesize, 8, 0, 0, value.bytesize + key.bytesize + 8, 0, cas, flags, ttl, key, value].pack(FORMAT[:set])
284
- write(req)
285
- cas_response unless multi?
394
+ opkey = multi? ? :setq : :set
395
+ process_value_req(opkey, key, value, ttl, cas, options)
286
396
  end
287
397
 
288
398
  def add(key, value, ttl, options)
289
- (value, flags) = serialize(key, value, options)
290
- ttl = TtlSanitizer.sanitize(ttl)
291
-
292
- guard_max_value(key, value)
293
-
294
- req = [REQUEST, OPCODES[multi? ? :addq : :add], key.bytesize, 8, 0, 0, value.bytesize + key.bytesize + 8, 0, 0, flags, ttl, key, value].pack(FORMAT[:add])
295
- write(req)
296
- cas_response unless multi?
399
+ opkey = multi? ? :addq : :add
400
+ cas = 0
401
+ process_value_req(opkey, key, value, ttl, cas, options)
297
402
  end
298
403
 
299
404
  def replace(key, value, ttl, cas, options)
300
- (value, flags) = serialize(key, value, options)
301
- ttl = TtlSanitizer.sanitize(ttl)
405
+ opkey = multi? ? :replaceq : :replace
406
+ process_value_req(opkey, key, value, ttl, cas, options)
407
+ end
302
408
 
303
- guard_max_value(key, value)
409
+ # rubocop:disable Metrics/ParameterLists
410
+ def process_value_req(opkey, key, value, ttl, cas, options)
411
+ (value, bitflags) = @value_marshaller.store(key, value, options)
412
+ ttl = TtlSanitizer.sanitize(ttl)
304
413
 
305
- req = [REQUEST, OPCODES[multi? ? :replaceq : :replace], key.bytesize, 8, 0, 0, value.bytesize + key.bytesize + 8, 0, cas, flags, ttl, key, value].pack(FORMAT[:replace])
414
+ req = RequestFormatter.standard_request(opkey: opkey, key: key,
415
+ value: value, bitflags: bitflags,
416
+ ttl: ttl, cas: cas)
306
417
  write(req)
307
- cas_response unless multi?
418
+ @response_processor.cas_response unless multi?
308
419
  end
420
+ # rubocop:enable Metrics/ParameterLists
309
421
 
310
422
  def delete(key, cas)
311
- req = [REQUEST, OPCODES[multi? ? :deleteq : :delete], key.bytesize, 0, 0, 0, key.bytesize, 0, cas, key].pack(FORMAT[:delete])
423
+ opkey = multi? ? :deleteq : :delete
424
+ req = RequestFormatter.standard_request(opkey: opkey, key: key, cas: cas)
312
425
  write(req)
313
- generic_response unless multi?
426
+ @response_processor.generic_response unless multi?
314
427
  end
315
428
 
316
- def flush(ttl)
317
- req = [REQUEST, OPCODES[:flush], 0, 4, 0, 0, 4, 0, 0, 0].pack(FORMAT[:flush])
429
+ def flush(ttl = 0)
430
+ req = RequestFormatter.standard_request(opkey: :flush, ttl: ttl)
318
431
  write(req)
319
- generic_response
432
+ @response_processor.generic_response
320
433
  end
321
434
 
322
- def decr_incr(opcode, key, count, ttl, default)
323
- expiry = default ? TtlSanitizer.sanitize(ttl) : 0xFFFFFFFF
324
- default ||= 0
325
- (h, l) = split(count)
326
- (dh, dl) = split(default)
327
- req = [REQUEST, OPCODES[opcode], key.bytesize, 20, 0, 0, key.bytesize + 20, 0, 0, h, l, dh, dl, expiry, key].pack(FORMAT[opcode])
328
- write(req)
329
- body = generic_response
330
- body ? body.unpack1("Q>") : body
435
+ # This allows us to special case a nil initial value, and
436
+ # handle it differently than a zero. This special value
437
+ # for expiry causes memcached to return a not found
438
+ # if the key doesn't already exist, rather than
439
+ # setting the initial value
440
+ NOT_FOUND_EXPIRY = 0xFFFFFFFF
441
+
442
+ def decr_incr(opkey, key, count, ttl, initial)
443
+ expiry = initial ? TtlSanitizer.sanitize(ttl) : NOT_FOUND_EXPIRY
444
+ initial ||= 0
445
+ write(RequestFormatter.decr_incr_request(opkey: opkey, key: key,
446
+ count: count, initial: initial, expiry: expiry))
447
+ @response_processor.decr_incr_response
331
448
  end
332
449
 
333
- def decr(key, count, ttl, default)
334
- decr_incr :decr, key, count, ttl, default
450
+ def decr(key, count, ttl, initial)
451
+ decr_incr :decr, key, count, ttl, initial
335
452
  end
336
453
 
337
- def incr(key, count, ttl, default)
338
- decr_incr :incr, key, count, ttl, default
454
+ def incr(key, count, ttl, initial)
455
+ decr_incr :incr, key, count, ttl, initial
339
456
  end
340
457
 
341
- def write_append_prepend(opcode, key, value)
342
- write_generic [REQUEST, OPCODES[opcode], key.bytesize, 0, 0, 0, value.bytesize + key.bytesize, 0, 0, key, value].pack(FORMAT[opcode])
458
+ def write_append_prepend(opkey, key, value)
459
+ write_generic RequestFormatter.standard_request(opkey: opkey, key: key, value: value)
343
460
  end
344
461
 
345
462
  def write_generic(bytes)
346
463
  write(bytes)
347
- generic_response
464
+ @response_processor.generic_response
348
465
  end
349
466
 
350
467
  def write_noop
351
- req = [REQUEST, OPCODES[:noop], 0, 0, 0, 0, 0, 0, 0].pack(FORMAT[:noop])
468
+ req = RequestFormatter.standard_request(opkey: :noop)
352
469
  write(req)
353
470
  end
354
471
 
@@ -356,7 +473,7 @@ module Dalli
356
473
  # We need to read all the responses at once.
357
474
  def noop
358
475
  write_noop
359
- multi_response
476
+ @response_processor.multi_with_keys_response
360
477
  end
361
478
 
362
479
  def append(key, value)
@@ -367,188 +484,36 @@ module Dalli
367
484
  write_append_prepend :prepend, key, value
368
485
  end
369
486
 
370
- def stats(info = "")
371
- req = [REQUEST, OPCODES[:stat], info.bytesize, 0, 0, 0, info.bytesize, 0, 0, info].pack(FORMAT[:stat])
487
+ def stats(info = '')
488
+ req = RequestFormatter.standard_request(opkey: :stat, key: info)
372
489
  write(req)
373
- keyvalue_response
490
+ @response_processor.multi_with_keys_response
374
491
  end
375
492
 
376
493
  def reset_stats
377
- write_generic [REQUEST, OPCODES[:stat], "reset".bytesize, 0, 0, 0, "reset".bytesize, 0, 0, "reset"].pack(FORMAT[:stat])
494
+ write_generic RequestFormatter.standard_request(opkey: :stat, key: 'reset')
378
495
  end
379
496
 
380
497
  def cas(key)
381
- req = [REQUEST, OPCODES[:get], key.bytesize, 0, 0, 0, key.bytesize, 0, 0, key].pack(FORMAT[:get])
498
+ req = RequestFormatter.standard_request(opkey: :get, key: key)
382
499
  write(req)
383
- data_cas_response
500
+ @response_processor.data_cas_response
384
501
  end
385
502
 
386
503
  def version
387
- write_generic [REQUEST, OPCODES[:version], 0, 0, 0, 0, 0, 0, 0].pack(FORMAT[:noop])
504
+ write_generic RequestFormatter.standard_request(opkey: :version)
388
505
  end
389
506
 
390
507
  def touch(key, ttl)
391
508
  ttl = TtlSanitizer.sanitize(ttl)
392
- write_generic [REQUEST, OPCODES[:touch], key.bytesize, 4, 0, 0, key.bytesize + 4, 0, 0, ttl, key].pack(FORMAT[:touch])
509
+ write_generic RequestFormatter.standard_request(opkey: :touch, key: key, ttl: ttl)
393
510
  end
394
511
 
395
512
  def gat(key, ttl, options = nil)
396
513
  ttl = TtlSanitizer.sanitize(ttl)
397
- req = [REQUEST, OPCODES[:gat], key.bytesize, 4, 0, 0, key.bytesize + 4, 0, 0, ttl, key].pack(FORMAT[:gat])
514
+ req = RequestFormatter.standard_request(opkey: :gat, key: key, ttl: ttl)
398
515
  write(req)
399
- generic_response(true, !!(options && options.is_a?(Hash) && options[:cache_nils]))
400
- end
401
-
402
- # https://www.hjp.at/zettel/m/memcached_flags.rxml
403
- # Looks like most clients use bit 0 to indicate native language serialization
404
- FLAG_SERIALIZED = 0x1
405
-
406
- def serialize(key, value, options = nil)
407
- marshalled = false
408
- value = if options && options[:raw]
409
- value.to_s
410
- else
411
- marshalled = true
412
- begin
413
- serializer.dump(value)
414
- rescue Timeout::Error => e
415
- raise e
416
- rescue => ex
417
- # Marshalling can throw several different types of generic Ruby exceptions.
418
- # Convert to a specific exception so we can special case it higher up the stack.
419
- exc = Dalli::MarshalError.new(ex.message)
420
- exc.set_backtrace ex.backtrace
421
- raise exc
422
- end
423
- end
424
-
425
- bitflags = 0
426
- value, bitflags = @value_compressor.store(value, options, bitflags)
427
-
428
- bitflags |= FLAG_SERIALIZED if marshalled
429
- [value, bitflags]
430
- end
431
-
432
- def deserialize(value, flags)
433
- value = @value_compressor.retrieve(value, flags)
434
- value = serializer.load(value) if (flags & FLAG_SERIALIZED) != 0
435
- value
436
- rescue TypeError
437
- raise unless /needs to have method `_load'|exception class\/object expected|instance of IO needed|incompatible marshal file format/.match?($!.message)
438
- raise UnmarshalError, "Unable to unmarshal value: #{$!.message}"
439
- rescue ArgumentError
440
- raise unless /undefined class|marshal data too short/.match?($!.message)
441
- raise UnmarshalError, "Unable to unmarshal value: #{$!.message}"
442
- rescue NameError
443
- raise unless /uninitialized constant/.match?($!.message)
444
- raise UnmarshalError, "Unable to unmarshal value: #{$!.message}"
445
- end
446
-
447
- def data_cas_response
448
- (extras, _, status, count, _, cas) = read_header.unpack(CAS_HEADER)
449
- data = read(count) if count > 0
450
- if status == 1
451
- nil
452
- elsif status != 0
453
- raise Dalli::DalliError, "Response error #{status}: #{RESPONSE_CODES[status]}"
454
- elsif data
455
- flags = data[0...extras].unpack1("N")
456
- value = data[extras..-1]
457
- data = deserialize(value, flags)
458
- end
459
- [data, cas]
460
- end
461
-
462
- CAS_HEADER = "@4CCnNNQ"
463
- NORMAL_HEADER = "@4CCnN"
464
- KV_HEADER = "@2n@6nN@16Q"
465
-
466
- def guard_max_value(key, value)
467
- return if value.bytesize <= @options[:value_max_bytes]
468
-
469
- message = "Value for #{key} over max size: #{@options[:value_max_bytes]} <= #{value.bytesize}"
470
- raise Dalli::ValueOverMaxSize, message
471
- end
472
-
473
- # Implements the NullObject pattern to store an application-defined value for 'Key not found' responses.
474
- class NilObject; end
475
- NOT_FOUND = NilObject.new
476
-
477
- def generic_response(unpack = false, cache_nils = false)
478
- (extras, _, status, count) = read_header.unpack(NORMAL_HEADER)
479
- data = read(count) if count > 0
480
- if status == 1
481
- cache_nils ? NOT_FOUND : nil
482
- elsif status == 2 || status == 5
483
- false # Not stored, normal status for add operation
484
- elsif status != 0
485
- raise Dalli::DalliError, "Response error #{status}: #{RESPONSE_CODES[status]}"
486
- elsif data
487
- flags = data.byteslice(0, extras).unpack1("N")
488
- value = data.byteslice(extras, data.bytesize - extras)
489
- unpack ? deserialize(value, flags) : value
490
- else
491
- true
492
- end
493
- end
494
-
495
- def cas_response
496
- (_, _, status, count, _, cas) = read_header.unpack(CAS_HEADER)
497
- read(count) if count > 0 # this is potential data that we don't care about
498
- if status == 1
499
- nil
500
- elsif status == 2 || status == 5
501
- false # Not stored, normal status for add operation
502
- elsif status != 0
503
- raise Dalli::DalliError, "Response error #{status}: #{RESPONSE_CODES[status]}"
504
- else
505
- cas
506
- end
507
- end
508
-
509
- def keyvalue_response
510
- hash = {}
511
- loop do
512
- (key_length, _, body_length, _) = read_header.unpack(KV_HEADER)
513
- return hash if key_length == 0
514
- key = read(key_length)
515
- value = read(body_length - key_length) if body_length - key_length > 0
516
- hash[key] = value
517
- end
518
- end
519
-
520
- def multi_response
521
- hash = {}
522
- loop do
523
- (key_length, _, body_length, _) = read_header.unpack(KV_HEADER)
524
- return hash if key_length == 0
525
- flags = read(4).unpack1("N")
526
- key = read(key_length)
527
- value = read(body_length - key_length - 4) if body_length - key_length - 4 > 0
528
- hash[key] = deserialize(value, flags)
529
- end
530
- end
531
-
532
- def write(bytes)
533
- @inprogress = true
534
- result = @sock.write(bytes)
535
- @inprogress = false
536
- result
537
- rescue SystemCallError, Timeout::Error => e
538
- failure!(e)
539
- end
540
-
541
- def read(count)
542
- @inprogress = true
543
- data = @sock.readfull(count)
544
- @inprogress = false
545
- data
546
- rescue SystemCallError, Timeout::Error, EOFError => e
547
- failure!(e)
548
- end
549
-
550
- def read_header
551
- read(24) || raise(Dalli::NetworkError, "No response")
516
+ @response_processor.generic_response(unpack: true, cache_nils: cache_nils?(options))
552
517
  end
553
518
 
554
519
  def connect
@@ -556,13 +521,9 @@ module Dalli
556
521
 
557
522
  begin
558
523
  @pid = Process.pid
559
- @sock = if socket_type == :unix
560
- Dalli::Socket::UNIX.open(hostname, self, options)
561
- else
562
- Dalli::Socket::TCP.open(hostname, port, self, options)
563
- end
564
- sasl_authentication if need_auth?
565
- @version = version # trigger actual connect
524
+ @sock = memcached_socket
525
+ authenticate_connection if require_auth?
526
+ @version = version # Connect socket if not authed
566
527
  up!
567
528
  rescue Dalli::DalliError # SASL auth failure
568
529
  raise
@@ -572,132 +533,27 @@ module Dalli
572
533
  end
573
534
  end
574
535
 
575
- def split(n)
576
- [n >> 32, 0xFFFFFFFF & n]
577
- end
578
-
579
- REQUEST = 0x80
580
- RESPONSE = 0x81
581
-
582
- # Response codes taken from:
583
- # https://github.com/memcached/memcached/wiki/BinaryProtocolRevamped#response-status
584
- RESPONSE_CODES = {
585
- 0 => "No error",
586
- 1 => "Key not found",
587
- 2 => "Key exists",
588
- 3 => "Value too large",
589
- 4 => "Invalid arguments",
590
- 5 => "Item not stored",
591
- 6 => "Incr/decr on a non-numeric value",
592
- 7 => "The vbucket belongs to another server",
593
- 8 => "Authentication error",
594
- 9 => "Authentication continue",
595
- 0x20 => "Authentication required",
596
- 0x81 => "Unknown command",
597
- 0x82 => "Out of memory",
598
- 0x83 => "Not supported",
599
- 0x84 => "Internal error",
600
- 0x85 => "Busy",
601
- 0x86 => "Temporary failure"
602
- }
603
-
604
- OPCODES = {
605
- get: 0x00,
606
- set: 0x01,
607
- add: 0x02,
608
- replace: 0x03,
609
- delete: 0x04,
610
- incr: 0x05,
611
- decr: 0x06,
612
- flush: 0x08,
613
- noop: 0x0A,
614
- version: 0x0B,
615
- getkq: 0x0D,
616
- append: 0x0E,
617
- prepend: 0x0F,
618
- stat: 0x10,
619
- setq: 0x11,
620
- addq: 0x12,
621
- replaceq: 0x13,
622
- deleteq: 0x14,
623
- incrq: 0x15,
624
- decrq: 0x16,
625
- auth_negotiation: 0x20,
626
- auth_request: 0x21,
627
- auth_continue: 0x22,
628
- touch: 0x1C,
629
- gat: 0x1D
630
- }
631
-
632
- HEADER = "CCnCCnNNQ"
633
- OP_FORMAT = {
634
- get: "a*",
635
- set: "NNa*a*",
636
- add: "NNa*a*",
637
- replace: "NNa*a*",
638
- delete: "a*",
639
- incr: "NNNNNa*",
640
- decr: "NNNNNa*",
641
- flush: "N",
642
- noop: "",
643
- getkq: "a*",
644
- version: "",
645
- stat: "a*",
646
- append: "a*a*",
647
- prepend: "a*a*",
648
- auth_request: "a*a*",
649
- auth_continue: "a*a*",
650
- touch: "Na*",
651
- gat: "Na*"
652
- }
653
- FORMAT = OP_FORMAT.each_with_object({}) { |(k, v), memo| memo[k] = HEADER + v; }
654
-
655
- #######
656
- # SASL authentication support for NorthScale
657
- #######
658
-
659
- def need_auth?
660
- @options[:username] || ENV["MEMCACHE_USERNAME"]
536
+ def memcached_socket
537
+ if socket_type == :unix
538
+ Dalli::Socket::UNIX.open(hostname, options)
539
+ else
540
+ Dalli::Socket::TCP.open(hostname, port, options)
541
+ end
542
+ end
543
+
544
+ def require_auth?
545
+ !username.nil?
661
546
  end
662
547
 
663
548
  def username
664
- @options[:username] || ENV["MEMCACHE_USERNAME"]
549
+ @options[:username] || ENV['MEMCACHE_USERNAME']
665
550
  end
666
551
 
667
552
  def password
668
- @options[:password] || ENV["MEMCACHE_PASSWORD"]
553
+ @options[:password] || ENV['MEMCACHE_PASSWORD']
669
554
  end
670
555
 
671
- def sasl_authentication
672
- Dalli.logger.info { "Dalli/SASL authenticating as #{username}" }
673
-
674
- # negotiate
675
- req = [REQUEST, OPCODES[:auth_negotiation], 0, 0, 0, 0, 0, 0, 0].pack(FORMAT[:noop])
676
- write(req)
677
-
678
- (extras, _type, status, count) = read_header.unpack(NORMAL_HEADER)
679
- raise Dalli::NetworkError, "Unexpected message format: #{extras} #{count}" unless extras == 0 && count > 0
680
- content = read(count).tr("\u0000", " ")
681
- return Dalli.logger.debug("Authentication not required/supported by server") if status == 0x81
682
- mechanisms = content.split(" ")
683
- raise NotImplementedError, "Dalli only supports the PLAIN authentication mechanism" unless mechanisms.include?("PLAIN")
684
-
685
- # request
686
- mechanism = "PLAIN"
687
- msg = "\x0#{username}\x0#{password}"
688
- req = [REQUEST, OPCODES[:auth_request], mechanism.bytesize, 0, 0, 0, mechanism.bytesize + msg.bytesize, 0, 0, mechanism, msg].pack(FORMAT[:auth_request])
689
- write(req)
690
-
691
- (extras, _type, status, count) = read_header.unpack(NORMAL_HEADER)
692
- raise Dalli::NetworkError, "Unexpected message format: #{extras} #{count}" unless extras == 0 && count > 0
693
- content = read(count)
694
- return Dalli.logger.info("Dalli/SASL: #{content}") if status == 0
695
-
696
- raise Dalli::DalliError, "Error authenticating: #{status}" unless status == 0x21
697
- raise NotImplementedError, "No two-step authentication mechanisms supported"
698
- # (step, msg) = sasl.receive('challenge', content)
699
- # raise Dalli::NetworkError, "Authentication failed" if sasl.failed? || step != 'response'
700
- end
556
+ include SaslAuthentication
701
557
  end
702
558
  end
703
559
  end