From 0c30d8b333418f79ff0cfb46f6051fe93aaff14f Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 2 Jun 2026 14:41:33 -0400 Subject: [PATCH 1/2] Add circuit breaker for external store adapters (v1.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SafeMemoize::Stores::CircuitBreaker` — Stores::Base wrapper with three-state circuit breaker (closed/open/half_open); reads return MISS and writes are no-ops when open, falling back to the per-instance hash - `circuit_breaker:` option on `memoize` auto-wraps the configured store; accepts `true` (defaults: error_threshold 5, probe_interval 30 s) or a Hash with custom values; raises if no store is configured - Does not double-wrap a store already wrapped in CircuitBreaker - Successful calls in closed state reset the consecutive error counter - `circuit_breaker:` accepted by `safe_memoize_options` as a class default - 32 new tests covering state transitions, fallback behavior, introspection, validation, and the memoize option Co-Authored-By: Claude Sonnet 4.6 --- lib/safe_memoize.rb | 1 + lib/safe_memoize/class_methods.rb | 22 +- lib/safe_memoize/stores/circuit_breaker.rb | 178 ++++++++++ sig/safe_memoize.rbs | 28 +- spec/safe_memoize_spec.rb | 374 +++++++++++++++++++++ 5 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 lib/safe_memoize/stores/circuit_breaker.rb diff --git a/lib/safe_memoize.rb b/lib/safe_memoize.rb index 268ccfd..9974945 100644 --- a/lib/safe_memoize.rb +++ b/lib/safe_memoize.rb @@ -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" diff --git a/lib/safe_memoize/class_methods.rb b/lib/safe_memoize/class_methods.rb index b2d117e..46edd88 100644 --- a/lib/safe_memoize/class_methods.rb +++ b/lib/safe_memoize/class_methods.rb @@ -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 # @@ -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) @@ -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 @@ -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) @@ -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 diff --git a/lib/safe_memoize/stores/circuit_breaker.rb b/lib/safe_memoize/stores/circuit_breaker.rb new file mode 100644 index 0000000..6f331d2 --- /dev/null +++ b/lib/safe_memoize/stores/circuit_breaker.rb @@ -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 diff --git a/sig/safe_memoize.rbs b/sig/safe_memoize.rbs index c9995a4..3c0ce6b 100644 --- a/sig/safe_memoize.rbs +++ b/sig/safe_memoize.rbs @@ -66,7 +66,7 @@ module SafeMemoize end module ClassMethods - def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, ?copy_on_read: bool, ?group: Symbol | String | nil, **untyped extension_options) -> void + def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, ?copy_on_read: bool, ?group: Symbol | String | nil, ?circuit_breaker: bool | Hash[Symbol, untyped] | nil, **untyped extension_options) -> void def safe_memoize_store: () -> Stores::Base? def safe_memoize_store=: (Stores::Base?) -> Stores::Base? def safe_memoize_namespace: () -> String? @@ -300,6 +300,32 @@ module SafeMemoize def expired?: ({ expires_at: Float?, value: untyped, cached_at: Float }) -> bool end + + class CircuitBreaker < Base + DEFAULT_ERROR_THRESHOLD: Integer + DEFAULT_PROBE_INTERVAL: Float + + attr_reader wrapped_store: Base + attr_reader error_threshold: Integer + attr_reader probe_interval: Float + + def initialize: (Base store, ?error_threshold: Integer, ?probe_interval: Numeric) -> void + def read: (untyped key) -> untyped + def write: (untyped key, untyped value, ?expires_in: Numeric?) -> void + def delete: (untyped key) -> void + def clear: () -> void + def keys: () -> Array[untyped] + def state: () -> Symbol + def open?: () -> bool + def error_count: () -> Integer + def reset!: () -> void + + private + + def current_state: () -> Symbol + def record_success: (Symbol prior_state) -> void + def record_failure: () -> void + end end module Adapters diff --git a/spec/safe_memoize_spec.rb b/spec/safe_memoize_spec.rb index 5c31586..4f489b0 100644 --- a/spec/safe_memoize_spec.rb +++ b/spec/safe_memoize_spec.rb @@ -2072,4 +2072,378 @@ def bar = 2 end end end + + describe "SafeMemoize::Stores::CircuitBreaker" do + # A store double that raises on demand + let(:inner_store) do + store = instance_double(SafeMemoize::Stores::Memory) + allow(store).to receive(:is_a?).with(SafeMemoize::Stores::Base).and_return(true) + allow(store).to receive(:is_a?).with(SafeMemoize::Stores::CircuitBreaker).and_return(false) + store + end + + let(:cb) do + SafeMemoize::Stores::CircuitBreaker.new( + inner_store, + error_threshold: 3, + probe_interval: 0.05 + ) + end + + def make_store_healthy + allow(inner_store).to receive(:read).and_return(SafeMemoize::Stores::Base::MISS) + allow(inner_store).to receive(:write) + end + + def make_store_raise + allow(inner_store).to receive(:read).and_raise(RuntimeError, "store down") + allow(inner_store).to receive(:write).and_raise(RuntimeError, "store down") + end + + context "initial state" do + it "starts closed" do + expect(cb.state).to eq(:closed) + end + + it "reports error_count of zero" do + expect(cb.error_count).to eq(0) + end + + it "open? is false" do + expect(cb.open?).to be false + end + + it "exposes error_threshold and probe_interval" do + expect(cb.error_threshold).to eq(3) + expect(cb.probe_interval).to eq(0.05) + end + + it "exposes wrapped_store" do + expect(cb.wrapped_store).to eq(inner_store) + end + end + + context "closed state — normal operation" do + it "delegates read to the inner store" do + allow(inner_store).to receive(:read).with(:key).and_return("value") + expect(cb.read(:key)).to eq("value") + end + + it "delegates write to the inner store" do + allow(inner_store).to receive(:write).with(:key, "v", expires_in: 60) + cb.write(:key, "v", expires_in: 60) + expect(inner_store).to have_received(:write).once + end + + it "delegates delete to the inner store" do + allow(inner_store).to receive(:delete).with(:key) + cb.delete(:key) + expect(inner_store).to have_received(:delete).once + end + + it "delegates clear to the inner store" do + allow(inner_store).to receive(:clear) + cb.clear + expect(inner_store).to have_received(:clear).once + end + + it "delegates keys to the inner store" do + allow(inner_store).to receive(:keys).and_return([:a, :b]) + expect(cb.keys).to eq([:a, :b]) + end + + it "accumulates errors toward the threshold" do + make_store_raise + 2.times { cb.read(:k) } + expect(cb.error_count).to eq(2) + expect(cb.state).to eq(:closed) + end + end + + context "tripping to open state" do + before { make_store_raise } + + it "opens after error_threshold consecutive failures" do + 3.times { cb.read(:k) } + expect(cb.state).to eq(:open) + end + + it "returns MISS from read without calling inner store while open" do + 3.times { cb.read(:k) } + + expect(inner_store).not_to receive(:read) + result = cb.read(:key) + + expect(result).to eq(SafeMemoize::Stores::Base::MISS) + end + + it "silently no-ops write without calling inner store while open" do + 3.times { cb.read(:k) } + + expect(inner_store).not_to receive(:write) + cb.write(:key, "v") + end + + it "returns empty array from keys while open" do + 3.times { cb.read(:k) } + expect(cb.keys).to eq([]) + end + + it "reports open? true" do + 3.times { cb.read(:k) } + expect(cb.open?).to be true + end + end + + context "half-open probe after probe_interval" do + before { make_store_raise } + + def trip_and_wait + 3.times { cb.read(:k) } + expect(cb.state).to eq(:open) + sleep(0.06) + expect(cb.state).to eq(:half_open) + end + + it "transitions to half_open after probe_interval" do + trip_and_wait + end + + it "lets a read through to the inner store in half_open" do + trip_and_wait + make_store_healthy + + cb.read(:key) + + expect(inner_store).to have_received(:read).with(:key) + end + + it "closes the circuit after a successful read in half_open" do + trip_and_wait + make_store_healthy + + cb.read(:key) + + expect(cb.state).to eq(:closed) + expect(cb.error_count).to eq(0) + end + + it "closes the circuit after a successful write in half_open" do + trip_and_wait + make_store_healthy + + cb.write(:key, "v") + + expect(cb.state).to eq(:closed) + end + + it "re-opens and resets the timer if the probe read fails" do + trip_and_wait + + # store still raising + cb.read(:k) + + expect(cb.state).to eq(:open) + end + end + + context "reset!" do + it "clears error count and closes the circuit" do + make_store_raise + 3.times { cb.read(:k) } + expect(cb.state).to eq(:open) + + cb.reset! + + expect(cb.state).to eq(:closed) + expect(cb.error_count).to eq(0) + end + end + + context "success resets error counter in closed state" do + it "a successful call resets the error counter" do + allow(inner_store).to receive(:read).and_raise(RuntimeError) + 2.times { cb.read(:k) } + expect(cb.error_count).to eq(2) + + allow(inner_store).to receive(:read).and_return("ok") + cb.read(:k) + + expect(cb.error_count).to eq(0) + expect(cb.state).to eq(:closed) + end + end + + context "validation" do + it "raises ArgumentError if wrapped object is not a Stores::Base" do + expect do + SafeMemoize::Stores::CircuitBreaker.new("not a store") + end.to raise_error(ArgumentError, /Stores::Base/) + end + + it "raises ArgumentError for non-positive error_threshold" do + store = SafeMemoize::Stores::Memory.new + expect do + SafeMemoize::Stores::CircuitBreaker.new(store, error_threshold: 0) + end.to raise_error(ArgumentError, /error_threshold/) + end + + it "raises ArgumentError for non-positive probe_interval" do + store = SafeMemoize::Stores::Memory.new + expect do + SafeMemoize::Stores::CircuitBreaker.new(store, probe_interval: 0) + end.to raise_error(ArgumentError, /probe_interval/) + end + end + end + + describe "circuit_breaker: memoize option" do + let(:flaky_store) { SafeMemoize::Stores::Memory.new } + + def make_klass(cb_opt) + store = flaky_store + Class.new do + prepend SafeMemoize + + attr_reader :calls + + def initialize + @calls = 0 + end + + def fetch(id) + @calls += 1 + "result_#{id}" + end + + memoize :fetch, store: store, circuit_breaker: cb_opt + end + end + + context "circuit_breaker: true" do + it "wraps the store in a CircuitBreaker with default options" do + klass = make_klass(true) + # Access the memoize wrapper to inspect — just verify the class works + obj = klass.new + expect(obj.fetch(1)).to eq("result_1") + expect(obj.fetch(1)).to eq("result_1") + expect(obj.calls).to eq(1) + end + end + + context "circuit_breaker: { ... }" do + it "wraps the store with custom options" do + klass = Class.new do + prepend SafeMemoize + + attr_reader :calls + + def initialize + @calls = 0 + end + + def compute + @calls += 1 + 42 + end + + memoize :compute, + store: SafeMemoize::Stores::Memory.new, + circuit_breaker: {error_threshold: 2, probe_interval: 0.01} + end + + obj = klass.new + expect(obj.compute).to eq(42) + expect(obj.calls).to eq(1) + end + end + + context "fallback to per-instance cache when store is unhealthy" do + it "continues serving values from the in-memory cache after store failures" do + # Build a store that always raises + raising_store = Class.new(SafeMemoize::Stores::Base) do + def read(_key) = raise("redis down") + + def write(_key, _value, expires_in: nil) = raise("redis down") + + def delete(_key) = nil + + def clear = nil + + def keys = [] + end.new + + klass = Class.new do + prepend SafeMemoize + + attr_reader :calls + + def initialize + @calls = 0 + end + + def data + @calls += 1 + "value" + end + + memoize :data, + store: raising_store, + circuit_breaker: {error_threshold: 1, probe_interval: 60} + end + + obj = klass.new + + # First call: store raises on write (circuit trips after 1 error), + # method body runs, value returned + result1 = obj.data + expect(result1).to eq("value") + + # Second call: circuit is open, store bypassed; the per-instance hash + # has the value from the first call so it returns without recomputing + result2 = obj.data + expect(result2).to eq("value") + expect(obj.calls).to be <= 2 # at most 2 computations + end + end + + context "validation" do + it "raises ArgumentError when no store is configured" do + expect do + Class.new do + prepend SafeMemoize + + def data = 1 + memoize :data, circuit_breaker: true + end + end.to raise_error(ArgumentError, /requires a store/) + end + + it "raises ArgumentError for invalid circuit_breaker value" do + expect do + Class.new do + prepend SafeMemoize + + def data = 1 + memoize :data, store: SafeMemoize::Stores::Memory.new, circuit_breaker: 42 + end + end.to raise_error(ArgumentError, /must be true or a Hash/) + end + + it "does not double-wrap an already-wrapped CircuitBreaker store" do + store = SafeMemoize::Stores::CircuitBreaker.new( + SafeMemoize::Stores::Memory.new, + error_threshold: 2 + ) + klass = Class.new do + prepend SafeMemoize + + define_method(:data) { 1 } + memoize :data, store: store, circuit_breaker: true + end + # Smoke test — no double-wrap error + expect(klass.new.data).to eq(1) + end + end + end end From 5990b2c333da7b3a045cf2de6e2ce2dcfe408c35 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 2 Jun 2026 14:41:50 -0400 Subject: [PATCH 2/2] Docs: circuit breaker for external stores (v1.6.0) Update CHANGELOG, ROADMAP, and README for Stores::CircuitBreaker and the circuit_breaker: memoize option. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 ++++++ README.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++- ROADMAP.md | 2 +- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 600fa27..18a48e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 318bac8..c1794a5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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) @@ -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) diff --git a/ROADMAP.md b/ROADMAP.md index 2e546d5..bcf3d69 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | ---