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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +186 -1
- data/ROADMAP.md +0 -21
- data/lib/safe_memoize/cache_record_methods.rb +4 -2
- data/lib/safe_memoize/class_methods.rb +83 -10
- data/lib/safe_memoize/stores/circuit_breaker.rb +178 -0
- 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 +3 -0
- data/sig/safe_memoize.rbs +60 -2
- metadata +4 -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,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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
data/lib/safe_memoize/version.rb
CHANGED
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
|
|
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.
|
|
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
|