This roadmap outlines the evolution of Dalli from v4.x through v5.0, focusing on:
- Deprecating and removing the binary protocol
- Expanding meta protocol support (especially thundering herd features)
- Performance improvements from Shopify's fork
- Codebase simplification and modernization
- Add deprecation warnings for binary protocol
- Add new meta protocol features
- Backport performance improvements from Shopify/dalli
- Fix outstanding bugs
- Remove binary protocol entirely
- Remove SASL authentication (meta protocol doesn't support it)
- Require Ruby 3.3+ and memcached 1.6+ (meta protocol minimum)
- Replace
readfullwithIO#readfor ~7% read performance improvement - Simplify codebase structure
Files: lib/dalli/protocol/binary.rb, lib/dalli/client.rb
- Add deprecation warning when
protocol: :binaryis used - Add deprecation warning when SASL credentials are provided
- Update documentation to recommend meta protocol
- Note: Binary remains functional, just deprecated
Reference: Shopify#59 (similar pattern), Shopify#39
Files to create/modify:
lib/dalli/pipelined_setter.rb(new)lib/dalli/client.rblib/dalli/protocol/meta.rblib/dalli/protocol/meta/request_formatter.rb
# New API
client.set_multi({ 'key1' => 'val1', 'key2' => 'val2' }, ttl, options)Currently users must use quiet { } blocks for pipelined sets, which is awkward.
Reference: Shopify#59
Files to create/modify:
lib/dalli/pipelined_deleter.rb(new)lib/dalli/client.rblib/dalli/protocol/meta.rblib/dalli/protocol/meta/request_formatter.rb
# New API
client.delete_multi(['key1', 'key2', 'key3'])Reference: memcached protocol.txt, Shopify#46
Added support for thundering herd protection flags:
For mg (get) - implemented:
| Flag | Purpose | Use Case |
|---|---|---|
N(ttl) |
Create stub on miss | Thundering herd protection |
R(ttl) |
Win recache if TTL below threshold | Thundering herd protection |
Response flags parsed:
| Flag | Purpose |
|---|---|
W |
Client won recache rights |
X |
Item marked stale |
Z |
Another client already won recache |
For md (delete) - implemented:
| Flag | Purpose | Use Case |
|---|---|---|
I |
Mark stale instead of deleting | Graceful invalidation |
Metadata flags - implemented:
| Flag | Purpose | Use Case |
|---|---|---|
h |
Return hit status (0/1) | Metrics/debugging |
l |
Seconds since last access | Cache analytics |
u |
Skip LRU bump | Read without affecting eviction |
Files:
lib/dalli/protocol/meta/request_formatter.rblib/dalli/protocol/meta/response_processor.rb
Status: ✅ COMPLETE
New client-level method for advanced cache operations:
# Get value with metadata
result = client.get_with_metadata('key')
# => { value: "data", cas: 123, won_recache: false, stale: false, lost_recache: false }
# Get with hit status
result = client.get_with_metadata('key', return_hit_status: true)
# => { ..., hit_before: false } # First access
# Get with last access time
result = client.get_with_metadata('key', return_last_access: true)
# => { ..., last_access: 42 } # Seconds since last access
# Skip LRU bump (read without affecting eviction order)
result = client.get_with_metadata('key', skip_lru_bump: true)
# Combined with thundering herd protection
result = client.get_with_metadata('key', vivify_ttl: 30, recache_ttl: 60)Files:
lib/dalli/client.rblib/dalli/protocol/meta.rb
Reference: Shopify#46
New method that uses meta protocol's N and R flags:
# New API - prevents multiple clients from regenerating same cache entry
client.fetch_with_lock(key, ttl: 300, lock_ttl: 30) do
expensive_database_query
endFiles:
lib/dalli/client.rblib/dalli/protocol/meta.rb
Reference: Shopify#55, Shopify#38
Implemented:
- Set
socket.sync = falseto enable buffered I/O - Added explicit
flushcalls before reading responses - Reduces syscalls for pipelined operations (100-300% improvement for multi ops)
Files modified:
lib/dalli/protocol/connection_manager.rb- Addedflushmethod andsync = falselib/dalli/protocol/meta.rb- Added flush calls before response readinglib/dalli/protocol/binary.rb- Added flush calls before response reading
Reference: Shopify#56
Implemented lightweight distributed tracing that auto-detects OpenTelemetry SDK:
# Automatically instruments when OTel is present
# Zero overhead when OTel is not loaded
client = Dalli::Client.new('localhost:11211')Features:
- Traces
get,set,delete,get_multi,set_multi,delete_multi,get_with_metadata,fetch_with_lock - Spans include
db.system: memcachedanddb.operationattributes - Single-key operations include
server.addressattribute - Multi-key operations include
db.memcached.key_count,hit_count,miss_count - Exceptions are automatically recorded on spans with error status
Files created/modified:
lib/dalli/instrumentation.rb- New lightweight tracing modulelib/dalli/client.rb- Added tracing to all operationstest/test_instrumentation.rb- Unit tests for instrumentation
Reference: Shopify#44, Shopify#45
Implemented:
- Use
Setinstead ofArrayfor deleted server tracking (O(1) vs O(n) lookups) - Use
select!(&:connected?)instead ofdelete_if { |s| !s.connected? } - Skip bitflags request in raw mode (saves 2 bytes/request, skips parsing)
Files modified:
lib/dalli/pipelined_getter.rblib/dalli/protocol/meta/request_formatter.rblib/dalli/protocol/meta.rblib/dalli/protocol/base.rb
These optimizations from Shopify's fork were not included in the v4.2.0 scope but could provide additional performance benefits. They are documented here for potential future work.
Reference: Shopify#45
The read_data() method in response_processor.rb creates allocations on every call. Shopify's PR achieved ~56% allocation reduction through:
| Optimization | Status | Benefit |
|---|---|---|
Reuse terminator buffer in read_data() |
❌ Not done | Fewer allocations per get |
| Pre-size buffers | ❌ Not done | Fewer reallocations |
Implementation notes:
- Requires careful refactoring of the response processor
- Most impactful in tight loops (get_multi with many keys)
- Should benchmark before/after to validate gains
Reference: Shopify#45
For the common case of a single memcached server with raw mode enabled, a simplified code path could avoid overhead from multi-server handling.
Implementation notes:
- Detect single-server + raw mode configuration
- Skip server grouping and ring lookups when only one server
- Estimated 10-20% improvement for this specific use case
| Issue | Description | Priority | Status |
|---|---|---|---|
| #1034 | struct timeval architecture-dependent packing | High | Affects Ruby 3.1 only |
| #776/#941 | get_multi hangs with large key counts | Medium | Ready for Implementation |
| #1022 | Empty string with cache_nils: false + raw: true |
Medium | Needs Rails Input |
| #1019 | Make NAMESPACE_SEPARATOR configurable | Low | Easy Fix |
| #805 | Migration path for instrument_errors | Low | Likely Resolved |
| #1039 | "No request in progress" after Ruby 3.4.2 | Low | Insufficient Info |
Status: Affects Ruby 3.1 users on non-x86_64 architectures only
Background: The struct timeval used for SO_RCVTIMEO/SO_SNDTIMEO socket options has architecture-dependent sizes. The code uses 'l_2' pack format which doesn't work on all architectures (e.g., 64-bit time_t with 32-bit long on ARM).
Current State:
- PR #1025 (merged in v4.0.0) uses
IO#timeout=when available, bypassingsetsockoptentirely - However,
IO#timeout=was introduced in Ruby 3.2 (not 3.0 as previously thought) - Dalli currently supports Ruby 3.1+, so Ruby 3.1 users fall through to the buggy
setsockoptpath - This affects Ruby 3.1 users on non-standard architectures (ARM, certain 32-bit systems)
Options for v4.3.0:
| Option | Implementation | Trade-off |
|---|---|---|
| A. Fix fallback | Adopt @lnussbaum's detection approach | Adds complexity, fixes edge case |
| B. Document limitation | Add note to README | No code change, Ruby 3.1 + exotic arch users have broken timeouts |
| C. Bump min Ruby to 3.2 | Remove fallback code entirely | Breaking change, drops Ruby 3.1 support |
Recommended approach:
- For v4.3.0: Option A (fix the fallback) - small code change, maintains Ruby 3.1 support
- For v5.0.0: Bump to Ruby 3.3+ - simplifies code, removes need for fallback on CRuby entirely
Implementation for Option A:
File: lib/dalli/socket.rb, method configure_timeout:
def self.configure_timeout(sock, options)
return unless options[:socket_timeout]
if sock.respond_to?(:timeout=)
# Ruby 3.2+ - use IO#timeout for reliable timeout handling
sock.timeout = options[:socket_timeout]
else
# Ruby 3.1 fallback - detect correct timeval format for this architecture
seconds, fractional = options[:socket_timeout].divmod(1)
microseconds = (fractional * 1_000_000).to_i
# struct timeval varies by architecture: (time_t, suseconds_t)
# Detect correct format by comparing with getsockopt result
timeval_formats = ['q l_', 'l l_', 'q l_ x4']
expected_length = sock.getsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO).data.length
timeval_format = timeval_formats.find { |fmt| [0, 0].pack(fmt).length == expected_length }
raise Dalli::DalliError, 'Unable to determine timeval format for socket timeout' unless timeval_format
timeval = [seconds, microseconds].pack(timeval_format)
sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO, timeval)
sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_SNDTIMEO, timeval)
end
endStatus: Ready for implementation - leverages v4.2.0 buffered I/O infrastructure
Background: When get_multi is called with a large number of keys (60k+), the operation hangs. This occurs because:
- Dalli sends all
getkq(quiet get) requests before reading responses - Memcached doesn't actually wait for the final
noopbefore responding - it buffers responses and sends them once an internal buffer fills - If Dalli's send buffer fills before all requests are sent, and memcached's response buffer fills, both sides deadlock waiting on each other
Workaround: Users can increase sndbuf and rcvbuf socket options, or batch keys externally (e.g., 500k key batches).
Implementation Plan:
The v4.2.0 buffered I/O changes (socket.sync = false, explicit flush calls, ConnectionManager#flush) provide the infrastructure needed to implement interleaved reading/writing.
Current flow in PipelinedGetter:
make_getkq_requests() → sends ALL requests to ALL servers
finish_queries() → sends noop to each server
fetch_responses() → reads ALL responses
Proposed interleaved flow:
make_getkq_requests() → for each server:
for each chunk of CHUNK_SIZE keys:
send pipelined_get requests
flush socket buffer
drain_available_responses() if large batch
finish_queries() → sends noop to each server
fetch_responses() → reads remaining responses
Files to modify:
lib/dalli/pipelined_getter.rb- Main implementation
Key changes:
-
Add chunk size constant:
INTERLEAVE_THRESHOLD = 10_000 # Only interleave for large batches CHUNK_SIZE = 10_000
-
Modify
make_getkq_requests:def make_getkq_requests(groups) groups.each do |server, 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 and reads keys_for_server.each_slice(CHUNK_SIZE) do |chunk| server.request(:pipelined_get, chunk) server.connection_manager.flush drain_available_responses(server) end end rescue DalliError, NetworkError => e # existing error handling end end
-
Add response draining method:
def drain_available_responses(server) # Non-blocking read of any available responses # Store partial results for later processing server.pipeline_next_responses.each_pair do |key, value_list| @partial_results[key] = value_list end rescue IO::WaitReadable # No data available yet - that's fine end
-
Track partial results:
def initialize(ring, key_manager) @ring = ring @key_manager = key_manager @partial_results = {} # NEW: track responses received during send phase end
-
Merge partial results in
process_server:def process_server(server) # First yield any partial results collected during interleaved send @partial_results.each_pair do |key, value_list| yield @key_manager.key_without_namespace(key), value_list end @partial_results.clear # Then process remaining responses server.pipeline_next_responses.each_pair do |key, value_list| yield @key_manager.key_without_namespace(key), value_list end server.pipeline_complete? end
Testing strategy:
- Add test with 100k+ keys to verify no deadlock
- Verify small batches (<10k) behavior unchanged
- Benchmark to ensure no performance regression for normal use cases
- Test with multiple servers to verify per-server interleaving works
Considerations:
- Only affects large key sets - small operations use existing fast path
- Meta protocol only (binary protocol deprecated)
- Timeout handling: existing timeout logic in
fetch_responsesstill applies - Memory: partial results are stored in Ruby hash during send phase
Status: Needs Rails team input before implementing
Background: When storing nil with raw: true, Dalli converts it to an empty string "". This is problematic because:
cache_nils: true+raw: true→ stores"", returns""(notnil)cache_nils: false+raw: true→ stores"", returns""(should error or not cache)
Proposed Behavior (from @nickamorim):
raw: true+cache_nils: true→ Store a sentinel value, returnnilon getraw: true+cache_nils: false→ RaiseArgumentError
Alternative (from @grcooper):
raw: trueshould only accept strings - any non-string (includingnil) should raiseArgumentError- This is a stricter interpretation: raw mode means "I know what I'm doing with strings"
Blocked On: Need @byroot's input on Rails MemCacheStore behavior:
- Does Rails pass
nilvalues to Dalli withraw: true? - What does
StringMarshallerdo withnil? - What behavior does Rails expect?
Action: Comment on issue requesting Rails team clarification before implementing.
Status: Low priority, easy fix
Background: The namespace separator is hardcoded as :. Some users want to customize this.
Implementation:
- Add
namespace_separatoroption to client - Default to
:for backwards compatibility - Validate that separator contains only allowed characters (alphanumeric, common punctuation)
- Must not contain characters that would break memcached protocol (spaces, newlines, etc.)
Allowed Characters: Should match memcached key restrictions - printable ASCII except space and control characters. Recommend restricting to: A-Za-z0-9_\-:.
Status: Likely resolved by OpenTelemetry support
Background: The old DalliStore (removed in favor of Rails' MemCacheStore) had an instrument_errors parameter that generated ActiveSupport::Notifications events on errors.
Current State:
- Dalli 4.2.0 now has OpenTelemetry support with automatic error recording on spans
- Rails 8.0+ improved error handling in
MemCacheStore- errors are now rescued and reported toRails.error - The combination of OTel spans + Rails.error may provide equivalent or better observability
Action:
- Document the migration path in README: use OpenTelemetry for error visibility
- Verify Rails 8.0+
MemCacheStoreerror handling is sufficient - Close issue with documentation update if OTel + Rails.error covers the use case
Status: Insufficient information, no other reports
Background: Single user report of "No request in progress" error after upgrading to Ruby 3.4.2. No reproduction steps or additional context provided.
Current State: @petergoldstein asked for more information; no response from reporter. No other users have reported this issue.
Action: Keep issue open but deprioritize. If more reports come in or reporter responds with details, investigate then.
Strategy: Add TruffleRuby to CI as a non-blocking job (allow failures). The goal is to surface incompatibilities early, not to block PRs.
Rationale:
- TruffleRuby compatibility is valuable for users who need it
- However, incompatibilities should be reported to the TruffleRuby team to address
- @nirvdrum has offered to be a point of contact for TruffleRuby issues
Implementation:
- Add TruffleRuby to the test matrix in
.github/workflows/tests.yml - Use
continue-on-error: truefor TruffleRuby jobs - When failures occur:
- Document the incompatibility in a GitHub issue
- Report to TruffleRuby team (tag @nirvdrum or @eregon)
- Track resolution status
Example workflow configuration (v4.x):
matrix:
ruby-version: ['3.1', '3.2', '3.3', '3.4', '4.0', 'head', 'truffleruby', 'jruby-10']
include:
- ruby-version: truffleruby
continue-on-error: true # Non-blockingv5.0 workflow configuration:
matrix:
ruby-version: ['3.3', '3.4', '4.0', 'head', 'truffleruby', 'jruby-10']
include:
- ruby-version: truffleruby
continue-on-error: true # Non-blockingCurrent State: ✅ Benchmarks already run on every push/PR via .github/workflows/benchmarks.yml
- Tests: set, get, get_multi, set_multi operations
- Compares: binary client vs meta client vs raw socket
- Runs on Ruby 4.0 with YJIT enabled
Potential Improvements:
- Update
set_multibenchmark - The benchmark script has a TODO to enable theset_multitest now that we've implemented the feature - Store benchmark artifacts - Save results for historical comparison
- Add regression detection - Compare results against a baseline, warn if performance degrades significantly
- Multi-Ruby benchmarks - Run on multiple Ruby versions to track performance across interpreters
Priority: Low - current setup is functional, improvements are nice-to-have
- Increase test coverage for meta protocol edge cases
- Add tests for large key count scenarios (for #776/#941 fix)
Reference: Shopify#13
Delete files:
lib/dalli/protocol/binary.rblib/dalli/protocol/binary/(entire directory)- Related test files
Modify:
lib/dalli/client.rb- Remove protocol selection logiclib/dalli.rb- Remove binary requires- Flatten
lib/dalli/protocol/meta/tolib/dalli/protocol/
Meta protocol doesn't support authentication. Users requiring auth should:
- Use network-level security (VPN, firewall rules)
- Use memcached's TLS support
- Stay on Dalli 4.x with binary protocol
- Ruby 3.3+ (Ruby 3.2 EOL is March 2026; starting with 3.3+ gives v5.0 a longer support window)
- memcached 1.6+ (meta protocol minimum)
- JRuby support retained (requires maintaining
readfullfallback since JRuby lacksIO#timeout=)
Current structure:
lib/dalli/protocol/
├── base.rb
├── binary.rb
├── binary/
│ ├── request_formatter.rb
│ ├── response_header.rb
│ ├── response_processor.rb
│ └── sasl_authentication.rb
├── meta.rb
├── meta/
│ ├── key_regularizer.rb
│ ├── request_formatter.rb
│ └── response_processor.rb
├── connection_manager.rb
├── server_config_parser.rb
├── ttl_sanitizer.rb
├── value_compressor.rb
├── value_marshaller.rb
└── value_serializer.rb
v5.0 structure (after binary removal):
lib/dalli/protocol/
├── base.rb
├── key_regularizer.rb
├── request_formatter.rb
├── response_processor.rb
├── connection_manager.rb
├── server_config_parser.rb
├── ttl_sanitizer.rb
├── value_compressor.rb
├── value_marshaller.rb
└── value_serializer.rb
Reference: PR #1026 (grcooper)
Replace the manual readfull loop with Ruby's built-in IO#read method for ~7% performance improvement on read operations.
Why this is possible in v5.0:
IO#readrelies onIO#timeout=for proper timeout handlingIO#timeout=was introduced in Ruby 3.2- v5.0 requires Ruby 3.3+, so
IO#timeout=is always available on CRuby - JRuby lacks
IO#timeout=support, so it continues usingreadfull
Implementation:
File: lib/dalli/protocol/connection_manager.rb
def read(count)
if RUBY_ENGINE == 'jruby'
@sock.readfull(count)
else
@sock.read(count)
end
rescue ...
endFile: lib/dalli/socket.rb
- Keep
readfullmethod for JRuby compatibility - Keep
append_to_buffer?andnonblock_timed_out?methods (used byreadfull)
| Flag | Current | v4.1+ | Description |
|---|---|---|---|
v |
✅ | ✅ | Return value |
f |
✅ | ✅ | Return bitflags |
c |
✅ | ✅ | Return CAS |
b |
✅ | ✅ | Base64 key |
T |
✅ | ✅ | Touch TTL |
k |
✅ | ✅ | Return key |
q |
✅ | ✅ | Quiet mode |
s |
✅ | ✅ | Return size |
h |
❌ | ✅ | Hit status |
l |
❌ | ✅ | Last access time |
u |
❌ | ✅ | Skip LRU bump |
N |
❌ | ✅ | Vivify on miss |
R |
❌ | ✅ | Recache threshold |
| Flag | Current | v4.1+ | Description |
|---|---|---|---|
c |
✅ | ✅ | Return CAS |
b |
✅ | ✅ | Base64 key |
F |
✅ | ✅ | Set bitflags |
C |
✅ | ✅ | Compare CAS |
T |
✅ | ✅ | Set TTL |
M |
✅ | ✅ | Mode (S/E/R/A/P) |
q |
✅ | ✅ | Quiet mode |
I |
❌ | ✅ | Mark invalid |
| Flag | Current | v4.1+ | Description |
|---|---|---|---|
b |
✅ | ✅ | Base64 key |
C |
✅ | ✅ | Compare CAS |
q |
✅ | ✅ | Quiet mode |
I |
❌ | ✅ | Mark stale |
x |
❌ | ✅ | Remove value only |
- ✅ Binary protocol deprecation warnings
- ✅
set_multiimplementation - ✅
delete_multiimplementation - ✅ Thundering herd flags (N, R, W, X, Z)
- ✅
fetch_with_lockmethod - ✅ Metadata flags (h, l, u)
- ✅
get_with_metadatamethod
- ✅ Buffered I/O improvements
- ✅ OpenTelemetry support (with enhanced span attributes)
- ✅ get_multi optimizations (Set, select!, raw mode skip_flags)
- Bug fixes from GitHub issues
- CI/testing improvements
- Remove binary protocol
- Remove SASL auth
- Update minimum requirements (Ruby 3.3+, keep JRuby)
- Simplify codebase structure
- Replace
readfullwithIO#read(CRuby performance improvement)
| PR | Status | Feature | Dalli Status |
|---|---|---|---|
| #59 | Open | delete_multi | ✅ Done in v4.1.0 |
| #46 | Open | fetch_with_lock (thundering herd) | ✅ Done in v4.1.0 |
| #56 | Merged | OpenTelemetry tracing | ✅ Done in v4.2.0 (enhanced) |
| #55 | Merged | Buffered I/O | ✅ Done in v4.2.0 |
| #45 | Open | get_multi optimizations | |
| #44 | Merged | Raw mode optimizations | ✅ Done in v4.2.0 |
| #13 | Reference | Binary protocol removal | 📋 Planned for v5.0 |
| #11 | Reference | Non-blocking I/O | 📋 Low priority |
After implementing each phase:
- Run
bundle exec rubocop- must pass - Run
bundle exec rake- all tests must pass - Run benchmarks to verify no performance regression
- Test against memcached 1.6.x (meta protocol)
- For v4.x: Also test against memcached 1.4.x/1.5.x (binary protocol)