safe_memoize 1.5.0 → 1.7.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: e7a646739aa7f6c178b20192ddc8e94768df0c6b000097586796153f5d2138bc
4
+ data.tar.gz: 9454f9a6cf6b8e420d27b82f39740484eddab506d871e94034483ae8caf12622
5
5
  SHA512:
6
- metadata.gz: 8a034c60f1f200e3971be9828563d43b406e1fc1feef4d2e044f880456ac2d1e0b7cf98b3d1e3292fb2641bb8c5e9d3e21c387f1a0041ae8996fa004db863d92
7
- data.tar.gz: 1f72d605f086951abc9bff9a2dc452ecece2694cd7ad27a118589ba8c48b4e02fb6a35b97271e0007e1dde55ff1af798bf278fabedc07b2c0b74d955af270a56
6
+ metadata.gz: f569af43ba1ca765dcdd9490f5629bb572f1dbedaa676538a08c199282fe55a6b40afd29f8870ddbab86c091a20a89c10e87c3b060a124f4994313b2a2ffdda4
7
+ data.tar.gz: 2d0634278dd32e98fc0c23a74abfb98bf1c8d5d087e7eb7a8006dd54846bd11f0bbce27d704e4f82bf0fc81c961c34b1c62fb8dc92cddafbcec26c2b930e79d8
data/CHANGELOG.md CHANGED
@@ -8,6 +8,27 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.7.0] - 2026-06-02
12
+
13
+ ### Added
14
+
15
+ - `SafeMemoize::Stores::Multilevel` — multi-level (L1/L2/…) cache store that checks faster layers first and promotes values from deeper layers into shallower ones on a miss ("read-through promotion"). Reads walk the list from first (fastest) to last; writes go to every level simultaneously; deletes and clears apply to all levels. Accepts `promote_expires_in:` to control the TTL of promoted entries (default: no TTL, relying on the L1 store's own eviction). Raises `ArgumentError` if fewer than two stores are supplied.
16
+ - `store: [l1, l2]` shorthand on `memoize` — passing an `Array` of `Stores::Base` instances is automatically converted to `Stores::Multilevel.new(*stores)`, enabling multi-level caching without explicit wrapper construction.
17
+ - `SafeMemoize::Stores::XFetch` — wraps any `Stores::Base` adapter with probabilistic early expiry (the XFetch algorithm) to prevent cache stampedes. Values are stored with an envelope that includes `expires_at`; on read the XFetch formula `now − (delta × beta × log(rand)) ≥ expires_at` decides whether to return the value or `MISS` (triggering early recomputation). Configurable via `beta:` (aggressiveness scalar, default 1.0) and `delta:` (estimated computation time in seconds, default 0.1). Composes naturally with `Multilevel` and `CircuitBreaker`.
18
+ - `stampede_protection:` option on `memoize` — enables the XFetch algorithm for the per-instance in-process cache. Pass `true` (default beta 1.0) or a `Numeric` for a custom beta. Records actual computation time as `delta` on each miss so the XFetch probability adapts to real observed latency. Requires `ttl:`. Incompatible with `store:` (use `Stores::XFetch` for external stores) and `ractor_safe:`. Accepted by `safe_memoize_options` as a class-wide default.
19
+
20
+ ## [1.6.0] - 2026-06-02
21
+
22
+ ### Added
23
+
24
+ - `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.
25
+ - `state` — returns `:closed`, `:open`, or `:half_open`
26
+ - `open?` — `true` when the circuit is not fully closed
27
+ - `error_count` — current consecutive error count
28
+ - `reset!` — manually close the circuit and clear the counter
29
+ - `wrapped_store`, `error_threshold`, `probe_interval` — readers
30
+ - `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.
31
+
11
32
  ## [1.5.0] - 2026-06-02
12
33
 
13
34
  ### Added
data/README.md CHANGED
@@ -80,6 +80,9 @@ 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)
84
+ - [Multi-level (L1/L2) caching — `Stores::Multilevel` or `store: [l1, l2]` shorthand; reads from the fastest layer first and promotes on miss](#multi-level-caching)
85
+ - [Stampede protection — `stampede_protection:` option applies the XFetch algorithm to the per-instance cache; `Stores::XFetch` applies it to external stores](#stampede-protection)
83
86
 
84
87
  ## Installation
85
88
 
@@ -1448,6 +1451,186 @@ end
1448
1451
 
1449
1452
  [↑ Back to features](#features)
1450
1453
 
1454
+ ## Circuit breaker for external stores
1455
+
1456
+ `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.
1457
+
1458
+ ### How it works
1459
+
1460
+ The breaker moves through three states:
1461
+
1462
+ | State | Behaviour |
1463
+ |---|---|
1464
+ | `:closed` | Normal — every call passes through to the wrapped store; consecutive errors are counted |
1465
+ | `:open` | Tripped — reads return `MISS` (triggering the per-instance fallback), writes are no-ops; no calls reach the store until the probe interval elapses |
1466
+ | `:half_open` | Probe — calls are let through; the first success closes the circuit; any failure re-opens it and resets the timer |
1467
+
1468
+ Any successful call in `:closed` state resets the consecutive error counter, so transient blips do not accumulate toward the threshold.
1469
+
1470
+ ### Usage
1471
+
1472
+ **Direct wrapping:**
1473
+
1474
+ ```ruby
1475
+ redis = SafeMemoize::Stores::CircuitBreaker.new(
1476
+ MyRedisStore.new,
1477
+ error_threshold: 5, # trip after 5 consecutive failures (default)
1478
+ probe_interval: 30 # wait 30 s before probing (default)
1479
+ )
1480
+
1481
+ class CatalogService
1482
+ prepend SafeMemoize
1483
+
1484
+ def products = fetch_from_redis
1485
+ memoize :products, store: redis
1486
+ end
1487
+ ```
1488
+
1489
+ **Via the `circuit_breaker:` option** (auto-wraps the configured store):
1490
+
1491
+ ```ruby
1492
+ class CatalogService
1493
+ prepend SafeMemoize
1494
+
1495
+ def products = fetch_from_redis
1496
+ memoize :products, store: MyRedisStore.new, circuit_breaker: true
1497
+
1498
+ def orders = fetch_orders
1499
+ memoize :orders,
1500
+ store: MyRedisStore.new,
1501
+ circuit_breaker: { error_threshold: 3, probe_interval: 60 }
1502
+ end
1503
+ ```
1504
+
1505
+ 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.
1506
+
1507
+ ### Introspection and manual control
1508
+
1509
+ ```ruby
1510
+ cb = SafeMemoize::Stores::CircuitBreaker.new(store)
1511
+
1512
+ cb.state # => :closed | :open | :half_open
1513
+ cb.open? # => false
1514
+ cb.error_count # => 0
1515
+ cb.error_threshold # => 5
1516
+ cb.probe_interval # => 30.0
1517
+ cb.wrapped_store # => the inner adapter
1518
+ cb.reset! # manually close the circuit and clear error count
1519
+ ```
1520
+
1521
+ ### Class-wide default
1522
+
1523
+ ```ruby
1524
+ class ApiService
1525
+ prepend SafeMemoize
1526
+ safe_memoize_options circuit_breaker: { error_threshold: 3, probe_interval: 15 }
1527
+
1528
+ def users = http.get("/users")
1529
+ def orders = http.get("/orders")
1530
+
1531
+ memoize :users, store: redis
1532
+ memoize :orders, store: redis # both get the circuit breaker
1533
+ end
1534
+ ```
1535
+
1536
+ [↑ Back to features](#features)
1537
+
1538
+ ## Multi-level caching
1539
+
1540
+ `SafeMemoize::Stores::Multilevel` chains two or more store adapters from fastest (L1) to slowest. Reads walk the chain until a hit is found; on a miss in an earlier layer the value is fetched from the next layer and written back ("promoted") into all shallower layers. Writes and deletes reach every layer.
1541
+
1542
+ ```ruby
1543
+ l1 = SafeMemoize::Stores::Memory.new # fast, in-process
1544
+ l2 = MyRedisStore.new # slower, cross-process
1545
+
1546
+ class ProductService
1547
+ prepend SafeMemoize
1548
+
1549
+ def catalog = fetch_catalog_from_db
1550
+ memoize :catalog, store: [l1, l2], ttl: 300 # Array shorthand
1551
+ end
1552
+ ```
1553
+
1554
+ The `store: [l1, l2]` shorthand is equivalent to `store: Stores::Multilevel.new(l1, l2)`.
1555
+
1556
+ ### Promotion TTL
1557
+
1558
+ By default promoted entries have no TTL (the L1 store's own eviction — e.g. LRU — handles memory bounds). Set `promote_expires_in:` to give L1 entries a shorter lifetime than L2:
1559
+
1560
+ ```ruby
1561
+ store = SafeMemoize::Stores::Multilevel.new(l1, l2, promote_expires_in: 60)
1562
+ memoize :catalog, store: store, ttl: 300
1563
+ ```
1564
+
1565
+ ### Composition
1566
+
1567
+ `Multilevel` composes with `CircuitBreaker` and `XFetch`:
1568
+
1569
+ ```ruby
1570
+ safe_l2 = SafeMemoize::Stores::CircuitBreaker.new(MyRedisStore.new)
1571
+ store = SafeMemoize::Stores::Multilevel.new(SafeMemoize::Stores::Memory.new, safe_l2)
1572
+ memoize :catalog, store: store, ttl: 300
1573
+ ```
1574
+
1575
+ [↑ Back to features](#features)
1576
+
1577
+ ## Stampede protection
1578
+
1579
+ Cache stampedes (a.k.a. thundering-herd) happen when a popular entry expires and many processes simultaneously recompute it. SafeMemoize offers two mechanisms:
1580
+
1581
+ ### `stampede_protection:` — per-instance cache
1582
+
1583
+ The `stampede_protection:` option applies the **XFetch algorithm** to the per-instance in-process cache. Instead of expiring at a hard deadline, each cache read probabilistically triggers early recomputation as the entry approaches its TTL:
1584
+
1585
+ ```ruby
1586
+ class ApiClient
1587
+ prepend SafeMemoize
1588
+
1589
+ def catalog = fetch_catalog
1590
+ memoize :catalog, ttl: 300, stampede_protection: true # default beta=1.0
1591
+ # or
1592
+ memoize :catalog, ttl: 300, stampede_protection: 2.0 # custom beta (more aggressive)
1593
+ end
1594
+ ```
1595
+
1596
+ The measured computation time from each cache miss is stored as `delta` and used in subsequent reads, so the XFetch probability adapts to real observed latency automatically.
1597
+
1598
+ **Requirements:** `ttl:` must be set. Incompatible with `store:` (see `Stores::XFetch` below) and `ractor_safe:`.
1599
+
1600
+ ### `Stores::XFetch` — external stores
1601
+
1602
+ For external stores (Redis, Rails.cache, etc.) wrap the adapter with `Stores::XFetch`. Values are stored with an `expires_at` envelope so the wrapper can apply the formula even though the store's `read` returns only the plain value:
1603
+
1604
+ ```ruby
1605
+ store = SafeMemoize::Stores::XFetch.new(
1606
+ MyRedisStore.new,
1607
+ delta: 0.2, # estimated typical computation time (seconds)
1608
+ beta: 1.5 # aggressiveness scalar
1609
+ )
1610
+
1611
+ class CatalogService
1612
+ prepend SafeMemoize
1613
+
1614
+ def products = db_fetch
1615
+ memoize :products, store: store, ttl: 300
1616
+ end
1617
+ ```
1618
+
1619
+ **XFetch formula:** `now − (delta × beta × log(rand)) ≥ expires_at`
1620
+
1621
+ A higher `beta` triggers early recomputation more aggressively. A larger `delta` (longer computation) also increases the recomputation window.
1622
+
1623
+ `Stores::XFetch` composes with `Multilevel` and `CircuitBreaker`:
1624
+
1625
+ ```ruby
1626
+ store = SafeMemoize::Stores::XFetch.new(
1627
+ SafeMemoize::Stores::CircuitBreaker.new(MyRedisStore.new),
1628
+ delta: 0.1
1629
+ )
1630
+ ```
1631
+
1632
+ [↑ Back to features](#features)
1633
+
1451
1634
  ## Per-class default options (`safe_memoize_options`)
1452
1635
 
1453
1636
  `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 +1869,8 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
1686
1869
  | `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
1870
  | `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
1871
  | `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 |
1872
+ | `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 |
1873
+ | `stampede_protection:` | `true \| Numeric \| nil` | `nil` | Enables XFetch probabilistic early expiry on the per-instance cache; `true` uses beta=1.0; pass a `Numeric` for a custom beta; requires `ttl:`; incompatible with `store:` and `ractor_safe:` |
1689
1874
  | *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
1690
1875
 
1691
1876
  ### `memoize_all` options (class method)
@@ -1703,7 +1888,7 @@ All `memoize` option keys above, plus:
1703
1888
 
1704
1889
  | Option key | Type | Default | Notes |
1705
1890
  |---|---|---|---|
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:` |
1891
+ | 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:`, `stampede_protection:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` |
1707
1892
 
1708
1893
  ### Instance methods (public)
1709
1894
 
data/ROADMAP.md CHANGED
@@ -4,27 +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
- ## v1.7.0 — Advanced Store Features
18
-
19
- *Goal: multi-process performance patterns for high-traffic deployments.*
20
-
21
- | Feature | Description | Status |
22
- |---|---|---|
23
- | Multi-level (L1/L2) caching | `store: [memory_store, redis_store]` — check in-process first, fall back to the remote store on miss, and promote to L1 on read; each level can have independent TTL and eviction settings | Planned |
24
- | Stampede protection | Probabilistic early expiry (XFetch algorithm) for external stores; recomputes slightly before a TTL expires to prevent multiple processes hitting a cold miss simultaneously | Planned |
25
-
26
- ---
27
-
28
7
  ## v2.0.0 — Next Generation (Long Horizon)
29
8
 
30
9
  *Goal: incorporate real-world usage feedback, clean up accumulated API surface, and open a path for advanced extension.*
@@ -22,8 +22,10 @@ module SafeMemoize
22
22
  Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
23
23
  end
24
24
 
25
- def memo_record(value, expires_at:)
26
- {value: value, expires_at: expires_at, cached_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)}
25
+ def memo_record(value, expires_at:, delta: nil)
26
+ rec = {value: value, expires_at: expires_at, cached_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)}
27
+ rec[:delta] = delta if delta
28
+ rec
27
29
  end
28
30
 
29
31
  def memo_record_value(record)
@@ -69,6 +69,17 @@ 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.
77
+ # @param stampede_protection [Boolean, Numeric, nil] enables probabilistic early
78
+ # expiry (XFetch algorithm) on the per-instance in-process cache. Pass +true+ to
79
+ # use the default beta scalar (1.0), or a +Numeric+ to set a custom beta. A higher
80
+ # beta causes early recomputation to trigger more aggressively. Requires +ttl:+ to
81
+ # be set. Incompatible with +store:+ (use {Stores::XFetch} instead) and
82
+ # +ractor_safe:+. For the store-backed path, wrap the store with {Stores::XFetch}.
72
83
  # @return [void]
73
84
  # @raise [ArgumentError] if the method does not exist, or option values are invalid
74
85
  #
@@ -89,7 +100,7 @@ module SafeMemoize
89
100
  # @example With a custom store
90
101
  # STORE = SafeMemoize::Stores::Memory.new
91
102
  # 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)
103
+ 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, stampede_protection: UNSET, **extension_options)
93
104
  method_name = method_name.to_sym
