safe_memoize 1.4.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: e34f9a8aaa025eca2f817b633409686788e99442f10de2fede62f30443f9a0fc
4
- data.tar.gz: 21560a08d7060ee77da109da4a91024899674da14ec41e051ff5cb08a5d913f2
3
+ metadata.gz: 1920c31de4a855b818c5a4f37f35260f38489b7ff6ffe221b43d91e862133bde
4
+ data.tar.gz: 948979dfc49f4e42b16d21926344869fc06a39da3252ab1033697a5748a7362a
5
5
  SHA512:
6
- metadata.gz: '0408a2a2322c7861ec0eab88a62e02a15c59c552301e8fe3b7712691d03f385c81a2efbcf8ef06da57b50ae304edc18a7794001c43093b9fc49c180449a68199'
7
- data.tar.gz: 421ad6f07416395bef2723fd00a20d7e20c4becd2fec0da9f774ca476d370b1bb459b8d895c2cb7535da9898e27afcfdfcf3d089923ff8aac426dee0c89c5aa8
6
+ metadata.gz: 415c4095ce702b5823a887858f342fdf5742887394dc2cc113ccf711ef1f5789430e036d69ee3660d663a2153f34375f3622f1ff92d73143072e2376d2ae087b
7
+ data.tar.gz: 1fed071b0feb6591d4b95c69604f4c14440c88cefbdb13ce9f2e50a2eba5fad4723b28c1584f2cb7aaa33f7ba4e2140b5a16c45b99ba0cd9b13f47b2a16b0d58
data/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ 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
+
23
+ ## [1.5.0] - 2026-06-02
24
+
25
+ ### Added
26
+
27
+ - `group:` option on `memoize` — assigns a method to a named invalidation group (`memoize :find, group: :database`). Groups are stored on the class and survive re-memoization; a method can belong to at most one group at a time (re-memoizing with a different group moves it). Accepts any non-empty Symbol or String. Can be set as a class default via `safe_memoize_options group: :my_group`.
28
+ - `reset_memo_group(group_name)` instance method — clears all per-instance cached entries for every method in the named group in a single call; each evicted entry fires the `:on_evict` hook. A no-op for unknown groups.
29
+ - `reset_shared_memo_group(group_name)` class method — the shared-cache equivalent of `reset_memo_group`; clears all shared-cache entries for every method in the group that was memoized with `shared: true`.
30
+ - `memo_group_methods(group_name)` instance method — returns the array of method names belonging to the given group on the instance's class (empty array for unknown groups).
31
+ - `memo_groups` instance method — returns all group names registered on the instance's class.
32
+ - `safe_memo_group_methods(group_name)` class method — class-level equivalent of `memo_group_methods`.
33
+ - `safe_memo_groups` class method — class-level equivalent of `memo_groups`.
34
+
11
35
  ## [1.4.0] - 2026-06-02
12
36
 
13
37
  ### Added
data/README.md CHANGED
@@ -79,6 +79,8 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
79
79
  - [Plugin / extension architecture — `SafeMemoize::Extension` DSL for adding custom `memoize` options and global lifecycle handlers without monkey-patching](#plugin--extension-architecture)
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
+ - [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)
82
84
 
83
85
  ## Installation
84
86
 
@@ -194,6 +196,79 @@ obj.reset_all_memos # Clears all memoized values
194
196
 
195
197
  [↑ Back to features](#features)
196
198
 
199
+ ### Cache invalidation groups
200
+
201
+ Tag related methods with `group:` and bust them all at once with a single `reset_memo_group` call:
202
+
203
+ ```ruby
204
+ class RepoService
205
+ prepend SafeMemoize
206
+
207
+ def find_user(id) = db.query("SELECT * FROM users WHERE id=?", id)
208
+ def find_post(id) = db.query("SELECT * FROM posts WHERE id=?", id)
209
+ def site_config = db.query("SELECT * FROM config LIMIT 1")
210
+
211
+ memoize :find_user, group: :database
212
+ memoize :find_post, group: :database
213
+ memoize :site_config # no group — unaffected by group reset
214
+ end
215
+
216
+ svc = RepoService.new
217
+ svc.find_user(1)
218
+ svc.find_post(42)
219
+ svc.site_config
220
+
221
+ svc.reset_memo_group(:database) # invalidates find_user and find_post only
222
+ svc.memoized?(:site_config) # => true — unaffected
223
+ ```
224
+
225
+ For `shared: true` methods, use the class method:
226
+
227
+ ```ruby
228
+ class CatalogService
229
+ prepend SafeMemoize
230
+
231
+ def products = fetch_all_products
232
+ def categories = fetch_all_categories
233
+
234
+ memoize :products, shared: true, group: :catalog
235
+ memoize :categories, shared: true, group: :catalog
236
+ end
237
+
238
+ CatalogService.reset_shared_memo_group(:catalog) # clears shared cache for both methods
239
+ ```
240
+
241
+ #### Introspection
242
+
243
+ ```ruby
244
+ svc.memo_groups # => [:database] — all groups on the class
245
+ svc.memo_group_methods(:database) # => [:find_user, :find_post]
246
+ CatalogService.safe_memo_groups # => [:catalog]
247
+ CatalogService.safe_memo_group_methods(:catalog) # => [:products, :categories]
248
+ ```
249
+
250
+ #### Class-wide group default
251
+
252
+ Use `safe_memoize_options` to assign all subsequently memoized methods to the same group:
253
+
254
+ ```ruby
255
+ class ApiClient
256
+ prepend SafeMemoize
257
+ safe_memoize_options group: :api
258
+
259
+ def users = http.get("/users")
260
+ def orders = http.get("/orders")
261
+
262
+ memoize :users # group: :api
263
+ memoize :orders # group: :api
264
+ memoize :health, group: nil # override — no group
265
+ end
266
+ ```
267
+
268
+ A method belongs to at most one group at a time; re-memoizing with a different `group:` moves it.
269
+
270
+ [↑ Back to features](#features)
271
+
197
272
  ### Lifecycle hooks
198
273
 
199
274
  Register callbacks that fire when cached entries are evicted or expire.
@@ -1374,6 +1449,90 @@ end
1374
1449
 
1375
1450
  [↑ Back to features](#features)
1376
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
+
1377
1536
  ## Per-class default options (`safe_memoize_options`)
1378
1537
 
1379
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.
@@ -1611,6 +1770,8 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
1611
1770
  | `shared_cache:` | `String \| nil` | `nil` | Name of a globally-registered shared store; incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` |
1612
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:` |
1613
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:` |
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 |
1614
1775
  | *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
1615
1776
 
1616
1777
  ### `memoize_all` options (class method)
@@ -1628,7 +1789,7 @@ All `memoize` option keys above, plus:
1628
1789
 
1629
1790
  | Option key | Type | Default | Notes |
1630
1791
  |---|---|---|---|
1631
- | any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`; 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:` |
1632
1793
 
1633
1794
  ### Instance methods (public)
1634
1795
 
@@ -1650,10 +1811,18 @@ All `memoize` option keys above, plus:
1650
1811
  | Method | Returns |
1651
1812
  |---|---|
1652
1813
  | `reset_memo(method_name, *args, **kwargs)` | `nil` |
1814
+ | `reset_memo_group(group_name)` | `nil` |
1653
1815
  | `reset_all_memos` | `nil` |
1654
1816
  | `memo_touch(method_name, *args, ttl: nil, **kwargs)` | `Boolean` |
1655
1817
  | `memo_refresh(method_name, *args, **kwargs)` | cached value |
1656
1818
 
1819
+ **Group introspection**
1820
+
1821
+ | Method | Returns |
1822
+ |---|---|
1823
+ | `memo_groups` | `Array<Symbol>` — all group names on the class |
1824
+ | `memo_group_methods(group_name)` | `Array<Symbol>` — methods in the group |
1825
+
1657
1826
  **Warm-up and persistence**
1658
1827
 
1659
1828
  | Method | Returns |
@@ -1705,11 +1874,19 @@ All `memoize` option keys above, plus:
1705
1874
  |---|---|
1706
1875
  | `reset_shared_memo(method_name, *args, **kwargs)` | `nil` |
1707
1876
  | `reset_all_shared_memos` | `nil` |
1877
+ | `reset_shared_memo_group(group_name)` | `nil` |
1708
1878
  | `shared_memoized?(method_name, *args, **kwargs)` | `Boolean` |
1709
1879
  | `shared_memo_count(method_name = nil)` | `Integer` |
1710
1880
  | `shared_memo_age(method_name, *args, **kwargs)` | `Numeric \| nil` |
1711
1881
  | `shared_memo_stale?(method_name, *args, **kwargs)` | `Boolean` |
1712
1882
 
1883
+ ### Group class methods (available on any class that uses `group:`)
1884
+
1885
+ | Method | Returns |
1886
+ |---|---|
1887
+ | `safe_memo_groups` | `Array<Symbol>` — all group names on the class |
1888
+ | `safe_memo_group_methods(group_name)` | `Array<Symbol>` — methods belonging to the group |
1889
+
1713
1890
  **Ractor-safe shared cache (added when any method uses `ractor_safe: true`)**
1714
1891
 
1715
1892
  | Method | Returns |
data/ROADMAP.md CHANGED
@@ -4,26 +4,6 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
4
4
 
5
5
  ---
6
6
 
7
- ## v1.5.0 — Cache Invalidation
8
-
9
- *Goal: group-level cache invalidation so related methods can be busted in one operation.*
10
-
11
- | Feature | Description | Status |
12
- |---|---|---|
13
- | Memoization groups | `memoize :find, group: :database` then `reset_memo_group(:database)` to invalidate all methods tagged with the same group at once; groups can span multiple methods on the same class | Planned |
14
-
15
- ---
16
-
17
- ## v1.6.0 — Resilience
18
-
19
- *Goal: make external-store memoization resilient to infrastructure failures.*
20
-
21
- | Feature | Description | Status |
22
- |---|---|---|
23
- | 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 |
24
-
25
- ---
26
-
27
7
  ## v1.7.0 — Advanced Store Features
28
8
 
29
9
  *Goal: multi-process performance patterns for high-traffic deployments.*
@@ -64,6 +64,16 @@ module SafeMemoize
64
64
  # itself. Prevents callers from mutating shared cached state. Frozen and +nil+
65
65
  # values are returned as-is. Incompatible with +ractor_safe:+ (ractor values are
66
66
  # always frozen; use that guarantee instead).
67
+ # @param group [Symbol, String, nil] assigns the method to a named invalidation
68
+ # group. Call {PublicMethods#reset_memo_group} on an instance (or
69
+ # {.reset_shared_memo_group} for shared-mode methods) to bust every method in the
70
+ # group at once. A method may belong to at most one group; re-memoizing with a
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.
67
77
  # @return [void]
68
78
  # @raise [ArgumentError] if the method does not exist, or option values are invalid
69
79
  #
@@ -84,7 +94,7 @@ module SafeMemoize
84
94
  # @example With a custom store
85
95
  # STORE = SafeMemoize::Stores::Memory.new
86
96
  # memoize :fetch, store: STORE, ttl: 300
87
- 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, **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)
88
98
  method_name = method_name.to_sym
89
99
 
90
100
  unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
@@ -126,6 +136,8 @@ module SafeMemoize
126
136
  copy_on_read = cls_defaults[:copy_on_read] if copy_on_read.equal?(UNSET) && cls_defaults.key?(:copy_on_read)
127
137
  namespace = cls_defaults[:namespace] if namespace.equal?(UNSET) && cls_defaults.key?(:namespace)
128
138
  store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store)
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)
129
141
  end
130
142
 
131
143
  # Normalize remaining UNSET to original per-call defaults
@@ -141,6 +153,8 @@ module SafeMemoize
141
153
  shared_cache = nil if shared_cache.equal?(UNSET)
142
154
  cache_bust = nil if cache_bust.equal?(UNSET)
143
155
  copy_on_read = false if copy_on_read.equal?(UNSET)
156
+ group = nil if group.equal?(UNSET)
157
+ circuit_breaker = nil if circuit_breaker.equal?(UNSET)
144
158
  cond_if = nil if cond_if.equal?(UNSET)
145
159
  cond_unless = nil if cond_unless.equal?(UNSET)
146
160
 
@@ -213,6 +227,15 @@ module SafeMemoize
213
227
  __safe_memo_method_namespaces__[method_name] = namespace
214
228
  end
215
229
 
230
+ if group
231
+ unless group.is_a?(Symbol) || group.is_a?(String)
232
+ raise ArgumentError, "group: must be a Symbol or String (got #{group.class})"
233
+ end
234
+ group = group.to_sym
235
+ raise ArgumentError, "group: must not be empty" if group.empty?
236
+ __safe_memo_register_group__(method_name, group)
237
+ end
238
+
216
239
  if shared_cache
217
240
  raise ArgumentError, "shared_cache: must be a String (got #{shared_cache.class})" unless shared_cache.is_a?(String)
218
241
  raise ArgumentError, "shared_cache: must not be empty" if shared_cache.empty?
@@ -244,6 +267,19 @@ module SafeMemoize
244
267
  end
245
268
  end
246
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
+
247
283
  __safe_memo_class_key_generators__[method_name] = key if key
248
284
  __safe_memo_class_cache_bust_generators__[method_name] = cache_bust if cache_bust
249
285
 
@@ -763,6 +799,35 @@ module SafeMemoize
763
799
  end
764
800
  end
765
801
 
802
+ # Clears all shared-cache entries for every method in the given group.
803
+ #
804
+ # Only affects methods memoized with +shared: true+. For per-instance cache
805
+ # invalidation use {PublicMethods#reset_memo_group} on the instance.
806
+ #
807
+ # @param group_name [Symbol, String]
808
+ # @return [void]
809
+ def reset_shared_memo_group(group_name)
810
+ group_name = group_name.to_sym
811
+ (__safe_memo_groups__[group_name] || []).each { |m| reset_shared_memo(m) }
812
+ end
813
+
814
+ # Returns the method names belonging to the given invalidation group, or an
815
+ # empty array when the group is unknown.
816
+ #
817
+ # @param group_name [Symbol, String]
818
+ # @return [Array<Symbol>]
819
+ def safe_memo_group_methods(group_name)
820
+ group_name = group_name.to_sym
821
+ (__safe_memo_groups__[group_name] || []).dup
822
+ end
823
+
824
+ # Returns all group names registered on this class.
825
+ #
826
+ # @return [Array<Symbol>]
827
+ def safe_memo_groups
828
+ __safe_memo_groups__.keys
829
+ end
830
+
766
831
  private
767
832
 
768
833
  def __safe_memo_shared_cache__
@@ -793,6 +858,17 @@ module SafeMemoize
793
858
  @__safe_memoize_defaults__
794
859
  end
795
860
 
861
+ def __safe_memo_groups__
862
+ @__safe_memo_groups__ ||= {}
863
+ end
864
+
865
+ def __safe_memo_register_group__(method_name, group)
866
+ groups = __safe_memo_groups__
867
+ # Remove method from any prior group it belonged to
868
+ groups.each_value { |methods| methods.delete(method_name) }
869
+ (groups[group] ||= []) << method_name
870
+ end
871
+
796
872
  # Resolves the effective first-element key sym for a given bare method name,
797
873
  # applying the active namespace. Used by class-level cache operations where
798
874
  # instance methods (compute_cache_key) are unavailable.
@@ -359,6 +359,37 @@ module SafeMemoize
359
359
  end
360
360
  end
361
361
 
362
+ # Clears all per-instance cached entries for every method belonging to the
363
+ # given invalidation group (declared via +memoize :method, group: :name+).
364
+ #
365
+ # A no-op when the group is unknown or has no members. Each evicted entry
366
+ # fires the +:on_evict+ hook. For shared-mode methods use the class-level
367
+ # {ClassMethods.reset_shared_memo_group} instead.
368
+ #
369
+ # @param group_name [Symbol, String]
370
+ # @return [void]
371
+ def reset_memo_group(group_name)
372
+ group_name = group_name.to_sym
373
+ (self.class.send(:__safe_memo_groups__)[group_name] || []).each { |m| reset_memo(m) }
374
+ end
375
+
376
+ # Returns the method names belonging to the given invalidation group on
377
+ # this instance's class, or an empty array when the group is unknown.
378
+ #
379
+ # @param group_name [Symbol, String]
380
+ # @return [Array<Symbol>]
381
+ def memo_group_methods(group_name)
382
+ group_name = group_name.to_sym
383
+ (self.class.send(:__safe_memo_groups__)[group_name] || []).dup
384
+ end
385
+
386
+ # Returns all invalidation group names registered on this instance's class.
387
+ #
388
+ # @return [Array<Symbol>]
389
+ def memo_groups
390
+ self.class.send(:__safe_memo_groups__).keys
391
+ end
392
+
362
393
  # Clears all cached entries for every method on this instance.
363
394
  # Each evicted entry fires the +:on_evict+ hook.
364
395
  #
@@ -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.4.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
@@ -17,6 +17,7 @@ module SafeMemoize
17
17
  @__safe_memo_shared_cache__: Hash[memo_key, memo_record]?
18
18
  @__safe_memo_shared_mutex__: Mutex?
19
19
  @__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
20
+ @__safe_memo_groups__: Hash[Symbol, Array[Symbol]]?
20
21
 
21
22
  UNSET: untyped
22
23
  SHARED_CACHE_REGISTRY: Hash[String, Stores::Base]
@@ -65,7 +66,7 @@ module SafeMemoize
65
66
  end
66
67
 
67
68
  module ClassMethods
68
- 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, **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
69
70
  def safe_memoize_store: () -> Stores::Base?
70
71
  def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
71
72
  def safe_memoize_namespace: () -> String?
@@ -78,6 +79,9 @@ module SafeMemoize
78
79
  def shared_memo_count: (?Symbol | String method_name) -> Integer
79
80
  def shared_memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float?
80
81
  def shared_memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
82
+ def reset_shared_memo_group: (Symbol | String group_name) -> void
83
+ def safe_memo_group_methods: (Symbol | String group_name) -> Array[Symbol]
84
+ def safe_memo_groups: () -> Array[Symbol]
81
85
 
82
86
  private
83
87
 
@@ -88,6 +92,8 @@ module SafeMemoize
88
92
  def __safe_memo_method_namespaces__: () -> Hash[Symbol, String]
89
93
  def __safe_memo_class_cache_bust_generators__: () -> Hash[Symbol, Proc | Symbol]
90
94
  def __safe_memoize_defaults__: () -> Hash[Symbol, untyped]?
95
+ def __safe_memo_groups__: () -> Hash[Symbol, Array[Symbol]]
96
+ def __safe_memo_register_group__: (Symbol method_name, Symbol group) -> void
91
97
  def memoized_method_visibility: (Symbol method_name) -> Symbol
92
98
  end
93
99
 
@@ -114,6 +120,9 @@ module SafeMemoize
114
120
  def memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float?
115
121
  def memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
116
122
  def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
123
+ def reset_memo_group: (Symbol | String group_name) -> void
124
+ def memo_group_methods: (Symbol | String group_name) -> Array[Symbol]
125
+ def memo_groups: () -> Array[Symbol]
117
126
  def reset_all_memos: () -> void
118
127
  def memo_inspect: (Symbol | String method_name, *untyped args, **untyped kwargs) -> { cached: bool, value: untyped, hits: Integer, misses: Integer, ttl_remaining: Float?, age: Float?, custom_key: untyped, lru_position: Integer? }?
119
128
  end
@@ -291,6 +300,32 @@ module SafeMemoize
291
300
 
292
301
  def expired?: ({ expires_at: Float?, value: untyped, cached_at: Float }) -> bool
293
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
294
329
  end
295
330
 
296
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.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