pax_global_header 0000666 0000000 0000000 00000000064 15147323064 0014517 g ustar 00root root 0000000 0000000 52 comment=d93d9da13900ab3098ea75aea0c21efb3ab4f7b0
petergoldstein-dalli-8b467ad/ 0000775 0000000 0000000 00000000000 15147323064 0016253 5 ustar 00root root 0000000 0000000 petergoldstein-dalli-8b467ad/.devcontainer/ 0000775 0000000 0000000 00000000000 15147323064 0021012 5 ustar 00root root 0000000 0000000 petergoldstein-dalli-8b467ad/.devcontainer/Dockerfile 0000664 0000000 0000000 00000001572 15147323064 0023011 0 ustar 00root root 0000000 0000000 FROM ruby:3.4-bullseye
# Install dependencies
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
build-essential \
git \
libevent-dev \
libmemcached-tools \
curl \
procps \
wget \
bash \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
# Setup non-root user with sudo access
ARG USERNAME=vscode
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN groupadd --gid $USER_GID $USERNAME \
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
&& apt-get update \
&& apt-get install -y sudo \
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
&& chmod 0440 /etc/sudoers.d/$USERNAME
RUN sudo chown -R $USERNAME:$USERNAME /usr/local/bundle
# Install utilities
RUN gem install bundler
WORKDIR /workspace
# Switch to non-root user
USER $USERNAME
petergoldstein-dalli-8b467ad/.devcontainer/README.md 0000664 0000000 0000000 00000002600 15147323064 0022267 0 ustar 00root root 0000000 0000000 # Dalli Development Container
This directory contains configuration for a development container that provides a consistent environment for working on Dalli.
## Features
- Ruby 3.3+ environment with all necessary dependencies
- Memcached 1.6.34 installed with TLS support, matching the GitHub Actions CI environment
- VS Code extensions for Ruby development
## Setup Process
When the container is built and started, the following setup occurs:
1. The container is built with necessary dependencies but without memcached
2. The `setup.sh` script runs after the container is created which:
- Installs memcached 1.6.34 using the same script used in GitHub Actions
- Sets up environment variables needed for tests
- Installs gem dependencies
## Running Tests
Once the container is running, you can run tests with:
```bash
bundle exec rake test
```
To run specific test files:
```bash
bundle exec ruby -Ilib:test test/path/to/test_file.rb
```
## Troubleshooting
If you encounter issues with tests:
1. Verify memcached is running: `ps aux | grep memcached`
2. Check memcached version: `memcached -h | head -1`
3. Try restarting memcached: `sudo service memcached restart`
4. Check logs for any errors: `sudo journalctl -u memcached`
## Port Forwarding
The following memcached ports are forwarded for testing:
- 11211 - Default memcached port
- 11212-11215 - Additional ports used by tests
petergoldstein-dalli-8b467ad/.devcontainer/devcontainer.json 0000664 0000000 0000000 00000001565 15147323064 0024375 0 ustar 00root root 0000000 0000000 {
"name": "Ruby Dalli Development",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"customizations": {
"vscode": {
"extensions": [
"rebornix.ruby",
"castwide.solargraph",
"kaiwood.endwise",
"misogi.ruby-rubocop"
],
"settings": {
"ruby.useBundler": true,
"ruby.format": "rubocop",
"editor.formatOnSave": true,
"ruby.useLanguageServer": true,
"ruby.lint": {
"rubocop": {
"useBundler": true
}
},
"terminal.integrated.defaultProfile.linux": "bash"
}
}
},
"forwardPorts": [
11211,
11212,
11213,
11214,
11215
],
"postCreateCommand": "chmod +x .devcontainer/setup.sh && .devcontainer/setup.sh",
"waitFor": "postCreateCommand",
"remoteUser": "vscode"
} petergoldstein-dalli-8b467ad/.devcontainer/docker-compose.yml 0000664 0000000 0000000 00000000236 15147323064 0024450 0 ustar 00root root 0000000 0000000 version: '3'
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ..:/workspace:cached
command: sleep infinity
petergoldstein-dalli-8b467ad/.devcontainer/setup.sh 0000775 0000000 0000000 00000001747 15147323064 0022522 0 ustar 00root root 0000000 0000000 #!/bin/bash
set -e
echo "Setting up Dalli development environment..."
# Install memcached using the script from scripts directory
echo "Installing memcached..."
cd /workspace
export MEMCACHED_VERSION=1.6.34
chmod +x scripts/install_memcached.sh
scripts/install_memcached.sh
# Clean up memcached installation files
echo "Cleaning up memcached installation files..."
rm -f memcached-${MEMCACHED_VERSION}.tar.gz
rm -rf memcached-${MEMCACHED_VERSION}
# Create symlink for memcached-tool if needed
if [ ! -f /usr/local/bin/memcached-tool ]; then
echo "Creating symlink for memcached-tool..."
sudo ln -sf /usr/share/memcached/scripts/memcached-tool /usr/local/bin/memcached-tool
fi
# Fix permissions
sudo chown -R vscode:vscode /usr/local/bundle
echo "Installing dependencies..."
cd /workspace
bundle install
echo "Environment setup complete!"
echo "You can now run tests with: bundle exec rake test"
echo "To run a specific test file: bundle exec ruby -Ilib:test test/integration/test_fork.rb"
petergoldstein-dalli-8b467ad/.github/ 0000775 0000000 0000000 00000000000 15147323064 0017613 5 ustar 00root root 0000000 0000000 petergoldstein-dalli-8b467ad/.github/dependabot.yml 0000664 0000000 0000000 00000000321 15147323064 0022437 0 ustar 00root root 0000000 0000000 version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "weekly"
petergoldstein-dalli-8b467ad/.github/workflows/ 0000775 0000000 0000000 00000000000 15147323064 0021650 5 ustar 00root root 0000000 0000000 petergoldstein-dalli-8b467ad/.github/workflows/benchmarks.yml 0000664 0000000 0000000 00000001253 15147323064 0024511 0 ustar 00root root 0000000 0000000 name: Benchmarks
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- name: Install Memcached 1.6.23
working-directory: scripts
env:
MEMCACHED_VERSION: 1.6.23
run: |
chmod +x ./install_memcached.sh
./install_memcached.sh
memcached -d
memcached -d -p 11222
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0
bundler-cache: true # 'bundle install' and cache
- name: Run Benchmarks
run: RUBY_YJIT_ENABLE=1 BENCH_TARGET=all bundle exec bin/benchmark
petergoldstein-dalli-8b467ad/.github/workflows/claude-code-review.yml 0000664 0000000 0000000 00000002631 15147323064 0026041 0 ustar 00root root 0000000 0000000 name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
petergoldstein-dalli-8b467ad/.github/workflows/claude.yml 0000664 0000000 0000000 00000003536 15147323064 0023637 0 ustar 00root root 0000000 0000000 name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
petergoldstein-dalli-8b467ad/.github/workflows/codeql-analysis.yml 0000664 0000000 0000000 00000004144 15147323064 0025466 0 ustar 00root root 0000000 0000000 # For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '22 14 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'ruby' ]
steps:
- name: Checkout repository
uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v4
# âšī¸ Command-line programs to run using the OS shell.
# đ https://git.io/JvXDl
# âī¸ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
petergoldstein-dalli-8b467ad/.github/workflows/profile.yml 0000664 0000000 0000000 00000003031 15147323064 0024030 0 ustar 00root root 0000000 0000000 name: Profiles
on: [push, pull_request]
concurrency:
group: profiles-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Memcached 1.6.23
working-directory: scripts
env:
MEMCACHED_VERSION: 1.6.23
run: |
chmod +x ./install_memcached.sh
./install_memcached.sh
memcached -d
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4
bundler-cache: true # 'bundle install' and cache
- name: Run Profiles
run: RUBY_YJIT_ENABLE=1 BENCH_TARGET=all bundle exec bin/profile
# NOTE: to pull profile results, visit https://github.com/petergoldstein/dalli/actions/workflows/profile.yml
# click to view the run you are interested in (ex https://github.com/petergoldstein/dalli/actions/runs/13296952241)
# in the artifacts section, download the profile results
- name: Upload profile results
uses: actions/upload-artifact@v6
with:
name: profile-results
path: |
client_get_profile.json
socket_get_profile.json
client_set_profile.json
socket_set_profile.json
client_get_multi_profile.json
socket_get_multi_profile.json
client_set_multi_profile.json
socket_set_multi_profile.json
meta_client_get_multi_profile.json
meta_client_get_profile.json
meta_client_set_multi_profile.json
meta_client_set_profile.json
petergoldstein-dalli-8b467ad/.github/workflows/rubocop.yml 0000664 0000000 0000000 00000000654 15147323064 0024051 0 ustar 00root root 0000000 0000000 name: RuboCop
on: [push, pull_request]
concurrency:
group: rubocop-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ruby
bundler-cache: true # 'bundle install' and cache
- name: Run RuboCop
run: bundle exec rubocop --parallel --color
petergoldstein-dalli-8b467ad/.github/workflows/tests.yml 0000664 0000000 0000000 00000001765 15147323064 0023546 0 ustar 00root root 0000000 0000000 name: Tests
on: [push, pull_request]
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby-version:
- head
- '4.0'
- '3.4'
- '3.3'
- jruby-10
- truffleruby
memcached-version: ['1.6.40']
name: "Ruby ${{ matrix.ruby-version }} / Memcached ${{ matrix.memcached-version }}"
steps:
- uses: actions/checkout@v6
- name: Install Memcached ${{ matrix.memcached-version }}
working-directory: scripts
env:
MEMCACHED_VERSION: ${{ matrix.memcached-version }}
run: |
chmod +x ./install_memcached.sh
./install_memcached.sh
- name: Set up Ruby ${{ matrix.ruby-version }}
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true # 'bundle install' and cache
- name: Run tests
run: bundle exec rake
petergoldstein-dalli-8b467ad/.gitignore 0000664 0000000 0000000 00000001133 15147323064 0020241 0 ustar 00root root 0000000 0000000 *.gem
*.rbc
/.config
/coverage/
/InstalledFiles
/pkg/
/spec/reports/
/test/tmp/
/test/version_tmp/
/tmp/
## Specific to RubyMotion:
.dat*
.repl_history
build/
## Documentation cache and generated files:
/.yardoc/
/_yardoc/
/doc/
/html/
/rdoc/
profile.html
## Environment normalisation:
/.bundle/
/lib/bundler/man/
# for a library or gem, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
Gemfile.lock
gemfiles/*.lock
.ruby-version
.ruby-gemset
# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
.rvmrc
petergoldstein-dalli-8b467ad/.rubocop.yml 0000664 0000000 0000000 00000001042 15147323064 0020522 0 ustar 00root root 0000000 0000000 inherit_from: .rubocop_todo.yml
plugins:
- rubocop-minitest
- rubocop-performance
- rubocop-rake
- rubocop-thread_safety
ThreadSafety:
Enabled: true
Exclude:
- "**/*.gemspec"
- test/**/*
AllCops:
NewCops: enable
TargetRubyVersion: 3.3
Exclude:
- 'bin/benchmark_*'
- 'vendor/**/*'
Metrics/BlockLength:
Max: 50
Exclude:
- 'test/**/*'
Metrics/MethodLength:
Max: 20
Style/Documentation:
Exclude:
- 'test/**/*'
Naming/PredicateMethod:
Enabled: false
Naming/PredicatePrefix:
Enabled: false
petergoldstein-dalli-8b467ad/.rubocop_todo.yml 0000664 0000000 0000000 00000001700 15147323064 0021550 0 ustar 00root root 0000000 0000000 # This configuration was generated by
# `rubocop --auto-gen-config`
# on 2025-04-01 20:25:11 UTC using RuboCop version 1.75.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
# Offense count: 1
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
Metrics/AbcSize:
Max: 20
# Offense count: 9
# Configuration parameters: CountComments, CountAsOne.
Metrics/ClassLength:
Max: 335
# Offense count: 4
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Max: 20
Exclude:
- 'lib/dalli/pipelined_getter.rb'
- 'lib/dalli/protocol/base.rb'
# Offense count: 1
# Configuration parameters: CountComments, CountAsOne.
Metrics/ModuleLength:
Max: 108
petergoldstein-dalli-8b467ad/.standard.yml 0000664 0000000 0000000 00000000420 15147323064 0020650 0 ustar 00root root 0000000 0000000 fix: false # default: false
parallel: true # default: false
ruby_version: 3.1 # default: RUBY_VERSION
default_ignores: false # default: true
ignore: # default: []
- 'test/**/*':
- Style/GlobalVars
- Style/Semicolon
petergoldstein-dalli-8b467ad/3.0-Upgrade.md 0000664 0000000 0000000 00000002575 15147323064 0020473 0 ustar 00root root 0000000 0000000 # Dalli 3.0
This major version update contains several backwards incompatible changes.
* **:dalli_store** has been removed. Users should migrate to the
official Rails **:mem_cache_store**, documented in the [caching
guide](https://guides.rubyonrails.org/caching_with_rails.html#activesupport-cache-memcachestore).
* Attempting to store a larger value than allowed by memcached used to
print a warning and truncate the value. This now raises an error to
prevent silent data corruption.
* Compression now defaults to `true` for large values (greater than 4KB).
This is intended to minimize errors due to the previous note.
* Errors marshalling values now raise rather than just printing an error.
* The Rack session adapter has been refactored to remove support for thread-unsafe
configurations. You will need to include the `connection_pool` gem in
your Gemfile to ensure session operations are thread-safe.
* Support for the `kgio` gem has been removed, it is not relevant in Ruby 2.3+.
* Removed inline native code, use Ruby 2.3+ support for bsearch instead.
* The CAS operations previously in 'dalli/cas/client' have been
integrated into 'dalli/client'.
## Future Directions
The memcached project has deprecated the binary protocol used by Dalli
in favor of a new `meta/text` protocol that is somewhat human readable.
Dalli 4.0 will move in this direction and require memcached 1.6+.
petergoldstein-dalli-8b467ad/4.0-Upgrade.md 0000664 0000000 0000000 00000004010 15147323064 0020456 0 ustar 00root root 0000000 0000000 # Dalli 4.0
This major version update contains several backwards incompatible changes.
## Breaking Changes
* **Ruby 3.1+ required** - Support for Ruby 2.6, 2.7, and 3.0 has been dropped.
* **Dalli::Server removed** - The deprecated `Dalli::Server` alias has been removed. Use `Dalli::Protocol::Binary` instead if you were referencing this directly.
* **:compression option removed** - Use `:compress` instead. The old option name was deprecated in 3.x.
* **close_on_fork removed** - Use `reconnect_on_fork` instead. The old method name was deprecated in 3.x.
## New Features
* **Security warning for Marshal** - Dalli now warns when using the default Marshal serializer, as deserializing untrusted data with Marshal can lead to remote code execution. Silence with `silence_marshal_warning: true` if you understand the risks, or switch to a safer serializer like JSON.
* **string_fastpath option** - New option to skip serialization for simple strings, improving performance for string-only workloads.
* **Meta protocol improvements** - Performance improvements for set operations when using the meta protocol.
## Other Changes
* Defense-in-depth input validation for stats command arguments
* Fix connection_pool 3.0 compatibility for Rack session store
* Fix session recovery after deletion
* Graceful reconnection when a fork is detected (instead of crashing)
* Support for SERVER_ERROR response from Memcached
## Migration Guide
1. **Update Ruby version** - Ensure you're running Ruby 3.1 or later.
2. **Update deprecated options** - If you use any of these, update them:
```ruby
# Before
Dalli::Client.new(servers, compression: true)
# After
Dalli::Client.new(servers, compress: true)
```
3. **Consider serializer security** - If you're caching user-controlled data, consider switching from Marshal to a safer serializer:
```ruby
Dalli::Client.new(servers, serializer: JSON)
```
4. **Update Dalli::Server references** - If you referenced `Dalli::Server` directly (unlikely), update to `Dalli::Protocol::Binary`.
petergoldstein-dalli-8b467ad/5.0-Upgrade.md 0000664 0000000 0000000 00000005777 15147323064 0020504 0 ustar 00root root 0000000 0000000 # Dalli 5.0
This major version update contains several backwards incompatible changes.
## Breaking Changes
* **Ruby 3.3+ required** - Support for Ruby 3.1 and 3.2 has been dropped.
* **memcached 1.6+ required** - The meta protocol requires memcached 1.6 or later.
* **Binary protocol removed** - The `:protocol` option has been removed. Dalli 5.0 only supports the meta protocol.
* **SASL authentication removed** - The `:username` and `:password` options are no longer functional. The meta protocol does not support authentication.
* **Dalli::Protocol::Binary removed** - If you referenced this class directly, it no longer exists.
## Migration Guide
### 1. Update Ruby version
Ensure you're running Ruby 3.3 or later. JRuby and TruffleRuby are still supported.
### 2. Update memcached version
Ensure you're running memcached 1.6 or later. Earlier versions do not support the meta protocol.
### 3. Remove the :protocol option
The `:protocol` option is no longer needed and will emit a warning if provided:
```ruby
# Before (Dalli 4.x)
Dalli::Client.new(servers, protocol: :meta)
# After (Dalli 5.0)
Dalli::Client.new(servers)
```
### 4. Remove authentication options
If you were using SASL authentication with the binary protocol, this is no longer supported. You'll need to use network-level security instead:
```ruby
# Before (Dalli 4.x with binary protocol)
Dalli::Client.new(servers, username: 'user', password: 'pass')
# After (Dalli 5.0) - use network-level security
# Options include:
# - Firewall rules to restrict memcached access
# - VPN or private network isolation
# - memcached's TLS support for encryption (still supported via :ssl_context option)
Dalli::Client.new(servers)
```
### 5. Update Dalli::Protocol::Binary references
If you referenced `Dalli::Protocol::Binary` directly (unlikely), this class no longer exists:
```ruby
# Before
Dalli::Protocol::Binary
# After - there is only one protocol implementation
Dalli::Protocol::Meta
```
## Staying on Dalli 4.x
If you require SASL authentication or need to support older memcached versions, you can stay on the 4.x release series:
```ruby
# Gemfile
gem 'dalli', '~> 4.0'
```
The 4.x series will continue to receive security updates.
## New Features in 5.0
* **IO Performance** - CRuby users benefit from using Ruby's native `IO#read` with timeout support instead of a custom implementation.
* **Simplified codebase** - Removal of the binary protocol and SASL authentication makes the codebase smaller and easier to maintain.
## Features Added in 4.x (Available in 5.0)
If you're upgrading from Dalli 3.x, you'll also benefit from features added in 4.x:
* **`set_multi`** - Set multiple keys in a single pipelined operation
* **`delete_multi`** - Delete multiple keys in a single pipelined operation
* **`get_with_metadata`** - Retrieve values with CAS, hit status, and last access time
* **`fetch_with_lock`** - Thundering herd protection using meta protocol's recache semantics
* **OpenTelemetry tracing** - Automatic instrumentation when the OpenTelemetry SDK is present
petergoldstein-dalli-8b467ad/5_0_ROADMAP.md 0000664 0000000 0000000 00000063435 15147323064 0020336 0 ustar 00root root 0000000 0000000 # Dalli Roadmap: Path to v5.0
## Executive Summary
This roadmap outlines the evolution of Dalli from v4.x through v5.0, focusing on:
1. Deprecating and removing the binary protocol
2. Expanding meta protocol support (especially thundering herd features)
3. Performance improvements from Shopify's fork
4. Codebase simplification and modernization
---
## Version Strategy
### v4.x Series (Incremental, Non-Breaking)
- Add deprecation warnings for binary protocol
- Add new meta protocol features
- Backport performance improvements from Shopify/dalli
- Fix outstanding bugs
### v5.0 (Breaking Changes)
- 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 `readfull` with `IO#read` for ~7% read performance improvement
- Simplify codebase structure
---
## v4.1.0 - Deprecation & New Features â COMPLETE
### 1. Deprecate Binary Protocol
**Files:** `lib/dalli/protocol/binary.rb`, `lib/dalli/client.rb`
- Add deprecation warning when `protocol: :binary` is used
- Add deprecation warning when SASL credentials are provided
- Update documentation to recommend meta protocol
- Note: Binary remains functional, just deprecated
### 2. Add `set_multi` Operation
**Reference:** Shopify/dalli#59 (similar pattern), Shopify/dalli#39
**Files to create/modify:**
- `lib/dalli/pipelined_setter.rb` (new)
- `lib/dalli/client.rb`
- `lib/dalli/protocol/meta.rb`
- `lib/dalli/protocol/meta/request_formatter.rb`
```ruby
# New API
client.set_multi({ 'key1' => 'val1', 'key2' => 'val2' }, ttl, options)
```
Currently users must use `quiet { }` blocks for pipelined sets, which is awkward.
### 3. Add `delete_multi` Operation
**Reference:** Shopify/dalli#59
**Files to create/modify:**
- `lib/dalli/pipelined_deleter.rb` (new)
- `lib/dalli/client.rb`
- `lib/dalli/protocol/meta.rb`
- `lib/dalli/protocol/meta/request_formatter.rb`
```ruby
# New API
client.delete_multi(['key1', 'key2', 'key3'])
```
### 4. Thundering Herd Meta Protocol Flags
**Reference:** memcached protocol.txt, Shopify/dalli#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.rb`
- `lib/dalli/protocol/meta/response_processor.rb`
### 5. `get_with_metadata` and Metadata Flags
**Status:** â COMPLETE
New client-level method for advanced cache operations:
```ruby
# 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.rb`
- `lib/dalli/protocol/meta.rb`
### 6. Thundering Herd Protection (`fetch_with_lock`)
**Reference:** Shopify/dalli#46
New method that uses meta protocol's `N` and `R` flags:
```ruby
# New API - prevents multiple clients from regenerating same cache entry
client.fetch_with_lock(key, ttl: 300, lock_ttl: 30) do
expensive_database_query
end
```
**Files:**
- `lib/dalli/client.rb`
- `lib/dalli/protocol/meta.rb`
---
## v4.2.0 - Performance & Observability â COMPLETE
### 6. Buffered I/O Improvements â
**Reference:** Shopify/dalli#55, Shopify/dalli#38
Implemented:
- Set `socket.sync = false` to enable buffered I/O
- Added explicit `flush` calls before reading responses
- Reduces syscalls for pipelined operations (100-300% improvement for multi ops)
**Files modified:**
- `lib/dalli/protocol/connection_manager.rb` - Added `flush` method and `sync = false`
- `lib/dalli/protocol/meta.rb` - Added flush calls before response reading
- `lib/dalli/protocol/binary.rb` - Added flush calls before response reading
### 7. OpenTelemetry Tracing Support â
**Reference:** Shopify/dalli#56
Implemented lightweight distributed tracing that auto-detects OpenTelemetry SDK:
```ruby
# 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: memcached` and `db.operation` attributes
- Single-key operations include `server.address` attribute
- 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 module
- `lib/dalli/client.rb` - Added tracing to all operations
- `test/test_instrumentation.rb` - Unit tests for instrumentation
### 8. get_multi Optimizations â
**Reference:** Shopify/dalli#44, Shopify/dalli#45
Implemented:
- Use `Set` instead of `Array` for deleted server tracking (O(1) vs O(n) lookups)
- Use `select!(&:connected?)` instead of `delete_if { |s| !s.connected? }`
- Skip bitflags request in raw mode (saves 2 bytes/request, skips parsing)
**Files modified:**
- `lib/dalli/pipelined_getter.rb`
- `lib/dalli/protocol/meta/request_formatter.rb`
- `lib/dalli/protocol/meta.rb`
- `lib/dalli/protocol/base.rb`
---
## Future Performance Work (From Shopify PRs)
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.
### Allocation Reduction in Response Processor
**Reference:** Shopify/dalli#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
### Single-Server Raw Mode Fast Path
**Reference:** Shopify/dalli#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
---
## v4.3.0 - Bug Fixes & Quality
### 9. GitHub Issues to Address
| 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 |
#### Issue #1034: struct timeval Architecture-Dependent Packing
**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, bypassing `setsockopt` entirely
- **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 `setsockopt` path
- 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`:
```ruby
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
end
```
#### Issue #776 / #941: get_multi Hangs with Large Key Counts
**Status:** 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:
1. Dalli sends all `getkq` (quiet get) requests before reading responses
2. Memcached doesn't actually wait for the final `noop` before responding - it buffers responses and sends them once an internal buffer fills
3. 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:**
1. Add chunk size constant:
```ruby
INTERLEAVE_THRESHOLD = 10_000 # Only interleave for large batches
CHUNK_SIZE = 10_000
```
2. Modify `make_getkq_requests`:
```ruby
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
```
3. Add response draining method:
```ruby
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
```
4. Track partial results:
```ruby
def initialize(ring, key_manager)
@ring = ring
@key_manager = key_manager
@partial_results = {} # NEW: track responses received during send phase
end
```
5. Merge partial results in `process_server`:
```ruby
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_responses` still applies
- Memory: partial results are stored in Ruby hash during send phase
#### Issue #1022: Empty String with `cache_nils: false` + `raw: true`
**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 `""` (not `nil`)
- `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, return `nil` on get
- `raw: true` + `cache_nils: false` â Raise `ArgumentError`
**Alternative (from @grcooper):**
- `raw: true` should only accept strings - any non-string (including `nil`) should raise `ArgumentError`
- 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 `nil` values to Dalli with `raw: true`?
- What does `StringMarshaller` do with `nil`?
- What behavior does Rails expect?
**Action:** Comment on issue requesting Rails team clarification before implementing.
#### Issue #1019: Make NAMESPACE_SEPARATOR Configurable
**Status:** Low priority, easy fix
**Background:** The namespace separator is hardcoded as `:`. Some users want to customize this.
**Implementation:**
- Add `namespace_separator` option 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_\-:.`
#### Issue #805: Migration Path for instrument_errors
**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 to `Rails.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+ `MemCacheStore` error handling is sufficient
- Close issue with documentation update if OTel + Rails.error covers the use case
#### Issue #1039: "No request in progress" after Ruby 3.4.2
**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.
### 10. Testing & CI Improvements
#### TruffleRuby Support (#988)
**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:**
1. Add TruffleRuby to the test matrix in `.github/workflows/tests.yml`
2. Use `continue-on-error: true` for TruffleRuby jobs
3. 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):**
```yaml
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-blocking
```
**v5.0 workflow configuration:**
```yaml
matrix:
ruby-version: ['3.3', '3.4', '4.0', 'head', 'truffleruby', 'jruby-10']
include:
- ruby-version: truffleruby
continue-on-error: true # Non-blocking
```
#### Benchmark CI Improvements
**Current 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:**
1. **Update `set_multi` benchmark** - The benchmark script has a TODO to enable the `set_multi` test now that we've implemented the feature
2. **Store benchmark artifacts** - Save results for historical comparison
3. **Add regression detection** - Compare results against a baseline, warn if performance degrades significantly
4. **Multi-Ruby benchmarks** - Run on multiple Ruby versions to track performance across interpreters
**Priority:** Low - current setup is functional, improvements are nice-to-have
#### Other Testing Improvements
- Increase test coverage for meta protocol edge cases
- Add tests for large key count scenarios (for #776/#941 fix)
---
## v5.0.0 - Breaking Changes
### 11. Remove Binary Protocol
**Reference:** Shopify/dalli#13
**Delete files:**
- `lib/dalli/protocol/binary.rb`
- `lib/dalli/protocol/binary/` (entire directory)
- Related test files
**Modify:**
- `lib/dalli/client.rb` - Remove protocol selection logic
- `lib/dalli.rb` - Remove binary requires
- Flatten `lib/dalli/protocol/meta/` to `lib/dalli/protocol/`
### 12. Remove SASL Authentication
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
### 13. Update Minimum Requirements
- 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 `readfull` fallback since JRuby lacks `IO#timeout=`)
### 14. Simplify Codebase Structure
**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
```
### 15. Replace `readfull` with `IO#read` (CRuby only)
**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#read` relies on `IO#timeout=` for proper timeout handling
- `IO#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 using `readfull`
**Implementation:**
File: `lib/dalli/protocol/connection_manager.rb`
```ruby
def read(count)
if RUBY_ENGINE == 'jruby'
@sock.readfull(count)
else
@sock.read(count)
end
rescue ...
end
```
File: `lib/dalli/socket.rb`
- Keep `readfull` method for JRuby compatibility
- Keep `append_to_buffer?` and `nonblock_timed_out?` methods (used by `readfull`)
---
## Meta Protocol Flags: Current vs Planned Support
### mg (get) Flags
| 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 |
### ms (set) Flags
| 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 |
### md (delete) Flags
| Flag | Current | v4.1+ | Description |
|------|---------|-------|-------------|
| `b` | â | â | Base64 key |
| `C` | â | â | Compare CAS |
| `q` | â | â | Quiet mode |
| `I` | â | â | Mark stale |
| `x` | â | â | Remove value only |
---
## Implementation Priority
### Phase 1: v4.1.0 (High Impact) â COMPLETE
1. â Binary protocol deprecation warnings
2. â `set_multi` implementation
3. â `delete_multi` implementation
4. â Thundering herd flags (N, R, W, X, Z)
5. â `fetch_with_lock` method
6. â Metadata flags (h, l, u)
7. â `get_with_metadata` method
### Phase 2: v4.2.0 (Performance) â COMPLETE
6. â Buffered I/O improvements
7. â OpenTelemetry support (with enhanced span attributes)
8. â get_multi optimizations (Set, select!, raw mode skip_flags)
### Phase 3: v4.3.0 (Polish)
9. Bug fixes from GitHub issues
10. CI/testing improvements
### Phase 4: v5.0.0 (Cleanup)
11. Remove binary protocol
12. Remove SASL auth
13. Update minimum requirements (Ruby 3.3+, keep JRuby)
14. Simplify codebase structure
15. Replace `readfull` with `IO#read` (CRuby performance improvement)
---
## Key Shopify PRs to Reference
| 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 | â ī¸ Partial (see Future Work) |
| #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 |
---
## Verification
After implementing each phase:
1. Run `bundle exec rubocop` - must pass
2. Run `bundle exec rake` - all tests must pass
3. Run benchmarks to verify no performance regression
4. Test against memcached 1.6.x (meta protocol)
5. For v4.x: Also test against memcached 1.4.x/1.5.x (binary protocol)
petergoldstein-dalli-8b467ad/CHANGELOG.md 0000664 0000000 0000000 00000102730 15147323064 0020067 0 ustar 00root root 0000000 0000000 Dalli Changelog
=====================
5.0.2
==========
Performance:
- Add single-server fast path for `get_multi`, `set_multi`, and `delete_multi` (#1077)
- When only one memcached server is configured, bypass the `Pipelined*` machinery (IO.select, response buffering, server grouping) and issue all quiet meta requests inline followed by a noop terminator
- `get_multi` shows ~1.5x improvement at 10 keys and ~1.75x at 100â500 keys compared to the `PipelinedGetter` path
- Thanks to Dan Mayer (Shopify) for this contribution
Development:
- Add `bin/benchmark_branch` script for benchmarking against the current branch
5.0.1
==========
Performance:
- Reduce object allocations in pipelined get response processing (#1072, #1078)
- Offset-based `ResponseBuffer`: track a read offset instead of slicing a new string after every parsed response; compact only when the consumed portion exceeds 4KB and more than half the buffer
- Inline response processor parsing: avoid intermediate array allocations from `split`-based header parsing
- Block-based `pipeline_next_responses`: yield `(key, value, cas)` directly when a block is given, avoiding per-call Hash allocation
- `PipelinedGetter`: replace Hash-based socket-to-server mapping with linear scan (faster for typical 1-5 server counts); use `Process.clock_gettime(CLOCK_MONOTONIC)` instead of `Time.now`
- Add cross-version benchmark script (`bin/compare_versions`) for reproducible performance comparisons across Dalli versions
Bug Fixes:
- Rescue `IOError` in connection manager `write`/`flush` methods (#1075)
- Prevents unhandled exceptions when a connection is closed mid-operation
- Thanks to Graham Cooper (Shopify) for this fix
Development:
- Add `rubocop-thread_safety` for detecting thread-safety issues (#1076)
- Add CONTRIBUTING.md with AI contribution policy (#1074)
5.0.0
==========
**Breaking Changes:**
- **Removed binary protocol** - The meta protocol is now the only supported protocol
- The `:protocol` option is no longer used
- Requires memcached 1.6+ (for meta protocol support)
- Users on older memcached versions must upgrade or stay on Dalli 4.x
- **Removed SASL authentication** - The meta protocol does not support authentication
- Use network-level security (firewall rules, VPN) or memcached's TLS support instead
- Users requiring SASL authentication must stay on Dalli 4.x with binary protocol
- **Ruby 3.3+ required** - Dropped support for Ruby 3.1 and 3.2
- Ruby 3.2 reached end-of-life in March 2026
- JRuby remains supported
Performance:
- **~7% read performance improvement** (CRuby only)
- Use native `IO#read` instead of custom `readfull` implementation
- Enabled by Ruby 3.3's `IO#timeout=` support
- JRuby continues to use `readfull` for compatibility
OpenTelemetry:
- Migrate to stable OTel semantic conventions (#1070)
- `db.system` renamed to `db.system.name`
- `db.operation` renamed to `db.operation.name`
- `server.address` now contains hostname only; `server.port` is a separate integer attribute
- `get_with_metadata` and `fetch_with_lock` now include `server.address`/`server.port`
- Add `db.query.text` span attribute with configurable modes
- `:otel_db_statement` option: `:include`, `:obfuscate`, or `nil` (default: omitted)
- Add `peer.service` span attribute
- `:otel_peer_service` option for logical service naming
Internal:
- Simplified protocol directory structure: moved `lib/dalli/protocol/meta/*` to `lib/dalli/protocol/`
- Removed deprecated binary protocol files and SASL authentication code
- Removed `require 'set'` (autoloaded in Ruby 3.3+)
4.3.3
==========
Performance:
- Reduce object allocations in pipelined get response processing (#1072)
- Offset-based `ResponseBuffer`: track a read offset instead of slicing a new string after every parsed response; compact only when the consumed portion exceeds 4KB and more than half the buffer
- Inline response processor parsing: avoid intermediate array allocations from `split`-based header parsing in both binary and meta protocols
- Block-based `pipeline_next_responses`: yield `(key, value, cas)` directly when a block is given, avoiding per-call Hash allocation
- `PipelinedGetter`: replace Hash-based socket-to-server mapping with linear scan (faster for typical 1-5 server counts); use `Process.clock_gettime(CLOCK_MONOTONIC)` instead of `Time.now`
- Add cross-version benchmark script (`bin/compare_versions`) for reproducible performance comparisons across Dalli versions
Bug Fixes:
- Skip OTel integration tests when meta protocol is unavailable (#1072)
4.3.2
==========
OpenTelemetry:
- Migrate to stable OTel semantic conventions
- `db.system` renamed to `db.system.name`
- `db.operation` renamed to `db.operation.name`
- `server.address` now contains hostname only; `server.port` is a separate integer attribute
- `get_with_metadata` and `fetch_with_lock` now include `server.address`/`server.port`
- Add `db.query.text` span attribute with configurable modes
- `:otel_db_statement` option: `:include`, `:obfuscate`, or `nil` (default: omitted)
- Add `peer.service` span attribute
- `:otel_peer_service` option for logical service naming
4.3.1
==========
Bug Fixes:
- Fix socket compatibility with gems that monkey-patch TCPSocket (#996, #1012)
- Gems like `socksify` and `resolv-replace` modify `TCPSocket#initialize`, breaking Ruby 3.0+'s `connect_timeout:` keyword argument
- Detection now uses parameter signature checking instead of gem-specific method detection
- Falls back to `Timeout.timeout` when monkey-patching is detected
- Detection result is cached for performance
- Fix network retry bug with `socket_max_failures: 0` (#1065)
- Previously, setting `socket_max_failures: 0` could still cause retries due to error handling
- Introduced `RetryableNetworkError` subclass to distinguish retryable vs non-retryable errors
- `down!` now raises non-retryable `NetworkError`, `reconnect!` raises `RetryableNetworkError`
- Thanks to Graham Cooper (Shopify) for this fix
- Fix "character class has duplicated range" Ruby warning (#1067)
- Fixed regex in `KeyManager::VALID_NAMESPACE_SEPARATORS` that caused warnings on newer Ruby versions
- Thanks to Hartley McGuire for this fix
Improvements:
- Add StrictWarnings test helper to catch Ruby warnings early (#1067)
- Use bulk attribute setter for OpenTelemetry spans (#1068)
- Reduces lock acquisitions when setting span attributes
- Thanks to Robert Laurin (Shopify) for this optimization
- Fix double recording of exceptions on OpenTelemetry spans (#1069)
- OpenTelemetry's `in_span` method already records exceptions and sets error status automatically
- Removed redundant explicit exception recording that caused exceptions to appear twice in traces
- Thanks to Robert Laurin (Shopify) for this fix
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
==========
Performance:
- Buffered I/O: Use `socket.sync = false` with explicit flush to reduce syscalls for pipelined operations
- get_multi optimizations: Use Set for O(1) server tracking lookups
- Raw mode optimization: Skip bitflags request in meta protocol when in raw mode (saves 2 bytes per request)
New Features:
- OpenTelemetry tracing support: Automatically instruments operations when OpenTelemetry SDK is present
- Zero overhead when OpenTelemetry is not loaded
- Traces `get`, `set`, `delete`, `get_multi`, `set_multi`, `delete_multi`, `get_with_metadata`, and `fetch_with_lock`
- Spans include `db.system: memcached` and `db.operation` attributes
- Single-key operations include `server.address` attribute
- Multi-key operations include `db.memcached.key_count` attribute
- `get_multi` spans include `db.memcached.hit_count` and `db.memcached.miss_count` for cache efficiency metrics
- Exceptions are automatically recorded on spans with error status
4.1.0
==========
New Features:
- Add `set_multi` for efficient bulk set operations using pipelined requests
- Add `delete_multi` for efficient bulk delete operations using pipelined requests
- Add `fetch_with_lock` for thundering herd protection using meta protocol's vivify/recache flags (requires memcached 1.6+)
- Add thundering herd protection support to meta protocol (requires memcached 1.6+):
- `N` (vivify) flag for creating stubs on cache miss
- `R` (recache) flag for winning recache race when TTL is below threshold
- Response flags `W` (won recache), `X` (stale), `Z` (lost race)
- `delete_stale` method for marking items as stale instead of deleting
- Add `get_with_metadata` for advanced cache operations with metadata retrieval (requires memcached 1.6+):
- Returns hash with `:value`, `:cas`, `:won_recache`, `:stale`, `:lost_recache`
- Optional `:return_hit_status` returns `:hit_before` (true/false for previous access)
- Optional `:return_last_access` returns `:last_access` (seconds since last access)
- Optional `:skip_lru_bump` prevents LRU update on access
- Optional `:vivify_ttl` and `:recache_ttl` for thundering herd protection
Deprecations:
- Binary protocol is deprecated and will be removed in Dalli 5.0. Use `protocol: :meta` instead (requires memcached 1.6+)
- SASL authentication is deprecated and will be removed in Dalli 5.0. Consider using network-level security or memcached's TLS support
4.0.1
==========
- Add `:raw` client option to skip serialization entirely, returning raw byte strings
- Handle `OpenSSL::SSL::SSLError` in connection manager
4.0.0
==========
BREAKING CHANGES:
- Require Ruby 3.1+ (dropped support for Ruby 2.6, 2.7, and 3.0)
- Removed `Dalli::Server` deprecated alias - use `Dalli::Protocol::Binary` instead
- Removed `:compression` option - use `:compress` instead
- Removed `close_on_fork` method - use `reconnect_on_fork` instead
Other changes:
- Add security warning when using default Marshal serializer (silence with `silence_marshal_warning: true`)
- Add defense-in-depth input validation for stats command arguments
- Add `string_fastpath` option to skip serialization for simple strings (byroot)
- Meta protocol set performance improvement (danmayer)
- Fix connection_pool 3.0 compatibility for Rack session store
- Fix session recovery after deletion (stengineering0)
- Fix cannot read response data included terminator `\r\n` when use meta protocol (matsubara0507)
- Support SERVER_ERROR response from Memcached as per the [memcached spec](https://github.com/memcached/memcached/blob/e43364402195c8e822bb8f88755a60ab8bbed62a/doc/protocol.txt#L172) (grcooper)
- Update Socket timeout handling to use Socket#timeout= when available (nickamorim)
- Serializer: reraise all .load errors as UnmarshalError (olleolleolle)
- Reconnect gracefully when a fork is detected instead of crashing (PatrickTulskie)
- Update CI to test against memcached 1.6.40
3.2.8
==========
- Handle IO::TimeoutError when establishing connection (eugeneius)
- Drop dependency on base64 gem (Earlopain)
- Address incompatibility with resolv-replace (y9v)
- Add rubygems.org metadata (m-nakamura145)
3.2.7
==========
- Fix cascading error when there's an underlying network error in a pipelined get (eugeneius)
- Ruby 3.4/head compatibility by adding base64 to gemspec (tagliala)
- Add Ruby 3.3 to CI (m-nakamura145)
- Use Socket's connect_timeout when available, and pass timeout to the socket's send and receive timeouts (mlarraz)
3.2.6
==========
- Rescue IO::TimeoutError raised by Ruby since 3.2.0 on blocking reads/writes (skaes)
- Fix rubydoc link (JuanitoFatas)
3.2.5
==========
- Better handle memcached requests being interrupted by Thread#raise or Thread#kill (byroot)
- Unexpected errors are no longer treated as `Dalli::NetworkError`, including errors raised by `Timeout.timeout` (byroot)
3.2.4
==========
- Cache PID calls for performance since glibc no longer caches in recent versions (byroot)
- Preallocate the read buffer in Socket#readfull (byroot)
3.2.3
==========
- Sanitize CAS inputs to ensure additional commands are not passed to memcached (xhzeem / petergoldstein)
- Sanitize input to flush command to ensure additional commands are not passed to memcached (xhzeem / petergoldstein)
- Namespaces passed as procs are now evaluated every time, as opposed to just on initialization (nrw505)
- Fix missing require of uri in ServerConfigParser (adam12)
- Fix link to the CHANGELOG.md file in README.md (rud)
3.2.2
==========
- Ensure apps are resilient against old session ids (kbrock)
3.2.1
==========
- Fix null replacement bug on some SASL-authenticated services (veritas1)
3.2.0
==========
- BREAKING CHANGE: Remove protocol_implementation client option (petergoldstein)
- Add protocol option with meta implementation (petergoldstein)
3.1.6
==========
- Fix bug with cas/cas! with "Not found" value (petergoldstein)
- Add Ruby 3.1 to CI (petergoldstein)
- Replace reject(&:nil?) with compact (petergoldstein)
3.1.5
==========
- Fix bug with get_cas key with "Not found" value (petergoldstein)
- Replace should return nil, not raise error, on miss (petergoldstein)
3.1.4
==========
- Improve response parsing performance (byroot)
- Reorganize binary protocol parsing a bit (petergoldstein)
- Fix handling of non-ASCII keys in get_multi (petergoldstein)
3.1.3
==========
- Restore falsey behavior on delete/delete_cas for nonexistent key (petergoldstein)
3.1.2
==========
- Make quiet? / multi? public on Dalli::Protocol::Binary (petergoldstein)
3.1.1
==========
- Add quiet support for incr, decr, append, depend, and flush (petergoldstein)
- Additional refactoring to allow reuse of connection behavior (petergoldstein)
- Fix issue in flush such that it wasn't passing the delay argument to memcached (petergoldstein)
3.1.0
==========
- BREAKING CHANGE: Update Rack::Session::Dalli to inherit from Abstract::PersistedSecure. This will invalidate existing sessions (petergoldstein)
- BREAKING CHANGE: Use of unsupported operations in a multi block now raise an error. (petergoldstein)
- Extract PipelinedGetter from Dalli::Client (petergoldstein)
- Fix SSL socket so that it works with pipelined gets (petergoldstein)
- Additional refactoring to split classes (petergoldstein)
3.0.6
==========
- Fix regression in SASL authentication response parsing (petergoldstein)
3.0.5
==========
- Add Rubocop and fix most outstanding issues (petergoldstein)
- Extract a number of classes, to simplify the largest classes (petergoldstein)
- Ensure against socket corruption if an error occurs in a multi block (petergoldstein)
3.0.4
==========
- Clean connections and retry after NetworkError in get_multi (andrejbl)
- Internal refactoring and cleanup (petergoldstein)
3.0.3
==========
- Restore ability for `compress` to be disabled on a per request basis (petergoldstein)
- Fix broken image in README (deining)
- Use bundler-cache in CI (olleolleolle)
- Remove the OpenSSL extensions dependency (petergoldstein)
- Add Memcached 1.5.x to the CI matrix
- Updated compression documentation (petergoldstein)
3.0.2
==========
- Restore Windows compatibility (petergoldstein)
- Add JRuby to CI and make requisite changes (petergoldstein)
- Clarify documentation for supported rubies (petergoldstein)
3.0.1
==========
- Fix syntax error that prevented inclusion of Dalli::Server (ryanfb)
- Restore with method required by ActiveSupport::Cache::MemCacheStore
3.0.0
==========
- BREAKING CHANGES:
* Removes :dalli_store.
Use Rails' official :mem_cache_store instead.
https://guides.rubyonrails.org/caching_with_rails.html
* Attempting to store a larger value than allowed by memcached used to
print a warning and truncate the value. This now raises an error to
prevent silent data corruption.
* Compression now defaults to `true` for large values (greater than 4KB).
This is intended to minimize errors due to the previous note.
* Errors marshalling values now raise rather than just printing an error.
* The Rack session adapter has been refactored to remove support for thread-unsafe
configurations. You will need to include the `connection_pool` gem in
your Gemfile to ensure session operations are thread-safe.
* When using namespaces, the algorithm for calculating truncated keys was
changed. Non-truncated keys and truncated keys for the non-namespace
case were left unchanged.
- Raise NetworkError when multi response gets into corrupt state (mervync, #783)
- Validate servers argument (semaperepelitsa, petergoldstein, #776)
- Enable SSL support (bdunne, #775)
- Add gat operation (tbeauvais, #769)
- Removes inline native code, use Ruby 2.3+ support for bsearch instead. (mperham)
- Switch repo to Github Actions and upgrade Ruby versions (petergoldstein, bdunne, Fryguy)
- Update benchmark test for Rubyprof changes (nateberkopec)
- Remove support for the `kgio` gem, it is not relevant in Ruby 2.3+. (mperham)
- Remove inline native code, use Ruby 2.3+ support for bsearch instead. (mperham)
2.7.11
==========
- DEPRECATION: :dalli_store will be removed in Dalli 3.0.
Use Rails' official :mem_cache_store instead.
https://guides.rubyonrails.org/caching_with_rails.html
- Add new `digest_class` option to Dalli::Client [#724]
- Don't treat NameError as a network error [#728]
- Handle nested comma separated server strings (sambostock)
2.7.10
==========
- Revert frozen string change (schneems)
- Advertise supports_cached_versioning? in DalliStore (schneems)
- Better detection of fork support, to allow specs to run under Truffle Ruby (deepj)
- Update logging for over max size to log as error (aeroastro)
2.7.9
==========
- Fix behavior for Rails 5.2+ cache_versioning (GriwMF)
- Ensure fetch provides the key to the fallback block as an argument (0exp)
- Assorted performance improvements (schneems)
2.7.8
==========
- Rails 5.2 compatibility (pbougie)
- Fix Session Cache compatibility (pixeltrix)
2.7.7
==========
- Support large cache keys on fetch multi (sobrinho)
- Not found checks no longer trigger the result's equality method (dannyfallon)
- Use SVG build badges (olleolleolle)
- Travis updates (junaruga, tiarly, petergoldstein)
- Update default down_retry_delay (jaredhales)
- Close kgio socket after IO.select timeouts
- Documentation updates (tipair)
- Instrument DalliStore errors with instrument_errors configuration option. (btatnall)
2.7.6
==========
- Rails 5.0.0.beta2 compatibility (yui-knk, petergoldstein)
- Add cas!, a variant of the #cas method that yields to the block whether or not the key already exist (mwpastore)
- Performance improvements (nateberkopec)
- Add Ruby 2.3.0 to support matrix (tricknotes)
2.7.5
==========
- Support rcvbuff and sndbuff byte configuration. (btatnall)
- Add `:cache_nils` option to support nil values in `DalliStore#fetch` and `Dalli::Client#fetch` (wjordan, #559)
- Log retryable server errors with 'warn' instead of 'info' (phrinx)
- Fix timeout issue with Dalli::Client#get_multi_yielder (dspeterson)
- Escape namespaces with special regexp characters (Steven Peckins)
- Ensure LocalCache supports the `:raw` option and Entry unwrapping (sj26)
- Ensure bad ttl values don't cause Dalli::RingError (eagletmt, petergoldstein)
- Always pass namespaced key to instrumentation API (kaorimatz)
- Replace use of deprecated TimeoutError with Timeout::Error (eagletmt)
- Clean up gemspec, and use Bundler for loading (grosser)
- Dry up local cache testing (grosser)
2.7.4
==========
- Restore Windows compatibility (dfens, #524)
2.7.3
==========
- Assorted spec improvements
- README changes to specify defaults for failover and compress options (keen99, #470)
- SASL authentication changes to deal with Unicode characters (flypiggy, #477)
- Call to_i on ttl to accomodate ActiveSupport::Duration (#494)
- Change to implicit blocks for performance (glaucocustodio, #495)
- Change to each_key for performance (jastix, #496)
- Support stats settings - (dterei, #500)
- Raise DallError if hostname canno be parsed (dannyfallon, #501)
- Fix instrumentation for falsey values (AlexRiedler, #514)
- Support UNIX socket configurations (r-stu31, #515)
2.7.2
==========
- The fix for #423 didn't make it into the released 2.7.1 gem somehow.
2.7.1
==========
- Rack session will check if servers are up on initialization (arthurnn, #423)
- Add support for IPv6 addresses in hex form, ie: "[::1]:11211" (dplummer, #428)
- Add symbol support for namespace (jingkai #431)
- Support expiration intervals longer than 30 days (leonid-shevtsov #436)
2.7.0
==========
- BREAKING CHANGE:
Dalli::Client#add and #replace now return a truthy value, not boolean true or false.
- Multithreading support with dalli\_store:
Use :pool\_size to create a pool of shared, threadsafe Dalli clients in Rails:
```ruby
config.cache_store = :dalli_store, "cache-1.example.com", "cache-2.example.com", :compress => true, :pool_size => 5, :expires_in => 300
```
This will ensure the Rails.cache singleton does not become a source of contention.
**PLEASE NOTE** Rails's :mem\_cache\_store does not support pooling as of
Rails 4.0. You must use :dalli\_store.
- Implement `version` for retrieving version of connected servers [dterei, #384]
- Implement `fetch_multi` for batched read/write [sorentwo, #380]
- Add more support for safe updates with multiple writers: [philipmw, #395]
`require 'dalli/cas/client'` augments Dalli::Client with the following methods:
* Get value with CAS: `[value, cas] = get_cas(key)`
`get_cas(key) {|value, cas| ...}`
* Get multiple values with CAS: `get_multi_cas(k1, k2, ...) {|value, metadata| cas = metadata[:cas]}`
* Set value with CAS: `new_cas = set_cas(key, value, cas, ttl, options)`
* Replace value with CAS: `replace_cas(key, new_value, cas, ttl, options)`
* Delete value with CAS: `delete_cas(key, cas)`
- Fix bug with get key with "Not found" value [uzzz, #375]
2.6.4
=======
- Fix ADD command, aka `write(unless_exist: true)` (pitr, #365)
- Upgrade test suite from mini\_shoulda to minitest.
- Even more performance improvements for get\_multi (xaop, #331)
2.6.3
=======
- Support specific stats by passing `:items` or `:slabs` to `stats` method [bukhamseen]
- Fix 'can't modify frozen String' errors in `ActiveSupport::Cache::DalliStore` [dblock]
- Protect against objects with custom equality checking [theron17]
- Warn if value for key is too large to store [locriani]
2.6.2
=======
- Properly handle missing RubyInline
2.6.1
=======
- Add optional native C binary search for ring, add:
gem 'RubyInline'
to your Gemfile to get a 10% speedup when using many servers.
You will see no improvement if you are only using one server.
- More get_multi performance optimization [xaop, #315]
- Add lambda support for cache namespaces [joshwlewis, #311]
2.6.0
=======
- read_multi optimization, now checks local_cache [chendo, #306]
- Re-implement get_multi to be non-blocking [tmm1, #295]
- Add `dalli` accessor to dalli_store to access the underlying
Dalli::Client, for things like `get_multi`.
- Add `Dalli::GzipCompressor`, primarily for compatibility with nginx's HttpMemcachedModule using `memcached_gzip_flag`
2.5.0
=======
- Don't escape non-ASCII keys, memcached binary protocol doesn't care. [#257]
- :dalli_store now implements LocalCache [#236]
- Removed lots of old session_store test code, tests now all run without a default memcached server [#275]
- Changed Dalli ActiveSupport adapter to always attempt instrumentation [brianmario, #284]
- Change write operations (add/set/replace) to return false when value is too large to store [brianmario, #283]
- Allowing different compressors per client [naseem]
2.4.0
=======
- Added the ability to swap out the compressed used to [de]compress cache data [brianmario, #276]
- Fix get\_multi performance issues with lots of memcached servers [tmm1]
- Throw more specific exceptions [tmm1]
- Allowing different types of serialization per client [naseem]
2.3.0
=======
- Added the ability to swap out the serializer used to [de]serialize cache data [brianmario, #274]
2.2.1
=======
- Fix issues with ENV-based connections. [#266]
- Fix problem with SessionStore in Rails 4.0 [#265]
2.2.0
=======
- Add Rack session with\_lock helper, for Rails 4.0 support [#264]
- Accept connection string in the form of a URL (e.g., memcached://user:pass@hostname:port) [glenngillen]
- Add touch operation [#228, uzzz]
2.1.0
=======
- Add Railtie to auto-configure Dalli when included in Gemfile [#217, steveklabnik]
2.0.5
=======
- Create proper keys for arrays of objects passed as keys [twinturbo, #211]
- Handle long key with namespace [#212]
- Add NODELAY to TCP socket options [#206]
2.0.4
=======
- Dalli no longer needs to be reset after Unicorn/Passenger fork [#208]
- Add option to re-raise errors rescued in the session and cache stores. [pitr, #200]
- DalliStore#fetch called the block if the cached value == false [#205]
- DalliStore should have accessible options [#195]
- Add silence and mute support for DalliStore [#207]
- Tracked down and fixed socket corruption due to Timeout [#146]
2.0.3
=======
- Allow proper retrieval of stored `false` values [laserlemon, #197]
- Allow non-ascii and whitespace keys, only the text protocol has those restrictions [#145]
- Fix DalliStore#delete error-handling [#196]
2.0.2
=======
- Fix all dalli\_store operations to handle nil options [#190]
- Increment and decrement with :initial => nil now return nil (lawrencepit, #112)
2.0.1
=======
- Fix nil option handling in dalli\_store#write [#188]
2.0.0
=======
- Reimplemented the Rails' dalli\_store to remove use of
ActiveSupport::Cache::Entry which added 109 bytes overhead to every
value stored, was a performance bottleneck and duplicated a lot of
functionality already in Dalli. One benchmark went from 4.0 sec to 3.0
sec with the new dalli\_store. [#173]
- Added reset\_stats operation [#155]
- Added support for configuring keepalive on TCP connections to memcached servers (@bianster, #180)
Notes:
* data stored with dalli\_store 2.x is NOT backwards compatible with 1.x.
Upgraders are advised to namespace their keys and roll out the 2.x
upgrade slowly so keys do not clash and caches are warmed.
`config.cache_store = :dalli_store, :expires_in => 24.hours.to_i, :namespace => 'myapp2'`
* data stored with plain Dalli::Client API is unchanged.
* removed support for dalli\_store's race\_condition\_ttl option.
* removed support for em-synchrony and unix socket connection options.
* removed support for Ruby 1.8.6
* removed memcache-client compability layer and upgrade documentation.
1.1.5
=======
- Coerce input to incr/decr to integer via #to\_i [#165]
- Convert test suite to minitest/spec (crigor, #166)
- Fix encoding issue with keys [#162]
- Fix double namespacing with Rails and dalli\_store. [#160]
1.1.4
=======
- Use 127.0.0.1 instead of localhost as default to avoid IPv6 issues
- Extend DalliStore's :expires\_in when :race\_condition\_ttl is also used.
- Fix :expires\_in option not propogating from DalliStore to Client, GH-136
- Added support for native Rack session store. Until now, Dalli's
session store has required Rails. Now you can use Dalli to store
sessions for any Rack application.
require 'rack/session/dalli'
use Rack::Session::Dalli, :memcache_server => 'localhost:11211', :compression => true
1.1.3
=======
- Support Rails's autoloading hack for loading sessions with objects
whose classes have not be required yet, GH-129
- Support Unix sockets for connectivity. Shows a 2x performance
increase but keep in mind they only work on localhost. (dfens)
1.1.2
=======
- Fix incompatibility with latest Rack session API when destroying
sessions, thanks @twinge!
1.1.1
=======
v1.1.0 was a bad release. Yanked.
1.1.0
=======
- Remove support for Rails 2.3, add support for Rails 3.1
- Fix socket failure retry logic, now you can restart memcached and Dalli won't complain!
- Add support for fibered operation via em-synchrony (eliaslevy)
- Gracefully handle write timeouts, GH-99
- Only issue bug warning for unexpected StandardErrors, GH-102
- Add travis-ci build support (ryanlecompte)
- Gracefully handle errors in get_multi (michaelfairley)
- Misc fixes from crash2burn, fphilipe, igreg, raggi
1.0.5
=======
- Fix socket failure retry logic, now you can restart memcached and Dalli won't complain!
1.0.4
=======
- Handle non-ASCII key content in dalli_store
- Accept key array for read_multi in dalli_store
- Fix multithreaded race condition in creation of mutex
1.0.3
=======
- Better handling of application marshalling errors
- Work around jruby IO#sysread compatibility issue
1.0.2
=======
- Allow browser session cookies (blindsey)
- Compatibility fixes (mwynholds)
- Add backwards compatibility module for memcache-client, require 'dalli/memcache-client'. It makes
Dalli more compatible with memcache-client and prints out a warning any time you do something that
is no longer supported so you can fix your code.
1.0.1
=======
- Explicitly handle application marshalling bugs, GH-56
- Add support for username/password as options, to allow multiple bucket access
from the same Ruby process, GH-52
- Add support for >1MB values with :value_max_bytes option, GH-54 (r-stu31)
- Add support for default TTL, :expires_in, in Rails 2.3. (Steven Novotny)
config.cache_store = :dalli_store, 'localhost:11211', {:expires_in => 4.hours}
1.0.0
=======
Welcome gucki as a Dalli committer!
- Fix network and namespace issues in get_multi (gucki)
- Better handling of unmarshalling errors (mperham)
0.11.2
=======
- Major reworking of socket error and failover handling (gucki)
- Add basic JRuby support (mperham)
0.11.1
======
- Minor fixes, doc updates.
- Add optional support for kgio sockets, gives a 10-15% performance boost.
0.11.0
======
Warning: this release changes how Dalli marshals data. I do not guarantee compatibility until 1.0 but I will increment the minor version every time a release breaks compatibility until 1.0.
IT IS HIGHLY RECOMMENDED YOU FLUSH YOUR CACHE BEFORE UPGRADING.
- multi() now works reentrantly.
- Added new Dalli::Client option for default TTLs, :expires_in, defaults to 0 (aka forever).
- Added new Dalli::Client option, :compression, to enable auto-compression of values.
- Refactor how Dalli stores data on the server. Values are now tagged
as "marshalled" or "compressed" so they can be automatically deserialized
without the client having to know how they were stored.
0.10.1
======
- Prefer server config from environment, fixes Heroku session store issues (thanks JoshMcKin)
- Better handling of non-ASCII values (size -> bytesize)
- Assert that keys are ASCII only
0.10.0
======
Warning: this release changed how Rails marshals data with Dalli. Unfortunately previous versions double marshalled values. It is possible that data stored with previous versions of Dalli will not work with this version.
IT IS HIGHLY RECOMMENDED YOU FLUSH YOUR CACHE BEFORE UPGRADING.
- Rework how the Rails cache store does value marshalling.
- Rework old server version detection to avoid a socket read hang.
- Refactor the Rails 2.3 :dalli\_store to be closer to :mem\_cache\_store.
- Better documentation for session store config (plukevdh)
0.9.10
----
- Better server retry logic (next2you)
- Rails 3.1 compatibility (gucki)
0.9.9
----
- Add support for *_multi operations for add, set, replace and delete. This implements
pipelined network operations; Dalli disables network replies so we're not limited by
latency, allowing for much higher throughput.
dc = Dalli::Client.new
dc.multi do
dc.set 'a', 1
dc.set 'b', 2
dc.set 'c', 3
dc.delete 'd'
end
- Minor fix to set the continuum sorted by value (kangster)
- Implement session store with Rails 2.3. Update docs.
0.9.8
-----
- Implement namespace support
- Misc fixes
0.9.7
-----
- Small fix for NewRelic integration.
- Detect and fail on older memcached servers (pre-1.4).
0.9.6
-----
- Patches for Rails 3.0.1 integration.
0.9.5
-----
- Major design change - raw support is back to maximize compatibility with Rails
and the increment/decrement operations. You can now pass :raw => true to most methods
to bypass (un)marshalling.
- Support symbols as keys (ddollar)
- Rails 2.3 bug fixes
0.9.4
-----
- Dalli support now in rack-bug (http://github.com/brynary/rack-bug), give it a try!
- Namespace support for Rails 2.3 (bpardee)
- Bug fixes
0.9.3
-----
- Rails 2.3 support (beanieboi)
- Rails SessionStore support
- Passenger integration
- memcache-client upgrade docs, see Upgrade.md
0.9.2
----
- Verify proper operation in Heroku.
0.9.1
----
- Add fetch and cas operations (mperham)
- Add incr and decr operations (mperham)
- Initial support for SASL authentication via the MEMCACHE_{USERNAME,PASSWORD} environment variables, needed for Heroku (mperham)
0.9.0
-----
- Initial gem release.
petergoldstein-dalli-8b467ad/CLAUDE.md 0000664 0000000 0000000 00000005626 15147323064 0017543 0 ustar 00root root 0000000 0000000 # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Dalli is a high-performance pure Ruby client for accessing memcached servers. It supports failover, SSL/TLS, and thread-safe operation. Requires memcached 1.6+ (meta protocol).
## Common Commands
```bash
# Install dependencies
bundle install
# Run all tests (requires memcached installed locally)
bundle exec rake
# Run a single test file
bundle exec ruby -Itest test/integration/test_operations.rb
# Run a specific test by name
bundle exec ruby -Itest test/integration/test_operations.rb -n test_get_set
# Run benchmarks
bundle exec rake bench
# Lint code
bundle exec rubocop
# Auto-fix lint issues
bundle exec rubocop -a
```
## Architecture
### Core Components
**Dalli::Client** (`lib/dalli/client.rb`) - Main entry point. Handles key validation, server selection via the ring, and retries on network errors. All memcached operations flow through the `perform` method.
**Dalli::Ring** (`lib/dalli/ring.rb`) - Implements consistent hashing for distributing keys across multiple servers. Uses CRC32 hashing and a configurable number of points per server (160 by default). Handles failover by trying alternate hash positions when a server is down.
**Protocol Layer** (`lib/dalli/protocol/`) - Uses the memcached meta protocol (requires memcached 1.6+). `Protocol::Meta` inherits from `Protocol::Base` which contains common connection management, pipelining, and value marshalling logic.
**Value Pipeline** - Values flow through three stages:
1. `ValueSerializer` - Serializes Ruby objects (default: Marshal)
2. `ValueCompressor` - Compresses large values (default: Zlib, 4KB threshold)
3. `ValueMarshaller` - Coordinates serialization and compression, manages bitflags
**Connection Management** (`lib/dalli/protocol/connection_manager.rb`) - Handles socket lifecycle, reconnection logic, and timeout handling. Supports both TCP and UNIX domain sockets.
**Rack::Session::Dalli** (`lib/rack/session/dalli.rb`) - Rack session middleware using memcached for storage. Supports connection pooling via the `connection_pool` gem.
### Threading Model
By default, Dalli wraps each server connection with mutex locks (`Dalli::Threadsafe` module). For connection pool usage, threadsafe mode can be disabled per-client.
### Test Infrastructure
Tests require a local memcached 1.6+ installation. The `MemcachedManager` (`test/utils/memcached_manager.rb`) spawns memcached instances on random ports for test isolation.
SSL tests use self-signed certificates generated at runtime via `CertificateGenerator`.
## Development Workflow
**After any code changes, you MUST verify:**
1. **Run Rubocop** - `bundle exec rubocop` must pass with no offenses
2. **Run Tests** - `bundle exec rake` must pass with no failures
Do not consider a change complete until both checks pass. If either fails, fix the issues before finishing.
petergoldstein-dalli-8b467ad/CONTRIBUTING.md 0000664 0000000 0000000 00000003117 15147323064 0020506 0 ustar 00root root 0000000 0000000 # Contributing to Dalli
We welcome contributions from the community. Whether you're fixing a bug, adding a feature, or improving documentation, thank you for helping make Dalli better.
## How to Contribute
1. Fork the repository on GitHub.
2. Create a topic branch from `main` for your change.
3. Make your changes and ensure they include tests that verify the fix or feature.
4. Update the [CHANGELOG](CHANGELOG.md) with a one-sentence description of your change so you get credit as a contributor.
5. Make sure `bundle exec rubocop` passes with no offenses.
6. Make sure `bundle exec rake` passes with no test failures.
7. Submit a pull request.
## AI-Authored Contributions
Issues and pull requests from AI bots or AI-assisted tools are welcome. They are held to the same standards as any other contribution:
- Include tests that verify the change.
- Update the changelog.
- Pass CI (tests and linting).
**Disclosure is required.** If a contribution is authored or substantially generated by AI, indicate this in one of the following ways:
- Submit from a clearly identified bot account.
- Include a note in the PR or issue body (e.g., "This PR was generated with the assistance of [tool name]").
- Apply a `generated-by-ai` label.
## Development Setup
After cloning the repo:
```bash
bundle install # Install dependencies
bundle exec rake # Run the full test suite (requires memcached 1.6+ locally)
bundle exec rubocop # Run the linter
```
See the [README](README.md) and [wiki](https://github.com/petergoldstein/dalli/wiki) for more details on the project and its architecture.
petergoldstein-dalli-8b467ad/Gemfile 0000664 0000000 0000000 00000001325 15147323064 0017547 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
source 'https://rubygems.org'
gemspec
group :development, :test do
gem 'benchmark'
gem 'cgi'
gem 'connection_pool'
gem 'debug' unless RUBY_PLATFORM == 'java'
if RUBY_VERSION >= '3.2'
gem 'minitest', '~> 6'
gem 'minitest-mock'
else
gem 'minitest', '~> 5'
end
gem 'rack', '~> 3'
gem 'rack-session'
gem 'rake', '~> 13.0'
gem 'rubocop'
gem 'rubocop-minitest'
gem 'rubocop-performance'
gem 'rubocop-rake'
gem 'rubocop-thread_safety'
gem 'simplecov'
end
group :test do
gem 'ruby-prof', platform: :mri
# For socket compatibility testing (these gems monkey-patch TCPSocket)
gem 'resolv-replace', require: false
gem 'socksify', require: false
end
petergoldstein-dalli-8b467ad/LICENSE 0000664 0000000 0000000 00000002056 15147323064 0017263 0 ustar 00root root 0000000 0000000 Copyright (c) Peter M. Goldstein, Mike Perham
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
petergoldstein-dalli-8b467ad/Performance.md 0000664 0000000 0000000 00000004447 15147323064 0021047 0 ustar 00root root 0000000 0000000 Performance
====================
> **Note:** This document contains historical benchmark data from 2012. The `kgio` gem is no longer supported or needed in modern Ruby versions. For current benchmarks, run `bundle exec rake bench` locally.
Caching is all about performance, so I carefully track Dalli performance to ensure no regressions.
Note I've added some benchmarks over time to Dalli that the other libraries don't necessarily have.
memcache-client
---------------
Testing 1.8.5 with ruby 1.9.3p0 (2011-10-30 revision 33570) [x86_64-darwin11.2.0]
user system total real
set:plain:memcache-client 1.860000 0.310000 2.170000 ( 2.188030)
set:ruby:memcache-client 1.830000 0.290000 2.120000 ( 2.130212)
get:plain:memcache-client 1.830000 0.340000 2.170000 ( 2.176156)
get:ruby:memcache-client 1.900000 0.330000 2.230000 ( 2.235045)
multiget:ruby:memcache-client 0.860000 0.120000 0.980000 ( 0.987348)
missing:ruby:memcache-client 1.630000 0.320000 1.950000 ( 1.954867)
mixed:ruby:memcache-client 3.690000 0.670000 4.360000 ( 4.364469)
dalli
-----
Testing with Rails 3.2.1
Using kgio socket IO
Testing 2.0.0 with ruby 1.9.3p125 (2012-02-16 revision 34643) [x86_64-darwin11.3.0]
user system total real
mixed:rails:dalli 1.580000 0.570000 2.150000 ( 3.008839)
set:plain:dalli 0.730000 0.300000 1.030000 ( 1.567098)
setq:plain:dalli 0.520000 0.120000 0.640000 ( 0.634402)
set:ruby:dalli 0.800000 0.300000 1.100000 ( 1.640348)
get:plain:dalli 0.840000 0.330000 1.170000 ( 1.668425)
get:ruby:dalli 0.850000 0.330000 1.180000 ( 1.665716)
multiget:ruby:dalli 0.700000 0.260000 0.960000 ( 0.965423)
missing:ruby:dalli 0.720000 0.320000 1.040000 ( 1.511720)
mixed:ruby:dalli 1.660000 0.640000 2.300000 ( 3.320743)
mixedq:ruby:dalli 1.630000 0.510000 2.140000 ( 2.629734)
incr:ruby:dalli 0.270000 0.100000 0.370000 ( 0.547618)
petergoldstein-dalli-8b467ad/README.md 0000664 0000000 0000000 00000013144 15147323064 0017535 0 ustar 00root root 0000000 0000000 Dalli [](https://github.com/petergoldstein/dalli/actions/workflows/tests.yml)
=====
Dalli is a high performance pure Ruby client for accessing memcached servers.
Dalli supports:
* Simple and complex memcached configurations
* Failover between memcached instances
* Fine-grained control of data serialization and compression
* Thread-safe operation (either through use of a connection pool, or by using the Dalli client in threadsafe mode)
* SSL/TLS connections to memcached
* OpenTelemetry distributed tracing (automatic when SDK is present)
The name is a variant of Salvador Dali for his famous painting [The Persistence of Memory](http://en.wikipedia.org/wiki/The_Persistence_of_Memory).
## Requirements
* Ruby 3.3 or later (JRuby also supported)
* memcached 1.6 or later
## 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:
```ruby
Dalli::Client.new('localhost:11211', serializer: JSON)
```
See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
## OpenTelemetry Tracing
Dalli automatically instruments operations with [OpenTelemetry](https://opentelemetry.io/) when the SDK is present. No configuration is required - just add the OpenTelemetry gems to your application:
```ruby
# Gemfile
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp' # or your preferred exporter
```
When OpenTelemetry is loaded, Dalli creates spans for:
- Single key operations: `get`, `set`, `delete`, `add`, `replace`, `incr`, `decr`, etc.
- Multi-key operations: `get_multi`, `set_multi`, `delete_multi`
- Advanced operations: `get_with_metadata`, `fetch_with_lock`
### Span Attributes
All spans include:
- `db.system`: `memcached`
- `db.operation`: The operation name (e.g., `get`, `set_multi`)
Single-key operations also include:
- `server.address`: The memcached server that handled the request (e.g., `localhost:11211`)
Multi-key operations include cache efficiency metrics:
- `db.memcached.key_count`: Number of keys in the request
- `db.memcached.hit_count`: Number of keys found (for `get_multi`)
- `db.memcached.miss_count`: Number of keys not found (for `get_multi`)
### Error Handling
Exceptions are automatically recorded on spans with error status. When an operation fails:
1. The exception is recorded on the span via `span.record_exception(e)`
2. The span status is set to error with the exception message
3. The exception is re-raised to the caller
### Zero Overhead
When OpenTelemetry is not present, there is zero overhead - the tracing code checks once at startup and bypasses all instrumentation logic entirely when the SDK is not loaded.

## Documentation and Information
* [User Documentation](https://github.com/petergoldstein/dalli/wiki) - The documentation is maintained in the repository's wiki.
* [Announcements](https://github.com/petergoldstein/dalli/discussions/categories/announcements) - Announcements of interest to the Dalli community will be posted here.
* [Bug Reports](https://github.com/petergoldstein/dalli/issues) - If you discover a problem with Dalli, please submit a bug report in the tracker.
* [Forum](https://github.com/petergoldstein/dalli/discussions/categories/q-a) - If you have questions about Dalli, please post them here.
* [Client API](https://www.rubydoc.info/gems/dalli) - Ruby documentation for the `Dalli::Client` API
## Development
After checking out the repo, run `bin/setup` to install dependencies. You can run `bin/console` for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run `bundle exec rake install`.
## Contributing
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute, including our policy on AI-authored contributions.
## Appreciation
Dalli would not exist in its current form without the contributions of many people. But special thanks go to several individuals and organizations:
* Mike Perham - for originally authoring the Dalli project and serving as maintainer and primary contributor for many years
* Eric Wong - for help using his [kgio](http://bogomips.org/kgio/) library.
* Brian Mitchell - for his remix-stash project which was helpful when implementing and testing the binary protocol support.
* [CouchBase](http://couchbase.com) - for their sponsorship of the original development
## Authors
* [Peter M. Goldstein](https://github.com/petergoldstein) - current maintainer
* [Mike Perham](https://github.com/mperham) and contributors
## Copyright
Copyright (c) Mike Perham, Peter M. Goldstein. See LICENSE for details.
petergoldstein-dalli-8b467ad/Rakefile 0000664 0000000 0000000 00000000465 15147323064 0017725 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
require 'bundler/gem_tasks'
require 'rake/testtask'
Rake::TestTask.new(:test) do |test|
test.pattern = 'test/**/test_*.rb'
test.warning = true
test.verbose = true
end
task default: :test
Rake::TestTask.new(:bench) do |test|
test.pattern = 'test/benchmark_test.rb'
end
petergoldstein-dalli-8b467ad/bin/ 0000775 0000000 0000000 00000000000 15147323064 0017023 5 ustar 00root root 0000000 0000000 petergoldstein-dalli-8b467ad/bin/benchmark 0000775 0000000 0000000 00000020021 15147323064 0020676 0 ustar 00root root 0000000 0000000 #!/usr/bin/env ruby
# frozen_string_literal: true
# This helps benchmark current performance of Dalli
# as well as compare performance of optimized and non-optimized calls like multi-set vs set
#
# run with:
# bundle exec bin/benchmark
# RUBY_YJIT_ENABLE=1 BENCH_TARGET=get bundle exec bin/benchmark
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'benchmark-ips'
gem 'logger'
end
require 'json'
require 'benchmark/ips'
require 'monitor'
require_relative '../lib/dalli'
##
# NoopSerializer is a serializer that avoids the overhead of Marshal or JSON.
##
class NoopSerializer
def self.dump(value)
value
end
def self.load(value)
value
end
end
dalli_url = ENV['BENCH_CACHE_URL'] || '127.0.0.1:11211'
bench_target = ENV['BENCH_TARGET'] || 'all'
bench_time = (ENV['BENCH_TIME'] || 10).to_i
bench_warmup = (ENV['BENCH_WARMUP'] || 3).to_i
bench_payload_size = (ENV['BENCH_PAYLOAD_SIZE'] || 700_000).to_i
payload = 'B' * bench_payload_size
TERMINATOR = "\r\n"
puts "yjit: #{RubyVM::YJIT.enabled?}"
client = Dalli::Client.new(dalli_url, serializer: NoopSerializer, compress: false, raw: true)
meta_client = Dalli::Client.new(dalli_url, protocol: :meta, serializer: NoopSerializer, compress: false, raw: true)
multi_client = Dalli::Client.new('localhost:11211,localhost:11222', serializer: NoopSerializer, compress: false,
raw: true)
# The raw socket implementation is used to benchmark the performance of dalli & the overhead of the various abstractions
# in the library.
sock = TCPSocket.new('127.0.0.1', '11211', connect_timeout: 1)
sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, true)
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
# Benchmarks didn't see any performance gains from increasing the SO_RCVBUF buffer size
# sock.setsockopt(Socket::SOL_SOCKET, ::Socket::SO_RCVBUF, 1024 * 1024 * 8)
# Benchmarks did see an improvement in performance when increasing the SO_SNDBUF buffer size
# sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 1024 * 1024 * 8)
# ensure the clients are all connected and working
client.set('key', payload)
meta_client.set('meta_key', payload)
multi_client.set('multi_key', payload)
sock.write("set sock_key 0 3600 #{payload.bytesize}\r\n")
sock.write(payload)
sock.write(TERMINATOR)
sock.flush
sock.readline # clear the buffer
raise 'dalli client mismatch' if payload != client.get('key')
raise 'multi dalli client mismatch' if payload != multi_client.get('multi_key')
sock.write("mg sock_key v\r\n")
sock.readline
sock_value = sock.read(payload.bytesize)
sock.read(TERMINATOR.bytesize)
raise 'sock mismatch' if payload != sock_value
# ensure we have basic data for the benchmarks and get calls
payload_smaller = 'B' * (bench_payload_size / 10)
pairs = {}
100.times do |i|
pairs["multi_#{i}"] = payload_smaller
end
client.quiet do
pairs.each do |key, value|
client.set(key, value, 3600, raw: true)
end
end
###
# GC Suite
# benchmark without GC skewing things
###
class GCSuite
def warming(*)
run_gc
end
def running(*)
run_gc
end
def warmup_stats(*)
GC.enable
end
def add_report(*)
GC.enable
end
private
def run_gc
GC.enable
GC.start
GC.disable
end
end
suite = GCSuite.new
# rubocop:disable Metrics/PerceivedComplexity
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/CyclomaticComplexity
def sock_get_multi(sock, pairs)
count = pairs.length
pairs.each_key do |key|
count -= 1
tail = count.zero? ? '' : 'q'
sock.write("mg #{key} v f k #{tail}\r\n")
end
sock.flush
# read all the memcached responses back and build a hash of key value pairs
results = {}
last_result = false
while (line = sock.readline.chomp!(TERMINATOR)) != ''
last_result = true if line.start_with?('EN ')
next unless line.start_with?('VA ') || last_result
_, value_length, _flags, key = line.split
results[key[1..]] = sock.read(value_length.to_i)
sock.read(TERMINATOR.length)
break if results.size == pairs.size
break if last_result
end
results
end
# rubocop:enable Metrics/PerceivedComplexity
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/CyclomaticComplexity
if %w[all set].include?(bench_target)
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report('client set') { client.set('key', payload) }
x.report('meta client set') { meta_client.set('meta_key', payload) }
# x.report('multi client set') { multi_client.set('string_key', payload) }
x.report('raw sock set') do
sock.write("ms sock_key #{payload.bytesize} T3600 MS\r\n")
sock.write(payload)
sock.write("\r\n")
sock.flush
sock.readline # clear the buffer
end
x.compare!
end
end
@lock = Monitor.new
if %w[all get].include?(bench_target)
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report('get dalli') do
result = client.get('key')
raise 'mismatch' unless result == payload
end
x.report('get meta client') do
result = meta_client.get('meta_key')
raise 'mismatch' unless result == payload
end
# NOTE: while this is the fastest it is not thread safe and is blocking vs IO sharing friendly
x.report('get sock') do
sock.write("mg sock_key v\r\n")
sock.readline
result = sock.read(payload.bytesize)
sock.read(TERMINATOR.bytesize)
raise 'mismatch' unless result == payload
end
# NOTE: This shows that when adding thread safety & non-blocking IO we are slower for single process/thread use case
x.report('get sock non-blocking') do
@lock.synchronize do
sock.write("mg sock_key v\r\n")
sock.readline
count = payload.bytesize
value = String.new(capacity: count + 1)
loop do
begin
value << sock.read_nonblock(count - value.bytesize)
rescue Errno::EAGAIN
sock.wait_readable
retry
rescue EOFError
puts 'EOFError'
break
end
break if value.bytesize == count
end
sock.read(TERMINATOR.bytesize)
raise 'mismatch' unless value == payload
end
end
x.compare!
end
end
if %w[all get_multi].include?(bench_target)
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report('get 100 keys') do
result = client.get_multi(pairs.keys)
raise 'mismatch' unless result == pairs
end
x.report('get 100 keys meta client') do
result = meta_client.get_multi(pairs.keys)
raise 'mismatch' unless result == pairs
end
x.report('get 100 keys raw sock') do
result = sock_get_multi(sock, pairs)
raise 'mismatch' unless result == pairs
end
x.compare!
end
end
if %w[all set_multi].include?(bench_target)
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report('write 100 keys simple') do
client.quiet do
pairs.each do |key, value|
client.set(key, value, 3600, raw: true)
end
end
end
x.report('write 100 keys meta client') do
meta_client.quiet do
pairs.each do |key, value|
meta_client.set(key, value, 3600, raw: true)
end
end
end
# TODO: uncomment this once we add PR adding set_multi
# x.report('multi client set_multi 100') do
# multi_client.set_multi(pairs, 3600, raw: true)
# end
x.report('write 100 keys rawsock') do
count = pairs.length
tail = ''
value_bytesize = payload_smaller.bytesize
ttl = 3600
pairs.each do |key, value|
count -= 1
tail = count.zero? ? '' : 'q'
sock.write(String.new("ms #{key} #{value_bytesize} c F0 T#{ttl} MS #{tail}\r\n",
capacity: key.size + value_bytesize + 40) << value << TERMINATOR)
end
sock.flush
sock.gets(TERMINATOR) # clear the buffer
end
# x.report('write_mutli 100 keys') { client.set_multi(pairs, 3600, raw: true) }
x.compare!
end
end
petergoldstein-dalli-8b467ad/bin/benchmark_branch 0000775 0000000 0000000 00000022516 15147323064 0022226 0 ustar 00root root 0000000 0000000 #!/usr/bin/env ruby
# frozen_string_literal: true
# Compare dalli performance between the current working tree and a base branch
# (default: main) within the SAME repo. Uses git stash to switch between versions.
#
# This script:
# 1. Stashes uncommitted changes
# 2. Benchmarks the base branch code (baseline)
# 3. Restores stashed changes
# 4. Benchmarks the current code (with changes)
# 5. Prints comparison
#
# Requirements:
# - memcached running (set BENCH_CACHE_URL if not on localhost:11211)
# - benchmark-ips gem installed (or in Gemfile)
# - Uncommitted or staged changes to compare against base branch
#
# Usage:
# bundle exec ruby bin/benchmark_branch
#
# # Only benchmark get_multi:
# BENCH_TARGET=get_multi bundle exec ruby bin/benchmark_branch
#
# # Compare against a different base branch:
# BENCH_BASE=develop bundle exec ruby bin/benchmark_branch
#
# Environment variables:
# BENCH_BASE - base branch to compare against (default: main)
# BENCH_TARGET - all, get, get_multi, set, set_multi (default: get_multi)
# BENCH_TIME - seconds per benchmark run (default: 10)
# BENCH_WARMUP - seconds warmup (default: 3)
# BENCH_PAYLOAD_SIZE - value size in bytes (default: 100)
# BENCH_MULTI_COUNTS - comma-separated multi key counts (default: 10,100,500)
# BENCH_CACHE_URL - memcached url (default: 127.0.0.1:11211)
# BENCH_SAVE_FILE - path for save! results file (default: /tmp/dalli_bench_branch)
require 'benchmark/ips'
require 'fileutils'
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
base_branch = ENV.fetch('BENCH_BASE', 'main')
bench_target = ENV.fetch('BENCH_TARGET', 'get_multi')
bench_time = Integer(ENV.fetch('BENCH_TIME', '10'))
bench_warmup = Integer(ENV.fetch('BENCH_WARMUP', '3'))
payload_size = Integer(ENV.fetch('BENCH_PAYLOAD_SIZE', '100'))
multi_counts = ENV.fetch('BENCH_MULTI_COUNTS', '10,100,500').split(',').map { |s| Integer(s) }
dalli_url = ENV.fetch('BENCH_CACHE_URL', '127.0.0.1:11211')
save_file = ENV.fetch('BENCH_SAVE_FILE', '/tmp/dalli_bench_branch')
# ---------------------------------------------------------------------------
# Helper: run benchmarks with the currently-loaded dalli code
# ---------------------------------------------------------------------------
def run_benchmarks(label:, client:, bench_target:, bench_time:, bench_warmup:,
suite:, payload_size:, multi_pairs:, multi_counts:, save_file:)
if %w[all get].include?(bench_target)
puts '-' * 70
puts "Single GET (raw, #{payload_size}B payload)"
puts '-' * 70
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report("#{label} get") { client.get('bench_key') }
x.save! save_file
x.compare!
end
puts
end
if %w[all get_multi].include?(bench_target)
multi_counts.each do |count|
puts '-' * 70
puts "get_multi (#{count} keys, #{payload_size}B values)"
puts '-' * 70
keys = multi_pairs[count].keys
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report("#{label} get_multi(#{count})") { client.get_multi(*keys) }
x.save! save_file
x.compare!
end
puts
end
end
if %w[all set].include?(bench_target)
puts '-' * 70
puts "Single SET (raw, #{payload_size}B payload)"
puts '-' * 70
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report("#{label} set") { client.set('bench_key', 'x' * payload_size) }
x.save! save_file
x.compare!
end
puts
end
return unless %w[all set_multi].include?(bench_target)
pairs = multi_pairs[100] || multi_pairs[multi_counts.first]
count = pairs.size
puts '-' * 70
puts "set_multi (#{count} keys, #{payload_size}B values)"
puts '-' * 70
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report("#{label} set_multi(#{count})") { client.set_multi(pairs, 3600) }
x.save! save_file
x.compare!
end
puts
end
# ---------------------------------------------------------------------------
# NoopSerializer - avoids Marshal overhead so we measure Dalli, not Marshal
# ---------------------------------------------------------------------------
class NoopSerializer
def self.dump(value)
value
end
def self.load(value)
value
end
end
# ---------------------------------------------------------------------------
# GCSuite - disable GC during benchmarks for consistent results
# ---------------------------------------------------------------------------
class GCSuite
def warming(*)
run_gc
end
def running(*)
run_gc
end
def warmup_stats(*)
GC.enable
end
def add_report(*)
GC.enable
end
private
def run_gc
GC.enable
GC.start
GC.disable
end
end
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
# Clean previous results
FileUtils.rm_f(save_file)
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
current_sha = `git rev-parse --short HEAD`.strip
has_changes = !system('git diff --quiet HEAD')
puts '=' * 70
puts 'Dalli Branch Benchmark'
puts '=' * 70
puts "ruby: #{RUBY_DESCRIPTION}"
yjit = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
puts "yjit: #{yjit}"
puts "target: #{bench_target}"
puts "payload size: #{payload_size} bytes"
puts "multi counts: #{multi_counts.join(', ')}"
puts "bench time: #{bench_time}s (warmup: #{bench_warmup}s)"
puts "memcached: #{dalli_url}"
puts "current branch: #{current_branch} (#{current_sha})"
puts "base branch: #{base_branch}"
puts "has changes: #{has_changes}"
puts '=' * 70
puts
suite = GCSuite.new
payload = 'x' * payload_size
# ---------------------------------------------------------------------------
# Phase 1: Benchmark the base branch (baseline)
# ---------------------------------------------------------------------------
puts '#' * 70
puts "PHASE 1: Benchmarking baseline (#{base_branch})"
puts '#' * 70
puts
if has_changes
puts '=> Stashing uncommitted changes...'
system('git stash --include-untracked -q') || abort('Failed to stash changes')
elsif current_branch != base_branch
puts "=> Checking out #{base_branch}..."
system("git checkout #{base_branch} -q") || abort("Failed to checkout #{base_branch}")
end
begin
# Reload dalli from the base branch code
# We need to fork a subprocess so we get a clean Ruby load
pid = fork do
# Re-require dalli fresh
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
load File.expand_path('../lib/dalli.rb', __dir__)
label = "baseline (#{base_branch})"
client_opts = { serializer: NoopSerializer, compress: false, raw: true }
client = Dalli::Client.new(dalli_url, **client_opts)
# Seed data
client.set('bench_key', payload)
multi_pairs = {}
multi_counts.each do |count|
pairs = {}
count.times { |i| pairs["bench_multi_#{count}_#{i}"] = payload }
multi_pairs[count] = pairs
pairs.each { |k, v| client.set(k, v) }
end
puts "Baseline client verified. Seeded #{multi_pairs.values.sum(&:size)} multi keys."
puts
run_benchmarks(
label: label, client: client, bench_target: bench_target,
bench_time: bench_time, bench_warmup: bench_warmup, suite: suite,
payload_size: payload_size, multi_pairs: multi_pairs,
multi_counts: multi_counts, save_file: save_file
)
client.close
end
_, status = Process.waitpid2(pid)
abort('Baseline benchmark failed!') unless status.success?
ensure
# Restore to feature branch
if has_changes
puts '=> Restoring stashed changes...'
system('git stash pop -q') || abort('Failed to restore stash')
elsif current_branch != base_branch
puts "=> Checking out #{current_branch}..."
system("git checkout #{current_branch} -q") || abort("Failed to checkout #{current_branch}")
end
end
puts
puts '#' * 70
puts "PHASE 2: Benchmarking current changes (#{current_branch})"
puts '#' * 70
puts
# Phase 2 runs in a fork too, for symmetry and clean loading
pid = fork do
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
load File.expand_path('../lib/dalli.rb', __dir__)
label = "current (#{current_branch})"
client_opts = { serializer: NoopSerializer, compress: false, raw: true }
client = Dalli::Client.new(dalli_url, **client_opts)
# Seed data
client.set('bench_key', payload)
multi_pairs = {}
multi_counts.each do |count|
pairs = {}
count.times { |i| pairs["bench_multi_#{count}_#{i}"] = payload }
multi_pairs[count] = pairs
pairs.each { |k, v| client.set(k, v) }
end
puts "Current client verified. Seeded #{multi_pairs.values.sum(&:size)} multi keys."
puts
run_benchmarks(
label: label, client: client, bench_target: bench_target,
bench_time: bench_time, bench_warmup: bench_warmup, suite: suite,
payload_size: payload_size, multi_pairs: multi_pairs,
multi_counts: multi_counts, save_file: save_file
)
client.close
end
_, status = Process.waitpid2(pid)
abort('Current benchmark failed!') unless status.success?
puts '=' * 70
puts 'Done! Comparison shown above after each benchmark.'
puts '=' * 70
petergoldstein-dalli-8b467ad/bin/compare_versions 0000775 0000000 0000000 00000021506 15147323064 0022333 0 ustar 00root root 0000000 0000000 #!/usr/bin/env ruby
# frozen_string_literal: true
# Cross-version Dalli benchmark for investigating performance regression (Issue #930)
#
# Installs a specific Dalli version from RubyGems via bundler/inline and runs
# identical workloads against a local memcached instance.
#
# Environment variables:
# DALLI_VERSION - Dalli gem version to benchmark (e.g., "2.7.11", "4.3.2", "5.0.0")
# DALLI_PROTOCOL - Protocol to use: "binary" or "meta" (ignored for 2.x and 5.x)
# BENCH_TIME - Benchmark time in seconds per test (default: 8)
# BENCH_WARMUP - Warmup time in seconds (default: 3)
# BENCH_PORT - Memcached port (default: auto-selected)
#
# Usage:
# DALLI_VERSION=2.7.11 ruby bin/compare_versions
# DALLI_VERSION=4.3.2 DALLI_PROTOCOL=binary ruby bin/compare_versions
# DALLI_VERSION=4.3.2 DALLI_PROTOCOL=meta ruby bin/compare_versions
# DALLI_VERSION=5.0.0 ruby bin/compare_versions
#
# Recommended: run all versions on the same Ruby (e.g., Ruby 3.3) for consistency:
# rbenv shell 3.3.4
# DALLI_VERSION=2.7.11 ruby bin/compare_versions
# DALLI_VERSION=4.3.2 DALLI_PROTOCOL=binary ruby bin/compare_versions
# DALLI_VERSION=4.3.2 DALLI_PROTOCOL=meta ruby bin/compare_versions
# DALLI_VERSION=5.0.0 ruby bin/compare_versions
dalli_version = ENV.fetch('DALLI_VERSION') do
abort 'DALLI_VERSION env var required (e.g., 2.7.11, 4.3.2, 5.0.0)'
end
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'benchmark-ips'
gem 'dalli', dalli_version
gem 'logger'
end
require 'benchmark/ips'
require 'dalli'
require 'socket'
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
dalli_protocol = ENV.fetch('DALLI_PROTOCOL', nil)
bench_time = (ENV['BENCH_TIME'] || 8).to_i
bench_warmup = (ENV['BENCH_WARMUP'] || 3).to_i
memcached_port = (ENV['BENCH_PORT'] || 0).to_i
# Determine effective protocol based on version
major_version = Gem::Version.new(dalli_version).segments.first
effective_protocol = case major_version
when 0..2
'binary'
when 5..99
'meta'
else
dalli_protocol || 'meta'
end
puts '=' * 70
puts 'Dalli Cross-Version Benchmark'
puts '=' * 70
puts " Dalli version: #{dalli_version}"
puts " Protocol: #{effective_protocol}"
puts " Ruby: #{RUBY_DESCRIPTION}"
yjit_status = defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enabled?) ? RubyVM::YJIT.enabled? : 'N/A'
puts " YJIT: #{yjit_status}"
puts " Bench time: #{bench_time}s"
puts " Warmup: #{bench_warmup}s"
# ---------------------------------------------------------------------------
# Start a dedicated memcached instance
# ---------------------------------------------------------------------------
def find_memcached
['', '/opt/homebrew/bin/', '/usr/local/bin/', '/usr/bin/'].each do |prefix|
path = "#{prefix}memcached"
version_output = `#{path} -h 2>&1`.lines.first.to_s.strip
return [path, Regexp.last_match(1)] if version_output =~ /^memcached (\d+\.\d+\.\d+)/
end
abort 'Could not find memcached binary'
end
def find_available_port
server = TCPServer.new('127.0.0.1', 0)
port = server.addr[1]
server.close
port
end
memcached_bin, memcached_version = find_memcached
memcached_port = find_available_port if memcached_port.zero?
puts " Memcached: #{memcached_version} on port #{memcached_port}"
puts '=' * 70
puts
# Start memcached
memcached_pid = spawn("#{memcached_bin} -p #{memcached_port} -m 64 -l 127.0.0.1",
out: '/dev/null', err: '/dev/null')
at_exit do
begin
Process.kill('TERM', memcached_pid)
rescue StandardError
nil
end
begin
Process.wait(memcached_pid)
rescue StandardError
nil
end
end
# Wait for memcached to be ready
20.times do
s = TCPSocket.new('127.0.0.1', memcached_port)
s.close
break
rescue Errno::ECONNREFUSED
sleep 0.1
end
# ---------------------------------------------------------------------------
# Build Dalli client with version-appropriate options
# ---------------------------------------------------------------------------
server_addr = "127.0.0.1:#{memcached_port}"
client_options = { compress: false }
# Dalli 3.x+ supports the protocol option; 2.x does not
client_options[:protocol] = :meta if major_version >= 3 && major_version < 5 && effective_protocol == 'meta'
# Dalli 2.x uses :raw differently and doesn't have a NoopSerializer-style option,
# but we can use raw: true for string values to skip Marshal
client = Dalli::Client.new(server_addr, **client_options)
puts 'Setting up test data...'
# ---------------------------------------------------------------------------
# Test data â realistic small payloads
# ---------------------------------------------------------------------------
small_value = 'x' * 100 # 100 bytes â typical cache value
medium_value = 'y' * 1_000 # 1 KB
marshal_value = { user_id: 42, name: 'test', email: 'test@example.com', roles: %w[admin user] }
# Pre-populate keys for get and get_multi tests
client.set('bench_raw', small_value, 3600, raw: true)
client.set('bench_medium', medium_value, 3600, raw: true)
client.set('bench_marshal', marshal_value, 3600)
# Pre-populate keys for get_multi (6 keys matching reported workload)
multi_keys = (1..6).map { |i| "multi_#{i}" }
multi_keys.each { |k| client.set(k, small_value, 3600, raw: true) }
# Pre-populate a counter for incr tests
client.set('bench_counter', '0', 3600, raw: true)
# Verify everything works
raise 'raw get failed' unless client.get('bench_raw', raw: true) == small_value
raise 'marshal get failed' unless client.get('bench_marshal') == marshal_value
raise 'get_multi failed' unless client.get_multi(*multi_keys).size == 6
puts "Setup complete. Running benchmarks...\n\n"
# ---------------------------------------------------------------------------
# GC Suite â benchmark without GC skewing results
# ---------------------------------------------------------------------------
# Disables GC during benchmark iterations to reduce noise
class GCSuite
def warming(*) = run_gc
def running(*) = run_gc
def warmup_stats(*) = GC.enable
def add_report(*) = GC.enable
private
def run_gc
GC.enable
GC.start
GC.disable
end
end
suite = GCSuite.new
# ---------------------------------------------------------------------------
# Benchmarks
# ---------------------------------------------------------------------------
results = {}
# --- Single key GET (raw string) ---
puts '>>> get (raw, 100B)'
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report("get_raw [#{dalli_version}]") do
client.get('bench_raw', raw: true)
end
x.report("get_medium_raw [#{dalli_version}]") do
client.get('bench_medium', raw: true)
end
results[:get_raw] = x
end
puts
# --- Single key GET (marshalled object) ---
puts '>>> get (marshalled)'
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report("get_marshal [#{dalli_version}]") do
client.get('bench_marshal')
end
results[:get_marshal] = x
end
puts
# --- Single key SET (raw string) ---
puts '>>> set (raw, 100B)'
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report("set_raw [#{dalli_version}]") do
client.set('bench_raw', small_value, 3600, raw: true)
end
results[:set_raw] = x
end
puts
# --- Single key SET (marshalled object) ---
puts '>>> set (marshalled)'
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report("set_marshal [#{dalli_version}]") do
client.set('bench_marshal', marshal_value, 3600)
end
results[:set_marshal] = x
end
puts
# --- get_multi (6 keys â matches reported workload) ---
puts '>>> get_multi (6 keys)'
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report("get_multi_6 [#{dalli_version}]") do
client.get_multi(*multi_keys)
end
results[:get_multi] = x
end
puts
# --- Mixed workload (interleaved set/get) ---
puts '>>> mixed (set + get interleaved)'
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report("mixed [#{dalli_version}]") do
client.set('bench_mixed', small_value, 3600, raw: true)
client.get('bench_mixed', raw: true)
client.set('bench_mixed2', medium_value, 3600, raw: true)
client.get('bench_mixed2', raw: true)
end
results[:mixed] = x
end
puts
# --- Increment ---
puts '>>> incr'
Benchmark.ips do |x|
x.config(warmup: bench_warmup, time: bench_time, suite: suite)
x.report("incr [#{dalli_version}]") do
client.incr('bench_counter', 1)
end
results[:incr] = x
end
puts
puts '=' * 70
puts "Benchmark complete for Dalli #{dalli_version} (#{effective_protocol})"
puts '=' * 70
petergoldstein-dalli-8b467ad/bin/console 0000775 0000000 0000000 00000000562 15147323064 0020416 0 ustar 00root root 0000000 0000000 #!/usr/bin/env ruby
# frozen_string_literal: true
require 'bundler/setup'
require 'dalli'
# You can add fixtures and/or initialization code here to make experimenting
# with your gem easier. You can also use a different console, if you like.
# (If you use this, don't forget to add pry to your Gemfile!)
# require "pry"
# Pry.start
require 'irb'
IRB.start(__FILE__)
petergoldstein-dalli-8b467ad/bin/profile 0000775 0000000 0000000 00000015424 15147323064 0020417 0 ustar 00root root 0000000 0000000 #!/usr/bin/env ruby
# frozen_string_literal: true
# This helps profile specific call paths in Dalli
# finding and fixing performance issues in these profiles should result in improvements in the dalli benchmarks
#
# run with:
# RUBY_YJIT_ENABLE=1 bundle exec bin/profile
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'benchmark-ips'
gem 'vernier'
gem 'logger'
end
require 'json'
require 'benchmark/ips'
require 'vernier'
require_relative '../lib/dalli'
##
# NoopSerializer is a serializer that avoids the overhead of Marshal or JSON.
##
class NoopSerializer
def self.dump(value)
value
end
def self.load(value)
value
end
end
dalli_url = ENV['BENCH_CACHE_URL'] || '127.0.0.1:11211'
bench_target = ENV['BENCH_TARGET'] || 'all'
bench_time = (ENV['BENCH_TIME'] || 8).to_i
bench_payload_size = (ENV['BENCH_PAYLOAD_SIZE'] || 700_000).to_i
TERMINATOR = "\r\n"
puts "yjit: #{RubyVM::YJIT.enabled?}"
client = Dalli::Client.new(dalli_url, serializer: NoopSerializer, compress: false)
meta_client = Dalli::Client.new(dalli_url, protocol: :meta, serializer: NoopSerializer, compress: false, raw: true)
# The raw socket implementation is used to benchmark the performance of dalli & the overhead of the various abstractions
# in the library.
sock = TCPSocket.new('127.0.0.1', '11211', connect_timeout: 1)
sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, true)
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
# Benchmarks didn't see any performance gains from increasing the SO_RCVBUF buffer size
# sock.setsockopt(Socket::SOL_SOCKET, ::Socket::SO_RCVBUF, 1024 * 1024 * 8)
# Benchmarks did see an improvement in performance when increasing the SO_SNDBUF buffer size
# sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 1024 * 1024 * 8)
payload = 'B' * bench_payload_size
dalli_key = 'dalli_key'
# ensure the clients are all connected and working
client.set(dalli_key, payload)
meta_client.set(dalli_key, payload)
sock.write("set sock_key 0 3600 #{payload.bytesize}\r\n")
sock.write(payload)
sock.write(TERMINATOR)
sock.flush
sock.readline # clear the buffer
# ensure we have basic data for the benchmarks and get calls
payload_smaller = 'B' * (bench_payload_size / 10)
pairs = {}
100.times do |i|
pairs["multi_#{i}"] = payload_smaller
end
client.quiet do
pairs.each do |key, value|
client.set(key, value, 3600, raw: true)
end
end
# rubocop:disable Metrics/PerceivedComplexity
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/CyclomaticComplexity
def sock_get_multi(sock, pairs)
count = pairs.length
pairs.each_key do |key|
count -= 1
tail = count.zero? ? '' : 'q'
sock.write("mg #{key} v f k #{tail}\r\n")
end
sock.flush
# read all the memcached responses back and build a hash of key value pairs
results = {}
last_result = false
while (line = sock.readline.chomp!(TERMINATOR)) != ''
last_result = true if line.start_with?('EN ')
next unless line.start_with?('VA ') || last_result
_, value_length, _flags, key = line.split
results[key[1..]] = sock.read(value_length.to_i)
sock.read(TERMINATOR.length)
break if results.size == pairs.size
break if last_result
end
results
end
# rubocop:enable Metrics/PerceivedComplexity
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/CyclomaticComplexity
def sock_set_multi(sock, pairs)
count = pairs.length
tail = ''
ttl = 3600
pairs.each do |key, value|
count -= 1
tail = count.zero? ? '' : 'q'
sock.write(String.new("ms #{key} #{value.bytesize} c F0 T#{ttl} MS #{tail}\r\n",
capacity: key.size + value.bytesize + 40))
sock.write(value)
sock.write(TERMINATOR)
end
sock.flush
sock.gets(TERMINATOR) # clear the buffer
end
if %w[all get].include?(bench_target)
Vernier.profile(out: 'client_get_profile.json') do
start_time = Time.now
while Time.now - start_time < bench_time
result = client.get(dalli_key)
raise 'mismatch' unless result == payload
end
end
Vernier.profile(out: 'meta_client_get_profile.json') do
start_time = Time.now
while Time.now - start_time < bench_time
result = meta_client.get(dalli_key)
raise 'mismatch' unless result == payload
end
end
Vernier.profile(out: 'socket_get_profile.json') do
start_time = Time.now
while Time.now - start_time < bench_time
sock.write("mg sock_key v\r\n")
sock.readline
result = sock.read(payload.bytesize)
sock.read(TERMINATOR.bytesize)
raise 'mismatch' unless result == payload
end
end
end
if %w[all set].include?(bench_target)
Vernier.profile(out: 'client_set_profile.json') do
start_time = Time.now
client.set(dalli_key, payload, 3600, raw: true) while Time.now - start_time < bench_time
end
Vernier.profile(out: 'meta_client_set_profile.json') do
start_time = Time.now
meta_client.set(dalli_key, payload, 3600, raw: true) while Time.now - start_time < bench_time
end
Vernier.profile(out: 'socket_set_profile.json') do
start_time = Time.now
while Time.now - start_time < bench_time
sock.write("ms sock_key #{payload.bytesize} T3600 MS\r\n")
sock.write(payload)
sock.write("\r\n")
sock.flush
sock.readline # clear the buffer
end
end
end
if %w[all get_multi].include?(bench_target)
Vernier.profile(out: 'client_get_multi_profile.json') do
start_time = Time.now
while Time.now - start_time < bench_time
result = client.get_multi(pairs.keys)
raise 'mismatch' unless result == pairs
end
end
Vernier.profile(out: 'meta_client_get_multi_profile.json') do
start_time = Time.now
while Time.now - start_time < bench_time
result = meta_client.get_multi(pairs.keys)
raise 'mismatch' unless result == pairs
end
end
Vernier.profile(out: 'socket_get_multi_profile.json') do
start_time = Time.now
while Time.now - start_time < bench_time
result = sock_get_multi(sock, pairs)
raise 'mismatch' unless result == pairs
end
end
end
if %w[all set_multi].include?(bench_target)
Vernier.profile(out: 'client_set_multi_profile.json') do
start_time = Time.now
# until we port over set_multi, compare the simple loop
# client.set_multi(pairs, 3600, raw: true) while Time.now - start_time < bench_time
while Time.now - start_time < bench_time
pairs.each do |key, value|
client.set(key, value, 3600, raw: true)
end
end
end
Vernier.profile(out: 'meta_client_set_multi_profile.json') do
start_time = Time.now
while Time.now - start_time < bench_time
pairs.each do |key, value|
meta_client.set(key, value, 3600, raw: true)
end
end
end
Vernier.profile(out: 'socket_set_multi_profile.json') do
start_time = Time.now
sock_set_multi(sock, pairs) while Time.now - start_time < bench_time
end
end
petergoldstein-dalli-8b467ad/bin/setup 0000775 0000000 0000000 00000000203 15147323064 0020104 0 ustar 00root root 0000000 0000000 #!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -vx
bundle install
# Do any other automated setup that you need to do here
petergoldstein-dalli-8b467ad/code_of_conduct.md 0000664 0000000 0000000 00000004542 15147323064 0021717 0 ustar 00root root 0000000 0000000 # Contributor Code of Conduct
As contributors and maintainers of this project, and in the interest of
fostering an open and welcoming community, we pledge to respect all people who
contribute through reporting issues, posting feature requests, updating
documentation, submitting pull requests or patches, and other activities.
We are committed to making participation in this project a harassment-free
experience for everyone, regardless of level of experience, gender, gender
identity and expression, sexual orientation, disability, personal appearance,
body size, race, ethnicity, age, religion, or nationality.
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery
* Personal attacks
* Trolling or insulting/derogatory comments
* Public or private harassment
* Publishing other's private information, such as physical or electronic
addresses, without explicit permission
* Other unethical or unprofessional conduct
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
By adopting this Code of Conduct, project maintainers commit themselves to
fairly and consistently applying these principles to every aspect of managing
this project. Project maintainers who do not follow or enforce the Code of
Conduct may be permanently removed from the project team.
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project maintainer at peter.m.goldstein AT gmail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. Maintainers are
obligated to maintain confidentiality with regard to the reporter of an
incident.
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 1.3.0, available at
[http://contributor-covenant.org/version/1/3/0/][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/3/0/
petergoldstein-dalli-8b467ad/dalli.gemspec 0000664 0000000 0000000 00000001453 15147323064 0020710 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
require './lib/dalli/version'
Gem::Specification.new do |s|
s.name = 'dalli'
s.version = Dalli::VERSION
s.license = 'MIT'
s.authors = ['Peter M. Goldstein', 'Mike Perham']
s.description = s.summary = 'High performance memcached client for Ruby'
s.email = ['peter.m.goldstein@gmail.com', 'mperham@gmail.com']
s.files = Dir.glob('lib/**/*') + [
'LICENSE',
'README.md',
'CHANGELOG.md',
'Gemfile'
]
s.homepage = 'https://github.com/petergoldstein/dalli'
s.required_ruby_version = '>= 3.3'
s.metadata = {
'bug_tracker_uri' => 'https://github.com/petergoldstein/dalli/issues',
'changelog_uri' => 'https://github.com/petergoldstein/dalli/blob/main/CHANGELOG.md',
'rubygems_mfa_required' => 'true'
}
s.add_dependency 'logger'
end
petergoldstein-dalli-8b467ad/lib/ 0000775 0000000 0000000 00000000000 15147323064 0017021 5 ustar 00root root 0000000 0000000 petergoldstein-dalli-8b467ad/lib/dalli.rb 0000664 0000000 0000000 00000005102 15147323064 0020431 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
##
# Namespace for all Dalli code.
##
module Dalli
# generic error
class DalliError < RuntimeError; end
# socket/server communication error
class NetworkError < DalliError; end
# no server available/alive error
class RingError < DalliError; end
# application error in marshalling serialization
class MarshalError < DalliError; end
# application error in marshalling deserialization or decompression
class UnmarshalError < DalliError; end
# payload too big for memcached
class ValueOverMaxSize < DalliError; end
# operation is not permitted in a multi block
class NotPermittedMultiOpError < DalliError; end
# raised when Memcached response with a SERVER_ERROR
class ServerError < DalliError; end
# socket/server communication error that can be retried
class RetryableNetworkError < NetworkError; end
# Implements the NullObject pattern to store an application-defined value for 'Key not found' responses.
class NilObject; end # rubocop:disable Lint/EmptyClass
NOT_FOUND = NilObject.new
QUIET = :dalli_multi
def self.logger
@logger ||= rails_logger || default_logger # rubocop:disable ThreadSafety/ClassInstanceVariable
end
def self.rails_logger
(defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) ||
(defined?(RAILS_DEFAULT_LOGGER) && RAILS_DEFAULT_LOGGER.respond_to?(:debug) && RAILS_DEFAULT_LOGGER)
end
def self.default_logger
require 'logger'
l = Logger.new($stdout)
l.level = Logger::INFO
l
end
def self.logger=(logger)
@logger = logger # rubocop:disable ThreadSafety/ClassInstanceVariable
end
end
require_relative 'dalli/version'
require_relative 'dalli/instrumentation'
require_relative 'dalli/compressor'
require_relative 'dalli/client'
require_relative 'dalli/key_manager'
require_relative 'dalli/pipelined_getter'
require_relative 'dalli/pipelined_setter'
require_relative 'dalli/pipelined_deleter'
require_relative 'dalli/ring'
require_relative 'dalli/protocol'
require_relative 'dalli/protocol/base'
require_relative 'dalli/protocol/connection_manager'
require_relative 'dalli/protocol/meta'
require_relative 'dalli/protocol/response_buffer'
require_relative 'dalli/protocol/server_config_parser'
require_relative 'dalli/protocol/ttl_sanitizer'
require_relative 'dalli/protocol/value_compressor'
require_relative 'dalli/protocol/value_marshaller'
require_relative 'dalli/protocol/string_marshaller'
require_relative 'dalli/protocol/value_serializer'
require_relative 'dalli/servers_arg_normalizer'
require_relative 'dalli/socket'
require_relative 'dalli/options'
petergoldstein-dalli-8b467ad/lib/dalli/ 0000775 0000000 0000000 00000000000 15147323064 0020106 5 ustar 00root root 0000000 0000000 petergoldstein-dalli-8b467ad/lib/dalli/cas/ 0000775 0000000 0000000 00000000000 15147323064 0020654 5 ustar 00root root 0000000 0000000 petergoldstein-dalli-8b467ad/lib/dalli/cas/client.rb 0000664 0000000 0000000 00000000221 15147323064 0022452 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
puts "You can remove `require 'dalli/cas/client'` as this code has been rolled into the standard 'dalli/client'."
petergoldstein-dalli-8b467ad/lib/dalli/client.rb 0000664 0000000 0000000 00000061460 15147323064 0021720 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
require 'digest/md5'
# encoding: ascii
module Dalli
##
# Dalli::Client is the main class which developers will use to interact with
# Memcached.
##
class Client
##
# Dalli::Client is the main class which developers will use to interact with
# the memcached server. Usage:
#
# Dalli::Client.new(['localhost:11211:10',
# 'cache-2.example.com:11211:5',
# '192.168.0.1:22122:5',
# '/var/run/memcached/socket'],
# failover: true, expires_in: 300)
#
# servers is an Array of "host:port:weight" where weight allows you to distribute cache unevenly.
# Both weight and port are optional. If you pass in nil, Dalli will use the MEMCACHE_SERVERS
# environment variable or default to 'localhost:11211' if it is not present. Dalli also supports
# the ability to connect to Memcached on localhost through a UNIX socket. To use this functionality,
# use a full pathname (beginning with a slash character '/') in place of the "host:port" pair in
# the server configuration.
#
# Options:
# - :namespace - prepend each key with this value to provide simple namespacing.
# - :failover - if a server is down, look for and store values on another server in the ring. Default: true.
# - :threadsafe - ensure that only one thread is actively using a socket at a time. Default: true.
# - :expires_in - default TTL in seconds if you do not pass TTL as a parameter to an individual operation, defaults
# to 0 or forever.
# - :compress - if true Dalli will compress values larger than compression_min_size bytes before sending them
# to memcached. Default: true.
# - :compression_min_size - the minimum size (in bytes) for which Dalli will compress values sent to Memcached.
# Defaults to 4K.
# - :serializer - defaults to Marshal
# - :compressor - defaults to Dalli::Compressor, a Zlib-based implementation
# - :cache_nils - defaults to false, if true Dalli will not treat cached nil values as 'not found' for
# #fetch operations.
# - :raw - If set, disables serialization and compression entirely at the client level.
# Only String values are supported. This is useful when the caller handles its own
# serialization (e.g., Rails' ActiveSupport::Cache). Note: this is different from
# the per-request :raw option which converts values to strings but still uses the
# serialization pipeline.
# - :digest_class - defaults to Digest::MD5, allows you to pass in an object that responds to the hexdigest method,
# useful for injecting a FIPS compliant hash object.
# - :otel_db_statement - controls the +db.query.text+ span attribute when OpenTelemetry is loaded.
# +:include+ logs the full operation and key(s), +:obfuscate+ replaces keys with "?",
# +nil+ (default) omits the attribute entirely.
# - :otel_peer_service - when set, adds a +peer.service+ span attribute with this value for logical service naming.
#
def initialize(servers = nil, options = {})
@normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@options = normalize_options(options)
warn_removed_options(@options)
@key_manager = ::Dalli::KeyManager.new(@options)
@ring = nil
end
#
# The standard memcached instruction set
#
##
# Get the value associated with the key.
# If a value is not found, then +nil+ is returned.
def get(key, req_options = nil)
perform(:get, key, req_options)
end
##
# Gat (get and touch) fetch an item and simultaneously update its expiration time.
#
# If a value is not found, then +nil+ is returned.
def gat(key, ttl = nil)
perform(:gat, key, ttl_or_default(ttl))
end
##
# Touch updates expiration time for a given key.
#
# Returns true if key exists, otherwise nil.
def touch(key, ttl = nil)
resp = perform(:touch, key, ttl_or_default(ttl))
resp.nil? ? nil : true
end
##
# Get the value and CAS ID associated with the key. If a block is provided,
# value and CAS will be passed to the block.
def get_cas(key)
(value, cas) = perform(:cas, key)
return [value, cas] unless block_given?
yield value, cas
end
##
# Get value with extended metadata.
#
# @param key [String] the cache key
# @param options [Hash] options controlling what metadata to return
# - :return_cas [Boolean] return the CAS value (default: true)
# - :return_hit_status [Boolean] return whether item was previously accessed
# - :return_last_access [Boolean] return seconds since last access
# - :skip_lru_bump [Boolean] don't bump LRU or update access stats
#
# @return [Hash] containing:
# - :value - the cached value (or nil on miss)
# - :cas - the CAS value
# - :hit_before - true/false if previously accessed (only if return_hit_status: true)
# - :last_access - seconds since last access (only if return_last_access: true)
#
# @example Get with hit status
# result = client.get_with_metadata('key', return_hit_status: true)
# # => { value: "data", cas: 123, hit_before: true }
#
# @example Get with all metadata without affecting LRU
# result = client.get_with_metadata('key',
# return_hit_status: true,
# return_last_access: true,
# skip_lru_bump: true
# )
# # => { value: "data", cas: 123, hit_before: true, last_access: 42 }
#
def get_with_metadata(key, options = {})
key = key.to_s
key = @key_manager.validate_key(key)
server = ring.server_for_key(key)
Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
server.request(:meta_get, key, options)
end
rescue NetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { 'retrying get_with_metadata with new server' }
retry
end
##
# Fetch multiple keys efficiently.
# If a block is given, yields key/value pairs one at a time.
# Otherwise returns a hash of { 'key' => 'value', 'key2' => 'value1' }
# rubocop:disable Style/ExplicitBlockArgument
def get_multi(*keys)
keys.flatten!
keys.compact!
return {} if keys.empty?
if block_given?
get_multi_yielding(keys) { |k, v| yield k, v }
else
get_multi_hash(keys)
end
end
# rubocop:enable Style/ExplicitBlockArgument
##
# Fetch multiple keys efficiently, including available metadata such as CAS.
# If a block is given, yields key/data pairs one a time. Data is an array:
# [value, cas_id]
# If no block is given, returns a hash of
# { 'key' => [value, cas_id] }
def get_multi_cas(*keys)
if block_given?
pipelined_getter.process(keys) { |*args| yield(*args) }
else
{}.tap do |hash|
pipelined_getter.process(keys) { |k, data| hash[k] = data }
end
end
end
# Fetch the value associated with the key.
# If a value is found, then it is returned.
#
# If a value is not found and no block is given, then nil is returned.
#
# If a value is not found (or if the found value is nil and :cache_nils is false)
# and a block is given, the block will be invoked and its return value
# written to the cache and returned.
def fetch(key, ttl = nil, req_options = nil)
req_options = req_options.nil? ? CACHE_NILS : req_options.merge(CACHE_NILS) if cache_nils
val = get(key, req_options)
return val unless block_given? && not_found?(val)
new_val = yield
add(key, new_val, ttl_or_default(ttl), req_options)
new_val
end
##
# Fetch the value with thundering herd protection using the meta protocol's
# N (vivify) and R (recache) flags.
#
# This method prevents multiple clients from simultaneously regenerating the same
# cache entry (the "thundering herd" problem). Only one client wins the right to
# regenerate; other clients receive the stale value (if available) or wait.
#
# @param key [String] the cache key
# @param ttl [Integer] time-to-live for the cached value in seconds
# @param lock_ttl [Integer] how long the lock/stub lives (default: 30 seconds)
# This is the maximum time other clients will return stale data while
# waiting for regeneration. Should be longer than your expected regeneration time.
# @param recache_threshold [Integer, nil] if set, win the recache race when the
# item's remaining TTL is below this threshold. Useful for proactive recaching.
# @param req_options [Hash] options passed to set operations (e.g., raw: true)
#
# @yield Block to regenerate the value (only called if this client won the race)
# @return [Object] the cached value (may be stale if another client is regenerating)
#
# @example Basic usage
# client.fetch_with_lock('expensive_key', ttl: 300, lock_ttl: 30) do
# expensive_database_query
# end
#
# @example With proactive recaching (recache before expiry)
# client.fetch_with_lock('key', ttl: 300, lock_ttl: 30, recache_threshold: 60) do
# expensive_operation
# end
#
def fetch_with_lock(key, ttl: nil, lock_ttl: 30, recache_threshold: nil, req_options: nil, &block)
raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
key = key.to_s
key = @key_manager.validate_key(key)
server = ring.server_for_key(key)
Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
end
rescue NetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
retry
end
##
# compare and swap values using optimistic locking.
# Fetch the existing value for key.
# If it exists, yield the value to the block.
# Add the block's return value as the new value for the key.
# Add will fail if someone else changed the value.
#
# Returns:
# - nil if the key did not exist.
# - false if the value was changed by someone else.
# - true if the value was successfully updated.
def cas(key, ttl = nil, req_options = nil, &)
cas_core(key, false, ttl, req_options, &)
end
##
# like #cas, but will yield to the block whether or not the value
# already exists.
#
# Returns:
# - false if the value was changed by someone else.
# - true if the value was successfully updated.
def cas!(key, ttl = nil, req_options = nil, &)
cas_core(key, true, ttl, req_options, &)
end
##
# Turn on quiet aka noreply support for a number of
# memcached operations.
#
# All relevant operations within this block will be effectively
# pipelined as Dalli will use 'quiet' versions. The invoked methods
# will all return nil, rather than their usual response. Method
# latency will be substantially lower, as the caller will not be
# blocking on responses.
#
# Currently supports storage (set, add, replace, append, prepend),
# arithmetic (incr, decr), flush and delete operations. Use of
# unsupported operations inside a block will raise an error.
#
# Any error replies will be discarded at the end of the block, and
# Dalli client methods invoked inside the block will not
# have return values
def quiet
old = Thread.current[::Dalli::QUIET]
Thread.current[::Dalli::QUIET] = true
yield
ensure
@ring&.pipeline_consume_and_ignore_responses
Thread.current[::Dalli::QUIET] = old
end
alias multi quiet
def set(key, value, ttl = nil, req_options = nil)
set_cas(key, value, 0, ttl, req_options)
end
##
# Set multiple keys and values efficiently using pipelining.
# This method is more efficient than calling set() in a loop because
# it batches requests by server and uses quiet mode.
#
# @param hash [Hash] key-value pairs to set
# @param ttl [Integer] time-to-live in seconds (optional, uses default if not provided)
# @param req_options [Hash] options passed to each set operation
# @return [void]
#
# Example:
# client.set_multi({ 'key1' => 'value1', 'key2' => 'value2' }, 300)
def set_multi(hash, ttl = nil, req_options = nil)
return if hash.empty?
Instrumentation.trace('set_multi', multi_trace_attrs('set_multi', hash.size, hash.keys)) do
if ring.servers.size == 1
single_server_set_multi(hash, ttl_or_default(ttl), req_options)
else
pipelined_setter.process(hash, ttl_or_default(ttl), req_options)
end
end
end
##
# Set the key-value pair, verifying existing CAS.
# Returns the resulting CAS value if succeeded, and falsy otherwise.
def set_cas(key, value, cas, ttl = nil, req_options = nil)
perform(:set, key, value, ttl_or_default(ttl), cas, req_options)
end
##
# Conditionally add a key/value pair, if the key does not already exist
# on the server. Returns truthy if the operation succeeded.
def add(key, value, ttl = nil, req_options = nil)
perform(:add, key, value, ttl_or_default(ttl), req_options)
end
##
# Conditionally add a key/value pair, only if the key already exists
# on the server. Returns truthy if the operation succeeded.
def replace(key, value, ttl = nil, req_options = nil)
replace_cas(key, value, 0, ttl, req_options)
end
##
# Conditionally add a key/value pair, verifying existing CAS, only if the
# key already exists on the server. Returns the new CAS value if the
# operation succeeded, or falsy otherwise.
def replace_cas(key, value, cas, ttl = nil, req_options = nil)
perform(:replace, key, value, ttl_or_default(ttl), cas, req_options)
end
# Delete a key/value pair, verifying existing CAS.
# Returns true if succeeded, and falsy otherwise.
def delete_cas(key, cas = 0)
perform(:delete, key, cas)
end
def delete(key)
delete_cas(key, 0)
end
##
# Delete multiple keys efficiently using pipelining.
# This method is more efficient than calling delete() in a loop because
# it batches requests by server and uses quiet mode.
#
# @param keys [Array] keys to delete
# @return [void]
#
# Example:
# client.delete_multi(['key1', 'key2', 'key3'])
def delete_multi(keys)
return if keys.empty?
Instrumentation.trace('delete_multi', multi_trace_attrs('delete_multi', keys.size, keys)) do
if ring.servers.size == 1
single_server_delete_multi(keys)
else
pipelined_deleter.process(keys)
end
end
end
##
# Append value to the value already stored on the server for 'key'.
# Appending only works for values stored with :raw => true.
def append(key, value)
perform(:append, key, value.to_s)
end
##
# Prepend value to the value already stored on the server for 'key'.
# Prepending only works for values stored with :raw => true.
def prepend(key, value)
perform(:prepend, key, value.to_s)
end
##
# Incr adds the given amount to the counter on the memcached server.
# Amt must be a positive integer value.
#
# If default is nil, the counter must already exist or the operation
# will fail and will return nil. Otherwise this method will return
# the new value for the counter.
#
# Note that the ttl will only apply if the counter does not already
# exist. To increase an existing counter and update its TTL, use
# #cas.
#
# If the value already exists, it must have been set with raw: true
def incr(key, amt = 1, ttl = nil, default = nil)
check_positive!(amt)
perform(:incr, key, amt.to_i, ttl_or_default(ttl), default)
end
##
# Decr subtracts the given amount from the counter on the memcached server.
# Amt must be a positive integer value.
#
# memcached counters are unsigned and cannot hold negative values. Calling
# decr on a counter which is 0 will just return 0.
#
# If default is nil, the counter must already exist or the operation
# will fail and will return nil. Otherwise this method will return
# the new value for the counter.
#
# Note that the ttl will only apply if the counter does not already
# exist. To decrease an existing counter and update its TTL, use
# #cas.
#
# If the value already exists, it must have been set with raw: true
def decr(key, amt = 1, ttl = nil, default = nil)
check_positive!(amt)
perform(:decr, key, amt.to_i, ttl_or_default(ttl), default)
end
##
# Flush the memcached server, at 'delay' seconds in the future.
# Delay defaults to zero seconds, which means an immediate flush.
##
def flush(delay = 0)
ring.servers.map { |s| s.request(:flush, delay) }
end
alias flush_all flush
ALLOWED_STAT_KEYS = %i[items slabs settings].freeze
##
# Collect the stats for each server.
# You can optionally pass a type including :items, :slabs or :settings to get specific stats
# Returns a hash like { 'hostname:port' => { 'stat1' => 'value1', ... }, 'hostname2:port' => { ... } }
def stats(type = nil)
type = nil unless ALLOWED_STAT_KEYS.include? type
values = {}
ring.servers.each do |server|
values[server.name.to_s] = server.alive? ? server.request(:stats, type.to_s) : nil
end
values
end
##
# Reset stats for each server.
def reset_stats
ring.servers.map do |server|
server.alive? ? server.request(:reset_stats) : nil
end
end
##
## Version of the memcache servers.
def version
values = {}
ring.servers.each do |server|
values[server.name.to_s] = server.alive? ? server.request(:version) : nil
end
values
end
##
## Make sure memcache servers are alive, or raise an Dalli::RingError
def alive!
ring.server_for_key('')
end
##
# Close our connection to each server.
# If you perform another operation after this, the connections will be re-established.
def close
@ring&.close
@ring = nil
end
alias reset close
CACHE_NILS = { cache_nils: true }.freeze
def not_found?(val)
cache_nils ? val == ::Dalli::NOT_FOUND : val.nil?
end
def cache_nils
@options[:cache_nils]
end
# Stub method so a bare Dalli client can pretend to be a connection pool.
def with
yield self
end
private
def record_hit_miss_metrics(span, key_count, hit_count)
return unless span
span.add_attributes('db.memcached.hit_count' => hit_count,
'db.memcached.miss_count' => key_count - hit_count)
end
def get_multi_yielding(keys)
Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
hit_count = 0
pipelined_getter.process(keys) do |k, data|
hit_count += 1
yield k, data.first
end
record_hit_miss_metrics(span, keys.size, hit_count)
nil
end
end
def get_multi_hash(keys)
Instrumentation.trace_with_result('get_multi', get_multi_attributes(keys)) do |span|
hash = if ring.servers.size == 1
single_server_get_multi(keys)
else
{}.tap do |h|
pipelined_getter.process(keys) { |k, data| h[k] = data.first }
end
end
record_hit_miss_metrics(span, keys.size, hash.size)
hash
end
end
def single_server
server = ring.servers.first
server if server&.alive?
end
def single_server_get_multi(keys)
keys.map! { |k| @key_manager.validate_key(k.to_s) }
return {} unless (server = single_server)
result = server.request(:read_multi_req, keys)
result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
result
rescue Dalli::NetworkError
{}
end
def single_server_set_multi(hash, ttl, req_options)
pairs = hash.transform_keys { |k| @key_manager.validate_key(k.to_s) }
return unless (server = single_server)
server.request(:write_multi_req, pairs, ttl, req_options)
rescue Dalli::NetworkError
nil
end
def single_server_delete_multi(keys)
validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
return unless (server = single_server)
server.request(:delete_multi_req, validated_keys)
rescue Dalli::NetworkError
nil
end
def get_multi_attributes(keys)
multi_trace_attrs('get_multi', keys.size, keys)
end
def trace_attrs(operation, key, server)
attrs = { 'db.operation.name' => operation, 'server.address' => server.hostname }
attrs['server.port'] = server.port if server.socket_type == :tcp
attrs['peer.service'] = @options[:otel_peer_service] if @options[:otel_peer_service]
add_query_text(attrs, operation, key)
end
def multi_trace_attrs(operation, key_count, keys)
attrs = { 'db.operation.name' => operation, 'db.memcached.key_count' => key_count }
attrs['peer.service'] = @options[:otel_peer_service] if @options[:otel_peer_service]
add_query_text(attrs, operation, keys)
end
def add_query_text(attrs, operation, key_or_keys)
case @options[:otel_db_statement]
when :include
attrs['db.query.text'] = "#{operation} #{Array(key_or_keys).join(' ')}"
when :obfuscate
attrs['db.query.text'] = "#{operation} ?"
end
attrs
end
def check_positive!(amt)
raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
end
def cas_core(key, always_set, ttl = nil, req_options = nil)
(value, cas) = perform(:cas, key)
return if value.nil? && !always_set
newvalue = yield(value)
perform(:set, key, newvalue, ttl_or_default(ttl), cas, req_options)
end
def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
server = ring.server_for_key(key)
result = server.request(:meta_get, key, { vivify_ttl: lock_ttl, recache_ttl: recache_threshold })
return result[:value] unless result[:won_recache]
new_val = yield
set(key, new_val, ttl_or_default(ttl), req_options)
new_val
end
##
# Uses the argument TTL or the client-wide default. Ensures
# that the value is an integer
##
def ttl_or_default(ttl)
(ttl || @options[:expires_in]).to_i
rescue NoMethodError
raise ArgumentError, "Cannot convert ttl (#{ttl}) to an integer"
end
def ring
@ring ||= Dalli::Ring.new(@normalized_servers, @options)
end
##
# Chokepoint method for memcached methods with a key argument.
# Validates the key, resolves the key to the appropriate server
# instance, and invokes the memcached method on the appropriate
# server.
#
# This method also forces retries on network errors - when
# a particular memcached instance becomes unreachable, or the
# operation times out.
##
def perform(*all_args)
return yield if block_given?
op, key, *args = all_args
key = key.to_s
key = @key_manager.validate_key(key)
server = ring.server_for_key(key)
Instrumentation.trace(op.to_s, trace_attrs(op.to_s, key, server)) do
server.request(op, key, *args)
end
rescue RetryableNetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { 'retrying request with new server' }
retry
end
def normalize_options(opts)
opts[:expires_in] = opts[:expires_in].to_i if opts[:expires_in]
opts
rescue NoMethodError
raise ArgumentError, "cannot convert :expires_in => #{opts[:expires_in].inspect} to an integer"
end
REMOVED_OPTIONS = {
protocol: 'Dalli 5.0 only supports the meta protocol. The :protocol option has been removed.',
username: 'Dalli 5.0 removed SASL authentication support. The :username option is ignored.',
password: 'Dalli 5.0 removed SASL authentication support. The :password option is ignored.'
}.freeze
private_constant :REMOVED_OPTIONS
def warn_removed_options(opts)
REMOVED_OPTIONS.each do |key, message|
next unless opts.key?(key)
Dalli.logger.warn(message)
end
end
def pipelined_getter
PipelinedGetter.new(ring, @key_manager)
end
def pipelined_setter
PipelinedSetter.new(ring, @key_manager)
end
def pipelined_deleter
PipelinedDeleter.new(ring, @key_manager)
end
end
end
petergoldstein-dalli-8b467ad/lib/dalli/compressor.rb 0000664 0000000 0000000 00000001371 15147323064 0022631 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
require 'zlib'
require 'stringio'
module Dalli
##
# Default compressor used by Dalli, that uses
# Zlib DEFLATE to compress data.
##
class Compressor
def self.compress(data)
Zlib::Deflate.deflate(data)
end
def self.decompress(data)
Zlib::Inflate.inflate(data)
end
end
##
# Alternate compressor for Dalli, that uses
# Gzip. Gzip adds a checksum to each compressed
# entry.
##
class GzipCompressor
def self.compress(data)
io = StringIO.new(+'', 'w')
gz = Zlib::GzipWriter.new(io)
gz.write(data)
gz.close
io.string
end
def self.decompress(data)
io = StringIO.new(data, 'rb')
Zlib::GzipReader.new(io).read
end
end
end
petergoldstein-dalli-8b467ad/lib/dalli/instrumentation.rb 0000664 0000000 0000000 00000013560 15147323064 0023703 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
module Dalli
##
# Instrumentation support for Dalli. Provides hooks for distributed tracing
# via OpenTelemetry when the SDK is available.
#
# When OpenTelemetry is loaded, Dalli automatically creates spans for cache operations.
# When OpenTelemetry is not available, all tracing methods are no-ops with zero overhead.
#
# Dalli 5.0 uses the stable OTel semantic conventions for database spans.
#
# == Span Attributes
#
# All spans include the following default attributes:
# - +db.system.name+ - Always "memcached"
#
# Single-key operations (+get+, +set+, +delete+, +incr+, +decr+, etc.) add:
# - +db.operation.name+ - The operation name (e.g., "get", "set")
# - +server.address+ - The server hostname (e.g., "localhost")
# - +server.port+ - The server port as an integer (e.g., 11211); omitted for Unix sockets
#
# Multi-key operations (+get_multi+) add:
# - +db.operation.name+ - "get_multi"
# - +db.memcached.key_count+ - Number of keys requested
# - +db.memcached.hit_count+ - Number of keys found in cache
# - +db.memcached.miss_count+ - Number of keys not found
#
# Bulk write operations (+set_multi+, +delete_multi+) add:
# - +db.operation.name+ - The operation name
# - +db.memcached.key_count+ - Number of keys in the operation
#
# == Optional Attributes
#
# - +db.query.text+ - The operation and key(s), controlled by the +:otel_db_statement+ client option:
# - +:include+ - Full text (e.g., "get mykey")
# - +:obfuscate+ - Obfuscated (e.g., "get ?")
# - +nil+ (default) - Attribute omitted
# - +peer.service+ - Logical service name, set via the +:otel_peer_service+ client option
#
# == Error Handling
#
# When an exception occurs during a traced operation:
# - The exception is recorded on the span via +record_exception+
# - The span status is set to error with the exception message
# - The exception is re-raised to the caller
#
# @example Checking if tracing is enabled
# Dalli::Instrumentation.enabled? # => true if OpenTelemetry is loaded
#
##
module Instrumentation
# Default attributes included on all memcached spans.
# @return [Hash] frozen hash with 'db.system.name' => 'memcached'
DEFAULT_ATTRIBUTES = { 'db.system.name' => 'memcached' }.freeze
class << self
# Returns the OpenTelemetry tracer if available, nil otherwise.
#
# The tracer is cached after first lookup for performance.
# Uses the library name 'dalli' and current Dalli::VERSION.
#
# @return [OpenTelemetry::Trace::Tracer, nil] the tracer or nil if OTel unavailable
# rubocop:disable ThreadSafety/ClassInstanceVariable
def tracer
return @tracer if defined?(@tracer)
@tracer = (OpenTelemetry.tracer_provider.tracer('dalli', Dalli::VERSION) if defined?(OpenTelemetry))
end
# rubocop:enable ThreadSafety/ClassInstanceVariable
# Returns true if instrumentation is enabled (OpenTelemetry SDK is available).
#
# @return [Boolean] true if tracing is active, false otherwise
def enabled?
!tracer.nil?
end
# Wraps a block with a span if instrumentation is enabled.
#
# Creates a client span with the given name and attributes merged with
# DEFAULT_ATTRIBUTES. The block is executed within the span context.
# If an exception occurs, it is recorded on the span before re-raising.
#
# When tracing is disabled (OpenTelemetry not loaded), this method
# simply yields directly with zero overhead.
#
# @param name [String] the span name (e.g., 'get', 'set', 'delete')
# @param attributes [Hash] span attributes to merge with defaults.
# Common attributes include:
# - 'db.operation.name' - the operation name
# - 'server.address' - the server hostname
# - 'server.port' - the server port (integer)
# - 'db.memcached.key_count' - number of keys (for multi operations)
# @yield the cache operation to trace
# @return [Object] the result of the block
# @raise [StandardError] re-raises any exception from the block
#
# @example Tracing a set operation
# trace('set', { 'db.operation.name' => 'set', 'server.address' => 'localhost', 'server.port' => 11211 }) do
# server.set(key, value, ttl)
# end
#
def trace(name, attributes = {})
return yield unless enabled?
tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client) do |_span|
yield
end
end
# Like trace, but yields the span to allow adding attributes after execution.
#
# This is useful for operations where metrics are only known after the
# operation completes, such as get_multi where hit/miss counts depend
# on the cache response.
#
# When tracing is disabled, yields nil as the span argument.
#
# @param name [String] the span name (e.g., 'get_multi')
# @param attributes [Hash] initial span attributes to merge with defaults
# @yield [OpenTelemetry::Trace::Span, nil] the span object, or nil if disabled
# @return [Object] the result of the block
# @raise [StandardError] re-raises any exception from the block
#
# @example Recording hit/miss metrics after get_multi
# trace_with_result('get_multi', { 'db.operation.name' => 'get_multi' }) do |span|
# results = fetch_from_cache(keys)
# if span
# span.set_attribute('db.memcached.hit_count', results.size)
# span.set_attribute('db.memcached.miss_count', keys.size - results.size)
# end
# results
# end
#
def trace_with_result(name, attributes = {}, &)
return yield(nil) unless enabled?
tracer.in_span(name, attributes: DEFAULT_ATTRIBUTES.merge(attributes), kind: :client, &)
end
end
end
end
petergoldstein-dalli-8b467ad/lib/dalli/key_manager.rb 0000664 0000000 0000000 00000010326 15147323064 0022717 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
require 'digest/md5'
module Dalli
##
# This class manages and validates keys sent to Memcached, ensuring
# that they meet Memcached key length requirements, and supporting
# the implementation of optional namespaces on a per-Dalli client
# basis.
##
class KeyManager
MAX_KEY_LENGTH = 250
DEFAULT_NAMESPACE_SEPARATOR = ':'
# This is a hard coded md5 for historical reasons
TRUNCATED_KEY_SEPARATOR = ':md5:'
# This is 249 for historical reasons
TRUNCATED_KEY_TARGET_SIZE = 249
DEFAULTS = {
digest_class: ::Digest::MD5,
namespace_separator: DEFAULT_NAMESPACE_SEPARATOR
}.freeze
OPTIONS = %i[digest_class namespace namespace_separator].freeze
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 \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
##
# Validates the key, and transforms as needed.
#
# If the key is nil or empty, raises ArgumentError. Whitespace
# characters are allowed for historical reasons, but likely shouldn't
# be used.
# If the key (with namespace) is shorter than the memcached maximum
# allowed key length, just returns the argument key
# Otherwise computes a "truncated" key that uses a truncated prefix
# combined with a 32-byte hex digest of the whole key.
##
def validate_key(key)
raise ArgumentError, 'key cannot be blank' unless key&.length&.positive?
key = key_with_namespace(key)
key.length > MAX_KEY_LENGTH ? truncated_key(key) : key
end
##
# Returns the key with the namespace prefixed, if a namespace is
# defined. Otherwise just returns the key
##
def key_with_namespace(key)
return key if namespace.nil?
"#{evaluate_namespace}#{namespace_separator}#{key}"
end
def key_without_namespace(key)
return key if namespace.nil?
key.sub(namespace_regexp, '')
end
def digest_class
@digest_class ||= @key_options[:digest_class]
end
def namespace_regexp
return /\A#{Regexp.escape(evaluate_namespace)}#{Regexp.escape(namespace_separator)}/ if namespace.is_a?(Proc)
@namespace_regexp ||= /\A#{Regexp.escape(namespace)}#{Regexp.escape(namespace_separator)}/ unless namespace.nil?
end
def validate_digest_class_option(opts)
return if opts[:digest_class].respond_to?(:hexdigest)
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
return raw_namespace.to_s unless raw_namespace.is_a?(Proc)
raw_namespace
end
def evaluate_namespace
return namespace.call.to_s if namespace.is_a?(Proc)
namespace
end
##
# Produces a truncated key, if the raw key is longer than the maximum allowed
# length. The truncated key is produced by generating a hex digest
# of the key, and appending that to a truncated section of the key.
##
def truncated_key(key)
digest = digest_class.hexdigest(key)
"#{key[0, prefix_length(digest)]}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
end
def prefix_length(digest)
return TRUNCATED_KEY_TARGET_SIZE - (TRUNCATED_KEY_SEPARATOR.length + digest.length) if namespace.nil?
# For historical reasons, truncated keys with namespaces had a length of 250 rather
# than 249
TRUNCATED_KEY_TARGET_SIZE + 1 - (TRUNCATED_KEY_SEPARATOR.length + digest.length)
end
end
end
petergoldstein-dalli-8b467ad/lib/dalli/options.rb 0000664 0000000 0000000 00000001667 15147323064 0022140 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
require 'monitor'
module Dalli
# Make Dalli threadsafe by using a lock around all
# public server methods.
#
# Dalli::Protocol::Meta.extend(Dalli::Threadsafe)
#
module Threadsafe
def self.extended(obj)
obj.init_threadsafe
end
def request(opcode, *args)
@lock.synchronize do
super
end
end
def alive?
@lock.synchronize do
super
end
end
def close
@lock.synchronize do
super
end
end
def pipeline_response_setup
@lock.synchronize do
super
end
end
def pipeline_next_responses
@lock.synchronize do
super
end
end
def pipeline_abort
@lock.synchronize do
super
end
end
def lock!
@lock.mon_enter
end
def unlock!
@lock.mon_exit
end
def init_threadsafe
@lock = Monitor.new
end
end
end
petergoldstein-dalli-8b467ad/lib/dalli/pid_cache.rb 0000664 0000000 0000000 00000001717 15147323064 0022340 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
module Dalli
##
# Dalli::PIDCache is a wrapper class for PID checking to avoid system calls when checking the PID.
##
module PIDCache
if !Process.respond_to?(:fork) # JRuby or TruffleRuby
@pid = Process.pid
singleton_class.attr_reader(:pid)
elsif Process.respond_to?(:_fork) # Ruby 3.1+
class << self
attr_reader :pid
def update!
@pid = Process.pid # rubocop:disable ThreadSafety/ClassInstanceVariable
end
end
update!
##
# Dalli::PIDCache::CoreExt hooks into Process to be able to reset the PID cache after fork
##
module CoreExt
def _fork
child_pid = super
PIDCache.update! if child_pid.zero?
child_pid
end
end
Process.singleton_class.prepend(CoreExt)
else # Ruby 3.0 or older
class << self
def pid
Process.pid
end
end
end
end
end
petergoldstein-dalli-8b467ad/lib/dalli/pipelined_deleter.rb 0000664 0000000 0000000 00000004352 15147323064 0024114 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
module Dalli
##
# Contains logic for the pipelined delete operations implemented by the client.
# Efficiently deletes multiple keys by grouping requests by server
# and using quiet mode to minimize round trips.
##
class PipelinedDeleter
def initialize(ring, key_manager)
@ring = ring
@key_manager = key_manager
end
##
# Deletes multiple keys from memcached.
#
# @param keys [Array] keys to delete
# @return [void]
##
def process(keys)
return if keys.empty?
@ring.lock do
servers = setup_requests(keys)
finish_requests(servers)
end
rescue NetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { 'retrying pipelined deletes because of network error' }
retry
end
private
def setup_requests(keys)
groups = groups_for_keys(keys)
make_delete_requests(groups)
groups.keys
end
##
# Loop through the server-grouped sets of keys, writing
# the corresponding quiet delete requests to the appropriate servers
##
def make_delete_requests(groups)
groups.each do |server, keys_for_server|
keys_for_server.each do |key|
server.request(:pipelined_delete, key)
rescue DalliError, NetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { "unable to delete key #{key} for server #{server.name}" }
end
end
end
##
# Sends noop to each server to flush responses and ensure all deletes complete.
##
def finish_requests(servers)
servers.each do |server|
server.request(:noop)
rescue DalliError, NetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { "unable to complete pipelined delete on server #{server.name}" }
end
end
def groups_for_keys(keys)
validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
groups = @ring.keys_grouped_by_server(validated_keys)
if (unfound_keys = groups.delete(nil))
Dalli.logger.debug do
"unable to delete #{unfound_keys.length} keys because no matching server was found"
end
end
groups
end
end
end
petergoldstein-dalli-8b467ad/lib/dalli/pipelined_getter.rb 0000664 0000000 0000000 00000014146 15147323064 0023764 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
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
end
##
# Yields, one at a time, keys and their values+attributes.
#
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 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
# 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 Dalli::RetryableNetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { 'retrying pipelined gets because of timeout' }
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)
# TODO: How does this exit on a NetworkError
finish_queries(groups.keys)
end
##
# Loop through the server-grouped sets of keys, writing
# the corresponding getkq requests to the appropriate servers
#
# It's worth noting that we could potentially reduce bytes
# on the wire by switching from getkq to getq, and using
# the opaque value to match requests to responses.
##
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 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}" }
end
end
##
# This loops through the servers that have keys in
# our set, sending the noop to terminate the set of queries.
##
def finish_queries(servers)
deleted = Set.new
servers.each do |server|
next unless server.connected?
begin
finish_query_for_server(server)
rescue Dalli::NetworkError
raise
rescue Dalli::DalliError
deleted << server
end
end
servers.delete_if { |server| deleted.include?(server) }
rescue Dalli::NetworkError
abort_without_timeout(servers)
raise
end
def finish_query_for_server(server)
server.pipeline_response_setup
rescue Dalli::NetworkError
raise
rescue Dalli::DalliError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { "Results from server: #{server.name} will be missing from the results" }
raise
end
# Swallows Dalli::NetworkError
def abort_without_timeout(servers)
servers.each(&:pipeline_abort)
end
def fetch_responses(servers, start_time, timeout, &block)
# Remove any servers which are not connected
servers.select!(&:connected?)
return [] if servers.empty?
time_left = remaining_time(start_time, timeout)
readable_servers = servers_with_response(servers, time_left)
if readable_servers.empty?
abort_with_timeout(servers)
return []
end
# Loop through the servers with responses, and
# delete any from our list that are finished
readable_servers.each do |server|
servers.delete(server) if process_server(server, &block)
end
servers
rescue NetworkError
# Abort and raise if we encountered a network error. This triggers
# a retry at the top level on RetryableNetworkError.
abort_without_timeout(servers)
raise
end
def remaining_time(start, timeout)
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
return 0 if elapsed > timeout
timeout - elapsed
end
# Swallows Dalli::NetworkError
def abort_with_timeout(servers)
abort_without_timeout(servers)
servers.each do |server|
Dalli.logger.debug { "memcached at #{server.name} did not response within timeout" }
end
true # Required to simplify caller
end
# Processes responses from a server. Returns true if there are no
# additional responses from this server.
def process_server(server)
server.pipeline_next_responses do |key, value, cas|
yield @key_manager.key_without_namespace(key), [value, cas]
end
server.pipeline_complete?
end
def servers_with_response(servers, timeout)
return [] if servers.empty?
sockets = servers.map(&:sock)
readable, = IO.select(sockets, nil, nil, timeout)
return [] if readable.nil?
# For typical server counts (1-5), linear scan is faster than
# building and looking up a hash map
readable.filter_map { |sock| servers.find { |s| s.sock == sock } }
end
def groups_for_keys(*keys)
keys.flatten!
keys.map! { |a| @key_manager.validate_key(a.to_s) }
groups = @ring.keys_grouped_by_server(keys)
if (unfound_keys = groups.delete(nil))
Dalli.logger.debug do
"unable to get keys for #{unfound_keys.length} keys " \
'because no matching server was found'
end
end
groups
end
end
end
petergoldstein-dalli-8b467ad/lib/dalli/pipelined_setter.rb 0000664 0000000 0000000 00000005204 15147323064 0023773 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
module Dalli
##
# Contains logic for the pipelined set operations implemented by the client.
# Efficiently writes multiple key-value pairs by grouping requests by server
# and using quiet mode to minimize round trips.
##
class PipelinedSetter
def initialize(ring, key_manager)
@ring = ring
@key_manager = key_manager
end
##
# Writes multiple key-value pairs to memcached.
# Raises an error if any server is unavailable.
#
# @param hash [Hash] key-value pairs to set
# @param ttl [Integer] time-to-live in seconds
# @param req_options [Hash] options passed to each set operation
# @return [void]
##
def process(hash, ttl, req_options)
return if hash.empty?
@ring.lock do
servers = setup_requests(hash, ttl, req_options)
finish_requests(servers)
end
rescue Dalli::RetryableNetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { 'retrying pipelined sets because of network error' }
retry
end
private
def setup_requests(hash, ttl, req_options)
groups = groups_for_keys(hash.keys)
make_set_requests(groups, hash, ttl, req_options)
groups.keys
end
##
# Loop through the server-grouped sets of keys, writing
# the corresponding quiet set requests to the appropriate servers
##
def make_set_requests(groups, hash, ttl, req_options)
groups.each do |server, keys_for_server|
keys_for_server.each do |key|
original_key = @key_manager.key_without_namespace(key)
value = hash[original_key]
server.request(:pipelined_set, key, value, ttl, req_options)
rescue DalliError, NetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { "unable to set key #{key} for server #{server.name}" }
end
end
end
##
# Sends noop to each server to flush responses and ensure all writes complete.
##
def finish_requests(servers)
servers.each do |server|
server.request(:noop)
rescue DalliError, NetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { "unable to complete pipelined set on server #{server.name}" }
end
end
def groups_for_keys(keys)
validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
groups = @ring.keys_grouped_by_server(validated_keys)
if (unfound_keys = groups.delete(nil))
Dalli.logger.debug do
"unable to set #{unfound_keys.length} keys because no matching server was found"
end
end
groups
end
end
end
petergoldstein-dalli-8b467ad/lib/dalli/protocol.rb 0000664 0000000 0000000 00000001422 15147323064 0022273 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
require 'timeout'
module Dalli
module Protocol
# Preserved for backwards compatibility. Should be removed in 4.0
NOT_FOUND = ::Dalli::NOT_FOUND
# Ruby 3.2 raises IO::TimeoutError on blocking reads/writes, but
# it is not defined in earlier Ruby versions.
TIMEOUT_ERRORS =
if defined?(IO::TimeoutError)
[Timeout::Error, IO::TimeoutError]
else
[Timeout::Error]
end
# SSL errors that occur during read/write operations (not during initial
# handshake) should trigger reconnection. These indicate transient network
# issues, not configuration problems.
SSL_ERRORS =
if defined?(OpenSSL::SSL::SSLError)
[OpenSSL::SSL::SSLError]
else
[]
end
end
end
petergoldstein-dalli-8b467ad/lib/dalli/protocol/ 0000775 0000000 0000000 00000000000 15147323064 0021747 5 ustar 00root root 0000000 0000000 petergoldstein-dalli-8b467ad/lib/dalli/protocol/base.rb 0000664 0000000 0000000 00000026305 15147323064 0023214 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
require 'forwardable'
require 'socket'
require 'timeout'
module Dalli
module Protocol
##
# Base class for a single Memcached server, containing logic common to all
# protocols. Contains logic for managing connection state to the server and value
# handling.
##
class Base
extend Forwardable
attr_accessor :weight, :options
def_delegators :@value_marshaller, :serializer, :compressor, :compression_min_size, :compress_by_default?
def_delegators :@connection_manager, :name, :sock, :hostname, :port, :close, :connected?, :socket_timeout,
:socket_type, :up!, :down!, :write, :reconnect_down_server?, :raise_down_error
def initialize(attribs, client_options = {})
hostname, port, socket_type, @weight, user_creds = ServerConfigParser.parse(attribs)
warn_uri_credentials(user_creds)
@options = client_options.merge(user_creds)
@raw_mode = client_options[:raw]
@value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
@connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
end
# Returns true if client is in raw mode (no serialization/compression).
# In raw mode, we can skip requesting bitflags from the server.
def raw_mode?
@raw_mode
end
# Chokepoint method for error handling and ensuring liveness
def request(opkey, *args)
verify_state(opkey)
begin
@connection_manager.start_request!
response = send(opkey, *args)
# 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
log_marshal_err(args.first, e)
raise
rescue Dalli::DalliError
raise
rescue StandardError => e
log_unexpected_err(e)
close
raise
end
end
##
# Boolean method used by clients of this class to determine if this
# particular memcached instance is available for use.
def alive?
ensure_connected!
rescue Dalli::NetworkError
# ensure_connected! raises a NetworkError if connection fails. We
# want to capture that error and convert it to a boolean value here.
false
end
def lock!; end
def unlock!; end
# Start reading key/value pairs from this connection. This is usually called
# after a series of GETKQ commands. A NOOP is sent, and the server begins
# flushing responses for kv pairs that were found.
#
# Returns nothing.
def pipeline_response_setup
verify_pipelined_state(:getkq)
write_noop
# 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
# from this server. After #pipeline_response_setup, this should be invoked
# repeatedly whenever this server's socket is readable until
# #pipeline_complete?.
#
# When a block is given, yields (key, value, cas) for each response,
# avoiding intermediate Hash allocation. Returns nil.
# Without a block, returns a Hash of { key => [value, cas] }.
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def pipeline_next_responses(&block)
reconnect_on_pipeline_complete!
values = nil
response_buffer.read
status, cas, key, value = response_buffer.process_single_getk_response
# status is not nil only if we have a full response to parse
# in the buffer
until status.nil?
# If the status is ok and key is nil, then this is the response
# to the noop at the end of the pipeline
finish_pipeline && break if status && key.nil?
# If the status is ok and the key is not nil, then this is a
# getkq response with a value that we want to set in the response hash
unless key.nil?
if block
yield key, value, cas
else
values ||= {}
values[key] = [value, cas]
end
end
# Get the next response from the buffer
status, cas, key, value = response_buffer.process_single_getk_response
end
values || {}
rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
@connection_manager.error_on_request!(e)
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
# Abort current pipelined get. Generally used to signal an external
# timeout during pipelined get. The underlying socket is
# disconnected, and the exception is swallowed.
#
# Returns nothing.
def pipeline_abort
response_buffer.clear
@connection_manager.abort_request!
return true unless connected?
# Closes the connection, which ensures that our connection
# is in a clean state for future requests
@connection_manager.error_on_request!('External timeout')
rescue NetworkError
true
end
# Did the last call to #pipeline_response_setup complete successfully?
def pipeline_complete?
!response_buffer.in_progress?
end
def quiet?
Thread.current[::Dalli::QUIET]
end
alias multi? quiet?
# NOTE: Additional public methods should be overridden in Dalli::Threadsafe
private
URI_CREDENTIAL_WARNING = 'Dalli 5.0 removed SASL authentication. ' \
'Credentials in memcached:// URIs are ignored.'
private_constant :URI_CREDENTIAL_WARNING
def warn_uri_credentials(user_creds)
return if user_creds[:username].nil? && user_creds[:password].nil?
Dalli.logger.warn(URI_CREDENTIAL_WARNING)
end
ALLOWED_QUIET_OPS = %i[add replace set delete incr decr append prepend flush noop].freeze
private_constant :ALLOWED_QUIET_OPS
def verify_allowed_quiet!(opkey)
return if ALLOWED_QUIET_OPS.include?(opkey)
raise Dalli::NotPermittedMultiOpError, "The operation #{opkey} is not allowed in a quiet block."
end
##
# Checks to see if we can execute the specified operation. Checks
# whether the connection is in use, and whether the command is allowed
##
def verify_state(opkey)
@connection_manager.confirm_ready!
verify_allowed_quiet!(opkey) if quiet?
# The ensure_connected call has the side effect of connecting the
# underlying socket if it is not connected, or there's been a disconnect
# because of timeout or other error. Method raises an error
# if it can't connect
raise_down_error unless ensure_connected!
end
def verify_pipelined_state(_opkey)
@connection_manager.confirm_in_progress!
raise_down_error unless connected?
end
# The socket connection to the underlying server is initialized as a side
# effect of this call. In fact, this is the ONLY place where that
# socket connection is initialized.
#
# Both this method and connect need to be in this class so we can do auth
# as required
#
# Since this is invoked exclusively in verify_state!, we don't need to worry about
# thread safety. Using it elsewhere may require revisiting that assumption.
def ensure_connected!
return true if connected?
return false unless reconnect_down_server?
connect # This call needs to be in this class so we can do auth
connected?
end
def cache_nils?(opts)
return false unless opts.is_a?(Hash)
opts[:cache_nils] ? true : false
end
def connect
@connection_manager.establish_connection
@version = version
up!
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)
end
# Could send noop here instead of in pipeline_response_setup
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
# Called after the noop response is received at the end of a set
# of pipelined gets
def finish_pipeline
response_buffer.clear
@connection_manager.finish_request!
true # to simplify response
end
def reconnect_on_pipeline_complete!
@connection_manager.reconnect! 'pipelined get has completed' if pipeline_complete?
end
def log_marshal_err(key, err)
Dalli.logger.error "Marshalling error for key '#{key}': #{err.message}"
Dalli.logger.error 'You are trying to cache a Ruby object which cannot be serialized to memcached.'
end
def log_unexpected_err(err)
Dalli.logger.error "Unexpected exception during Dalli request: #{err.class.name}: #{err.message}"
Dalli.logger.error err.backtrace.join("\n\t")
end
end
end
end
petergoldstein-dalli-8b467ad/lib/dalli/protocol/connection_manager.rb 0000664 0000000 0000000 00000016160 15147323064 0026131 0 ustar 00root root 0000000 0000000 # frozen_string_literal: true
require 'English'
require 'socket'
require 'timeout'
require 'dalli/pid_cache'
module Dalli
module Protocol
##
# Manages the socket connection to the server, including ensuring liveness
# and retries.
##
class ConnectionManager
DEFAULTS = {
# seconds between trying to contact a remote server
down_retry_delay: 30,
# connect/read/write timeout for socket operations
socket_timeout: 1,
# times a socket operation may fail before considering the server dead
socket_max_failures: 2,
# amount of time to sleep between retries when a failure occurs
socket_failure_delay: 0.1,
# Set keepalive
keepalive: true
}.freeze
attr_accessor :hostname, :port, :socket_type, :options
attr_reader :sock
def initialize(hostname, port, socket_type, client_options)
@hostname = hostname
@port = port
@socket_type = socket_type
@options = DEFAULTS.merge(client_options)
@request_in_progress = false
@sock = nil
@pid = nil
reset_down_info
end
def name
if socket_type == :unix
hostname
else
"#{hostname}:#{port}"
end
end
def establish_connection
Dalli.logger.debug { "Dalli::Server#connect #{name}" }
@sock = memcached_socket
@sock.sync = false # Enable buffered I/O for better performance
@pid = PIDCache.pid
@request_in_progress = false
rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
# SocketError = DNS resolution failure
error_on_request!(e)
end
def reconnect_down_server?
return true unless @last_down_at
time_to_next_reconnect = @last_down_at + options[:down_retry_delay] - Time.now
return true unless time_to_next_reconnect.positive?
Dalli.logger.debug do
format('down_retry_delay not reached for %s (%