94
105
 
95
106
  unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
@@ -132,6 +143,8 @@ module SafeMemoize
132
143
  namespace = cls_defaults[:namespace] if namespace.equal?(UNSET) && cls_defaults.key?(:namespace)
133
144
  store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store)
134
145
  group = cls_defaults[:group] if group.equal?(UNSET) && cls_defaults.key?(:group)
146
+ circuit_breaker = cls_defaults[:circuit_breaker] if circuit_breaker.equal?(UNSET) && cls_defaults.key?(:circuit_breaker)
147
+ stampede_protection = cls_defaults[:stampede_protection] if stampede_protection.equal?(UNSET) && cls_defaults.key?(:stampede_protection)
135
148
  end
136
149
 
137
150
  # Normalize remaining UNSET to original per-call defaults
@@ -148,6 +161,8 @@ module SafeMemoize
148
161
  cache_bust = nil if cache_bust.equal?(UNSET)
149
162
  copy_on_read = false if copy_on_read.equal?(UNSET)
150
163
  group = nil if group.equal?(UNSET)
164
+ circuit_breaker = nil if circuit_breaker.equal?(UNSET)
165
+ stampede_protection = nil if stampede_protection.equal?(UNSET)
151
166
  cond_if = nil if cond_if.equal?(UNSET)
