safe_memoize 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ce254347308d6a616bc17df194ff5e91f9c151f6606d3b77963a963c8a3186c
4
- data.tar.gz: 7399a03d13d297b798cfc60716376129c0162697ae845e5420409c0da33ecf68
3
+ metadata.gz: 1920c31de4a855b818c5a4f37f35260f38489b7ff6ffe221b43d91e862133bde
4
+ data.tar.gz: 948979dfc49f4e42b16d21926344869fc06a39da3252ab1033697a5748a7362a
5
5
  SHA512:
6
- metadata.gz: 8a034c60f1f200e3971be9828563d43b406e1fc1feef4d2e044f880456ac2d1e0b7cf98b3d1e3292fb2641bb8c5e9d3e21c387f1a0041ae8996fa004db863d92
7
- data.tar.gz: 1f72d605f086951abc9bff9a2dc452ecece2694cd7ad27a118589ba8c48b4e02fb6a35b97271e0007e1dde55ff1af798bf278fabedc07b2c0b74d955af270a56
6
+ metadata.gz: 415c4095ce702b5823a887858f342fdf5742887394dc2cc113ccf711ef1f5789430e036d69ee3660d663a2153f34375f3622f1ff92d73143072e2376d2ae087b
7
+ data.tar.gz: 1fed071b0feb6591d4b95c69604f4c14440c88cefbdb13ce9f2e50a2eba5fad4723b28c1584f2cb7aaa33f7ba4e2140b5a16c45b99ba0cd9b13f47b2a16b0d58
data/CHANGELOG.md CHANGED
@@ -8,6 +8,18 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.6.0] - 2026-06-02
12
+
13
+ ### Added
14
+
15
+ - `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.
16
+ - `state` — returns `:closed`, `:open`, or `:half_open`
17
+ - `open?` — `true` when the circuit is not fully closed
18
+ - `error_count` — current consecutive error count
19
+ - `reset!` — manually close the circuit and clear the counter
20
+ - `wrapped_store`, `error_threshold`, `probe_interval` — readers
21
+ - `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.
22
+
11
23
  ## [1.5.0] - 2026-06-02
12
24
 
13
25
  ### Added
data/README.md CHANGED
@@ -80,6 +80,7 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
80
80
  - [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)
81
81
  - [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)
82
82
  - [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)
83
+ - [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)
83
84
 
84
85
  ## Installation
85
86
 
@@ -1448,6 +1449,90 @@ end
1448
1449
 
