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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min

## [Unreleased]

### Added

- `SafeMemoize::Stores::CircuitBreaker` — a `Stores::Base` wrapper that protects any external store adapter with a three-state circuit breaker (`:closed` → `:open` → `:half_open`). When the wrapped store raises on `read` or `write`, the error is swallowed: reads return `MISS` (triggering the per-instance in-process fallback cache) and writes are no-ops (the return value is unaffected). After a configurable number of consecutive failures (`error_threshold:`, default 5) the circuit opens and all store calls are bypassed until a probe interval elapses (`probe_interval:`, default 30 s), at which point a single probe request is let through; success closes the circuit, failure resets the timer. Any successful call in `:closed` state resets the consecutive error counter, so transient blips do not accumulate toward the threshold.
- `state` — returns `:closed`, `:open`, or `:half_open`
- `open?` — `true` when the circuit is not fully closed
- `error_count` — current consecutive error count
- `reset!` — manually close the circuit and clear the counter
- `wrapped_store`, `error_threshold`, `probe_interval` — readers
- `circuit_breaker:` option on `memoize` — syntactic sugar that auto-wraps the configured `store:` adapter in a `CircuitBreaker`. Pass `true` to use defaults, or a `Hash` with `:error_threshold` and/or `:probe_interval` keys to customise. Raises `ArgumentError` if no store is configured. Does not double-wrap a store that is already a `CircuitBreaker`. Accepted by `safe_memoize_options` as a class-wide default.

## [1.5.0] - 2026-06-02

