dalli 2.7.3 → 3.2.3

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} +211 -0
  3. data/Gemfile +3 -6
  4. data/LICENSE +1 -1
  5. data/README.md +30 -208
  6. data/lib/dalli/cas/client.rb +2 -57
  7. data/lib/dalli/client.rb +254 -253
  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/pipelined_getter.rb +177 -0
  12. data/lib/dalli/protocol/base.rb +241 -0
  13. data/lib/dalli/protocol/binary/request_formatter.rb +117 -0
  14. data/lib/dalli/protocol/binary/response_header.rb +36 -0
  15. data/lib/dalli/protocol/binary/response_processor.rb +239 -0
  16. data/lib/dalli/protocol/binary/sasl_authentication.rb +60 -0
  17. data/lib/dalli/protocol/binary.rb +173 -0
  18. data/lib/dalli/protocol/connection_manager.rb +252 -0
  19. data/lib/dalli/protocol/meta/key_regularizer.rb +31 -0
  20. data/lib/dalli/protocol/meta/request_formatter.rb +121 -0
  21. data/lib/dalli/protocol/meta/response_processor.rb +211 -0
  22. data/lib/dalli/protocol/meta.rb +178 -0
  23. data/lib/dalli/protocol/response_buffer.rb +54 -0
  24. data/lib/dalli/protocol/server_config_parser.rb +86 -0
  25. data/lib/dalli/protocol/ttl_sanitizer.rb +45 -0
  26. data/lib/dalli/protocol/value_compressor.rb +85 -0
  27. data/lib/dalli/protocol/value_marshaller.rb +59 -0
  28. data/lib/dalli/protocol/value_serializer.rb +91 -0
  29. data/lib/dalli/protocol.rb +8 -0
  30. data/lib/dalli/ring.rb +97 -86
  31. data/lib/dalli/server.rb +4 -719
  32. data/lib/dalli/servers_arg_normalizer.rb +54 -0
  33. data/lib/dalli/socket.rb +123 -115
  34. data/lib/dalli/version.rb +5 -1
  35. data/lib/dalli.rb +45 -14
  36. data/lib/rack/session/dalli.rb +162 -42
  37. metadata +136 -63
  38. data/Performance.md +0 -42
  39. data/Rakefile +0 -43
  40. data/dalli.gemspec +0 -29
  41. data/lib/action_dispatch/middleware/session/dalli_store.rb +0 -81
  42. data/lib/active_support/cache/dalli_store.rb +0 -372
  43. data/lib/dalli/railtie.rb +0 -7
  44. data/test/benchmark_test.rb +0 -243
  45. data/test/helper.rb +0 -56
  46. data/test/memcached_mock.rb +0 -201
  47. data/test/sasl/memcached.conf +0 -1
  48. data/test/sasl/sasldb +0 -1
  49. data/test/test_active_support.rb +0 -541
  50. data/test/test_cas_client.rb +0 -107
  51. data/test/test_compressor.rb +0 -52
  52. data/test/test_dalli.rb +0 -682
  53. data/test/test_encoding.rb +0 -32
  54. data/test/test_failover.rb +0 -137
  55. data/test/test_network.rb +0 -64
  56. data/test/test_rack_session.rb +0 -341
  57. data/test/test_ring.rb +0 -85
  58. data/test/test_sasl.rb +0 -105
  59. data/test/test_serializer.rb +0 -29
  60. data/test/test_server.rb +0 -110
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'socket'
5
+ require 'timeout'
6
+
7
+ module Dalli
8
+ module Protocol
9
+ ##
10
+ # Manages the socket connection to the server, including ensuring liveness
11
+ # and retries.
12
+ ##
13
+ class ConnectionManager
14
+ DEFAULTS = {
15
+ # seconds between trying to contact a remote server
16
+ down_retry_delay: 30,
17
+ # connect/read/write timeout for socket operations
18
+ socket_timeout: 1,
19
+ # times a socket operation may fail before considering the server dead
20
+ socket_max_failures: 2,
21
+ # amount of time to sleep between retries when a failure occurs
22
+ socket_failure_delay: 0.1,
23
+ # Set keepalive
24
+ keepalive: true
25
+ }.freeze
26
+
27
+ attr_accessor :hostname, :port, :socket_type, :options
28
+ attr_reader :sock
29
+
30
+ def initialize(hostname, port, socket_type, client_options)
31
+ @hostname = hostname
32
+ @port = port
33
+ @socket_type = socket_type
34
+ @options = DEFAULTS.merge(client_options)
35
+ @request_in_progress = false
36
+ @sock = nil
37
+ @pid = nil
38
+
39
+ reset_down_info
40
+ end
41
+
42
+ def name
43
+ if socket_type == :unix
44
+ hostname
45
+ else
46
+ "#{hostname}:#{port}"
47
+ end
48
+ end
49
+
50
+ def establish_connection
51
+ Dalli.logger.debug { "Dalli::Server#connect #{name}" }
52
+
53
+ @sock = memcached_socket
54
+ @pid = Process.pid
55
+ rescue SystemCallError, Timeout::Error, EOFError, SocketError => e
56
+ # SocketError = DNS resolution failure
57
+ error_on_request!(e)
58
+ end
59
+
60
+ def reconnect_down_server?
61
+ return true unless @last_down_at
62
+
63
+ time_to_next_reconnect = @last_down_at + options[:down_retry_delay] - Time.now
64
+ return true unless time_to_next_reconnect.positive?
65
+
66
+ Dalli.logger.debug do
67
+ format('down_retry_delay not reached for %<name>s (%<time>.3f seconds left)', name: name,
68
+ time: time_to_next_reconnect)
69
+ end
70
+ false
71
+ end
72
+
73
+ def up!
74
+ log_up_detected
75
+ reset_down_info
76
+ end
77
+
78
+ # Marks the server instance as down. Updates the down_at state
79
+ # and raises an Dalli::NetworkError that includes the underlying
80
+ # error in the message. Calls close to clean up socket state
81
+ def down!
82
+ close
83
+ log_down_detected
84
+
85
+ @error = $ERROR_INFO&.class&.name
86
+ @msg ||= $ERROR_INFO&.message
87
+ raise_down_error
88
+ end
89
+
90
+ def raise_down_error
91
+ raise Dalli::NetworkError, "#{name} is down: #{@error} #{@msg}"
92
+ end
93
+
94
+ def socket_timeout
95
+ @socket_timeout ||= @options[:socket_timeout]
96
+ end
97
+
98
+ def confirm_ready!
99
+ error_on_request!(RuntimeError.new('Already writing to socket')) if request_in_progress?
100
+ close_on_fork if fork_detected?
101
+ end
102
+
103
+ def close
104
+ return unless @sock
105
+
106
+ begin
107
+ @sock.close
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ @sock = nil
112
+ @pid = nil
113
+ abort_request!
114
+ end
115
+
116
+ def connected?
117
+ !@sock.nil?
118
+ end
119
+
120
+ def request_in_progress?
121
+ @request_in_progress
122
+ end
123
+
124
+ def start_request!
125
+ @request_in_progress = true
126
+ end
127
+
128
+ def finish_request!
129
+ @request_in_progress = false
130
+ end
131
+
132
+ def abort_request!
133
+ @request_in_progress = false
134
+ end
135
+
136
+ def read_line
137
+ start_request!
138
+ data = @sock.gets("\r\n")
139
+ error_on_request!('EOF in read_line') if data.nil?
140
+ finish_request!
141
+ data
142
+ rescue SystemCallError, Timeout::Error, EOFError => e
143
+ error_on_request!(e)
144
+ end
145
+
146
+ def read(count)
147
+ start_request!
148
+ data = @sock.readfull(count)
149
+ finish_request!
150
+ data
151
+ rescue SystemCallError, Timeout::Error, EOFError => e
152
+ error_on_request!(e)
153
+ end
154
+
155
+ def write(bytes)
156
+ start_request!
157
+ result = @sock.write(bytes)
158
+ finish_request!
159
+ result
160
+ rescue SystemCallError, Timeout::Error => e
161
+ error_on_request!(e)
162
+ end
163
+
164
+ # Non-blocking read. Should only be used in the context
165
+ # of a caller who has called start_request!, but not yet
166
+ # called finish_request!. Here to support the operation
167
+ # of the get_multi operation
168
+ def read_nonblock
169
+ @sock.read_available
170
+ end
171
+
172
+ def max_allowed_failures
173
+ @max_allowed_failures ||= @options[:socket_max_failures] || 2
174
+ end
175
+
176
+ def error_on_request!(err_or_string)
177
+ log_warn_message(err_or_string)
178
+
179
+ @fail_count += 1
180
+ if @fail_count >= max_allowed_failures
181
+ down!
182
+ else
183
+ # Closes the existing socket, setting up for a reconnect
184
+ # on next request
185
+ reconnect!('Socket operation failed, retrying...')
186
+ end
187
+ end
188
+
189
+ def reconnect!(message)
190
+ close
191
+ sleep(options[:socket_failure_delay]) if options[:socket_failure_delay]
192
+ raise Dalli::NetworkError, message
193
+ end
194
+
195
+ def reset_down_info
196
+ @fail_count = 0
197
+ @down_at = nil
198
+ @last_down_at = nil
199
+ @msg = nil
200
+ @error = nil
201
+ end
202
+
203
+ def memcached_socket
204
+ if socket_type == :unix
205
+ Dalli::Socket::UNIX.open(hostname, options)
206
+ else
207
+ Dalli::Socket::TCP.open(hostname, port, options)
208
+ end
209
+ end
210
+
211
+ def log_warn_message(err_or_string)
212
+ detail = err_or_string.is_a?(String) ? err_or_string : "#{err_or_string.class}: #{err_or_string.message}"
213
+ Dalli.logger.warn do
214
+ detail = err_or_string.is_a?(String) ? err_or_string : "#{err_or_string.class}: #{err_or_string.message}"
215
+ "#{name} failed (count: #{@fail_count}) #{detail}"
216
+ end
217
+ end
218
+
219
+ def close_on_fork
220
+ message = 'Fork detected, re-connecting child process...'
221
+ Dalli.logger.info { message }
222
+ # Close socket on a fork, setting us up for reconnect
223
+ # on next request.
224
+ close
225
+ raise Dalli::NetworkError, message
226
+ end
227
+
228
+ def fork_detected?
229
+ @pid && @pid != Process.pid
230
+ end
231
+
232
+ def log_down_detected
233
+ @last_down_at = Time.now
234
+
235
+ if @down_at
236
+ time = Time.now - @down_at
237
+ Dalli.logger.debug { format('%<name>s is still down (for %<time>.3f seconds now)', name: name, time: time) }
238
+ else
239
+ @down_at = @last_down_at
240
+ Dalli.logger.warn("#{name} is down")
241
+ end
242
+ end
243
+
244
+ def log_up_detected
245
+ return unless @down_at
246
+
247
+ time = Time.now - @down_at
248
+ Dalli.logger.warn { format('%<name>s is back (downtime was %<time>.3f seconds)', name: name, time: time) }
249
+ end
250
+ end
251
+ end
252
+ 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('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