1449
1450
  [↑ Back to features](#features)
1450
1451
 
1452
+ ## Circuit breaker for external stores
1453
+
1454
+ `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.
1455
+
1456
+ ### How it works
1457
+
1458
+ The breaker moves through three states:
1459
+
1460
+ | State | Behaviour |
1461
+ |---|---|
1462
+ | `:closed` | Normal — every call passes through to the wrapped store; consecutive errors are counted |
1463
+ | `:open` | Tripped — reads return `MISS` (triggering the per-instance fallback), writes are no-ops; no calls reach the store until the probe interval elapses |
1464
+ | `:half_open` | Probe — calls are let through; the first success closes the circuit; any failure re-opens it and resets the timer |
1465
+
1466
+ Any successful call in `:closed` state resets the consecutive error counter, so transient blips do not accumulate toward the threshold.
1467
+
1468
+ ### Usage
1469
+
1470
+ **Direct wrapping:**
1471
+
1472
+ ```ruby
1473
+ redis = SafeMemoize::Stores::CircuitBreaker.new(
1474
+ MyRedisStore.new,
1475
+ error_threshold: 5, # trip after 5 consecutive failures (default)
1476
+ probe_interval: 30 # wait 30 s before probing (default)
1477
+ )
1478
+
1479
+ class CatalogService
1480
+ prepend SafeMemoize
1481
+
1482
+ def products = fetch_from_redis
1483
+ memoize :products, store: redis
1484
+ end
1485
+ ```
1486
+
1487
+ **Via the `circuit_breaker:` option** (auto-wraps the configured store):
1488
+
1489
+ ```ruby
1490
+ class CatalogService
1491
+ prepend SafeMemoize
1492
+
1493
+ def products = fetch_from_redis
1494
+ memoize :products, store: MyRedisStore.new, circuit_breaker: true
1495
+
1496
+ def orders = fetch_orders
1497
+ memoize :orders,
1498
+ store: MyRedisStore.new,
1499
+ circuit_breaker: { error_threshold: 3, probe_interval: 60 }
1500
+ end
1501
+ ```
1502
+
1503
+ 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.
1504
+
1505
+ ### Introspection and manual control
1506
+
1507
+ ```ruby
1508
+ cb = SafeMemoize::Stores::CircuitBreaker.new(store)
1509
+
1510
+ cb.state # => :closed | :open | :half_open
1511
+ cb.open? # => false
1512
+ cb.error_count # => 0
1513
+ cb.error_threshold # => 5
1514
+ cb.probe_interval # => 30.0
1515
+ cb.wrapped_store # => the inner adapter
1516
+ cb.reset! # manually close the circuit and clear error count
1517
+ ```
1518
+
1519
+ ### Class-wide default
1520
+
1521
+ ```ruby
1522
+ class ApiService
1523
+ prepend SafeMemoize
1524
+ safe_memoize_options circuit_breaker: { error_threshold: 3, probe_interval: 15 }
1525
+
1526
+ def users = http.get("/users")
1527
+ def orders = http.get("/orders")
1528
+
1529
+ memoize :users, store: redis
1530
+ memoize :orders, store: redis # both get the circuit breaker
1531
+ end
1532
+ ```
1533
+
1534
+ [↑ Back to features](#features)
1535
+
1451
1536
  ## Per-class default options (`safe_memoize_options`)
1452
1537
 
1453
1538
  `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
1686
1771
  | `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:` |
1687
1772
  | `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:` |
1688
1773
  | `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 |
1774
+ | `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 |
1689
1775
  | *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
1690
1776
 
1691
1777
  ### `memoize_all` options (class method)
@@ -1703,7 +1789,7 @@ All `memoize` option keys above, plus:
1703
1789
 
1704
1790
  | Option key | Type | Default | Notes |
1705
1791
  |---|---|---|---|
1706
- | 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:` |
1792
+ | 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:` |
1707
1793
 
1708
1794
  ### Instance methods (public)
1709
1795
 
data/ROADMAP.md CHANGED
@@ -4,16 +4,6 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
4
4
 
5
5
  ---
6
6
 
7
- ## v1.6.0 — Resilience
8
-
9
- *Goal: make external-store memoization resilient to infrastructure failures.*
10
-
11
- | Feature | Description | Status |
12
- |---|---|---|
13
- | 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 |
14
-
15
- ---
16
-
17
7
  ## v1.7.0 — Advanced Store Features
18
8
 
19
9
  *Goal: multi-process performance patterns for high-traffic deployments.*
@@ -69,6 +69,11 @@ module SafeMemoize
69
69
  # {.reset_shared_memo_group} for shared-mode methods) to bust every method in the
70
70
  # group at once. A method may belong to at most one group; re-memoizing with a
71
71
  # different group moves it. Must be a non-empty Symbol or String.
72
+ # @param circuit_breaker [Boolean, Hash, nil] wraps the configured +store:+ adapter
73
+ # in a {Stores::CircuitBreaker}. Pass +true+ to use defaults (+error_threshold: 5+,
74
+ # +probe_interval: 30+), or a +Hash+ with +:error_threshold+ and/or +:probe_interval+
75
+ # keys to customise. Requires a +store:+ to be set (per-method, class-level, or
76
+ # global default); raises +ArgumentError+ otherwise.
72
77
  # @return [void]
73
78
  # @raise [ArgumentError] if the method does not exist, or option values are invalid
74
79
  #
@@ -89,7 +94,7 @@ module SafeMemoize
89
94
  # @example With a custom store
90
95
  # STORE = SafeMemoize::Stores::Memory.new
91
96
  # memoize :fetch, store: STORE, ttl: 300
92
- 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)
97
+ 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)
93
98
  method_name = method_name.to_sym