152
167
  cond_unless = nil if cond_unless.equal?(UNSET)
153
168
 
@@ -192,6 +207,24 @@ module SafeMemoize
192
207
  raise ArgumentError, "cache_bust: and key: cannot be combined" if key
193
208
  end
194
209
 
210
+ if store.is_a?(Array)
211
+ store.each_with_index do |s, i|
212
+ unless s.is_a?(SafeMemoize::Stores::Base)
213
+ raise ArgumentError, "store: Array entry [#{i}] must be a Stores::Base instance (got #{s.class})"
214
+ end
215
+ end
216
+ store = SafeMemoize::Stores::Multilevel.new(*store)
217
+ end
218
+
219
+ if stampede_protection
220
+ unless stampede_protection == true || stampede_protection.is_a?(Numeric)
221
+ raise ArgumentError, "stampede_protection: must be true or a Numeric beta value (got #{stampede_protection.class})"
222
+ end
223
+ raise ArgumentError, "stampede_protection: requires ttl: to be set" if ttl.nil?
224
+ raise ArgumentError, "stampede_protection: is incompatible with store: — use Stores::XFetch instead" if store
225
+ raise ArgumentError, "stampede_protection: is incompatible with ractor_safe:" if ractor_safe
226
+ end
227
+
195
228
  if store
