Stores¶
Background jobs retry. Webhooks arrive twice. Two workers pick up the same slice of work. You need a small, shared key/value layer — not your primary database — for idempotency tokens, rate-limit counters, circuit-breaker state, and (eventually) workflow checkpoints.
CMDx::Stores is that layer: get, set, incr, decr, and del, with optional TTL and Redis-style conditional writes (nx / xx). Call the module methods directly, or swap the backend on CMDx.configuration.store.
Configuration¶
The process default is an in-memory adapter — fine for tests and single-process dev, not shared across Sidekiq workers.
| Setting | Default | In plain English |
|---|---|---|
store |
Stores::Memory.new |
Backend for CMDx::Stores.* |
CMDx.reset_configuration! (common in specs) restores a fresh in-memory store along with the rest of global config.
See also: Configuration – Store.
Module API¶
All methods delegate to CMDx.configuration.store. Keys are coerced with to_s; empty keys raise ArgumentError.
get(key)¶
Returns the stored string, or nil when the key is missing or expired.
set(key, value, ttl: nil, nx: false, xx: false)¶
Writes value (coerced with to_s). Returns whether the write happened.
| Option | Meaning |
|---|---|
ttl: |
Positive integer seconds until expiry (nil = no expiry) |
nx: true |
Set only if the key is absent (compare Redis SET NX) |
xx: true |
Set only if the key is present |
nx and xx cannot both be true.
# Claim an idempotency slot for five minutes
claimed = CMDx::Stores.set("idem:pay_123", "processing", nx: true, ttl: 300)
# Mark complete only if we still own the key
CMDx::Stores.set("idem:pay_123", "done", xx: true, ttl: 300)
incr(key, ttl: nil)¶
Atomically increments a counter (starts at 1). Returns the integer count after increment.
When ttl: is set, built-in adapters refresh expiry on every increment — a sliding window for rate limits and circuit breaker failure counts.
decr(key, ttl: nil)¶
Atomically decrements a counter (missing keys behave as 0). Returns the integer count after decrement. Built-in adapters remove the key when the count reaches zero.
ttl: is accepted for API symmetry with incr; built-in adapters do not refresh expiry on decrement.
remaining_ttl(key)¶
Returns seconds until expiry when the configured backend implements ttl (Stores::Memory, Stores::Redis); otherwise nil. Used for metadata[:retry_after] on open circuits.
del(key)¶
Removes the key. Returns 1 if a key was removed, 0 otherwise.
del_if(key, expected)¶
Removes the key only when the stored value equals expected (string comparison after to_s). Returns whether a key was removed. Built-in adapters implement this atomically (Redis via EVAL). Used by lock_with so a late releaser cannot delete a newer holder's lease.
Adapters¶
CMDx::Stores::Memory¶
Thread-safe, process-local hash with monotonic-clock TTL. Used by default and in specs unless you assign another backend.
Note
Each Puma worker, Sidekiq process, and console session has its own memory store. Do not rely on it for cross-worker coordination.
CMDx::Stores::Redis¶
Wraps any client that quacks like redis-rb: get, set (with ex:, nx:, xx:), incr, decr, expire, ttl, del, and eval (for del_if). CMDx does not add a Redis gem dependency — you pass the connection your app already uses.
By default, Redis keys match the logical keys passed to CMDx::Stores (same as Stores::Memory). Set namespace when Redis is shared with other data so CMDx keys stay isolated.
CMDx::Stores::Redis.new(redis: Redis.current, namespace: "myapp:cmdx")
# writes "myapp:cmdx:token" in Redis
Custom backends¶
Any object that implements the core methods with the same signatures can be assigned to config.store. Add del_if when you use lock_with, and ttl when you need remaining_ttl (circuit breaker retry_after, lock metadata[:retry_after]). Use custom backends for Memcached, DynamoDB, observability wrappers, or an in-test spy.
Walkthrough with a recording store for specs and an instrumented Redis wrapper: Store Wrappers.
class NullStore
def get(_key) = nil
def set(_key, _value, ttl: nil, nx: false, xx: false) = true
def incr(_key, ttl: nil) = 1
def decr(_key, ttl: nil) = 0
def ttl(_key) = nil
def del(_key) = 0
def del_if(_key, _expected) = false
end
CMDx.configure { |c| c.store = NullStore.new }
Testing¶
Specs call CMDx.reset_configuration! in spec_helper, so CMDx::Stores starts on a clean in-memory backend every example. To assert against an isolated instance without touching global config:
store = CMDx::Stores::Memory.new
CMDx.configuration.store = store
store.set(:token, "abc")
expect(CMDx::Stores.get(:token)).to eq("abc")
For integration tests against Redis semantics, assign CMDx::Stores::Redis.new(redis: …) in a before block and point redis at MockRedis or a dedicated test Redis URL.