dalli 2.7.2 → 3.2.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +5 -5
  2. data/{History.md → CHANGELOG.md} +231 -0
  3. data/Gemfile +14 -5
  4. data/LICENSE +1 -1
  5. data/README.md +33 -201
  6. data/lib/dalli/cas/client.rb +2 -57
  7. data/lib/dalli/client.rb +259 -254
  8. data/lib/dalli/compressor.rb +13 -2
  9. data/lib/dalli/key_manager.rb +121 -0
  10. data/lib/dalli/options.rb +7 -7
  11. data/lib/dalli/pid_cache.rb +40 -0
  12. data/lib/dalli/pipelined_getter.rb +177 -0
  13. data/lib/dalli/protocol/base.rb +239 -0
  14. data/lib/dalli/protocol/binary/request_formatter.rb +117 -0
  15. data/lib/dalli/protocol/binary/response_header.rb +36 -0
  16. data/lib/dalli/protocol/binary/response_processor.rb +239 -0
  17. data/lib/dalli/protocol/binary/sasl_authentication.rb +60 -0
  18. data/lib/dalli/protocol/binary.rb +173 -0
  19. data/lib/dalli/protocol/connection_manager.rb +254 -0
  20. data/lib/dalli/protocol/meta/key_regularizer.rb +31 -0
  21. data/lib/dalli/protocol/meta/request_formatter.rb +121 -0
  22. data/lib/dalli/protocol/meta/response_processor.rb +211 -0
  23. data/lib/dalli/protocol/meta.rb +178 -0
  24. data/lib/dalli/protocol/response_buffer.rb +54 -0
  25. data/lib/dalli/protocol/server_config_parser.rb +86 -0
  26. data/lib/dalli/protocol/ttl_sanitizer.rb +45 -0
  27. data/lib/dalli/protocol/value_compressor.rb +85 -0
  28. data/lib/dalli/protocol/value_marshaller.rb +59 -0
  29. data/lib/dalli/protocol/value_serializer.rb +91 -0
  30. data/lib/dalli/protocol.rb +8 -0
  31. data/lib/dalli/ring.rb +97 -86
  32. data/lib/dalli/server.rb +4 -694
  33. data/lib/dalli/servers_arg_normalizer.rb +54 -0
  34. data/lib/dalli/socket.rb +122 -80
  35. data/lib/dalli/version.rb +5 -1
  36. data/lib/dalli.rb +45 -14
  37. data/lib/rack/session/dalli.rb +162 -42
  38. metadata +40 -96
  39. data/Performance.md +0 -42
  40. data/Rakefile +0 -42
  41. data/dalli.gemspec +0 -29
  42. data/lib/action_dispatch/middleware/session/dalli_store.rb +0 -81
  43. data/lib/active_support/cache/dalli_store.rb +0 -363
  44. data/lib/dalli/railtie.rb +0 -7
  45. data/test/benchmark_test.rb +0 -242
  46. data/test/helper.rb +0 -55
  47. data/test/memcached_mock.rb +0 -121
  48. data/test/sasldb +0 -1
  49. data/test/test_active_support.rb +0 -439
  50. data/test/test_cas_client.rb +0 -107
  51. data/test/test_compressor.rb +0 -53
  52. data/test/test_dalli.rb +0 -625
  53. data/test/test_encoding.rb +0 -32
  54. data/test/test_failover.rb +0 -128
  55. data/test/test_network.rb +0 -54
  56. data/test/test_rack_session.rb +0 -341
  57. data/test/test_ring.rb +0 -85
  58. data/test/test_sasl.rb +0 -110
  59. data/test/test_serializer.rb +0 -30
  60. data/test/test_server.rb +0 -80
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'socket'
5
+ require 'timeout'
6
+
7
+ require 'dalli/pid_cache'
8
+
9
+ module Dalli
10
+ module Protocol
11
+ ##
12
+ # Manages the socket connection to the server, including ensuring liveness
13
+ # and retries.
14
+ ##
15
+ class ConnectionManager
16
+ DEFAULTS = {
17
+ # seconds between trying to contact a remote server
18
+ down_retry_delay: 30,
19
+ # connect/read/write timeout for socket operations
20
+ socket_timeout: 1,
21
+ # times a socket operation may fail before considering the server dead
22
+ socket_max_failures: 2,
23
+ # amount of time to sleep between retries when a failure occurs
24
+ socket_failure_delay: 0.1,
25
+ # Set keepalive
26
+ keepalive: true
27
+ }.freeze
28
+
29
+ attr_accessor :hostname, :port, :socket_type, :options
30
+ attr_reader :sock
31
+
32
+ def initialize(hostname, port, socket_type, client_options)
33
+ @hostname = hostname
34
+ @port = port
35
+ @socket_type = socket_type
36
+ @options = DEFAULTS.merge(client_options)
37
+ @request_in_progress = false
38
+ @sock = nil
39
+ @pid = nil
40
+
41
+ reset_down_info
42
+ end
43
+
44
+ def name
45
+ if socket_type == :unix
46
+ hostname
47
+ else
48
+ "#{hostname}:#{port}"
49
+ end
50
+ end
51
+
52
+ def establish_connection
53
+ Dalli.logger.debug { "Dalli::Server#connect #{name}" }
54
+
55
+ @sock = memcached_socket
56
+ @pid = PIDCache.pid
57
+ rescue SystemCallError, Timeout::Error, EOFError, SocketError => e
58
+ # SocketError = DNS resolution failure
59
+ error_on_request!(e)
60
+ end
61
+
62
+ def reconnect_down_server?
63
+ return true unless @last_down_at
64
+
65
+ time_to_next_reconnect = @last_down_at + options[:down_retry_delay] - Time.now
66
+ return true unless time_to_next_reconnect.positive?
67
+
68
+ Dalli.logger.debug do
69
+ format('down_retry_delay not reached for %<name>s (%<time>.3f seconds left)', name: name,
70
+ time: time_to_next_reconnect)
71
+ end
72
+ false
73
+ end
74
+
75
+ def up!
76
+ log_up_detected
77
+ reset_down_info
78
+ end
79
+
80
+ # Marks the server instance as down. Updates the down_at state
81
+ # and raises an Dalli::NetworkError that includes the underlying
82
+ # error in the message. Calls close to clean up socket state
83
+ def down!
84
+ close
85
+ log_down_detected
86
+
87
+ @error = $ERROR_INFO&.class&.name
88
+ @msg ||= $ERROR_INFO&.message
89
+ raise_down_error
90
+ end
91
+
92
+ def raise_down_error
93
+ raise Dalli::NetworkError, "#{name} is down: #{@error} #{@msg}"
94
+ end
95
+
96
+ def socket_timeout
97
+ @socket_timeout ||= @options[:socket_timeout]
98
+ end
99
+
100
+ def confirm_ready!
101
+ error_on_request!(RuntimeError.new('Already writing to socket')) if request_in_progress?
102
+ close_on_fork if fork_detected?
103
+ end
104
+
105
+ def close
106
+ return unless @sock
107
+
108
+ begin
109
+ @sock.close
110
+ rescue StandardError
111
+ nil
112
+ end
113
+ @sock = nil
114
+ @pid = nil
115
+ abort_request!
116
+ end
117
+
118
+ def connected?
119
+ !@sock.nil?
120
+ end
121
+
122
+ def request_in_progress?
123
+ @request_in_progress
124
+ end
125
+
126
+ def start_request!
127
+ @request_in_progress = true
128
+ end
129
+
130
+ def finish_request!
131
+ @request_in_progress = false
132
+ end
133
+
134
+ def abort_request!
135
+ @request_in_progress = false
136
+ end
137
+
138
+ def read_line
139
+ start_request!
140
+ data = @sock.gets("\r\n")
141
+ error_on_request!('EOF in read_line') if data.nil?
142
+ finish_request!
143
+ data
144
+ rescue SystemCallError, Timeout::Error, EOFError => e
145
+ error_on_request!(e)
146
+ end
147
+
148
+ def read(count)
149
+ start_request!
150
+ data = @sock.readfull(count)
151
+ finish_request!
152
+ data
153
+ rescue SystemCallError, Timeout::Error, EOFError => e
154
+ error_on_request!(e)
155
+ end
156
+
157
+ def write(bytes)
158
+ start_request!
159
+ result = @sock.write(bytes)
160
+ finish_request!
161
+ result
162
+ rescue SystemCallError, Timeout::Error => e
163
+ error_on_request!(e)
164
+ end
165
+
166
+ # Non-blocking read. Should only be used in the context
167
+ # of a caller who has called start_request!, but not yet
168
+ # called finish_request!. Here to support the operation
169
+ # of the get_multi operation
170
+ def read_nonblock
171
+ @sock.read_available
172
+ end
173
+
174
+ def max_allowed_failures
175
+ @max_allowed_failures ||= @options[:socket_max_failures] || 2
176
+ end
177
+
178
+ def error_on_request!(err_or_string)
179
+ log_warn_message(err_or_string)
180
+
181
+ @fail_count += 1
182
+ if @fail_count >= max_allowed_failures
183
+ down!
184
+ else
185
+ # Closes the existing socket, setting up for a reconnect
186
+ # on next request
187
+ reconnect!('Socket operation failed, retrying...')
188
+ end
189
+ end
190
+
191
+ def reconnect!(message)
192
+ close
193
+ sleep(options[:socket_failure_delay]) if options[:socket_failure_delay]
194
+ raise Dalli::NetworkError, message
195
+ end
196
+
197
+ def reset_down_info
198
+ @fail_count = 0
199
+ @down_at = nil
200
+ @last_down_at = nil
201
+ @msg = nil
202
+ @error = nil
203
+ end
204
+
205
+ def memcached_socket
206
+ if socket_type == :unix
207
+ Dalli::Socket::UNIX.open(hostname, options)
208
+ else
209
+ Dalli::Socket::TCP.open(hostname, port, options)
210
+ end
211
+ end
212
+
213
+ def log_warn_message(err_or_string)
214
+ detail = err_or_string.is_a?(String) ? err_or_string : "#{err_or_string.class}: #{err_or_string.message}"
215
+ Dalli.logger.warn do
216
+ detail = err_or_string.is_a?(String) ? err_or_string : "#{err_or_string.class}: #{err_or_string.message}"
217
+ "#{name} failed (count: #{@fail_count}) #{detail}"
218
+ end
219
+ end
220
+
221
+ def close_on_fork
222
+ message = 'Fork detected, re-connecting child process...'
223
+ Dalli.logger.info { message }
224
+ # Close socket on a fork, setting us up for reconnect
225
+ # on next request.
226
+ close
227
+ raise Dalli::NetworkError, message
228
+ end
229
+
230
+ def fork_detected?
231
+ @pid && @pid != PIDCache.pid
232
+ end
233
+
234
+ def log_down_detected
235
+ @last_down_at = Time.now
236
+
237
+ if @down_at
238
+ time = Time.now - @down_at
239
+ Dalli.logger.debug { format('%<name>s is still down (for %<time>.3f seconds now)', name: name, time: time) }
240
+ else
241
+ @down_at = @last_down_at
242
+ Dalli.logger.warn("#{name} is down")
243
+ end
244
+ end
245
+
246
+ def log_up_detected
247
+ return unless @down_at
248
+
249
+ time = Time.now - @down_at
250
+ Dalli.logger.warn { format('%<name>s is back (downtime was %<time>.3f seconds)', name: name, time: time) }
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Dalli
6
+ module Protocol
7
+ class Meta
8
+ ##
9
+ # The meta protocol requires that keys be ASCII only, so Unicode keys are
10
+ # not supported. In addition, the use of whitespace in the key is not
11
+ # allowed.
12
+ # memcached supports the use of base64 hashes for keys containing
13
+ # whitespace or non-ASCII characters, provided the 'b' flag is included in the request.
14
+ class KeyRegularizer
15
+ WHITESPACE = /\s/.freeze
16
+
17
+ def self.encode(key)
18
+ return [key, false] if key.ascii_only? && !WHITESPACE.match(key)
19
+
20
+ [Base64.strict_encode64(key), true]
21
+ end
22
+
23
+ def self.decode(encoded_key, base64_encoded)
24
+ return encoded_key unless base64_encoded
25
+
26
+ Base64.strict_decode64(encoded_key).force_encoding(Encoding::UTF_8)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: false
2
+
3
+ module Dalli
4
+ module Protocol
5
+ class Meta
6
+ ##
7
+ # Class that encapsulates logic for formatting meta protocol requests
8
+ # to memcached.
9
+ ##
10
+ class RequestFormatter
11
+ # Since these are string construction methods, we're going to disable these
12
+ # Rubocop directives. We really can't make this construction much simpler,
13
+ # and introducing an intermediate object seems like overkill.
14
+ #
15
+ # rubocop:disable Metrics/CyclomaticComplexity
16
+ # rubocop:disable Metrics/MethodLength
17
+ # rubocop:disable Metrics/ParameterLists
18
+ # rubocop:disable Metrics/PerceivedComplexity
19
+ def self.meta_get(key:, value: true, return_cas: false, ttl: nil, base64: false, quiet: false)
20
+ cmd = "mg #{key}"
21
+ cmd << ' v f' if value
22
+ cmd << ' c' if return_cas
23
+ cmd << ' b' if base64
24
+ cmd << " T#{ttl}" if ttl
25
+ cmd << ' k q s' if quiet # Return the key in the response if quiet
26
+ cmd + TERMINATOR
27
+ end
28
+
29
+ def self.meta_set(key:, value:, bitflags: nil, cas: nil, ttl: nil, mode: :set, base64: false, quiet: false)
30
+ cmd = "ms #{key} #{value.bytesize}"
31
+ cmd << ' c' unless %i[append prepend].include?(mode)
32
+ cmd << ' b' if base64
33
+ cmd << " F#{bitflags}" if bitflags
34
+ cmd << cas_string(cas)
35
+ cmd << " T#{ttl}" if ttl
36
+ cmd << " M#{mode_to_token(mode)}"
37
+ cmd << ' q' if quiet
38
+ cmd << TERMINATOR
39
+ cmd << value
40
+ cmd + TERMINATOR
41
+ end
42
+
43
+ def self.meta_delete(key:, cas: nil, ttl: nil, base64: false, quiet: false)
44
+ cmd = "md #{key}"
45
+ cmd << ' b' if base64
46
+ cmd << cas_string(cas)
47
+ cmd << " T#{ttl}" if ttl
48
+ cmd << ' q' if quiet
49
+ cmd + TERMINATOR
50
+ end
51
+
52
+ def self.meta_arithmetic(key:, delta:, initial:, incr: true, cas: nil, ttl: nil, base64: false, quiet: false)
53
+ cmd = "ma #{key} v"
54
+ cmd << ' b' if base64
55
+ cmd << " D#{delta}" if delta
56
+ cmd << " J#{initial}" if initial
57
+ # Always set a TTL if an initial value is specified
58
+ cmd << " N#{ttl || 0}" if ttl || initial
59
+ cmd << cas_string(cas)
60
+ cmd << ' q' if quiet
61
+ cmd << " M#{incr ? 'I' : 'D'}"
62
+ cmd + TERMINATOR
63
+ end
64
+ # rubocop:enable Metrics/CyclomaticComplexity
65
+ # rubocop:enable Metrics/MethodLength
66
+ # rubocop:enable Metrics/ParameterLists
67
+ # rubocop:enable Metrics/PerceivedComplexity
68
+
69
+ def self.meta_noop
70
+ "mn#{TERMINATOR}"
71
+ end
72
+
73
+ def self.version
74
+ "version#{TERMINATOR}"
75
+ end
76
+
77
+ def self.flush(delay: nil, quiet: false)
78
+ cmd = +'flush_all'
79
+ cmd << " #{parse_to_64_bit_int(delay, 0)}" if delay
80
+ cmd << ' noreply' if quiet
81
+ cmd + TERMINATOR
82
+ end
83
+
84
+ def self.stats(arg = nil)
85
+ cmd = +'stats'
86
+ cmd << " #{arg}" if arg
87
+ cmd + TERMINATOR
88
+ end
89
+
90
+ # rubocop:disable Metrics/MethodLength
91
+ def self.mode_to_token(mode)
92
+ case mode
93
+ when :add
94
+ 'E'
95
+ when :replace
96
+ 'R'
97
+ when :append
98
+ 'A'
99
+ when :prepend
100
+ 'P'
101
+ else
102
+ 'S'
103
+ end
104
+ end
105
+ # rubocop:enable Metrics/MethodLength
106
+
107
+ def self.cas_string(cas)
108
+ cas = parse_to_64_bit_int(cas, nil)
109
+ cas.nil? || cas.zero? ? '' : " C#{cas}"
110
+ end
111
+
112
+ def self.parse_to_64_bit_int(val, default)
113
+ val.nil? ? nil : Integer(val)
114
+ rescue ArgumentError
115
+ # Sanitize to default if it isn't parsable as an integer
116
+ default
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ module Protocol
5
+ class Meta
6
+ ##
7
+ # Class that encapsulates logic for processing meta protocol responses
8
+ # from memcached. Includes logic for pulling data from an IO source
9
+ # and parsing into local values. Handles errors on unexpected values.
10
+ ##
11
+ class ResponseProcessor
12
+ EN = 'EN'
13
+ END_TOKEN = 'END'
14
+ EX = 'EX'
15
+ HD = 'HD'
16
+ MN = 'MN'
17
+ NF = 'NF'
18
+ NS = 'NS'
19
+ OK = 'OK'
20
+ RESET = 'RESET'
21
+ STAT = 'STAT'
22
+ VA = 'VA'
23
+ VERSION = 'VERSION'
24
+
25
+ def initialize(io_source, value_marshaller)
26
+ @io_source = io_source
27
+ @value_marshaller = value_marshaller
28
+ end
29
+
30
+ def meta_get_with_value(cache_nils: false)
31
+ tokens = error_on_unexpected!([VA, EN, HD])
32
+ return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
33
+ return true unless tokens.first == VA
34
+
35
+ @value_marshaller.retrieve(read_line, bitflags_from_tokens(tokens))
36
+ end
37
+
38
+ def meta_get_with_value_and_cas
39
+ tokens = error_on_unexpected!([VA, EN, HD])
40
+ return [nil, 0] if tokens.first == EN
41
+
42
+ cas = cas_from_tokens(tokens)
43
+ return [nil, cas] unless tokens.first == VA
44
+
45
+ [@value_marshaller.retrieve(read_line, bitflags_from_tokens(tokens)), cas]
46
+ end
47
+
48
+ def meta_get_without_value
49
+ tokens = error_on_unexpected!([EN, HD])
50
+ tokens.first == EN ? nil : true
51
+ end
52
+
53
+ def meta_set_with_cas
54
+ tokens = error_on_unexpected!([HD, NS, NF, EX])
55
+ return false unless tokens.first == HD
56
+
57
+ cas_from_tokens(tokens)
58
+ end
59
+
60
+ def meta_set_append_prepend
61
+ tokens = error_on_unexpected!([HD, NS, NF, EX])
62
+ return false unless tokens.first == HD
63
+
64
+ true
65
+ end
66
+
67
+ def meta_delete
68
+ tokens = error_on_unexpected!([HD, NF, EX])
69
+ tokens.first == HD
70
+ end
71
+
72
+ def decr_incr
73
+ tokens = error_on_unexpected!([VA, NF, NS, EX])
74
+ return false if [NS, EX].include?(tokens.first)
75
+ return nil if tokens.first == NF
76
+
77
+ read_line.to_i
78
+ end
79
+
80
+ def stats
81
+ tokens = error_on_unexpected!([END_TOKEN, STAT])
82
+ values = {}
83
+ while tokens.first != END_TOKEN
84
+ values[tokens[1]] = tokens[2]
85
+ tokens = next_line_to_tokens
86
+ end
87
+ values
88
+ end
89
+
90
+ def flush
91
+ error_on_unexpected!([OK])
92
+
93
+ true
94
+ end
95
+
96
+ def reset
97
+ error_on_unexpected!([RESET])
98
+
99
+ true
100
+ end
101
+
102
+ def version
103
+ tokens = error_on_unexpected!([VERSION])
104
+ tokens.last
105
+ end
106
+
107
+ def consume_all_responses_until_mn
108
+ tokens = next_line_to_tokens
109
+
110
+ tokens = next_line_to_tokens while tokens.first != MN
111
+ true
112
+ end
113
+
114
+ def tokens_from_header_buffer(buf)
115
+ header = header_from_buffer(buf)
116
+ tokens = header.split
117
+ header_len = header.bytesize + TERMINATOR.length
118
+ body_len = body_len_from_tokens(tokens)
119
+ [tokens, header_len, body_len]
120
+ end
121
+
122
+ def full_response_from_buffer(tokens, body, resp_size)
123
+ value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
124
+ [resp_size, tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value]
125
+ end
126
+
127
+ ##
128
+ # This method returns an array of values used in a pipelined
129
+ # getk process. The first value is the number of bytes by
130
+ # which to advance the pointer in the buffer. If the
131
+ # complete response is found in the buffer, this will
132
+ # be the response size. Otherwise it is zero.
133
+ #
134
+ # The remaining three values in the array are the ResponseHeader,
135
+ # key, and value.
136
+ ##
137
+ def getk_response_from_buffer(buf)
138
+ # There's no header in the buffer, so don't advance
139
+ return [0, nil, nil, nil, nil] unless contains_header?(buf)
140
+
141
+ tokens, header_len, body_len = tokens_from_header_buffer(buf)
142
+
143
+ # We have a complete response that has no body.
144
+ # This is either the response to the terminating
145
+ # noop or, if the status is not MN, an intermediate
146
+ # error response that needs to be discarded.
147
+ return [header_len, true, nil, nil, nil] if body_len.zero?
148
+
149
+ resp_size = header_len + body_len + TERMINATOR.length
150
+ # The header is in the buffer, but the body is not. As we don't have
151
+ # a complete response, don't advance the buffer
152
+ return [0, nil, nil, nil, nil] unless buf.bytesize >= resp_size
153
+
154
+ # The full response is in our buffer, so parse it and return
155
+ # the values
156
+ body = buf.slice(header_len, body_len)
157
+ full_response_from_buffer(tokens, body, resp_size)
158
+ end
159
+
160
+ def contains_header?(buf)
161
+ buf.include?(TERMINATOR)
162
+ end
163
+
164
+ def header_from_buffer(buf)
165
+ buf.split(TERMINATOR, 2).first
166
+ end
167
+
168
+ def error_on_unexpected!(expected_codes)
169
+ tokens = next_line_to_tokens
170
+ raise Dalli::DalliError, "Response error: #{tokens.first}" unless expected_codes.include?(tokens.first)
171
+
172
+ tokens
173
+ end
174
+
175
+ def bitflags_from_tokens(tokens)
176
+ value_from_tokens(tokens, 'f')&.to_i
177
+ end
178
+
179
+ def cas_from_tokens(tokens)
180
+ value_from_tokens(tokens, 'c')&.to_i
181
+ end
182
+
183
+ def key_from_tokens(tokens)
184
+ encoded_key = value_from_tokens(tokens, 'k')
185
+ base64_encoded = tokens.any?('b')
186
+ KeyRegularizer.decode(encoded_key, base64_encoded)
187
+ end
188
+
189
+ def body_len_from_tokens(tokens)
190
+ value_from_tokens(tokens, 's')&.to_i
191
+ end
192
+
193
+ def value_from_tokens(tokens, flag)
194
+ bitflags_token = tokens.find { |t| t.start_with?(flag) }
195
+ return 0 unless bitflags_token
196
+
197
+ bitflags_token[1..]
198
+ end
199
+
200
+ def read_line
201
+ @io_source.read_line&.chomp!(TERMINATOR)
202
+ end
203
+
204
+ def next_line_to_tokens
205
+ line = read_line
206
+ line&.split || []
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end