196
229
  raise ArgumentError, "store: must be a SafeMemoize::Stores::Base instance (got #{store.class})" unless store.is_a?(SafeMemoize::Stores::Base)
197
230
  raise ArgumentError, "max_size: is not supported with store: — use the store adapter's own eviction" if max_size
@@ -260,6 +293,19 @@ module SafeMemoize
260
293
  end
261
294
  end
262
295
 
296
+ if circuit_breaker
297
+ unless circuit_breaker == true || circuit_breaker.is_a?(Hash)
298
+ raise ArgumentError, "circuit_breaker: must be true or a Hash of options (got #{circuit_breaker.class})"
299
+ end
300
+ unless effective_store
301
+ raise ArgumentError, "circuit_breaker: requires a store: to be configured (no store is set for :#{method_name})"
302
+ end
303
+ unless effective_store.is_a?(Stores::CircuitBreaker)
304
+ cb_opts = circuit_breaker.is_a?(Hash) ? circuit_breaker : {}
305
+ effective_store = Stores::CircuitBreaker.new(effective_store, **cb_opts)
306
+ end
307
+ end
308
+
263
309
  __safe_memo_class_key_generators__[method_name] = key if key
264
310
  __safe_memo_class_cache_bust_generators__[method_name] = cache_bust if cache_bust
