dalli 2.0.1 → 3.2.8

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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +671 -0
  3. data/Gemfile +15 -3
  4. data/LICENSE +1 -1
  5. data/README.md +33 -148
  6. data/lib/dalli/cas/client.rb +3 -0
  7. data/lib/dalli/client.rb +293 -131
  8. data/lib/dalli/compressor.rb +40 -0
  9. data/lib/dalli/key_manager.rb +121 -0
  10. data/lib/dalli/options.rb +22 -4
  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 +250 -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 +255 -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 +19 -0
  31. data/lib/dalli/ring.rb +98 -50
  32. data/lib/dalli/server.rb +4 -524
  33. data/lib/dalli/servers_arg_normalizer.rb +54 -0
  34. data/lib/dalli/socket.rb +154 -53
  35. data/lib/dalli/version.rb +5 -1
  36. data/lib/dalli.rb +49 -13
  37. data/lib/rack/session/dalli.rb +169 -26
  38. metadata +53 -88
  39. data/History.md +0 -262
  40. data/Performance.md +0 -42
  41. data/Rakefile +0 -39
  42. data/dalli.gemspec +0 -28
  43. data/lib/action_dispatch/middleware/session/dalli_store.rb +0 -76
  44. data/lib/active_support/cache/dalli_store.rb +0 -203
  45. data/test/abstract_unit.rb +0 -281
  46. data/test/benchmark_test.rb +0 -187
  47. data/test/helper.rb +0 -41
  48. data/test/memcached_mock.rb +0 -113
  49. data/test/test_active_support.rb +0 -163
  50. data/test/test_dalli.rb +0 -461
  51. data/test/test_encoding.rb +0 -43
  52. data/test/test_failover.rb +0 -107
  53. data/test/test_network.rb +0 -54
  54. data/test/test_ring.rb +0 -85
  55. data/test/test_sasl.rb +0 -83
  56. data/test/test_session_store.rb +0 -224
