dalli 4.2.0 → 4.3.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/Gemfile +4 -0
- data/README.md +25 -0
- data/lib/dalli/client.rb +5 -3
- data/lib/dalli/instrumentation.rb +3 -13
- data/lib/dalli/key_manager.rb +22 -7
- data/lib/dalli/pipelined_getter.rb +32 -3
- data/lib/dalli/pipelined_setter.rb +1 -1
- data/lib/dalli/protocol/base.rb +55 -3
- data/lib/dalli/protocol/connection_manager.rb +1 -1
- data/lib/dalli/protocol/response_buffer.rb +9 -0
- data/lib/dalli/protocol/value_serializer.rb +26 -16
- data/lib/dalli/socket.rb +42 -9
- data/lib/dalli/version.rb +1 -1
- data/lib/dalli.rb +3 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c21c31c46fc4381658892703d01baf8611392f5c1f72b9473013d2e53bc23254
|
|
4
|
+
data.tar.gz: 1fd1a3ea06cc4bab1ebc1192e62ede8318521e7867a84b3dd09096364560979f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9c79888857cb9c9edc9a1f86447e630b54f52040305806ffe92d0966d326e78607e06387c6bbbcac8b96b380737389da2bcf98a9f93ef91e487e2a2152e7aad7
|
|
7
|
+
data.tar.gz: f1783996f1d49db8985b755e78c3df6cee25776504504aa1ce303d665fc4798d3b09b8136d97e23dc2a3de8243bca68e7db2e00dbd2942c05e2f918d8efd8d1a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
Dalli Changelog
|
|
2
2
|
=====================
|
|
3
3
|
|
|
4
|
+
4.3.1
|
|
5
|
+
==========
|
|
6
|
+
|
|
7
|
+
Bug Fixes:
|
|
8
|
+
|
|
9
|
+
- Fix socket compatibility with gems that monkey-patch TCPSocket (#996, #1012)
|
|
10
|
+
- Gems like `socksify` and `resolv-replace` modify `TCPSocket#initialize`, breaking Ruby 3.0+'s `connect_timeout:` keyword argument
|
|
11
|
+
- Detection now uses parameter signature checking instead of gem-specific method detection
|
|
12
|
+
- Falls back to `Timeout.timeout` when monkey-patching is detected
|
|
13
|
+
- Detection result is cached for performance
|
|
14
|
+
|
|
15
|
+
- Fix network retry bug with `socket_max_failures: 0` (#1065)
|
|
16
|
+
- Previously, setting `socket_max_failures: 0` could still cause retries due to error handling
|
|
17
|
+
- Introduced `RetryableNetworkError` subclass to distinguish retryable vs non-retryable errors
|
|
18
|
+
- `down!` now raises non-retryable `NetworkError`, `reconnect!` raises `RetryableNetworkError`
|
|
19
|
+
- Thanks to Graham Cooper (Shopify) for this fix
|
|
20
|
+
|
|
21
|
+
- Fix "character class has duplicated range" Ruby warning (#1067)
|
|
22
|
+
- Fixed regex in `KeyManager::VALID_NAMESPACE_SEPARATORS` that caused warnings on newer Ruby versions
|
|
23
|
+
- Thanks to Hartley McGuire for this fix
|
|
24
|
+
|
|
25
|
+
Improvements:
|
|
26
|
+
|
|
27
|
+
- Add StrictWarnings test helper to catch Ruby warnings early (#1067)
|
|
28
|
+
|
|
29
|
+
- Use bulk attribute setter for OpenTelemetry spans (#1068)
|
|
30
|
+
- Reduces lock acquisitions when setting span attributes
|
|
31
|
+
- Thanks to Robert Laurin (Shopify) for this optimization
|
|
32
|
+
|
|
33
|
+
- Fix double recording of exceptions on OpenTelemetry spans (#1069)
|
|
34
|
+
- OpenTelemetry's `in_span` method already records exceptions and sets error status automatically
|
|
35
|
+
- Removed redundant explicit exception recording that caused exceptions to appear twice in traces
|
|
36
|
+
- Thanks to Robert Laurin (Shopify) for this fix
|
|
37
|
+
|
|
38
|
+
4.3.0
|
|
39
|
+
==========
|
|
40
|
+
|
|
41
|
+
New Features:
|
|
42
|
+
|
|
43
|
+
- Add `namespace_separator` option to customize the separator between namespace and key (#1019)
|
|
44
|
+
- Default is `:` for backward compatibility
|
|
45
|
+
- Must be a single non-alphanumeric character (e.g., `:`, `/`, `|`, `.`)
|
|
46
|
+
- Example: `Dalli::Client.new(servers, namespace: 'myapp', namespace_separator: '/')`
|
|
47
|
+
|
|
48
|
+
Bug Fixes:
|
|
49
|
+
|
|
50
|
+
- Fix architecture-dependent struct timeval packing for socket timeouts (#1034)
|
|
51
|
+
- Detects correct pack format for time_t and suseconds_t on each platform
|
|
52
|
+
- Fixes timeout issues on architectures with 64-bit time_t
|
|
53
|
+
|
|
54
|
+
- Fix get_multi hanging with large key counts (#776, #941)
|
|
55
|
+
- Add interleaved read/write for pipelined gets to prevent socket buffer deadlock
|
|
56
|
+
- For batches over 10,000 keys per server, requests are now sent in chunks
|
|
57
|
+
|
|
58
|
+
- **Breaking:** Enforce string-only values in raw mode (#1022)
|
|
59
|
+
- `set(key, nil, raw: true)` now raises `MarshalError` instead of storing `""`
|
|
60
|
+
- `set(key, 123, raw: true)` now raises `MarshalError` instead of storing `"123"`
|
|
61
|
+
- This matches the behavior of client-level `raw: true` mode
|
|
62
|
+
- To store counters, use string values: `set('counter', '0', raw: true)`
|
|
63
|
+
|
|
64
|
+
CI:
|
|
65
|
+
|
|
66
|
+
- Add TruffleRuby to CI test matrix (#988)
|
|
67
|
+
|
|
4
68
|
4.2.0
|
|
5
69
|
==========
|
|
6
70
|
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -31,6 +31,31 @@ Dalli supports two protocols for communicating with memcached:
|
|
|
31
31
|
Dalli::Client.new('localhost:11211', protocol: :meta)
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
## Configuration Options
|
|
35
|
+
|
|
36
|
+
### Namespace
|
|
37
|
+
|
|
38
|
+
Use namespaces to partition your cache and avoid key collisions between different applications or environments:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# All keys will be prefixed with "myapp:"
|
|
42
|
+
Dalli::Client.new('localhost:11211', namespace: 'myapp')
|
|
43
|
+
|
|
44
|
+
# Dynamic namespace using a Proc (evaluated on each operation)
|
|
45
|
+
Dalli::Client.new('localhost:11211', namespace: -> { "tenant:#{Thread.current[:tenant_id]}" })
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Namespace Separator
|
|
49
|
+
|
|
50
|
+
By default, the namespace and key are joined with a colon (`:`). You can customize this with the `namespace_separator` option:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# Keys will be prefixed with "myapp/" instead of "myapp:"
|
|
54
|
+
Dalli::Client.new('localhost:11211', namespace: 'myapp', namespace_separator: '/')
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The separator must be a single non-alphanumeric character. Valid examples: `:`, `/`, `|`, `.`, `-`, `_`, `#`
|
|
58
|
+
|
|
34
59
|
## Security Note
|
|
35
60
|
|
|
36
61
|
By default, Dalli uses Ruby's Marshal for serialization. Deserializing untrusted data with Marshal can lead to remote code execution. If you cache user-controlled data, consider using a safer serializer:
|
data/lib/dalli/client.rb
CHANGED
|
@@ -522,8 +522,10 @@ module Dalli
|
|
|
522
522
|
def record_hit_miss_metrics(span, key_count, hit_count)
|
|
523
523
|
return unless span
|
|
524
524
|
|
|
525
|
-
span.
|
|
526
|
-
|
|
525
|
+
span.add_attributes({
|
|
526
|
+
'db.memcached.hit_count' => hit_count,
|
|
527
|
+
'db.memcached.miss_count' => key_count - hit_count
|
|
528
|
+
})
|
|
527
529
|
end
|
|
528
530
|
|
|
529
531
|
def get_multi_yielding(keys)
|
|
@@ -622,7 +624,7 @@ module Dalli
|
|
|
622
624
|
}) do
|
|
623
625
|
server.request(op, key, *args)
|
|
624
626
|
end
|
|
625
|
-
rescue
|
|
627
|
+
rescue RetryableNetworkError => e
|
|
626
628
|
Dalli.logger.debug { e.inspect }
|
|
627
629
|
Dalli.logger.debug { 'retrying request with new server' }
|
|
628
630
|
retry
|
|
@@ -90,12 +90,8 @@ module Dalli
|
|
|
90
90
|
def trace(name, attributes = {})
|
|
91
91
|
return yield unless enabled?
|
|
92
92
|
|
|
93
|
-
tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client) do |
|
|
93
|
+
tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client) do |_span|
|
|
94
94
|
yield
|
|
95
|
-
rescue StandardError => e
|
|
96
|
-
span.record_exception(e)
|
|
97
|
-
span.status = OpenTelemetry::Trace::Status.error(e.message)
|
|
98
|
-
raise
|
|
99
95
|
end
|
|
100
96
|
end
|
|
101
97
|
|
|
@@ -123,16 +119,10 @@ module Dalli
|
|
|
123
119
|
# results
|
|
124
120
|
# end
|
|
125
121
|
#
|
|
126
|
-
def trace_with_result(name, attributes = {})
|
|
122
|
+
def trace_with_result(name, attributes = {}, &)
|
|
127
123
|
return yield(nil) unless enabled?
|
|
128
124
|
|
|
129
|
-
tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client)
|
|
130
|
-
yield(span)
|
|
131
|
-
rescue StandardError => e
|
|
132
|
-
span.record_exception(e)
|
|
133
|
-
span.status = OpenTelemetry::Trace::Status.error(e.message)
|
|
134
|
-
raise
|
|
135
|
-
end
|
|
125
|
+
tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client, &)
|
|
136
126
|
end
|
|
137
127
|
end
|
|
138
128
|
end
|
data/lib/dalli/key_manager.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Dalli
|
|
|
12
12
|
class KeyManager
|
|
13
13
|
MAX_KEY_LENGTH = 250
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
DEFAULT_NAMESPACE_SEPARATOR = ':'
|
|
16
16
|
|
|
17
17
|
# This is a hard coded md5 for historical reasons
|
|
18
18
|
TRUNCATED_KEY_SEPARATOR = ':md5:'
|
|
@@ -21,19 +21,26 @@ module Dalli
|
|
|
21
21
|
TRUNCATED_KEY_TARGET_SIZE = 249
|
|
22
22
|
|
|
23
23
|
DEFAULTS = {
|
|
24
|
-
digest_class: ::Digest::MD5
|
|
24
|
+
digest_class: ::Digest::MD5,
|
|
25
|
+
namespace_separator: DEFAULT_NAMESPACE_SEPARATOR
|
|
25
26
|
}.freeze
|
|
26
27
|
|
|
27
|
-
OPTIONS = %i[digest_class namespace].freeze
|
|
28
|
+
OPTIONS = %i[digest_class namespace namespace_separator].freeze
|
|
28
29
|
|
|
29
|
-
attr_reader :namespace
|
|
30
|
+
attr_reader :namespace, :namespace_separator
|
|
31
|
+
|
|
32
|
+
# Valid separators: non-alphanumeric, single printable ASCII characters
|
|
33
|
+
# Excludes: alphanumerics, whitespace, control characters
|
|
34
|
+
VALID_NAMESPACE_SEPARATORS = /\A[^a-zA-Z0-9 \x00-\x1F\x7F]\z/
|
|
30
35
|
|
|
31
36
|
def initialize(client_options)
|
|
32
37
|
@key_options =
|
|
33
38
|
DEFAULTS.merge(client_options.slice(*OPTIONS))
|
|
34
39
|
validate_digest_class_option(@key_options)
|
|
40
|
+
validate_namespace_separator_option(@key_options)
|
|
35
41
|
|
|
36
42
|
@namespace = namespace_from_options
|
|
43
|
+
@namespace_separator = @key_options[:namespace_separator]
|
|
37
44
|
end
|
|
38
45
|
|
|
39
46
|
##
|
|
@@ -61,7 +68,7 @@ module Dalli
|
|
|
61
68
|
def key_with_namespace(key)
|
|
62
69
|
return key if namespace.nil?
|
|
63
70
|
|
|
64
|
-
"#{evaluate_namespace}#{
|
|
71
|
+
"#{evaluate_namespace}#{namespace_separator}#{key}"
|
|
65
72
|
end
|
|
66
73
|
|
|
67
74
|
def key_without_namespace(key)
|
|
@@ -75,9 +82,9 @@ module Dalli
|
|
|
75
82
|
end
|
|
76
83
|
|
|
77
84
|
def namespace_regexp
|
|
78
|
-
return /\A#{Regexp.escape(evaluate_namespace)}
|
|
85
|
+
return /\A#{Regexp.escape(evaluate_namespace)}#{Regexp.escape(namespace_separator)}/ if namespace.is_a?(Proc)
|
|
79
86
|
|
|
80
|
-
@namespace_regexp ||= /\A#{Regexp.escape(namespace)}
|
|
87
|
+
@namespace_regexp ||= /\A#{Regexp.escape(namespace)}#{Regexp.escape(namespace_separator)}/ unless namespace.nil?
|
|
81
88
|
end
|
|
82
89
|
|
|
83
90
|
def validate_digest_class_option(opts)
|
|
@@ -86,6 +93,14 @@ module Dalli
|
|
|
86
93
|
raise ArgumentError, 'The digest_class object must respond to the hexdigest method'
|
|
87
94
|
end
|
|
88
95
|
|
|
96
|
+
def validate_namespace_separator_option(opts)
|
|
97
|
+
sep = opts[:namespace_separator]
|
|
98
|
+
return if VALID_NAMESPACE_SEPARATORS.match?(sep)
|
|
99
|
+
|
|
100
|
+
raise ArgumentError,
|
|
101
|
+
'namespace_separator must be a single non-alphanumeric character (e.g., ":", "/", "|")'
|
|
102
|
+
end
|
|
103
|
+
|
|
89
104
|
def namespace_from_options
|
|
90
105
|
raw_namespace = @key_options[:namespace]
|
|
91
106
|
return nil unless raw_namespace
|
|
@@ -7,6 +7,13 @@ module Dalli
|
|
|
7
7
|
# Contains logic for the pipelined gets implemented by the client.
|
|
8
8
|
##
|
|
9
9
|
class PipelinedGetter
|
|
10
|
+
# For large batches, interleave sends with response draining to prevent
|
|
11
|
+
# socket buffer deadlock. Only kicks in above this threshold.
|
|
12
|
+
INTERLEAVE_THRESHOLD = 10_000
|
|
13
|
+
|
|
14
|
+
# Number of keys to send before draining responses during interleaved mode
|
|
15
|
+
CHUNK_SIZE = 10_000
|
|
16
|
+
|
|
10
17
|
def initialize(ring, key_manager)
|
|
11
18
|
@ring = ring
|
|
12
19
|
@key_manager = key_manager
|
|
@@ -19,16 +26,31 @@ module Dalli
|
|
|
19
26
|
return {} if keys.empty?
|
|
20
27
|
|
|
21
28
|
@ring.lock do
|
|
29
|
+
# Stores partial results collected during interleaved send phase
|
|
30
|
+
@partial_results = {}
|
|
22
31
|
servers = setup_requests(keys)
|
|
23
32
|
start_time = Time.now
|
|
33
|
+
|
|
34
|
+
# First yield any partial results collected during interleaved send
|
|
35
|
+
yield_partial_results(&block)
|
|
36
|
+
|
|
24
37
|
servers = fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
|
|
25
38
|
end
|
|
26
|
-
rescue
|
|
39
|
+
rescue Dalli::RetryableNetworkError => e
|
|
27
40
|
Dalli.logger.debug { e.inspect }
|
|
28
41
|
Dalli.logger.debug { 'retrying pipelined gets because of timeout' }
|
|
29
42
|
retry
|
|
30
43
|
end
|
|
31
44
|
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def yield_partial_results
|
|
48
|
+
@partial_results.each_pair do |key, value_list|
|
|
49
|
+
yield @key_manager.key_without_namespace(key), value_list
|
|
50
|
+
end
|
|
51
|
+
@partial_results.clear
|
|
52
|
+
end
|
|
53
|
+
|
|
32
54
|
def setup_requests(keys)
|
|
33
55
|
groups = groups_for_keys(keys)
|
|
34
56
|
make_getkq_requests(groups)
|
|
@@ -47,7 +69,14 @@ module Dalli
|
|
|
47
69
|
##
|
|
48
70
|
def make_getkq_requests(groups)
|
|
49
71
|
groups.each do |server, keys_for_server|
|
|
50
|
-
|
|
72
|
+
if keys_for_server.size <= INTERLEAVE_THRESHOLD
|
|
73
|
+
# Small batch - send all at once (existing behavior)
|
|
74
|
+
server.request(:pipelined_get, keys_for_server)
|
|
75
|
+
else
|
|
76
|
+
# Large batch - interleave sends with response draining
|
|
77
|
+
# Pass @partial_results directly to avoid hash allocation/merge overhead
|
|
78
|
+
server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results)
|
|
79
|
+
end
|
|
51
80
|
rescue DalliError, NetworkError => e
|
|
52
81
|
Dalli.logger.debug { e.inspect }
|
|
53
82
|
Dalli.logger.debug { "unable to get keys for server #{server.name}" }
|
|
@@ -114,7 +143,7 @@ module Dalli
|
|
|
114
143
|
servers
|
|
115
144
|
rescue NetworkError
|
|
116
145
|
# Abort and raise if we encountered a network error. This triggers
|
|
117
|
-
# a retry at the top level.
|
|
146
|
+
# a retry at the top level on RetryableNetworkError.
|
|
118
147
|
abort_without_timeout(servers)
|
|
119
148
|
raise
|
|
120
149
|
end
|
|
@@ -28,7 +28,7 @@ module Dalli
|
|
|
28
28
|
servers = setup_requests(hash, ttl, req_options)
|
|
29
29
|
finish_requests(servers)
|
|
30
30
|
end
|
|
31
|
-
rescue
|
|
31
|
+
rescue Dalli::RetryableNetworkError => e
|
|
32
32
|
Dalli.logger.debug { e.inspect }
|
|
33
33
|
Dalli.logger.debug { 'retrying pipelined sets because of network error' }
|
|
34
34
|
retry
|
data/lib/dalli/protocol/base.rb
CHANGED
|
@@ -42,8 +42,8 @@ module Dalli
|
|
|
42
42
|
@connection_manager.start_request!
|
|
43
43
|
response = send(opkey, *args)
|
|
44
44
|
|
|
45
|
-
# pipelined_get emit query but
|
|
46
|
-
@connection_manager.finish_request! unless opkey
|
|
45
|
+
# pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
|
|
46
|
+
@connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)
|
|
47
47
|
|
|
48
48
|
response
|
|
49
49
|
rescue Dalli::MarshalError => e
|
|
@@ -81,7 +81,9 @@ module Dalli
|
|
|
81
81
|
def pipeline_response_setup
|
|
82
82
|
verify_pipelined_state(:getkq)
|
|
83
83
|
write_noop
|
|
84
|
-
|
|
84
|
+
# Use ensure_ready instead of reset to preserve any data already buffered
|
|
85
|
+
# during interleaved pipelined get draining
|
|
86
|
+
response_buffer.ensure_ready
|
|
85
87
|
end
|
|
86
88
|
|
|
87
89
|
# Attempt to receive and parse as many key/value pairs as possible
|
|
@@ -220,6 +222,11 @@ module Dalli
|
|
|
220
222
|
end
|
|
221
223
|
|
|
222
224
|
def pipelined_get(keys)
|
|
225
|
+
# Clear buffer to remove any stale data from interrupted operations.
|
|
226
|
+
# Use clear (not reset) to keep pipeline_complete? = true, which is
|
|
227
|
+
# the expected state before pipeline_response_setup is called.
|
|
228
|
+
response_buffer.clear
|
|
229
|
+
|
|
223
230
|
req = +''
|
|
224
231
|
keys.each do |key|
|
|
225
232
|
req << quiet_get_request(key)
|
|
@@ -228,6 +235,51 @@ module Dalli
|
|
|
228
235
|
write(req)
|
|
229
236
|
end
|
|
230
237
|
|
|
238
|
+
# For large batches, interleave writing requests with draining responses.
|
|
239
|
+
# This prevents socket buffer deadlock when sending many keys.
|
|
240
|
+
# Populates the provided results hash with any responses drained during send.
|
|
241
|
+
def pipelined_get_interleaved(keys, chunk_size, results)
|
|
242
|
+
# Initialize the response buffer for draining during send phase
|
|
243
|
+
response_buffer.ensure_ready
|
|
244
|
+
|
|
245
|
+
keys.each_slice(chunk_size) do |chunk|
|
|
246
|
+
# Build and write this chunk of requests
|
|
247
|
+
req = +''
|
|
248
|
+
chunk.each do |key|
|
|
249
|
+
req << quiet_get_request(key)
|
|
250
|
+
end
|
|
251
|
+
write(req)
|
|
252
|
+
@connection_manager.flush
|
|
253
|
+
|
|
254
|
+
# Drain any available responses directly into results hash
|
|
255
|
+
drain_pipeline_responses(results)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Non-blocking read and processing of any available pipeline responses.
|
|
260
|
+
# Used during interleaved pipelined gets to prevent buffer deadlock.
|
|
261
|
+
# Populates the provided results hash directly to avoid allocation overhead.
|
|
262
|
+
def drain_pipeline_responses(results)
|
|
263
|
+
return unless connected?
|
|
264
|
+
|
|
265
|
+
# Non-blocking check if socket has data available
|
|
266
|
+
return unless sock.wait_readable(0)
|
|
267
|
+
|
|
268
|
+
# Read available data without blocking
|
|
269
|
+
response_buffer.read
|
|
270
|
+
|
|
271
|
+
# Process any complete responses in the buffer
|
|
272
|
+
loop do
|
|
273
|
+
status, cas, key, value = response_buffer.process_single_getk_response
|
|
274
|
+
break if status.nil? # No complete response available
|
|
275
|
+
|
|
276
|
+
results[key] = [value, cas] unless key.nil?
|
|
277
|
+
end
|
|
278
|
+
rescue SystemCallError, Dalli::NetworkError
|
|
279
|
+
# Ignore errors during drain - they'll be handled in fetch_responses
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
|
|
231
283
|
def response_buffer
|
|
232
284
|
@response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
|
|
233
285
|
end
|
|
@@ -41,6 +41,15 @@ module Dalli
|
|
|
41
41
|
@buffer = ''.b
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Ensures the buffer is initialized for reading without discarding
|
|
45
|
+
# existing data. Used by interleaved pipelined get which may have
|
|
46
|
+
# already buffered partial responses during the send phase.
|
|
47
|
+
def ensure_ready
|
|
48
|
+
return if in_progress?
|
|
49
|
+
|
|
50
|
+
@buffer = ''.b
|
|
51
|
+
end
|
|
52
|
+
|
|
44
53
|
# Clear the internal response buffer
|
|
45
54
|
def clear
|
|
46
55
|
@buffer = nil
|
|
@@ -32,22 +32,8 @@ module Dalli
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def store(value, req_options, bitflags)
|
|
35
|
-
if req_options
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# If the value is a simple string, going through serialization is costly
|
|
39
|
-
# for no benefit other than preserving encoding.
|
|
40
|
-
# Assuming most strings are either UTF-8 or BINARY we can just store
|
|
41
|
-
# that information in the bitflags.
|
|
42
|
-
if req_options[:string_fastpath] && value.instance_of?(String)
|
|
43
|
-
case value.encoding
|
|
44
|
-
when Encoding::BINARY
|
|
45
|
-
return [value, bitflags]
|
|
46
|
-
when Encoding::UTF_8
|
|
47
|
-
return [value, bitflags | FLAG_UTF8]
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
35
|
+
return store_raw(value, bitflags) if req_options&.dig(:raw)
|
|
36
|
+
return store_string_fastpath(value, bitflags) if use_string_fastpath?(value, req_options)
|
|
51
37
|
|
|
52
38
|
[serialize_value(value), bitflags | FLAG_SERIALIZED]
|
|
53
39
|
end
|
|
@@ -85,6 +71,30 @@ module Dalli
|
|
|
85
71
|
|
|
86
72
|
private
|
|
87
73
|
|
|
74
|
+
def store_raw(value, bitflags)
|
|
75
|
+
unless value.is_a?(String)
|
|
76
|
+
raise Dalli::MarshalError, "Dalli raw mode requires string values, got: #{value.class}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
[value, bitflags]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# If the value is a simple string, going through serialization is costly
|
|
83
|
+
# for no benefit other than preserving encoding.
|
|
84
|
+
# Assuming most strings are either UTF-8 or BINARY we can just store
|
|
85
|
+
# that information in the bitflags.
|
|
86
|
+
def store_string_fastpath(value, bitflags)
|
|
87
|
+
case value.encoding
|
|
88
|
+
when Encoding::BINARY then [value, bitflags]
|
|
89
|
+
when Encoding::UTF_8 then [value, bitflags | FLAG_UTF8]
|
|
90
|
+
else [serialize_value(value), bitflags | FLAG_SERIALIZED]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def use_string_fastpath?(value, req_options)
|
|
95
|
+
req_options&.dig(:string_fastpath) && value.instance_of?(String)
|
|
96
|
+
end
|
|
97
|
+
|
|
88
98
|
def warn_if_marshal_default(protocol_options)
|
|
89
99
|
return if protocol_options.key?(:serializer)
|
|
90
100
|
return if @@marshal_warning_logged
|
data/lib/dalli/socket.rb
CHANGED
|
@@ -90,6 +90,12 @@ module Dalli
|
|
|
90
90
|
# options - supports enhanced logging in the case of a timeout
|
|
91
91
|
attr_accessor :options
|
|
92
92
|
|
|
93
|
+
# Expected parameter signature for unmodified TCPSocket#initialize.
|
|
94
|
+
# Used to detect when gems like socksify or resolv-replace have monkey-patched
|
|
95
|
+
# TCPSocket, which breaks the connect_timeout: keyword argument.
|
|
96
|
+
TCPSOCKET_NATIVE_PARAMETERS = [[:rest]].freeze
|
|
97
|
+
private_constant :TCPSOCKET_NATIVE_PARAMETERS
|
|
98
|
+
|
|
93
99
|
def self.open(host, port, options = {})
|
|
94
100
|
create_socket_with_timeout(host, port, options) do |sock|
|
|
95
101
|
sock.options = { host: host, port: port }.merge(options)
|
|
@@ -99,15 +105,18 @@ module Dalli
|
|
|
99
105
|
end
|
|
100
106
|
end
|
|
101
107
|
|
|
108
|
+
# Detect and cache whether TCPSocket supports the connect_timeout: keyword argument.
|
|
109
|
+
# Returns false if TCPSocket#initialize has been monkey-patched by gems like
|
|
110
|
+
# socksify or resolv-replace, which don't support keyword arguments.
|
|
111
|
+
def self.supports_connect_timeout?
|
|
112
|
+
return @supports_connect_timeout if defined?(@supports_connect_timeout)
|
|
113
|
+
|
|
114
|
+
@supports_connect_timeout = RUBY_VERSION >= '3.0' &&
|
|
115
|
+
::TCPSocket.instance_method(:initialize).parameters == TCPSOCKET_NATIVE_PARAMETERS
|
|
116
|
+
end
|
|
117
|
+
|
|
102
118
|
def self.create_socket_with_timeout(host, port, options)
|
|
103
|
-
|
|
104
|
-
# (part of ruby standard library since 3.0.0, should be removed in 3.4.0),
|
|
105
|
-
# as it does not handle keyword arguments correctly.
|
|
106
|
-
# To check this we are using the fact that resolv-replace
|
|
107
|
-
# aliases TCPSocket#initialize method to #original_resolv_initialize.
|
|
108
|
-
# https://github.com/ruby/resolv-replace/blob/v0.1.1/lib/resolv-replace.rb#L21
|
|
109
|
-
if RUBY_VERSION >= '3.0' &&
|
|
110
|
-
!::TCPSocket.private_instance_methods.include?(:original_resolv_initialize)
|
|
119
|
+
if supports_connect_timeout?
|
|
111
120
|
sock = new(host, port, connect_timeout: options[:socket_timeout])
|
|
112
121
|
yield(sock)
|
|
113
122
|
else
|
|
@@ -138,16 +147,40 @@ module Dalli
|
|
|
138
147
|
return unless options[:socket_timeout]
|
|
139
148
|
|
|
140
149
|
if sock.respond_to?(:timeout=)
|
|
150
|
+
# Ruby 3.2+ has IO#timeout for reliable cross-platform timeout handling
|
|
141
151
|
sock.timeout = options[:socket_timeout]
|
|
142
152
|
else
|
|
153
|
+
# Ruby 3.1 fallback using socket options
|
|
154
|
+
# struct timeval has architecture-dependent sizes (time_t, suseconds_t)
|
|
143
155
|
seconds, fractional = options[:socket_timeout].divmod(1)
|
|
144
|
-
|
|
156
|
+
microseconds = (fractional * 1_000_000).to_i
|
|
157
|
+
timeval = pack_timeval(sock, seconds, microseconds)
|
|
145
158
|
|
|
146
159
|
sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO, timeval)
|
|
147
160
|
sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_SNDTIMEO, timeval)
|
|
148
161
|
end
|
|
149
162
|
end
|
|
150
163
|
|
|
164
|
+
# Pack formats for struct timeval across architectures.
|
|
165
|
+
# Uses fixed-size formats for JRuby compatibility (JRuby doesn't support _ modifier on q).
|
|
166
|
+
# - ll: 8 bytes (32-bit time_t, 32-bit suseconds_t)
|
|
167
|
+
# - qq: 16 bytes (64-bit time_t, 64-bit suseconds_t or padded 32-bit)
|
|
168
|
+
TIMEVAL_PACK_FORMATS = %w[ll qq].freeze
|
|
169
|
+
TIMEVAL_TEST_VALUES = [0, 0].freeze
|
|
170
|
+
|
|
171
|
+
# Detect and cache the correct pack format for struct timeval on this platform.
|
|
172
|
+
# Different architectures have different sizes for time_t and suseconds_t.
|
|
173
|
+
def self.timeval_pack_format(sock)
|
|
174
|
+
@timeval_pack_format ||= begin
|
|
175
|
+
expected_size = sock.getsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO).data.bytesize
|
|
176
|
+
TIMEVAL_PACK_FORMATS.find { |fmt| TIMEVAL_TEST_VALUES.pack(fmt).bytesize == expected_size } || 'll'
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def self.pack_timeval(sock, seconds, microseconds)
|
|
181
|
+
[seconds, microseconds].pack(timeval_pack_format(sock))
|
|
182
|
+
end
|
|
183
|
+
|
|
151
184
|
def self.wrapping_ssl_socket(tcp_socket, host, ssl_context)
|
|
152
185
|
ssl_socket = Dalli::Socket::SSLSocket.new(tcp_socket, ssl_context)
|
|
153
186
|
ssl_socket.hostname = host
|
data/lib/dalli/version.rb
CHANGED
data/lib/dalli.rb
CHANGED
|
@@ -28,6 +28,9 @@ module Dalli
|
|
|
28
28
|
# raised when Memcached response with a SERVER_ERROR
|
|
29
29
|
class ServerError < DalliError; end
|
|
30
30
|
|
|
31
|
+
# socket/server communication error that can be retried
|
|
32
|
+
class RetryableNetworkError < NetworkError; end
|
|
33
|
+
|
|
31
34
|
# Implements the NullObject pattern to store an application-defined value for 'Key not found' responses.
|
|
32
35
|
class NilObject; end # rubocop:disable Lint/EmptyClass
|
|
33
36
|
NOT_FOUND = NilObject.new
|