94
99
 
95
100
  unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
@@ -132,6 +137,7 @@ module SafeMemoize
132
137
  namespace = cls_defaults[:namespace] if namespace.equal?(UNSET) && cls_defaults.key?(:namespace)
133
138
  store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store)
134
139
  group = cls_defaults[:group] if group.equal?(UNSET) && cls_defaults.key?(:group)
140
+ circuit_breaker = cls_defaults[:circuit_breaker] if circuit_breaker.equal?(UNSET) && cls_defaults.key?(:circuit_breaker)
135
141
  end
136
142
 
137
143
  # Normalize remaining UNSET to original per-call defaults
@@ -148,6 +154,7 @@ module SafeMemoize
148
154
  cache_bust = nil if cache_bust.equal?(UNSET)
149
155
  copy_on_read = false if copy_on_read.equal?(UNSET)
150
156
  group = nil if group.equal?(UNSET)
157
+ circuit_breaker = nil if circuit_breaker.equal?(UNSET)
151
158
  cond_if = nil if cond_if.equal?(UNSET)
152
159
  cond_unless = nil if cond_unless.equal?(UNSET)
153
160
 
@@ -260,6 +267,19 @@ module SafeMemoize
260
267
  end
261
268
  end
262
269
 
270
+ if circuit_breaker
271
+ unless circuit_breaker == true || circuit_breaker.is_a?(Hash)
272
+ raise ArgumentError, "circuit_breaker: must be true or a Hash of options (got #{circuit_breaker.class})"
273
+ end
274
+ unless effective_store
275
+ raise ArgumentError, "circuit_breaker: requires a store: to be configured (no store is set for :#{method_name})"
276
+ end
277
+ unless effective_store.is_a?(Stores::CircuitBreaker)
278
+ cb_opts = circuit_breaker.is_a?(Hash) ? circuit_breaker : {}
279
+ effective_store = Stores::CircuitBreaker.new(effective_store, **cb_opts)
280
+ end
281
+ end
282
+
263
283
  __safe_memo_class_key_generators__[method_name] = key if key
264
284
  __safe_memo_class_cache_bust_generators__[method_name] = cache_bust if cache_bust
