dalli 4.3.10 → 5.0.0

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -161
  3. data/Gemfile +1 -1
  4. data/README.md +4 -55
  5. data/lib/dalli/client.rb +39 -98
  6. data/lib/dalli/compressor.rb +5 -53
  7. data/lib/dalli/instrumentation.rb +1 -1
  8. data/lib/dalli/key_manager.rb +2 -28
  9. data/lib/dalli/options.rb +2 -2
  10. data/lib/dalli/pipelined_getter.rb +32 -46
  11. data/lib/dalli/pipelined_setter.rb +8 -17
  12. data/lib/dalli/protocol/base.rb +31 -160
  13. data/lib/dalli/protocol/connection_manager.rb +20 -137
  14. data/lib/dalli/protocol/meta.rb +15 -54
  15. data/lib/dalli/protocol/{meta/request_formatter.rb → request_formatter.rb} +8 -21
  16. data/lib/dalli/protocol/response_buffer.rb +12 -27
  17. data/lib/dalli/protocol/{meta/response_processor.rb → response_processor.rb} +32 -64
  18. data/lib/dalli/protocol/server_config_parser.rb +1 -1
  19. data/lib/dalli/protocol/value_compressor.rb +7 -36
  20. data/lib/dalli/protocol/value_serializer.rb +1 -1
  21. data/lib/dalli/protocol.rb +9 -7
  22. data/lib/dalli/ring.rb +2 -2
  23. data/lib/dalli/servers_arg_normalizer.rb +1 -1
  24. data/lib/dalli/socket.rb +0 -29
  25. data/lib/dalli/version.rb +2 -2
  26. data/lib/dalli.rb +0 -3
  27. data/lib/rack/session/dalli.rb +2 -1
  28. metadata +7 -14
  29. data/lib/dalli/json_serializer.rb +0 -28
  30. data/lib/dalli/protocol/binary/request_formatter.rb +0 -116
  31. data/lib/dalli/protocol/binary/response_header.rb +0 -36
  32. data/lib/dalli/protocol/binary/response_processor.rb +0 -245
  33. data/lib/dalli/protocol/binary/sasl_authentication.rb +0 -60
  34. data/lib/dalli/protocol/binary.rb +0 -202
  35. data/lib/dalli/protocol_deprecations.rb +0 -45
  36. /data/lib/dalli/protocol/{meta/key_regularizer.rb → key_regularizer.rb} +0 -0
@@ -13,54 +13,8 @@ module Dalli
13
13
  Zlib::Deflate.deflate(data)
14
14
  end
15
15
 
16
- # DEFLATE can't expand data by more than about 1032:1
17
- MAX_EXPANSION = 1032
18
- # Compressed input is fed to zlib this many bytes at a time. MRI's zlib
19
- # yields the output in small chunks; one that returns it instead still
20
- # produces at most MAX_EXPANSION times this per step.
21
- INFLATE_SLICE_BYTES = 64 * 1024
22
-
23
- # max_bytes caps the decompressed size; inflating stops and raises as soon
24
- # as the output passes it, so a small compressed value can't expand into
25
- # gigabytes of memory.
26
- def self.decompress(data, max_bytes: nil)
27
- return Zlib::Inflate.inflate(data) unless may_exceed?(data, max_bytes)
28
-
29
- inflate_within_limit(Zlib::Inflate.new, data, max_bytes)
30
- end
31
-
32
- # False when even the largest possible expansion stays within max_bytes,
33
- # so the value can be inflated in one step
34
- def self.may_exceed?(data, max_bytes)
35
- max_bytes ? data.bytesize * MAX_EXPANSION > max_bytes : false
36
- end
37
-
38
- def self.inflate_within_limit(inflater, data, max_bytes)
39
- out = String.new(encoding: Encoding::BINARY)
40
- 0.step(data.bytesize - 1, INFLATE_SLICE_BYTES) do |pos|
41
- inflate_slice(inflater, data.byteslice(pos, INFLATE_SLICE_BYTES), out, max_bytes)
42
- # Anything after the end of the stream isn't part of the value, as
43
- # with Zlib::Inflate.inflate; finish would return it as output
44
- break if inflater.finished?
45
- end
46
- return out if inflater.finished?
47
-
48
- append_within_limit(out, inflater.finish, max_bytes)
49
- ensure
50
- inflater.close
51
- end
52
-
53
- # Both yielded and returned output count toward the limit
54
- def self.inflate_slice(inflater, slice, out, max_bytes)
55
- rest = inflater.inflate(slice) { |chunk| append_within_limit(out, chunk, max_bytes) }
56
- append_within_limit(out, rest, max_bytes)
57
- end
58
-
59
- def self.append_within_limit(out, chunk, max_bytes)
60
- out << chunk if chunk
61
- return out if out.bytesize <= max_bytes
62
-
63
- raise Dalli::UnmarshalError, "Decompressed value exceeds #{max_bytes} bytes (decompressed_max_bytes)"
16
+ def self.decompress(data)
17
+ Zlib::Inflate.inflate(data)
64
18
  end