265
311
 
@@ -334,7 +380,15 @@ module SafeMemoize
334
380
  fiber_cache = fiber_memo_cache!
335
381
  record = fiber_cache[cache_key]
336
382
 
337
- if memo_record_live?(record)
383
+ sp_early = stampede_protection && memo_record_live?(record) && record[:expires_at] &&
384
+ (record[:delta].to_f > 0) &&
385
+ begin
386
+ sp_beta = stampede_protection.is_a?(Numeric) ? stampede_protection.to_f : 1.0
387
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
388
+ now - record[:delta].to_f * sp_beta * Math.log(rand) >= record[:expires_at]
389
+ end
390
+
391
+ if memo_record_live?(record) && !sp_early
338
392
  if max_size
339
393
  lru = fiber_memo_lru![method_name] ||= {}
340
394
  lru.delete(cache_key)
@@ -345,7 +399,7 @@ module SafeMemoize
345
399
  call_memo_hooks(:on_hit, cache_key, record)
346
400
  dup_fn.call(memo_record_value(record))
347
401
  else
348
- call_memo_hooks(:on_expire, cache_key, record) if record
402
+ call_memo_hooks(:on_expire, cache_key, record) if record && (!memo_record_live?(record) || sp_early)
349
403
 
350
404
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
351
405
  value = Adapters::OpenTelemetry.trace(
@@ -353,7 +407,7 @@ module SafeMemoize
353
407
  ) { super(*args, **kwargs) }
354
408
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
355
409
 
356
- new_record = memo_record(value, expires_at: memo_expires_at(ttl))
410
+ new_record = memo_record(value, expires_at: memo_expires_at(ttl), delta: stampede_protection ? elapsed_time : nil)
357
411
 
358
412
  if !condition || condition.call(value)
359
413
  if max_size
@@ -412,7 +466,14 @@ module SafeMemoize
412
466
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
413
467
  record_live = record && (record[:expires_at].nil? || record[:expires_at] > now)
414
468
 
415
- if record_live
469
+ sp_early = record_live && stampede_protection && record[:expires_at] &&
470
+ (record[:delta].to_f > 0) &&
471
+ begin
472
+ sp_beta = stampede_protection.is_a?(Numeric) ? stampede_protection.to_f : 1.0
473
+ now - record[:delta].to_f * sp_beta * Math.log(rand) >= record[:expires_at]
474
+ end
475
+
476
+ if record_live && !sp_early
416
477
  if max_size
417
478
  lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
418
479
  lru.delete(cache_key)
@@ -423,13 +484,13 @@ module SafeMemoize
423
484
  call_memo_hooks(:on_hit, cache_key, record)
424
485
  dup_fn.call(record[:value])
425
486
  else
426
- call_memo_hooks(:on_expire, cache_key, record) if record && !record_live
487
+ call_memo_hooks(:on_expire, cache_key, record) if record && (!record_live || sp_early)
427
488
 
428
489
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
429
490
  value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, klass.name) { super(*args, **kwargs) }
430
491
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
431
492
 
432
- new_record = memo_record(value, expires_at: memo_expires_at(ttl))
493
+ new_record = memo_record(value, expires_at: memo_expires_at(ttl), delta: stampede_protection ? elapsed_time : nil)
433
494
 
434
495
  if !condition || condition.call(value)
435
496
  if max_size
@@ -473,10 +534,22 @@ module SafeMemoize
473
534
 
474
535
  cache_key = compute_cache_key(method_name, args, kwargs)
475
536
 
476
- if max_size || condition || ttl_refresh
477
- # Locked path: used when LRU tracking, conditional storage, or TTL refresh is needed.
537
+ if max_size || condition || ttl_refresh || stampede_protection
538
+ # Locked path: LRU, conditional storage, TTL refresh, or stampede protection.
539
+ sp_beta = stampede_protection.is_a?(Numeric) ? stampede_protection.to_f : 1.0
540
+
478
541
  memo_mutex!.synchronize do
