safe_memoize 1.6.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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +100 -1
- data/ROADMAP.md +0 -11
- data/lib/safe_memoize/cache_record_methods.rb +4 -2
- data/lib/safe_memoize/class_methods.rb +63 -10
- data/lib/safe_memoize/stores/multilevel.rb +86 -0
- data/lib/safe_memoize/stores/xfetch.rb +129 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +2 -0
- data/sig/safe_memoize.rbs +34 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7a646739aa7f6c178b20192ddc8e94768df0c6b000097586796153f5d2138bc
|
|
4
|
+
data.tar.gz: 9454f9a6cf6b8e420d27b82f39740484eddab506d871e94034483ae8caf12622
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f569af43ba1ca765dcdd9490f5629bb572f1dbedaa676538a08c199282fe55a6b40afd29f8870ddbab86c091a20a89c10e87c3b060a124f4994313b2a2ffdda4
|
|
7
|
+
data.tar.gz: 2d0634278dd32e98fc0c23a74abfb98bf1c8d5d087e7eb7a8006dd54846bd11f0bbce27d704e4f82bf0fc81c961c34b1c62fb8dc92cddafbcec26c2b930e79d8
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,15 @@ 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
|
+
|
|
11
20
|
## [1.6.0] - 2026-06-02
|
|
12
21
|
|
|
13
22
|
### Added
|
data/README.md
CHANGED
|
@@ -81,6 +81,8 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
|
|
|
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
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)
|
|
84
86
|
|
|
85
87
|
## Installation
|
|
86
88
|
|
|
@@ -1533,6 +1535,102 @@ end
|
|
|
1533
1535
|
|
|
1534
1536
|
[↑ Back to features](#features)
|
|
1535
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
|
+
|
|
1536
1634
|
## Per-class default options (`safe_memoize_options`)
|
|
1537
1635
|
|
|
1538
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.
|
|
@@ -1772,6 +1870,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
|
|
|
1772
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:` |
|
|
1773
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 |
|
|
1774
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:` |
|
|
1775
1874
|
| *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
|
|
1776
1875
|
|
|
1777
1876
|
### `memoize_all` options (class method)
|
|
@@ -1789,7 +1888,7 @@ All `memoize` option keys above, plus:
|
|
|
1789
1888
|
|
|
1790
1889
|
| Option key | Type | Default | Notes |
|
|
1791
1890
|
|---|---|---|---|
|
|
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:` |
|
|
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:` |
|
|
1793
1892
|
|
|
1794
1893
|
### Instance methods (public)
|
|
1795
1894
|
|
data/ROADMAP.md
CHANGED
|
@@ -4,17 +4,6 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## v1.7.0 — Advanced Store Features
|
|
8
|
-
|
|
9
|
-
*Goal: multi-process performance patterns for high-traffic deployments.*
|
|
10
|
-
|
|
11
|
-
| Feature | Description | Status |
|
|
12
|
-
|---|---|---|
|
|
13
|
-
| 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 |
|
|
14
|
-
| 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 |
|
|
15
|
-
|
|
16
|
-
---
|
|
17
|
-
|
|
18
7
|
## v2.0.0 — Next Generation (Long Horizon)
|
|
19
8
|
|
|
20
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)
|
|
@@ -74,6 +74,12 @@ module SafeMemoize
|
|
|
74
74
|
# +probe_interval: 30+), or a +Hash+ with +:error_threshold+ and/or +:probe_interval+
|
|
75
75
|
# keys to customise. Requires a +store:+ to be set (per-method, class-level, or
|
|
76
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}.
|
|
77
83
|
# @return [void]
|
|
78
84
|
# @raise [ArgumentError] if the method does not exist, or option values are invalid
|
|
79
85
|
#
|
|
@@ -94,7 +100,7 @@ module SafeMemoize
|
|
|
94
100
|
# @example With a custom store
|
|
95
101
|
# STORE = SafeMemoize::Stores::Memory.new
|
|
96
102
|
# memoize :fetch, store: STORE, ttl: 300
|
|
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)
|
|
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)
|
|
98
104
|
method_name = method_name.to_sym
|
|
99
105
|
|
|
100
106
|
unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
|
|
@@ -138,6 +144,7 @@ module SafeMemoize
|
|
|
138
144
|
store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store)
|
|
139
145
|
group = cls_defaults[:group] if group.equal?(UNSET) && cls_defaults.key?(:group)
|
|
140
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)
|
|
141
148
|
end
|
|
142
149
|
|
|
143
150
|
# Normalize remaining UNSET to original per-call defaults
|
|
@@ -155,6 +162,7 @@ module SafeMemoize
|
|
|
155
162
|
copy_on_read = false if copy_on_read.equal?(UNSET)
|
|
156
163
|
group = nil if group.equal?(UNSET)
|
|
157
164
|
circuit_breaker = nil if circuit_breaker.equal?(UNSET)
|
|
165
|
+
stampede_protection = nil if stampede_protection.equal?(UNSET)
|
|
158
166
|
cond_if = nil if cond_if.equal?(UNSET)
|
|
159
167
|
cond_unless = nil if cond_unless.equal?(UNSET)
|
|
160
168
|
|
|
@@ -199,6 +207,24 @@ module SafeMemoize
|
|
|
199
207
|
raise ArgumentError, "cache_bust: and key: cannot be combined" if key
|
|
200
208
|
end
|
|
201
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
|
+
|
|
202
228
|
if store
|
|
203
229
|
raise ArgumentError, "store: must be a SafeMemoize::Stores::Base instance (got #{store.class})" unless store.is_a?(SafeMemoize::Stores::Base)
|
|
204
230
|
raise ArgumentError, "max_size: is not supported with store: — use the store adapter's own eviction" if max_size
|
|
@@ -354,7 +380,15 @@ module SafeMemoize
|
|
|
354
380
|
fiber_cache = fiber_memo_cache!
|
|
355
381
|
record = fiber_cache[cache_key]
|
|
356
382
|
|
|
357
|
-
|
|
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
|
|
358
392
|
if max_size
|
|
359
393
|
lru = fiber_memo_lru![method_name] ||= {}
|
|
360
394
|
lru.delete(cache_key)
|
|
@@ -365,7 +399,7 @@ module SafeMemoize
|
|
|
365
399
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
366
400
|
dup_fn.call(memo_record_value(record))
|
|
367
401
|
else
|
|
368
|
-
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)
|
|
369
403
|
|
|
370
404
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
371
405
|
value = Adapters::OpenTelemetry.trace(
|
|
@@ -373,7 +407,7 @@ module SafeMemoize
|
|
|
373
407
|
) { super(*args, **kwargs) }
|
|
374
408
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
375
409
|
|
|
376
|
-
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)
|
|
377
411
|
|
|
378
412
|
if !condition || condition.call(value)
|
|
379
413
|
if max_size
|
|
@@ -432,7 +466,14 @@ module SafeMemoize
|
|
|
432
466
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
433
467
|
record_live = record && (record[:expires_at].nil? || record[:expires_at] > now)
|
|
434
468
|
|
|
435
|
-
|
|
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
|
|
436
477
|
if max_size
|
|
437
478
|
lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
|
|
438
479
|
lru.delete(cache_key)
|
|
@@ -443,13 +484,13 @@ module SafeMemoize
|
|
|
443
484
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
444
485
|
dup_fn.call(record[:value])
|
|
445
486
|
else
|
|
446
|
-
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)
|
|
447
488
|
|
|
448
489
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
449
490
|
value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, klass.name) { super(*args, **kwargs) }
|
|
450
491
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
451
492
|
|
|
452
|
-
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)
|
|
453
494
|
|
|
454
495
|
if !condition || condition.call(value)
|
|
455
496
|
if max_size
|
|
@@ -493,10 +534,22 @@ module SafeMemoize
|
|
|
493
534
|
|
|
494
535
|
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
495
536
|
|
|
496
|
-
if max_size || condition || ttl_refresh
|
|
497
|
-
# Locked path:
|
|
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
|
+
|
|
498
541
|
memo_mutex!.synchronize do
|
|
499
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
|
+
|
|
500
553
|
if record
|
|
501
554
|
lru_touch(method_name, cache_key) if max_size
|
|
502
555
|
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
@@ -508,7 +561,7 @@ module SafeMemoize
|
|
|
508
561
|
value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
|
|
509
562
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
510
563
|
|
|
511
|
-
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)
|
|
512
565
|
if !condition || condition.call(value)
|
|
513
566
|
lru_evict_if_over_limit(method_name, max_size) if max_size
|
|
514
567
|
@__safe_memo_cache__ ||= {}
|
|
@@ -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
|
data/lib/safe_memoize/version.rb
CHANGED
data/lib/safe_memoize.rb
CHANGED
|
@@ -6,6 +6,8 @@ require_relative "safe_memoize/extension"
|
|
|
6
6
|
require_relative "safe_memoize/stores/base"
|
|
7
7
|
require_relative "safe_memoize/stores/memory"
|
|
8
8
|
require_relative "safe_memoize/stores/circuit_breaker"
|
|
9
|
+
require_relative "safe_memoize/stores/multilevel"
|
|
10
|
+
require_relative "safe_memoize/stores/xfetch"
|
|
9
11
|
require_relative "safe_memoize/adapters/statsd"
|
|
10
12
|
require_relative "safe_memoize/adapters/opentelemetry"
|
|
11
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
|
|
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
|
|
@@ -301,6 +301,38 @@ module SafeMemoize
|
|
|
301
301
|
def expired?: ({ expires_at: Float?, value: untyped, cached_at: Float }) -> bool
|
|
302
302
|
end
|
|
303
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
|
+
|
|
304
336
|
class CircuitBreaker < Base
|
|
305
337
|
DEFAULT_ERROR_THRESHOLD: Integer
|
|
306
338
|
DEFAULT_PROBE_INTERVAL: Float
|
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.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -79,8 +79,10 @@ files:
|
|
|
79
79
|
- lib/safe_memoize/stores/base.rb
|
|
80
80
|
- lib/safe_memoize/stores/circuit_breaker.rb
|
|
81
81
|
- lib/safe_memoize/stores/memory.rb
|
|
82
|
+
- lib/safe_memoize/stores/multilevel.rb
|
|
82
83
|
- lib/safe_memoize/stores/rails_cache.rb
|
|
83
84
|
- lib/safe_memoize/stores/redis.rb
|
|
85
|
+
- lib/safe_memoize/stores/xfetch.rb
|
|
84
86
|
- lib/safe_memoize/version.rb
|
|
85
87
|
- rbi/safe_memoize.rbi
|
|
86
88
|
- sig/safe_memoize.rbs
|