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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ jobs:

steps:
- uses: actions/checkout@v6
- name: Install Memcached 1.6.23
- name: Install Memcached 1.6.41
working-directory: scripts
env:
MEMCACHED_VERSION: 1.6.23
MEMCACHED_VERSION: 1.6.41
run: |
chmod +x ./install_memcached.sh
./install_memcached.sh
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/profile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ jobs:

steps:
- uses: actions/checkout@v6
- name: Install Memcached 1.6.23
- name: Install Memcached 1.6.41
working-directory: scripts
env:
MEMCACHED_VERSION: 1.6.23
MEMCACHED_VERSION: 1.6.41
run: |
chmod +x ./install_memcached.sh
./install_memcached.sh
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
- '3.4'
- '3.3'
- '3.2'
memcached-version: ['1.6.23']
memcached-version: ['1.6.41']

steps:
- uses: actions/checkout@v6
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ Dalli Changelog
Unreleased
==========

- Add tombstone (mark-stale) support to `Client#delete` / `delete_cas` /
`delete_multi` via new `:invalidate`, `:tombstone_ttl`, `:drop_value`
request-option keys (corresponding to meta-protocol `I`, `T`, `x` flags
on `md`). A tombstoned item lives briefly in a "stale" window so
concurrent readers can tell a racing repopulate apart from a true miss
— useful for high-concurrency cache invalidation. (drinkbeer)
- Add `Client#get_with_status` returning a `Dalli::CacheResult` value
object with `value` / `stale?` / `miss?` / `hit?` predicates. Unlike
`#get`, it always returns a result (never nil) and surfaces the
meta-protocol `X` (stale) response flag, letting callers distinguish
a tombstone window from a true miss without changing the return shape
of `get`. (drinkbeer)
- Fix cannot read response data included terminator `\r\n` when use meta protocol (matsubara0507)
- Remove binary protocol support (grcooper)
- Add support for `raw` client option (nherson)
Expand Down
1 change: 1 addition & 0 deletions lib/dalli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def self.register(middleware)
require_relative 'dalli/version'
require_relative 'dalli/middlewares'

require_relative 'dalli/cache_result'
require_relative 'dalli/compressor'
require_relative 'dalli/client'
require_relative 'dalli/key_manager'
Expand Down
30 changes: 30 additions & 0 deletions lib/dalli/cache_result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

module Dalli
# Result of a stale-aware read (Client#get_with_status). Always returned —
# callers should branch on the predicate methods, not on nil-ness, since a
# tombstoned item has stale? == true with a (possibly empty) value, while
# a real cache miss has miss? == true.
class CacheResult
attr_reader :value

def initialize(value:, stale: false, miss: false)
@value = value
@stale = stale
@miss = miss
freeze
end

def stale?
@stale
end

def miss?
@miss
end

def hit?
!@miss
end
end
end
68 changes: 65 additions & 3 deletions lib/dalli/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,21 @@ def get_cas(key, req_options = nil)
yield value, cas
end

##
# Read a key and return a `Dalli::CacheResult` with stale-awareness.
#
# Unlike `#get`, this always returns a `CacheResult` (never nil). Callers
# branch on `result.stale?` / `result.miss?` / `result.hit?` rather than
# nil-ness, since a tombstoned item has `stale? == true` with a (possibly
# empty) value, while a real cache miss has `miss? == true`.
#
# Tombstones are produced via `#delete` with `invalidate: true`; `drop_value`
# can be combined to discard the previous value while keeping the stale marker.
# See `#delete` for details.
def get_with_status(key, req_options = nil)
perform(:get_with_status, key, req_options)
end

##
# Fetch multiple keys efficiently.
# If a block is given, yields key/value pairs one at a time.
Expand Down Expand Up @@ -129,6 +144,35 @@ def get_multi(*keys, **req_options)
end
end

##
# Fetch multiple keys efficiently and return stale-aware `Dalli::CacheResult`
# objects for every requested key.
#
# Unlike `#get_multi`, the returned hash includes misses so callers can
# distinguish a true miss from a tombstoned/stale item for each key:
# { 'key' => #<Dalli::CacheResult ...>, 'missing' => #<Dalli::CacheResult miss? ...> }
#
# If a block is given, yields key/result pairs one at a time for every
# requested key.
#
# See `get_multi` for documentation on the `req_options` trailing keyword
# arguments (e.g. `p_token:` / `l_token:`), including the kwargs-vs-positional caveat.
def get_multi_with_status(*keys, **req_options, &block)
keys.flatten!
keys.compact!