479
542
  record = memo_cache_record(cache_key)
543
+
544
+ if record && stampede_protection && record[:expires_at]
545
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
546
+ delta = record[:delta].to_f
547
+ if delta > 0 && now - delta * sp_beta * Math.log(rand) >= record[:expires_at]
548
+ call_memo_hooks(:on_expire, cache_key, record)
549
+ record = nil
550
+ end
551
+ end
552
+
480
553
  if record
481
554
  lru_touch(method_name, cache_key) if max_size
482
555
  record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
@@ -488,7 +561,7 @@ module SafeMemoize
488
561
  value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
489
562
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
490
563
 
491
- new_record = memo_record(value, expires_at: memo_expires_at(ttl))
564
+ new_record = memo_record(value, expires_at: memo_expires_at(ttl), delta: stampede_protection ? elapsed_time : nil)
492
565
  if !condition || condition.call(value)
493
566
  lru_evict_if_over_limit(method_name, max_size) if max_size
494
567
  @__safe_memo_cache__ ||= {}
@@ -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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module Stores
5
+ # Multi-level (L1/L2/…) cache store that checks faster layers first and
6
+ # promotes values up on a miss, reducing latency and load on slower backends.
7
+ #
8
+ # Reads walk the store list from first (fastest) to last (slowest). On a
9
+ # miss at level N the value is read from level N+1 and written back into
10
+ # all preceding levels ("read-through promotion"). Writes always go to every
11
+ # level so all layers stay consistent.
12
+ #
13
+ # @example In-process L1 + Redis L2
14
+ # l1 = SafeMemoize::Stores::Memory.new
15
+ # l2 = MyRedisStore.new
16
+ #
17
+ # memoize :fetch, store: SafeMemoize::Stores::Multilevel.new(l1, l2)
18
+ #
19
+ # @example Via the store: Array shorthand
20
+ # memoize :fetch, store: [l1, l2], ttl: 300
21
+ #
22
+ # @example With a short promote_expires_in for the L1 layer
23
+ # memoize :fetch, store: SafeMemoize::Stores::Multilevel.new(l1, l2, promote_expires_in: 60)
24
+ class Multilevel < Base
25
+ # @return [Array<Stores::Base>] the ordered store layers (fastest first)
26
+ attr_reader :stores
27
+
28
+ # @param stores [Array<Stores::Base>] two or more store instances, ordered
29
+ # from fastest (L1) to slowest (last)
30
+ # @param promote_expires_in [Numeric, nil] TTL applied when promoting a
31
+ # value from a deeper layer into a shallower one; +nil+ means no expiry
32
+ # on the promoted entry (the L1 store's own eviction — e.g. LRU — handles
33
+ # memory bounds instead)
34
+ # @raise [ArgumentError] if fewer than two stores are supplied, or any
35
+ # element is not a {Stores::Base} instance
36
+ def initialize(*stores, promote_expires_in: nil)
37
+ raise ArgumentError, "Multilevel requires at least 2 stores" if stores.size < 2
38
+
39
+ stores.each_with_index do |s, i|
40
+ unless s.is_a?(Base)
41
+ raise ArgumentError,
42
+ "Multilevel store[#{i}] must be a Stores::Base instance (got #{s.class})"
43
+ end
44
+ end
45
+
46
+ @stores = stores.freeze
47
+ @promote_expires_in = promote_expires_in ? Float(promote_expires_in) : nil
48
+ end
49
+
50
+ # Walk levels from fastest to slowest; return the first hit, promoting
51
+ # the value into all shallower layers.
52
+ def read(key)
53
+ @stores.each_with_index do |store, i|
54
+ result = store.read(key)
55
+ next if result.equal?(MISS)
56
+
57
+ # Promote into every shallower level
58
+ @stores.first(i).each { |s| s.write(key, result, expires_in: @promote_expires_in) }
59
+ return result
60
+ end
61
+
62
+ MISS
63
+ end
64
+
65
+ # Write to every level simultaneously.
66
+ def write(key, value, expires_in: nil)
67
+ @stores.each { |s| s.write(key, value, expires_in: expires_in) }
68
+ end
69
+
70
+ # Delete from every level.
71
+ def delete(key)
72
+ @stores.each { |s| s.delete(key) }
73
+ end
74
+
75
+ # Clear every level.
76
+ def clear
77
+ @stores.each(&:clear)
78
+ end
79
+
80
+ # Union of live keys across all levels.
81
+ def keys
82
+ @stores.flat_map(&:keys).uniq
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module Stores
5
+ # Wraps any {Base} store adapter with probabilistic early expiry (the
6
+ # XFetch algorithm) to prevent cache stampedes — the thundering-herd
7
+ # problem where many processes simultaneously recompute a value the moment
8
+ # it expires under high load.
9
+ #
10
+ # Instead of waiting until {#read} returns {MISS} at the hard expiry
11
+ # deadline, the wrapper stochastically returns {MISS} slightly before
12
+ # expiry, giving one process a head start on recomputation while everyone
13
+ # else still gets the cached value. The probability of early expiry rises
14
+ # as the entry approaches its deadline.
15
+ #
16
+ # === XFetch formula
17
+ #
18
+ # early_expire = now − (delta × beta × log(rand)) ≥ expires_at
19
+ #
20
+ # * +delta+ — estimated computation time in seconds (default 0.1 s).
21
+ # Configure this to the typical duration of the underlying computation.
22
+ # * +beta+ — aggressiveness scalar (default 1.0); higher values trigger
23
+ # early recomputation more eagerly.
24
+ #
25
+ # Values are stored internally as an envelope +{value:, expires_at:}+ so
26
+ # the wrapper always knows the hard deadline regardless of what the inner
27
+ # store exposes on read. The envelope survives standard Ruby Marshal
28
+ # serialization (Redis via the +redis-store+ or +redis-client+ gems,
29
+ # Rails.cache, etc.). Values that cannot be serialized alongside a small
30
+ # hash are not supported.
31
+ #
32
+ # @example Wrap a Redis store
33
+ # store = SafeMemoize::Stores::XFetch.new(
34
+ # MyRedisStore.new,
35
+ # delta: 0.2, # typical computation time in seconds
36
+ # beta: 1.5 # slightly aggressive early expiry
37
+ # )
38
+ # memoize :fetch, store: store, ttl: 300
39
+ #
40
+ # @example Compose with CircuitBreaker
41
+ # store = SafeMemoize::Stores::XFetch.new(
42
+ # SafeMemoize::Stores::CircuitBreaker.new(MyRedisStore.new),
43
+ # delta: 0.1
44
+ # )
45
+ # memoize :fetch, store: store, ttl: 60
46
+ class XFetch < Base
47
+ ENVELOPE_KEY = :__sm_xfetch_v1__
48
+
49
+ DEFAULT_BETA = 1.0
50
+ DEFAULT_DELTA = 0.1
51
+
52
+ # @return [Stores::Base] the wrapped inner store
53
+ attr_reader :wrapped_store
54
+ # @return [Float] aggressiveness scalar
55
+ attr_reader :beta
56
+ # @return [Float] estimated computation time in seconds
57
+ attr_reader :delta
58
+
59
+ # @param store [Stores::Base] the backing store to wrap
60
+ # @param beta [Numeric] aggressiveness scalar (default 1.0)
61
+ # @param delta [Numeric] estimated computation time in seconds (default 0.1)
62
+ # @raise [ArgumentError] if +store+ is not a {Stores::Base} instance, or
63
+ # if +beta+/+delta+ are not positive numbers
64
+ def initialize(store, beta: DEFAULT_BETA, delta: DEFAULT_DELTA)
65
+ unless store.is_a?(Base)
66
+ raise ArgumentError, "XFetch requires a Stores::Base instance (got #{store.class})"
67
+ end
68
+
69
+ @wrapped_store = store
70
+ @beta = Float(beta)
71
+ @delta = Float(delta)
72
+
73
+ raise ArgumentError, "beta must be positive" unless @beta > 0
74
+ raise ArgumentError, "delta must be positive" unless @delta > 0
75
+ end
76
+
77
+ # Read from the wrapped store and apply the XFetch probabilistic check.
78
+ #
79
+ # Returns {MISS} when:
80
+ # * the inner store has no entry for +key+
81
+ # * the stored value is not an XFetch envelope (possibly written by an
82
+ # older version or a different store wrapper)
83
+ # * the XFetch formula triggers early expiry
84
+ def read(key)
85
+ raw = @wrapped_store.read(key)
86
+ return MISS if raw.equal?(MISS)
87
+ return MISS unless envelope?(raw)
88
+
89
+ expires_at = raw[:expires_at]
90
+
91
+ if expires_at
92
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
+ early = now - @delta * @beta * Math.log(rand) >= expires_at
94
+ return MISS if early
95
+ end
96
+
97
+ raw[:value]
98
+ end
99
+
100
+ # Write the value to the wrapped store inside an XFetch envelope.
101
+ def write(key, value, expires_in: nil)
102
+ expires_at = expires_in ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + expires_in.to_f : nil
103
+ envelope = {ENVELOPE_KEY => true, :value => value, :expires_at => expires_at}
104
+ @wrapped_store.write(key, envelope, expires_in: expires_in)
105
+ end
106
+
107
+ # Delete from the wrapped store.
108
+ def delete(key)
109
+ @wrapped_store.delete(key)
110
+ end
111
+
112
+ # Clear the wrapped store.
113
+ def clear
114
+ @wrapped_store.clear
115
+ end
116
+
117
+ # Returns live keys from the wrapped store.
118
+ def keys
119
+ @wrapped_store.keys
120
+ end
121
+
122
+ private
123
+
124
+ def envelope?(value)
125
+ value.is_a?(Hash) && value[ENVELOPE_KEY]
126
+ end
127
+ end
128
+ end
129
+ 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.7.0"
6
6
  end
