Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
name: Benchmarks

on: [push, pull_request]
on:
push:
branches: [main]
workflow_dispatch:

jobs:
build:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/profile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ name: Profiles

on: [push, pull_request]

concurrency:
group: profiles-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ name: RuboCop

on: [push, pull_request]

concurrency:
group: rubocop-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ name: Tests

on: [push, pull_request]

concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
Expand All @@ -17,6 +21,7 @@ jobs:
- '3.2'
- '3.1'
- jruby-10
- truffleruby
memcached-version: ['1.5.22', '1.6.40']

name: "Ruby ${{ matrix.ruby-version }} / Memcached ${{ matrix.memcached-version }}"
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,36 @@
Dalli Changelog
=====================

4.3.0
==========

New Features:

- Add `namespace_separator` option to customize the separator between namespace and key (#1019)
- Default is `:` for backward compatibility
- Must be a single non-alphanumeric character (e.g., `:`, `/`, `|`, `.`)
- Example: `Dalli::Client.new(servers, namespace: 'myapp', namespace_separator: '/')`

Bug Fixes:

- Fix architecture-dependent struct timeval packing for socket timeouts (#1034)
- Detects correct pack format for time_t and suseconds_t on each platform
- Fixes timeout issues on architectures with 64-bit time_t

- Fix get_multi hanging with large key counts (#776, #941)
- Add interleaved read/write for pipelined gets to prevent socket buffer deadlock
- For batches over 10,000 keys per server, requests are now sent in chunks

- **Breaking:** Enforce string-only values in raw mode (#1022)
- `set(key, nil, raw: true)` now raises `MarshalError` instead of storing `""`
- `set(key, 123, raw: true)` now raises `MarshalError` instead of storing `"123"`
- This matches the behavior of client-level `raw: true` mode
- To store counters, use string values: `set('counter', '0', raw: true)`

CI:

- Add TruffleRuby to CI test matrix (#988)

4.2.0
==========

Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,31 @@ Dalli supports two protocols for communicating with memcached:
Dalli::Client.new('localhost:11211', protocol: :meta)
```

## Configuration Options

### Namespace

Use namespaces to partition your cache and avoid key collisions between different applications or environments:

```ruby
# All keys will be prefixed with "myapp:"
Dalli::Client.new('localhost:11211', namespace: 'myapp')

# Dynamic namespace using a Proc (evaluated on each operation)
Dalli::Client.new('localhost:11211', namespace: -> { "tenant:#{Thread.current[:tenant_id]}" })
```

### Namespace Separator

By default, the namespace and key are joined with a colon (`:`). You can customize this with the `namespace_separator` option:

```ruby
# Keys will be prefixed with "myapp/" instead of "myapp:"
Dalli::Client.new('localhost:11211', namespace: 'myapp', namespace_separator: '/')
```

The separator must be a single non-alphanumeric character. Valid examples: `:`, `/`, `|`, `.`, `-`, `_`, `#`

## Security Note

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:
Expand Down
29 changes: 22 additions & 7 deletions lib/dalli/key_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module Dalli
class KeyManager
MAX_KEY_LENGTH = 250

NAMESPACE_SEPARATOR = ':'
DEFAULT_NAMESPACE_SEPARATOR = ':'

# This is a hard coded md5 for historical reasons
TRUNCATED_KEY_SEPARATOR = ':md5:'
Expand All @@ -21,19 +21,26 @@ class KeyManager
TRUNCATED_KEY_TARGET_SIZE = 249

DEFAULTS = {
digest_class: ::Digest::MD5
digest_class: ::Digest::MD5,
namespace_separator: DEFAULT_NAMESPACE_SEPARATOR
}.freeze

OPTIONS = %i[digest_class namespace].freeze
OPTIONS = %i[digest_class namespace namespace_separator].freeze

attr_reader :namespace
attr_reader :namespace, :namespace_separator

# Valid separators: non-alphanumeric, single printable ASCII characters
# Excludes: alphanumerics, whitespace, control characters
VALID_NAMESPACE_SEPARATORS = /\A[^a-zA-Z0-9\s\x00-\x1F\x7F]\z/

def initialize(client_options)
@key_options =
DEFAULTS.merge(client_options.slice(*OPTIONS))
validate_digest_class_option(@key_options)
validate_namespace_separator_option(@key_options)

@namespace = namespace_from_options
@namespace_separator = @key_options[:namespace_separator]
end

##
Expand Down Expand Up @@ -61,7 +68,7 @@ def validate_key(key)
def key_with_namespace(key)
return key if namespace.nil?

"#{evaluate_namespace}#{NAMESPACE_SEPARATOR}#{key}"
"#{evaluate_namespace}#{namespace_separator}#{key}"
end

def key_without_namespace(key)
Expand All @@ -75,9 +82,9 @@ def digest_class
end

def namespace_regexp
return /\A#{Regexp.escape(evaluate_namespace)}:/ if namespace.is_a?(Proc)
return /\A#{Regexp.escape(evaluate_namespace)}#{Regexp.escape(namespace_separator)}/ if namespace.is_a?(Proc)

@namespace_regexp ||= /\A#{Regexp.escape(namespace)}:/ unless namespace.nil?
@namespace_regexp ||= /\A#{Regexp.escape(namespace)}#{Regexp.escape(namespace_separator)}/ unless namespace.nil?
end

def validate_digest_class_option(opts)
Expand All @@ -86,6 +93,14 @@ def validate_digest_class_option(opts)
raise ArgumentError, 'The digest_class object must respond to the hexdigest method'
end

def validate_namespace_separator_option(opts)
sep = opts[:namespace_separator]
return if VALID_NAMESPACE_SEPARATORS.match?(sep)

raise ArgumentError,
'namespace_separator must be a single non-alphanumeric character (e.g., ":", "/", "|")'
end

def namespace_from_options
raw_namespace = @key_options[:namespace]
return nil unless raw_namespace
Expand Down
31 changes: 30 additions & 1 deletion lib/dalli/pipelined_getter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ module Dalli
# Contains logic for the pipelined gets implemented by the client.
##
class PipelinedGetter
# For large batches, interleave sends with response draining to prevent
# socket buffer deadlock. Only kicks in above this threshold.
INTERLEAVE_THRESHOLD = 10_000

# Number of keys to send before draining responses during interleaved mode
CHUNK_SIZE = 10_000

def initialize(ring, key_manager)
@ring = ring
@key_manager = key_manager
Expand All @@ -19,8 +26,14 @@ def process(keys, &block)
return {} if keys.empty?

@ring.lock do
# Stores partial results collected during interleaved send phase
@partial_results = {}
servers = setup_requests(keys)
start_time = Time.now

# First yield any partial results collected during interleaved send
yield_partial_results(&block)

servers = fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
end
rescue NetworkError => e
Expand All @@ -29,6 +42,15 @@ def process(keys, &block)
retry
end

private

def yield_partial_results
@partial_results.each_pair do |key, value_list|
yield @key_manager.key_without_namespace(key), value_list
end
@partial_results.clear
end

def setup_requests(keys)
groups = groups_for_keys(keys)
make_getkq_requests(groups)
Expand All @@ -47,7 +69,14 @@ def setup_requests(keys)
##
def make_getkq_requests(groups)
groups.each do |server, keys_for_server|
server.request(:pipelined_get, keys_for_server)
if keys_for_server.size <= INTERLEAVE_THRESHOLD
# Small batch - send all at once (existing behavior)
server.request(:pipelined_get, keys_for_server)
else
# Large batch - interleave sends with response draining
# Pass @partial_results directly to avoid hash allocation/merge overhead
server.request(:pipelined_get_interleaved, keys_for_server, CHUNK_SIZE, @partial_results)
end
rescue DalliError, NetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { "unable to get keys for server #{server.name}" }
Expand Down
58 changes: 55 additions & 3 deletions lib/dalli/protocol/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ def request(opkey, *args)
@connection_manager.start_request!
response = send(opkey, *args)

# pipelined_get emit query but doesn't read the response(s)
@connection_manager.finish_request! unless opkey == :pipelined_get
# pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
@connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)

response
rescue Dalli::MarshalError => e
Expand Down Expand Up @@ -81,7 +81,9 @@ def unlock!; end
def pipeline_response_setup
verify_pipelined_state(:getkq)
write_noop
response_buffer.reset
# Use ensure_ready instead of reset to preserve any data already buffered
# during interleaved pipelined get draining
response_buffer.ensure_ready
end

# Attempt to receive and parse as many key/value pairs as possible
Expand Down Expand Up @@ -220,6 +222,11 @@ def connect
end

def pipelined_get(keys)
# Clear buffer to remove any stale data from interrupted operations.
# Use clear (not reset) to keep pipeline_complete? = true, which is
# the expected state before pipeline_response_setup is called.
response_buffer.clear

req = +''
keys.each do |key|
req << quiet_get_request(key)
Expand All @@ -228,6 +235,51 @@ def pipelined_get(keys)
write(req)
end

# For large batches, interleave writing requests with draining responses.
# This prevents socket buffer deadlock when sending many keys.
# Populates the provided results hash with any responses drained during send.
def pipelined_get_interleaved(keys, chunk_size, results)
# Initialize the response buffer for draining during send phase
response_buffer.ensure_ready

keys.each_slice(chunk_size) do |chunk|
# Build and write this chunk of requests
req = +''
chunk.each do |key|
req << quiet_get_request(key)
end
write(req)
@connection_manager.flush

# Drain any available responses directly into results hash
drain_pipeline_responses(results)
end
end

# Non-blocking read and processing of any available pipeline responses.
# Used during interleaved pipelined gets to prevent buffer deadlock.
# Populates the provided results hash directly to avoid allocation overhead.
def drain_pipeline_responses(results)
return unless connected?

# Non-blocking check if socket has data available
return unless sock.wait_readable(0)

# Read available data without blocking
response_buffer.read

# Process any complete responses in the buffer
loop do
status, cas, key, value = response_buffer.process_single_getk_response
break if status.nil? # No complete response available

results[key] = [value, cas] unless key.nil?
end
rescue SystemCallError, Dalli::NetworkError
# Ignore errors during drain - they'll be handled in fetch_responses
nil
end

def response_buffer
@response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
end
Expand Down
9 changes: 9 additions & 0 deletions lib/dalli/protocol/response_buffer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ def reset
@buffer = ''.b
end

# Ensures the buffer is initialized for reading without discarding
# existing data. Used by interleaved pipelined get which may have
# already buffered partial responses during the send phase.
def ensure_ready
return if in_progress?

@buffer = ''.b
end

# Clear the internal response buffer
def clear
@buffer = nil
Expand Down
Loading
Loading