265
285
 
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module Stores
5
+ # Wraps any {Base} store adapter with a circuit breaker that silently falls
6
+ # back to the per-instance in-process cache when the external store is
7
+ # unavailable, rather than propagating exceptions to callers.
8
+ #
9
+ # === States
10
+ #
11
+ # * +:closed+ — normal; every call goes through to the wrapped store;
12
+ # consecutive errors are counted
13
+ # * +:open+ — tripped; reads return {MISS} and writes are no-ops so the
14
+ # memoize wrapper falls back to the per-instance hash; no
15
+ # calls reach the wrapped store until the probe interval elapses
16
+ # * +:half_open+ — probe period (probe interval elapsed); calls are let
17
+ # through to the wrapped store; the first success closes the
18
+ # circuit, any failure re-opens it and resets the timer
19
+ #
20
+ # Any successful call while the circuit is +:closed+ resets the consecutive
21
+ # error counter, so transient blips do not accumulate toward the threshold.
22
+ #
23
+ # @example Wrap a custom Redis store
24
+ # store = SafeMemoize::Stores::CircuitBreaker.new(
25
+ # MyRedisStore.new,
26
+ # error_threshold: 5,
27
+ # probe_interval: 30
28
+ # )
29
+ # memoize :fetch, store: store
30
+ #
31
+ # @example Via the circuit_breaker: option (auto-wraps the configured store)
32
+ # memoize :fetch, store: MyRedisStore.new, circuit_breaker: true
33
+ # memoize :fetch, store: MyRedisStore.new,
34
+ # circuit_breaker: { error_threshold: 3, probe_interval: 60 }
35
+ class CircuitBreaker < Base
36
+ DEFAULT_ERROR_THRESHOLD = 5
37
+ DEFAULT_PROBE_INTERVAL = 30.0
38
+
39
+ # @return [Stores::Base] the wrapped inner store
40
+ attr_reader :wrapped_store
41
+ # @return [Integer] number of consecutive errors that trip the circuit
42
+ attr_reader :error_threshold
43
+ # @return [Float] seconds after tripping before a probe is attempted
44
+ attr_reader :probe_interval
45
+
46
+ # @param store [Stores::Base] the backing store to protect
47
+ # @param error_threshold [Integer] consecutive errors that trip the circuit (default 5)
48
+ # @param probe_interval [Numeric] seconds to wait before probing (default 30)
49
+ # @raise [ArgumentError] if +store+ is not a {Stores::Base} instance, or
50
+ # if threshold / interval are invalid
51
+ def initialize(store, error_threshold: DEFAULT_ERROR_THRESHOLD, probe_interval: DEFAULT_PROBE_INTERVAL)
52
+ unless store.is_a?(Base)
53
+ raise ArgumentError, "CircuitBreaker requires a Stores::Base instance (got #{store.class})"
54
+ end
55
+
56
+ @wrapped_store = store
57
+ @error_threshold = Integer(error_threshold)
58
+ @probe_interval = Float(probe_interval)
59
+
60
+ raise ArgumentError, "error_threshold must be positive" unless @error_threshold > 0
61
+ raise ArgumentError, "probe_interval must be positive" unless @probe_interval > 0
62
+
63
+ @mutex = Mutex.new
64
+ @error_count = 0
65
+ @opened_at = nil
66
+ end
67
+
68
+ # Read from the wrapped store, returning {MISS} on error or when the
69
+ # circuit is open instead of raising.
70
+ def read(key)
71
+ st = current_state
72
+ return MISS if st == :open
73
+
74
+ result = @wrapped_store.read(key)
75
+ record_success(st)
76
+ result
77
+ rescue
78
+ record_failure
79
+ MISS
80
+ end
81
+
82
+ # Write to the wrapped store, silently swallowing errors so the caller's
83
+ # return value is unaffected. A no-op when the circuit is open.
84
+ def write(key, value, expires_in: nil)
85
+ st = current_state
86
+ return if st == :open
87
+
88
+ @wrapped_store.write(key, value, expires_in: expires_in)
89
+ record_success(st)
90
+ rescue
91
+ record_failure
92
+ end
93
+
94
+ # Delete from the wrapped store. A no-op when the circuit is open.
95
+ def delete(key)
96
+ return if current_state == :open
97
+
98
+ @wrapped_store.delete(key)
99
+ rescue
100
+ record_failure
101
+ end
102
+
103
+ # Clear the wrapped store. Errors are recorded but not re-raised.
104
+ def clear
105
+ @wrapped_store.clear
106
+ rescue
107
+ record_failure
108
+ end
109
+
110
+ # Returns live keys from the wrapped store, or an empty array when the
111
+ # circuit is open or the store raises.
112
+ def keys
113
+ return [] if current_state == :open
114
+
115
+ @wrapped_store.keys
116
+ rescue
117
+ record_failure
118
+ []
119
+ end
120
+
121
+ # Returns the current circuit state: +:closed+, +:open+, or +:half_open+.
122
+ # @return [Symbol]
123
+ def state
124
+ current_state
125
+ end
126
+
127
+ # Returns +true+ when the circuit is not fully closed (i.e. open or half-open).
128
+ # @return [Boolean]
129
+ def open?
130
+ current_state != :closed
131
+ end
132
+
133
+ # Returns the current consecutive error count.
134
+ # @return [Integer]
135
+ def error_count
136
+ @mutex.synchronize { @error_count }
137
+ end
138
+
139
+ # Manually resets the circuit to +:closed+, clearing the error counter.
140
+ # @return [void]
141
+ def reset!
142
+ @mutex.synchronize do
143
+ @error_count = 0
144
+ @opened_at = nil
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def current_state
151
+ @mutex.synchronize do
152
+ next :closed if @opened_at.nil?
153
+
154
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @opened_at
155
+ (elapsed >= @probe_interval) ? :half_open : :open
156
+ end
157
+ end
158
+
159
+ def record_success(prior_state)
160
+ return unless prior_state == :half_open || @error_count > 0
161
+
162
+ @mutex.synchronize do
163
+ @error_count = 0
164
+ @opened_at = nil
165
+ end
166
+ end
167
+
168
+ def record_failure
169
+ @mutex.synchronize do
170
+ @error_count += 1
171
+ if @error_count >= @error_threshold || !@opened_at.nil?
172
+ @opened_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SafeMemoize
4
4
  # The current gem version string.