data/lib/safe_memoize.rb CHANGED
@@ -5,6 +5,9 @@ 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"
9
+ require_relative "safe_memoize/stores/multilevel"
10
+ require_relative "safe_memoize/stores/xfetch"
8
11
  require_relative "safe_memoize/adapters/statsd"
9
12
  require_relative "safe_memoize/adapters/opentelemetry"
10
13
  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 | Array[Stores::Base] | nil, ?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, ?stampede_protection: bool | Numeric | 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?
@@ -148,7 +148,7 @@ module SafeMemoize
148
148
 
149
149
  def memo_ttl: (Numeric? ttl) -> Float?
150
150
  def memo_expires_at: (Float? ttl) -> Float?
151
- def memo_record: (untyped value, expires_at: Float?) -> memo_record
151
+ def memo_record: (untyped value, expires_at: Float?, ?delta: Float?) -> memo_record
152
152
  def memo_record_value: (memo_record record) -> untyped
153
153
  def memo_record_live?: (memo_record? record) -> bool
154
154
  def memo_prune_expired_entries!: (Hash[memo_key, memo_record] cache) -> void
@@ -300,6 +300,64 @@ module SafeMemoize
300
300
 
301
301
  def expired?: ({ expires_at: Float?, value: untyped, cached_at: Float }) -> bool
