dalli 2.7.2 → 3.2.4

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 (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