5
- VERSION = "1.5.0"
5
+ VERSION = "1.6.0"
6
6
  end
data/lib/safe_memoize.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "safe_memoize/configuration"
5
5
  require_relative "safe_memoize/extension"
6
6
  require_relative "safe_memoize/stores/base"
7
7
  require_relative "safe_memoize/stores/memory"
8
+ require_relative "safe_memoize/stores/circuit_breaker"
8
9
  require_relative "safe_memoize/adapters/statsd"
9
10
  require_relative "safe_memoize/adapters/opentelemetry"
10
11
  require_relative "safe_memoize/adapters/concurrent_ruby"
data/sig/safe_memoize.rbs CHANGED
@@ -66,7 +66,7 @@ module SafeMemoize
66
66
  end
67
67
 
68
68
  module ClassMethods
69
- 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
69
+ 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
70
70
  def safe_memoize_store: () -> Stores::Base?
71
71
  def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
72
72
  def safe_memoize_namespace: () -> String?
@@ -300,6 +300,32 @@ module SafeMemoize
300
300
 
301
301
  def expired?: ({ expires_at: Float?, value: untyped, cached_at: Float }) -> bool
302
302
  end
303
+
304
+ class CircuitBreaker < Base
305
+ DEFAULT_ERROR_THRESHOLD: Integer
306
+ DEFAULT_PROBE_INTERVAL: Float
307
+
308
+ attr_reader wrapped_store: Base
309
+ attr_reader error_threshold: Integer
310
+ attr_reader probe_interval: Float
311
+
312
+ def initialize: (Base store, ?error_threshold: Integer, ?probe_interval: Numeric) -> void
313
+ def read: (untyped key) -> untyped
314
+ def write: (untyped key, untyped value, ?expires_in: Numeric?) -> void
315
+ def delete: (untyped key) -> void
316
+ def clear: () -> void
317
+ def keys: () -> Array[untyped]
318
+ def state: () -> Symbol
319
+ def open?: () -> bool
320
+ def error_count: () -> Integer
321
+ def reset!: () -> void
322
+
323
+ private
324
+
325
+ def current_state: () -> Symbol
326
+ def record_success: (Symbol prior_state) -> void
327
+ def record_failure: () -> void
328
+ end
303
329
  end
304
330
 
305
331
  module Adapters
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: safe_memoize
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -77,6 +77,7 @@ files:
77
77
  - lib/safe_memoize/rails/request_scoped.rb
78
78
  - lib/safe_memoize/release_tooling.rb
79
79
  - lib/safe_memoize/stores/base.rb
80
+ - lib/safe_memoize/stores/circuit_breaker.rb
80
81
  - lib/safe_memoize/stores/memory.rb
81
82
  - lib/safe_memoize/stores/rails_cache.rb
82
83
  - lib/safe_memoize/stores/redis.rb