return {} if keys.empty?

req_options = nil if req_options.empty?
results = pipelined_getter.process_with_status(keys, req_options)

if block
results.each(&block)
else
results
end
end

##
# 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:
Expand Down Expand Up @@ -273,10 +317,28 @@ def replace_cas(key, value, cas, ttl = nil, req_options = nil)

# Delete a key/value pair, verifying existing CAS.
# Returns true if succeeded, and falsy otherwise.
#
# `req_options` recognizes the same meta-delete keys as `#delete`:
# `:invalidate`, `:tombstone_ttl`, `:drop_value`.
def delete_cas(key, cas = 0, req_options = nil)
perform(:delete, key, cas, req_options)
end

##
# Delete a key.
#
# `req_options` may include memcached meta-delete options:
# - `:invalidate` (Boolean) — mark the item stale instead of removing it.
# This is the tombstone marker: `#get_with_status` returns `stale?`, and
# the existing value remains readable unless `:drop_value` is also set.
# - `:drop_value` (Boolean) — remove the item value but leave the item.
# Alone this is not a tombstone: reads are a non-stale hit with an empty
# string value.
# - `:invalidate` + `:drop_value` — leave a stale tombstone marker with an
# empty value, so readers can distinguish it from a miss without retaining
# the previous value.
# - `:tombstone_ttl` (Integer seconds) — how long the stale tombstone lives;
# requires `:invalidate`. After this elapses, reads see `miss?`.
def delete(key, req_options = nil)
delete_cas(key, 0, req_options)
end
Expand All @@ -285,9 +347,9 @@ def delete(key, req_options = nil)
# Delete multiple keys efficiently in pipelined mode.
# Returns the number of keys that were successfully deleted.
#
# `req_options` is applied to every delete in the pipeline (e.g.
# `meta_flags: ['Proute=...']`). Best-effort; the same options apply to
# every key.
# `req_options` is applied to every delete in the pipeline. Recognized
# meta-delete keys (`:invalidate`, `:tombstone_ttl`, `:drop_value`) are
# applied uniformly to every key in the batch — see `#delete`.
def delete_multi(keys, req_options = nil)
return 0 if keys.empty?

Expand Down
21 changes: 21 additions & 0 deletions lib/dalli/pipelined_getter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,27 @@ def optimized_for_single_server(keys, req_options = nil)
@key_manager.key_values_without_namespace(results)
end

def process_with_status(keys, req_options = nil)
return {} if keys.empty?

@ring.lock do
groups = groups_for_keys(keys)
results = groups.each_with_object({}) do |(server, keys_for_server), hash|
hash.merge!(server.request(:read_multi_with_status_req, keys_for_server, req_options))
rescue RetryableNetworkError
raise
rescue DalliError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { "unable to get keys for server #{server.name}" }
end
@key_manager.key_values_without_namespace(results)
end
rescue RetryableNetworkError => e
Dalli.logger.debug { e.inspect }
Dalli.logger.debug { 'retrying pipelined get with status because of timeout' }
retry
end

##
# Yields, one at a time, keys and their values+attributes.
#
Expand Down
21 changes: 21 additions & 0 deletions lib/dalli/protocol/base.rb
Comment thread
drinkbeer marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,27 @@ def routing_tokens?(opts)
!blank_token?(opts[:p_token]) || !blank_token?(opts[:l_token])
end

# Extracts meta-delete kwargs (`invalidate`, `tombstone_ttl`,
# `drop_value`) from a request-options Hash so they can be splatted
# into a RequestFormatter.meta_delete call. Returns `{}` when none
# of the keys are set, so the splat is a no-op for the common path.
# Tombstone TTL uses the same memcached expiration semantics as other
# TTL-bearing operations, so sanitize it before formatting the request.
# Validation (e.g. `tombstone_ttl` requiring `invalidate`) happens at
# the wire-formatter level, where it can ArgumentError uniformly.
def tombstone_kwargs(opts)
return {} unless opts.is_a?(Hash)

invalidate = opts[:invalidate]
tombstone_ttl = opts[:tombstone_ttl]
drop_value = opts[:drop_value]
return {} unless invalidate || tombstone_ttl || drop_value

tombstone_ttl = TtlSanitizer.sanitize(Integer(tombstone_ttl)) if tombstone_ttl

{ invalidate: invalidate, tombstone_ttl: tombstone_ttl, drop_value: drop_value }.compact
end

def blank_token?(value)
value.nil? || (value.respond_to?(:empty?) && value.empty?)
end
Expand Down
Loading
Loading