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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +87 -1
- data/ROADMAP.md +0 -10
- data/lib/safe_memoize/class_methods.rb +21 -1
- data/lib/safe_memoize/stores/circuit_breaker.rb +178 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +1 -0
- data/sig/safe_memoize.rbs +27 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1920c31de4a855b818c5a4f37f35260f38489b7ff6ffe221b43d91e862133bde
|
|
4
|
+
data.tar.gz: 948979dfc49f4e42b16d21926344869fc06a39da3252ab1033697a5748a7362a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/safe_memoize/version.rb
CHANGED
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.
|
|
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
|