65
19
  end
66
20
 
@@ -78,11 +32,9 @@ module Dalli
78
32
  io.string
79
33
  end
80
34
 
81
- def self.decompress(data, max_bytes: nil)
82
- return Zlib::GzipReader.new(StringIO.new(data, 'rb')).read unless Compressor.may_exceed?(data, max_bytes)
83
-
84
- # MAX_WBITS + 16 makes zlib read the gzip format (and check its CRC)
85
- Compressor.inflate_within_limit(Zlib::Inflate.new(Zlib::MAX_WBITS + 16), data, max_bytes)
35
+ def self.decompress(data)
36
+ io = StringIO.new(data, 'rb')
37
+ Zlib::GzipReader.new(io).read
86
38
  end
87
39
  end
88
40
  end
@@ -8,7 +8,7 @@ module Dalli
8
8
  # When OpenTelemetry is loaded, Dalli automatically creates spans for cache operations.
9
9
  # When OpenTelemetry is not available, all tracing methods are no-ops with zero overhead.
10
10
  #
11
- # Dalli 4.3.2 uses the stable OTel semantic conventions for database spans.
11
+ # Dalli 5.0 uses the stable OTel semantic conventions for database spans.
12
12
  #
13
13
  # == Span Attributes
14
14
  #
@@ -41,9 +41,6 @@ module Dalli
41
41
 
42
42
  @namespace = namespace_from_options
43
43
  @namespace_separator = @key_options[:namespace_separator]
44
- # The meta protocol base64-encodes keys with whitespace or non-ASCII
45
- # characters, so their length on the wire differs from their length here
46
- @meta_protocol = client_options[:protocol]&.to_s == 'meta'
47
44
  end
48
45
 
49
46
  ##
@@ -61,7 +58,7 @@ module Dalli
61
58
  raise ArgumentError, 'key cannot be blank' unless key&.length&.positive?
62
59
 
63
60
  key = key_with_namespace(key)
64
- wire_length(key) > MAX_KEY_LENGTH ? truncated_key(key) : key
61
+ key.length > MAX_KEY_LENGTH ? truncated_key(key) : key
65
62
  end
66
63
 
67
64
  ##
@@ -125,30 +122,7 @@ module Dalli
125
122
  ##
126
123
  def truncated_key(key)
127
124
  digest = digest_class.hexdigest(key)
128
- prefix = key[0, [prefix_length(digest), 0].max]
129
- truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
130
- # A prefix with whitespace or non-ASCII characters sends the whole key
131
- # base64-encoded, which can still be too long on the wire; shorten it.
132
- while wire_length(truncated) > MAX_KEY_LENGTH && !prefix.empty?
133
- prefix = prefix[0...-1]
134
- truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
135
- end
136
- return truncated if wire_length(truncated) <= MAX_KEY_LENGTH
137
-
138
- # Only possible with a digest_class whose digests aren't short hex strings
139
- raise ArgumentError, "#{digest_class} digests are too long to fit in a truncated key"
140
- end
141
-
142
- # memcached's key limit applies to the key as sent: in bytes, and, with
143
- # the meta protocol, after base64 encoding when the key needs it. A key
144
- # within the limit in characters can still be too long. With the meta
145
- # protocol memcached rejects it with a CLIENT_ERROR that must never reach
146
- # a pipelined get; with the binary protocol it drops the connection.
147
- def wire_length(key)
148
- return key.bytesize unless @meta_protocol
149
-
150
- encoded, = Dalli::Protocol::Meta::KeyRegularizer.encode(key)
151
- encoded.bytesize
125
+ "#{key[0, prefix_length(digest)]}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
152
126
  end
153
127
 
154
128
  def prefix_length(digest)
data/lib/dalli/options.rb CHANGED
@@ -6,7 +6,7 @@ module Dalli
6
6
  # Make Dalli threadsafe by using a lock around all
7
7
  # public server methods.
8
8
  #
9
- # Dalli::Protocol::Binary.extend(Dalli::Threadsafe)
9
+ # Dalli::Protocol::Meta.extend(Dalli::Threadsafe)
10
10
  #
11
11
  module Threadsafe
12
12
  def self.extended(obj)
@@ -43,7 +43,7 @@ module Dalli
43
43
  end
44
44
  end
45
45
 
46
- def pipeline_abort(...)
46
+ def pipeline_abort
47
47
  @lock.synchronize do