302
302
  end
303
+
304
+ class Multilevel < Base
305
+ attr_reader stores: Array[Base]
306
+
307
+ def initialize: (*Base stores, ?promote_expires_in: Numeric?) -> void
308
+ def read: (untyped key) -> untyped
309
+ def write: (untyped key, untyped value, ?expires_in: Numeric?) -> void
310
+ def delete: (untyped key) -> void
311
+ def clear: () -> void
312
+ def keys: () -> Array[untyped]
313
+ end
314
+
315
+ class XFetch < Base
316
+ ENVELOPE_KEY: Symbol
317
+ DEFAULT_BETA: Float
318
+ DEFAULT_DELTA: Float
319
+
320
+ attr_reader wrapped_store: Base
321
+ attr_reader beta: Float
322
+ attr_reader delta: Float
323
+
324
+ def initialize: (Base store, ?beta: Numeric, ?delta: Numeric) -> void
325
+ def read: (untyped key) -> untyped
326
+ def write: (untyped key, untyped value, ?expires_in: Numeric?) -> void
327
+ def delete: (untyped key) -> void
328
+ def clear: () -> void
329
+ def keys: () -> Array[untyped]
330
+
331
+ private
332
+
333
+ def envelope?: (untyped value) -> bool
334
+ end
335
+
336
+ class CircuitBreaker < Base
337
+ DEFAULT_ERROR_THRESHOLD: Integer
338
+ DEFAULT_PROBE_INTERVAL: Float
339
+
340
+ attr_reader wrapped_store: Base
341
+ attr_reader error_threshold: Integer
342
+ attr_reader probe_interval: Float
343
+
344
+ def initialize: (Base store, ?error_threshold: Integer, ?probe_interval: Numeric) -> void
345
+ def read: (untyped key) -> untyped
346
+ def write: (untyped key, untyped value, ?expires_in: Numeric?) -> void
347
+ def delete: (untyped key) -> void
348
+ def clear: () -> void
349
+ def keys: () -> Array[untyped]
350
+ def state: () -> Symbol
351
+ def open?: () -> bool
352
+ def error_count: () -> Integer
353
+ def reset!: () -> void
354
+
355
+ private
356
+
357
+ def current_state: () -> Symbol
358
+ def record_success: (Symbol prior_state) -> void
359
+ def record_failure: () -> void
360
+ end
303
361
  end
304
362
 
305
363
  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.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -77,9 +77,12 @@ 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
82
+ - lib/safe_memoize/stores/multilevel.rb
81
83
  - lib/safe_memoize/stores/rails_cache.rb
82
84
  - lib/safe_memoize/stores/redis.rb
85
+ - lib/safe_memoize/stores/xfetch.rb
83
86
  - lib/safe_memoize/version.rb
84
87
  - rbi/safe_memoize.rbi
85
88
  - sig/safe_memoize.rbs