@@ -0,0 +1,255 @@
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
+ @request_in_progress = false
58
+ rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
59
+ # SocketError = DNS resolution failure
60
+ error_on_request!(e)
61
+ end
62
+
63
+ def reconnect_down_server?
64
+ return true unless @last_down_at
65
+
66
+ time_to_next_reconnect = @last_down_at + options[:down_retry_delay] - Time.now
67
+ return true unless time_to_next_reconnect.positive?
68
+
69
+ Dalli.logger.debug do
70
+ format('down_retry_delay not reached for %<name>s (%<time>.3f seconds left)', name: name,
71
+ time: time_to_next_reconnect)
72
+ end
73
+ false
74
+ end
75
+
76
+ def up!
77
+ log_up_detected
78
+ reset_down_info
79
+ end
80
+
81
+ # Marks the server instance as down. Updates the down_at state
82
+ # and raises an Dalli::NetworkError that includes the underlying
83
+ # error in the message. Calls close to clean up socket state
84
+ def down!
85
+ close
86
+ log_down_detected
87
+
88
+ @error = $ERROR_INFO&.class&.name
89
+ @msg ||= $ERROR_INFO&.message
90
+ raise_down_error
91
+ end
92
+
93
+ def raise_down_error
94
+ raise Dalli::NetworkError, "#{name} is down: #{@error} #{@msg}"
95
+ end
96
+
97
+ def socket_timeout
98
+ @socket_timeout ||= @options[:socket_timeout]
99
+ end
100
+
101
+ def confirm_ready!
102
+ close if request_in_progress?
103
+ close_on_fork if fork_detected?
104
+ end
105
+
106
+ def confirm_in_progress!
107
+ raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless request_in_progress?
108
+
109
+ close_on_fork if fork_detected?
110
+ end
111
+
112
+ def close
113
+ return unless @sock
114
+
115
+ begin
116
+ @sock.close
117
+ rescue StandardError
118
+ nil
119
+ end
120
+ @sock = nil
121
+ @pid = nil
122
+ abort_request!
123
+ end
124
+
125
+ def connected?
126
+ !@sock.nil?
127
+ end
128
+
129
+ def request_in_progress?
130
+ @request_in_progress
131
+ end
132
+
133
+ def start_request!
134
+ raise '[Dalli] Request already in progress. This may be a bug in Dalli.' if @request_in_progress
135
+
136
+ @request_in_progress = true
137
+ end
138
+
139
+ def finish_request!
140
+ raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless @request_in_progress
141
+
142
+ @request_in_progress = false
143
+ end
144
+
145
+ def abort_request!
146
+ @request_in_progress = false
147
+ end
148
+
149
+ def read_line
150
+ data = @sock.gets("\r\n")
151
+ error_on_request!('EOF in read_line') if data.nil?
152
+ data
153
+ rescue SystemCallError, *TIMEOUT_ERRORS, EOFError => e
154
+ error_on_request!(e)
155
+ end
156
+
157
+ def read(count)
158
+ @sock.readfull(count)
159
+ rescue SystemCallError, *TIMEOUT_ERRORS, EOFError => e
160
+ error_on_request!(e)
161
+ end
162
+
163
+ def write(bytes)
164
+ @sock.write(bytes)
165
+ rescue SystemCallError, *TIMEOUT_ERRORS => e
166
+ error_on_request!(e)
167
+ end
168
+
169
+ # Non-blocking read. Here to support the operation
170
+ # of the get_multi operation
171
+ def read_nonblock
172
+ @sock.read_available
173
+ end
174
+
175
+ def max_allowed_failures
176
+ @max_allowed_failures ||= @options[:socket_max_failures] || 2
177
+ end
178
+
179
+ def error_on_request!(err_or_string)
180
+ log_warn_message(err_or_string)
181
+
182
+ @fail_count += 1
183
+ if @fail_count >= max_allowed_failures
184
+ down!
185
+ else
186
+ # Closes the existing socket, setting up for a reconnect
187
+ # on next request
188
+ reconnect!('Socket operation failed, retrying...')
189
+ end
190
+ end
191
+
192
+ def reconnect!(message)
193
+ close
194
+ sleep(options[:socket_failure_delay]) if options[:socket_failure_delay]
195
+ raise Dalli::NetworkError, message
196
+ end
197
+
198
+ def reset_down_info
199
+ @fail_count = 0
200
+ @down_at = nil
201
+ @last_down_at = nil
202
+ @msg = nil
203
+ @error = nil
204
+ end
205
+
206
+ def memcached_socket
207
+ if socket_type == :unix
208
+ Dalli::Socket::UNIX.open(hostname, options)
209
+ else
210
+ Dalli::Socket::TCP.open(hostname, port, options)
211
+ end
212
+ end
213
+
214
+ def log_warn_message(err_or_string)
215
+ detail = err_or_string.is_a?(String) ? err_or_string : "#{err_or_string.class}: #{err_or_string.message}"
216
+ Dalli.logger.warn do
217
+ detail = err_or_string.is_a?(String) ? err_or_string : "#{err_or_string.class}: #{err_or_string.message}"
218
+ "#{name} failed (count: #{@fail_count}) #{detail}"
219
+ end
220
+ end
221
+
222
+ def close_on_fork
223
+ message = 'Fork detected, re-connecting child process...'
224
+ Dalli.logger.info { message }
225
+ # Close socket on a fork, setting us up for reconnect
226
+ # on next request.
227
+ close
228
+ raise Dalli::NetworkError, message
229
+ end
230
+
231
+ def fork_detected?
232
+ @pid && @pid != PIDCache.pid
233
+ end
234
+
235
+ def log_down_detected
236
+ @last_down_at = Time.now
237
+
238
+ if @down_at
239
+ time = Time.now - @down_at
240
+ Dalli.logger.debug { format('%<name>s is still down (for %<time>.3f seconds now)', name: name, time: time) }
241
+ else
242
+ @down_at = @last_down_at
243
+ Dalli.logger.warn("#{name} is down")
244
+ end
245
+ end
246
+
247
+ def log_up_detected
248
+ return unless @down_at
249
+
250
+ time = Time.now - @down_at
251
+ Dalli.logger.warn { format('%<name>s is back (downtime was %<time>.3f seconds)', name: name, time: time) }
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dalli
4
+ module Protocol
5
+ class Meta
6
+ ##
7
+ # The meta protocol requires that keys be ASCII only, so Unicode keys are
8
+ # not supported. In addition, the use of whitespace in the key is not
9
+ # allowed.
10
+ # memcached supports the use of base64 hashes for keys containing
11
+ # whitespace or non-ASCII characters, provided the 'b' flag is included in the request.
12
+ class KeyRegularizer
13
+ WHITESPACE = /\s/.freeze
14
+
15
+ def self.encode(key)
16
+ return [key, false] if key.ascii_only? && !WHITESPACE.match(key)
17
+
18
+ strict_base64_encoded = [key].pack('m0')
19
+ [strict_base64_encoded, true]
20
+ end
21
+
22
+ def self.decode(encoded_key, base64_encoded)
23
+ return encoded_key unless base64_encoded
24
+
25
+ strict_base64_decoded = encoded_key.unpack1('m0')
26
+ strict_base64_decoded.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