### Added
Expand Down
88 changes: 87 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
- [Per-class default options via `safe_memoize_options` — set TTL, max size, copy-on-read, and other defaults for every `memoize` call on the class without repeating them](#per-class-default-options-safe_memoize_options)
- [Copy-on-read via `copy_on_read: true` — returns a `dup`/`deep_dup` on every cache read to protect shared cached state from caller mutation](#copy-on-read)
- [Cache invalidation groups via `group:` — tag related methods with a group name and bust them all with a single `reset_memo_group` call](#cache-invalidation-groups)
- [Circuit breaker for external stores — `Stores::CircuitBreaker` wraps any store adapter and falls back to the per-instance cache when the store is down; configurable error threshold and probe interval](#circuit-breaker-for-external-stores)

## Installation

Expand Down Expand Up @@ -1448,6 +1449,90 @@ end

[↑ Back to features](#features)

## Circuit breaker for external stores

`SafeMemoize::Stores::CircuitBreaker` wraps any `Stores::Base` adapter and silently falls back to the per-instance in-process cache when the external store is unavailable, rather than propagating exceptions to callers.

### How it works

The breaker moves through three states:

| State | Behaviour |
|---|---|
| `:closed` | Normal — every call passes through to the wrapped store; consecutive errors are counted |
| `:open` | Tripped — reads return `MISS` (triggering the per-instance fallback), writes are no-ops; no calls reach the store until the probe interval elapses |
| `:half_open` | Probe — calls are let through; the first success closes the circuit; any failure re-opens it and resets the timer |

Any successful call in `:closed` state resets the consecutive error counter, so transient blips do not accumulate toward the threshold.

### Usage

**Direct wrapping:**

```ruby
redis = SafeMemoize::Stores::CircuitBreaker.new(
MyRedisStore.new,
error_threshold: 5, # trip after 5 consecutive failures (default)
probe_interval: 30 # wait 30 s before probing (default)
)

class CatalogService
prepend SafeMemoize

def products = fetch_from_redis
memoize :products, store: redis
end
```

**Via the `circuit_breaker:` option** (auto-wraps the configured store):

```ruby
class CatalogService
prepend SafeMemoize

def products = fetch_from_redis
memoize :products, store: MyRedisStore.new, circuit_breaker: true

def orders = fetch_orders
memoize :orders,
store: MyRedisStore.new,
circuit_breaker: { error_threshold: 3, probe_interval: 60 }
end
```

When the store raises, `products` falls back to the per-instance in-memory hash — callers see no exceptions and computation still runs once per instance until the circuit closes.

### Introspection and manual control

```ruby
cb = SafeMemoize::Stores::CircuitBreaker.new(store)

cb.state # => :closed | :open | :half_open
cb.open? # => false
cb.error_count # => 0
cb.error_threshold # => 5
cb.probe_interval # => 30.0
cb.wrapped_store # => the inner adapter
cb.reset! # manually close the circuit and clear error count
```

### Class-wide default

```ruby
class ApiService
prepend SafeMemoize
safe_memoize_options circuit_breaker: { error_threshold: 3, probe_interval: 15 }

def users = http.get("/users")
def orders = http.get("/orders")

memoize :users, store: redis
memoize :orders, store: redis # both get the circuit breaker
end
```

[↑ Back to features](#features)

## Per-class default options (`safe_memoize_options`)

`safe_memoize_options` sets option defaults for every `memoize` call on the class, eliminating repetition when many methods share the same TTL, LRU cap, or other option. Per-call options still take precedence; class defaults take precedence over global `SafeMemoize.configure` defaults.
Expand Down Expand Up @@ -1686,6 +1771,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
| `cache_bust:` | `Proc \| Symbol \| nil` | `nil` | Version-token callable; invoked on the instance at each lookup; token is folded into the key; incompatible with `key:` |
| `copy_on_read:` | `Boolean` | `false` | Return a `dup`/`deep_dup` of the cached value on every read; protects shared state from caller mutation; nil and frozen values pass through; incompatible with `ractor_safe:` |
| `group:` | `Symbol \| String \| nil` | `nil` | Assigns the method to a named invalidation group; call `reset_memo_group` / `reset_shared_memo_group` to bust all methods in the group at once; a method belongs to at most one group |
| `circuit_breaker:` | `true \| Hash \| nil` | `nil` | Wraps the configured `store:` in a `Stores::CircuitBreaker`; `true` uses defaults (`error_threshold: 5`, `probe_interval: 30`); pass a Hash to customise; requires a store to be set; does not double-wrap |
| *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |

### `memoize_all` options (class method)
Expand All @@ -1703,7 +1789,7 @@ All `memoize` option keys above, plus:

| Option key | Type | Default | Notes |
|---|---|---|---|
| any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`, `group:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` |
| any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`, `group:`, `circuit_breaker:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` |

### Instance methods (public)

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey

| Feature | Description | Status |
|---|---|---|
| Circuit breaker for external stores | When a `store:` adapter raises on `read` or `write`, automatically fall back to the per-instance in-process hash rather than propagating the exception; configurable error threshold and recovery probe interval | Planned |
| Circuit breaker for external stores | When a `store:` adapter raises on `read` or `write`, automatically fall back to the per-instance in-process hash rather than propagating the exception; configurable error threshold and recovery probe interval | Shipped |

---

Expand Down
1 change: 1 addition & 0 deletions lib/safe_memoize.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require_relative "safe_memoize/extension"
require_relative "safe_memoize/stores/base"
require_relative "safe_memoize/stores/memory"
require_relative "safe_memoize/stores/circuit_breaker"
require_relative "safe_memoize/adapters/statsd"
require_relative "safe_memoize/adapters/opentelemetry"
require_relative "safe_memoize/adapters/concurrent_ruby"
Expand Down
22 changes: 21 additions & 1 deletion lib/safe_memoize/class_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ module ClassMethods
# {.reset_shared_memo_group} for shared-mode methods) to bust every method in the
# group at once. A method may belong to at most one group; re-memoizing with a
# different group moves it. Must be a non-empty Symbol or String.
# @param circuit_breaker [Boolean, Hash, nil] wraps the configured +store:+ adapter
# in a {Stores::CircuitBreaker}. Pass +true+ to use defaults (+error_threshold: 5+,
# +probe_interval: 30+), or a +Hash+ with +:error_threshold+ and/or +:probe_interval+
# keys to customise. Requires a +store:+ to be set (per-method, class-level, or
# global default); raises +ArgumentError+ otherwise.
# @return [void]
# @raise [ArgumentError] if the method does not exist, or option values are invalid
#
Expand All @@ -89,7 +94,7 @@ module ClassMethods
# @example With a custom store
# STORE = SafeMemoize::Stores::Memory.new
# memoize :fetch, store: STORE, ttl: 300
def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UNSET, unless: UNSET, shared: UNSET, key: UNSET, store: UNSET, fiber_local: UNSET, ractor_safe: UNSET, namespace: UNSET, shared_cache: UNSET, cache_bust: UNSET, copy_on_read: UNSET, group: UNSET, **extension_options)
def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UNSET, unless: UNSET, shared: UNSET, key: UNSET, store: UNSET, fiber_local: UNSET, ractor_safe: UNSET, namespace: UNSET, shared_cache: UNSET, cache_bust: UNSET, copy_on_read: UNSET, group: UNSET, circuit_breaker: UNSET, **extension_options)
method_name = method_name.to_sym

unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
Expand Down Expand Up @@ -132,6 +137,7 @@ def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UN
namespace = cls_defaults[:namespace] if namespace.equal?(UNSET) && cls_defaults.key?(:namespace)
store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store)
group = cls_defaults[:group] if group.equal?(UNSET) && cls_defaults.key?(:group)
circuit_breaker = cls_defaults[:circuit_breaker] if circuit_breaker.equal?(UNSET) && cls_defaults.key?(:circuit_breaker)
end

# Normalize remaining UNSET to original per-call defaults
Expand All @@ -148,6 +154,7 @@ def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UN
cache_bust = nil if cache_bust.equal?(UNSET)
copy_on_read = false if copy_on_read.equal?(UNSET)
group = nil if group.equal?(UNSET)
circuit_breaker = nil if circuit_breaker.equal?(UNSET)
cond_if = nil if cond_if.equal?(UNSET)
cond_unless = nil if cond_unless.equal?(UNSET)

Expand Down Expand Up @@ -260,6 +267,19 @@ def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UN
end
end

if circuit_breaker
unless circuit_breaker == true || circuit_breaker.is_a?(Hash)
raise ArgumentError, "circuit_breaker: must be true or a Hash of options (got #{circuit_breaker.class})"
end
unless effective_store
raise ArgumentError, "circuit_breaker: requires a store: to be configured (no store is set for :#{method_name})"
end
unless effective_store.is_a?(Stores::CircuitBreaker)
cb_opts = circuit_breaker.is_a?(Hash) ? circuit_breaker : {}
effective_store = Stores::CircuitBreaker.new(effective_store, **cb_opts)
end
end

__safe_memo_class_key_generators__[method_name] = key if key
__safe_memo_class_cache_bust_generators__[method_name] = cache_bust if cache_bust

Expand Down
178 changes: 178 additions & 0 deletions lib/safe_memoize/stores/circuit_breaker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# frozen_string_literal: true

module SafeMemoize
module Stores
# Wraps any {Base} store adapter with a circuit breaker that silently falls
# back to the per-instance in-process cache when the external store is
# unavailable, rather than propagating exceptions to callers.
#
# === States
#
# * +:closed+ — normal; every call goes through to the wrapped store;
# consecutive errors are counted
# * +:open+ — tripped; reads return {MISS} and writes are no-ops so the
# memoize wrapper falls back to the per-instance hash; no
# calls reach the wrapped store until the probe interval elapses
# * +:half_open+ — probe period (probe interval elapsed); calls are let
# through to the wrapped store; the first success closes the
# circuit, any failure re-opens it and resets the timer
#
# Any successful call while the circuit is +:closed+ resets the consecutive
# error counter, so transient blips do not accumulate toward the threshold.
#
# @example Wrap a custom Redis store
# store = SafeMemoize::Stores::CircuitBreaker.new(
# MyRedisStore.new,
# error_threshold: 5,
# probe_interval: 30
# )
# memoize :fetch, store: store
#
# @example Via the circuit_breaker: option (auto-wraps the configured store)
# memoize :fetch, store: MyRedisStore.new, circuit_breaker: true
# memoize :fetch, store: MyRedisStore.new,
# circuit_breaker: { error_threshold: 3, probe_interval: 60 }
class CircuitBreaker < Base
DEFAULT_ERROR_THRESHOLD = 5
DEFAULT_PROBE_INTERVAL = 30.0

# @return [Stores::Base] the wrapped inner store
attr_reader :wrapped_store
# @return [Integer] number of consecutive errors that trip the circuit
attr_reader :error_threshold
# @return [Float] seconds after tripping before a probe is attempted
attr_reader :probe_interval

# @param store [Stores::Base] the backing store to protect
# @param error_threshold [Integer] consecutive errors that trip the circuit (default 5)
# @param probe_interval [Numeric] seconds to wait before probing (default 30)
# @raise [ArgumentError] if +store+ is not a {Stores::Base} instance, or
# if threshold / interval are invalid
def initialize(store, error_threshold: DEFAULT_ERROR_THRESHOLD, probe_interval: DEFAULT_PROBE_INTERVAL)
unless store.is_a?(Base)
raise ArgumentError, "CircuitBreaker requires a Stores::Base instance (got #{store.class})"
end

@wrapped_store = store
@error_threshold = Integer(error_threshold)
@probe_interval = Float(probe_interval)

raise ArgumentError, "error_threshold must be positive" unless @error_threshold > 0
raise ArgumentError, "probe_interval must be positive" unless @probe_interval > 0

@mutex = Mutex.new
@error_count = 0
@opened_at = nil
end

# Read from the wrapped store, returning {MISS} on error or when the
# circuit is open instead of raising.
def read(key)
st = current_state
return MISS if st == :open

result = @wrapped_store.read(key)
record_success(st)
result
rescue
record_failure
MISS
end

# Write to the wrapped store, silently swallowing errors so the caller's
# return value is unaffected. A no-op when the circuit is open.
def write(key, value, expires_in: nil)
st = current_state
return if st == :open

@wrapped_store.write(key, value, expires_in: expires_in)
record_success(st)
rescue
record_failure
end

# Delete from the wrapped store. A no-op when the circuit is open.
def delete(key)
return if current_state == :open

@wrapped_store.delete(key)
rescue
record_failure
end

# Clear the wrapped store. Errors are recorded but not re-raised.
def clear
@wrapped_store.clear
rescue
record_failure
end

# Returns live keys from the wrapped store, or an empty array when the
# circuit is open or the store raises.
def keys
return [] if current_state == :open

@wrapped_store.keys
rescue
record_failure
[]
end

# Returns the current circuit state: +:closed+, +:open+, or +:half_open+.
# @return [Symbol]
def state
current_state
end

# Returns +true+ when the circuit is not fully closed (i.e. open or half-open).
# @return [Boolean]
def open?
current_state != :closed
end

# Returns the current consecutive error count.
# @return [Integer]
def error_count
@mutex.synchronize { @error_count }
end

# Manually resets the circuit to +:closed+, clearing the error counter.
# @return [void]
def reset!
@mutex.synchronize do
@error_count = 0
@opened_at = nil
end
end

private

def current_state
@mutex.synchronize do
next :closed if @opened_at.nil?

elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @opened_at
(elapsed >= @probe_interval) ? :half_open : :open
end
end

def record_success(prior_state)
return unless prior_state == :half_open || @error_count > 0

@mutex.synchronize do
@error_count = 0
@opened_at = nil
end
end

def record_failure
@mutex.synchronize do
@error_count += 1
if @error_count >= @error_threshold || !@opened_at.nil?
@opened_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
end
end
end
end
Loading
Loading