Class: SafeMemoize::Stores::CircuitBreaker

Inherits:
Base
  • Object
show all
Defined in:
lib/safe_memoize/stores/circuit_breaker.rb

Overview

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 Base::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.

Examples:

Wrap a custom Redis store

store = SafeMemoize::Stores::CircuitBreaker.new(
  MyRedisStore.new,
  error_threshold: 5,
  probe_interval:  30
)
memoize :fetch, store: store

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 }

Constant Summary collapse

DEFAULT_ERROR_THRESHOLD =
5
DEFAULT_PROBE_INTERVAL =
30.0

Constants inherited from Base

Base::MISS

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#exist?

Constructor Details

#initialize(store, error_threshold: DEFAULT_ERROR_THRESHOLD, probe_interval: DEFAULT_PROBE_INTERVAL) ⇒ CircuitBreaker

Returns a new instance of CircuitBreaker.

Parameters:

  • store (Stores::Base)

    the backing store to protect

  • error_threshold (Integer) (defaults to: DEFAULT_ERROR_THRESHOLD)

    consecutive errors that trip the circuit (default 5)

  • probe_interval (Numeric) (defaults to: DEFAULT_PROBE_INTERVAL)

    seconds to wait before probing (default 30)

Raises:

  • (ArgumentError)

    if +store+ is not a Base instance, or if threshold / interval are invalid



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 51

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

Instance Attribute Details

#error_thresholdInteger (readonly)

Returns number of consecutive errors that trip the circuit.

Returns:

  • (Integer)

    number of consecutive errors that trip the circuit



42
43
44
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 42

def error_threshold
  @error_threshold
end

#probe_intervalFloat (readonly)

Returns seconds after tripping before a probe is attempted.

Returns:

  • (Float)

    seconds after tripping before a probe is attempted



44
45
46
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 44

def probe_interval
  @probe_interval
end

#wrapped_storeStores::Base (readonly)

Returns the wrapped inner store.

Returns:



40
41
42
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 40

def wrapped_store
  @wrapped_store
end

Instance Method Details

#clearObject

Clear the wrapped store. Errors are recorded but not re-raised.



104
105
106
107
108
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 104

def clear
  @wrapped_store.clear
rescue
  record_failure
end

#delete(key) ⇒ Object

Delete from the wrapped store. A no-op when the circuit is open.



95
96
97
98
99
100
101
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 95

def delete(key)
  return if current_state == :open

  @wrapped_store.delete(key)
rescue
  record_failure
end

#error_countInteger

Returns the current consecutive error count.

Returns:

  • (Integer)


135
136
137
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 135

def error_count
  @mutex.synchronize { @error_count }
end

#keysObject

Returns live keys from the wrapped store, or an empty array when the circuit is open or the store raises.



112
113
114
115
116
117
118
119
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 112

def keys
  return [] if current_state == :open

  @wrapped_store.keys
rescue
  record_failure
  []
end

#open?Boolean

Returns +true+ when the circuit is not fully closed (i.e. open or half-open).

Returns:

  • (Boolean)


129
130
131
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 129

def open?
  current_state != :closed
end

#read(key) ⇒ Object

Read from the wrapped store, returning Base::MISS on error or when the circuit is open instead of raising.



70
71
72
73
74
75
76
77
78
79
80
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 70

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

#reset!void

This method returns an undefined value.

Manually resets the circuit to +:closed+, clearing the error counter.



141
142
143
144
145
146
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 141

def reset!
  @mutex.synchronize do
    @error_count = 0
    @opened_at = nil
  end
end

#stateSymbol

Returns the current circuit state: +:closed+, +:open+, or +:half_open+.

Returns:

  • (Symbol)


123
124
125
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 123

def state
  current_state
end

#write(key, value, expires_in: nil) ⇒ Object

Write to the wrapped store, silently swallowing errors so the caller's return value is unaffected. A no-op when the circuit is open.



84
85
86
87
88
89
90
91
92
# File 'lib/safe_memoize/stores/circuit_breaker.rb', line 84

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