48
48
  super
49
49
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
-
5
3
  module Dalli
6
4
  ##
7
5
  # Contains logic for the pipelined gets implemented by the client.
@@ -26,21 +24,15 @@ module Dalli
26
24
  return {} if keys.empty?
27
25
 
28
26
  @ring.lock do
29
- # The servers with replies still to read, updated in place
30
- servers = []
31
- begin
32
- # Stores partial results collected during interleaved send phase
33
- @partial_results = {}
34
- setup_requests(servers, keys)
35
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ # Stores partial results collected during interleaved send phase
28
+ @partial_results = {}
29
+ servers = setup_requests(keys)
30
+ start_time = Time.now
36
31
 
37
- # First yield any partial results collected during interleaved send
38
- yield_partial_results(&block)
32
+ # First yield any partial results collected during interleaved send
33
+ yield_partial_results(&block)
39
34
 
40
- fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
41
- ensure
42
- abort_unfinished(servers)
43
- end
35
+ servers = fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
44
36
  end
45
37
  rescue Dalli::RetryableNetworkError => e
46
38
  Dalli.logger.debug { e.inspect }
@@ -57,15 +49,12 @@ module Dalli
57
49
  @partial_results.clear
58
50
  end
59
51
 
60
- # Adds the servers with a pending response to servers before writing
61
- # their queries, so #process can abort them if it stops early.
62
- def setup_requests(servers, keys)
52
+ def setup_requests(keys)
63
53
  groups = groups_for_keys(keys)
64
- servers.concat(groups.keys)
65
54
  make_getkq_requests(groups)
66
55
 
67
56
  # TODO: How does this exit on a NetworkError
68
- finish_queries(servers)
57
+ finish_queries(groups.keys)
69
58
  end
70
59
 
71
60
  ##
@@ -127,39 +116,29 @@ module Dalli
127
116
  raise
128
117
  end
129
118
 
130
- # Swallows Dalli::NetworkError. Empties servers, so they aren't aborted
131
- # again.
119
+ # Swallows Dalli::NetworkError
132
120
  def abort_without_timeout(servers)
133
121
  servers.each(&:pipeline_abort)
134
- servers.clear
135
122
  end
136
123
 
137
- # When #process stops before reading every server's replies (the caller's
138
- # block raises or breaks, a Timeout.timeout fires, a value can't be
139
- # deserialized), the rest are still on the socket and some may be in the
140
- # response buffer. Closing those connections keeps them from being read
141
- # as a later request's replies. Aborts that #process already handled
142
- # emptied servers.
143
- def abort_unfinished(servers)
144
- servers.each { |server| server.pipeline_abort(timed_out: false) }
145
- servers.clear
146
- end
147
-
148
- # Reads the replies available, removing finished servers from servers
149
124
  def fetch_responses(servers, start_time, timeout, &block)
150
125
  # Remove any servers which are not connected
151
126
  servers.select!(&:connected?)
152
- return if servers.empty?
127
+ return [] if servers.empty?
153
128
 
154
129
  time_left = remaining_time(start_time, timeout)
155
130
  readable_servers = servers_with_response(servers, time_left)
156
- return abort_with_timeout(servers) if readable_servers.empty?
131
+ if readable_servers.empty?
132
+ abort_with_timeout(servers)
133
+ return []
134
+ end
157
135
 
158
136
  # Loop through the servers with responses, and
159
137
  # delete any from our list that are finished
160
138
  readable_servers.each do |server|
161
139
  servers.delete(server) if process_server(server, &block)
162
140
  end
141
+ servers
163
142
  rescue NetworkError
164
143
  # Abort and raise if we encountered a network error. This triggers
165
144
  # a retry at the top level on RetryableNetworkError.
@@ -168,7 +147,7 @@ module Dalli
168
147
  end
169
148
 
170
149
  def remaining_time(start, timeout)
171
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
150
+ elapsed = Time.now - start
172
151
  return 0 if elapsed > timeout
173
152
 
174
153
  timeout - elapsed
@@ -176,17 +155,19 @@ module Dalli
176
155
 
177
156
  # Swallows Dalli::NetworkError
178
157
  def abort_with_timeout(servers)
158
+ abort_without_timeout(servers)
179
159
  servers.each do |server|
180
160
  Dalli.logger.debug { "memcached at #{server.name} did not response within timeout" }
181
161
  end
182
- abort_without_timeout(servers)
162
+
163
+ true # Required to simplify caller
183
164
  end
184
165
 
185
166
  # Processes responses from a server. Returns true if there are no
186
167
  # additional responses from this server.
187
168
  def process_server(server)
188
- server.pipeline_next_responses do |key, value, cas|
189
- yield @key_manager.key_without_namespace(key), [value, cas]
169
+ server.pipeline_next_responses.each_pair do |key, value_list|
170
+ yield @key_manager.key_without_namespace(key), value_list
190
171
  end
191
172
 
192
173
  server.pipeline_complete?
@@ -195,13 +176,18 @@ module Dalli
195
176
  def servers_with_response(servers, timeout)
196
177
  return [] if servers.empty?
197
178
 
198
- sockets = servers.map(&:sock)
199
- readable, = IO.select(sockets, nil, nil, timeout)
179
+ # TODO: - This is a bit challenging. Essentially the PipelinedGetter
180
+ # is a reactor, but without the benefit of a Fiber or separate thread.
181
+ # My suspicion is that we may want to try and push this down into the
182
+ # individual servers, but I'm not sure. For now, we keep the
183
+ # mapping between the alerted object (the socket) and the
184
+ # corrresponding server here.
185
+ server_map = servers.each_with_object({}) { |s, h| h[s.sock] = s }
186
+
187
+ readable, = IO.select(server_map.keys, nil, nil, timeout)
200
188
  return [] if readable.nil?
201
189
 
202
- # For typical server counts (1-5), linear scan is faster than
203
- # building and looking up a hash map
204
- readable.filter_map { |sock| servers.find { |s| s.sock == sock } }
190
+ readable.map { |sock| server_map[sock] }
205
191
  end
206
192
 
207
193
  def groups_for_keys(*keys)
@@ -16,9 +16,6 @@ module Dalli
16
16
  # Writes multiple key-value pairs to memcached.
17
17
  # Raises an error if any server is unavailable.
18
18
  #
19
- # A value that can't be marshalled or is over value_max_bytes is skipped;
20
- # the other keys are still stored, then the first such error is raised.
21
- #
22
19
  # @param hash [Hash] key-value pairs to set
23
20
  # @param ttl [Integer] time-to-live in seconds
24
21
  # @param req_options [Hash] options passed to each set operation
@@ -27,12 +24,10 @@ module Dalli
27
24
  def process(hash, ttl, req_options)
28
25
  return if hash.empty?
29
26
 
30
- @value_error = nil
31
27
  @ring.lock do
32
28
  servers = setup_requests(hash, ttl, req_options)
33
29
  finish_requests(servers)
34
30
  end
35
- raise @value_error if @value_error
36
31
  rescue Dalli::RetryableNetworkError => e
37
32
  Dalli.logger.debug { e.inspect }
38
33
  Dalli.logger.debug { 'retrying pipelined sets because of network error' }
@@ -41,12 +36,9 @@ module Dalli
41
36
 
42
37
  private
43
38
 
44
- # Values are looked up by each key as sent. The caller's key can't be
45
- # rebuilt from it: a long key is digested, and a Symbol becomes a String.
46
39
  def setup_requests(hash, ttl, req_options)
47
- pairs = hash.transform_keys { |k| @key_manager.validate_key(k.to_s) }
48
- groups = groups_for_keys(pairs.keys)
49
- make_set_requests(groups, pairs, ttl, req_options)
40
+ groups = groups_for_keys(hash.keys)
41
+ make_set_requests(groups, hash, ttl, req_options)
50
42
  groups.keys
51
43
  end
52
44
 
@@ -54,14 +46,12 @@ module Dalli
54
46
  # Loop through the server-grouped sets of keys, writing
55
47
  # the corresponding quiet set requests to the appropriate servers
56
48
  ##
57
- def make_set_requests(groups, pairs, ttl, req_options)
49
+ def make_set_requests(groups, hash, ttl, req_options)
58
50
  groups.each do |server, keys_for_server|
59
51
  keys_for_server.each do |key|
60
- server.request(:pipelined_set, key, pairs[key], ttl, req_options)
61
- rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
62
- # Nothing was written for this key, so the server's other keys are
63
- # unaffected; raised by #process once the batch is complete
64
- @value_error ||= e
52
+ original_key = @key_manager.key_without_namespace(key)
53
+ value = hash[original_key]
54
+ server.request(:pipelined_set, key, value, ttl, req_options)
65
55
  rescue DalliError, NetworkError => e
66
56
  Dalli.logger.debug { e.inspect }
67
57
  Dalli.logger.debug { "unable to set key #{key} for server #{server.name}" }
@@ -82,7 +72,8 @@ module Dalli
82
72
  end
83
73
 
84
74
  def groups_for_keys(keys)
85
- groups = @ring.keys_grouped_by_server(keys)
75
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
76
+ groups = @ring.keys_grouped_by_server(validated_keys)
86
77
 
87
78
  if (unfound_keys = groups.delete(nil))
88
79
  Dalli.logger.debug do