safe_memoize 1.6.0 → 1.7.1
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/.github/workflows/ci.yml +31 -0
- data/CHANGELOG.md +28 -1
- data/README.md +185 -91
- data/ROADMAP.md +0 -11
- data/benchmarks/BENCHMARK.md +114 -0
- data/benchmarks/README.md +3 -1
- data/lib/safe_memoize/cache_record_methods.rb +4 -2
- data/lib/safe_memoize/class_methods.rb +65 -15
- data/lib/safe_memoize/ractor_shared_methods.rb +59 -8
- data/lib/safe_memoize/release_tooling.rb +19 -1
- 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 +42 -2
- metadata +5 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b2558ad027e3b8bb09ec956d8a18eed759b56c9baa1234a173fdebd7e5e9913
|
|
4
|
+
data.tar.gz: ae5302c29efe49c336a323bc7a6ad8dcd4a6b10cebc394e786fccd12c00a66ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5822061de749e6001cee636a687c19bfd59253ed075b4d9e82123aa88954578a3a8f2dcd0a5a9cbfb29cad8d86b6768a5461a454210dfd7bf56ba624e4e6df6c
|
|
7
|
+
data.tar.gz: 89e21d16cb9e37a00333f98ec12adf7b9ec6a3e746bb1a9f8377cfafd16c27abbd92de5a79074e207f51fcacaa25a0e9c58092da94a82798be501d35a677489c
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -34,6 +34,9 @@ jobs:
|
|
|
34
34
|
test:
|
|
35
35
|
name: Ruby ${{ matrix.ruby }}
|
|
36
36
|
runs-on: ubuntu-latest
|
|
37
|
+
# Normal runs finish in under a minute; fail fast if the suite hangs
|
|
38
|
+
# (default is 6 hours).
|
|
39
|
+
timeout-minutes: 10
|
|
37
40
|
strategy:
|
|
38
41
|
fail-fast: false
|
|
39
42
|
matrix:
|
|
@@ -55,6 +58,12 @@ jobs:
|
|
|
55
58
|
- name: Run test suite
|
|
56
59
|
run: bundle exec rake spec
|
|
57
60
|
|
|
61
|
+
# SimpleCov marks :nocov: lines as "ignored"; Codecov rejects non-numeric
|
|
62
|
+
# line values and discards the whole report, so treat them as non-lines.
|
|
63
|
+
- name: Normalize ignored lines for Codecov
|
|
64
|
+
if: matrix.ruby == '3.4'
|
|
65
|
+
run: sed -i 's/"ignored"/null/g' coverage/coverage.json
|
|
66
|
+
|
|
58
67
|
- name: Upload coverage to Codecov
|
|
59
68
|
uses: codecov/codecov-action@v5
|
|
60
69
|
if: matrix.ruby == '3.4'
|
|
@@ -62,3 +71,25 @@ jobs:
|
|
|
62
71
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
63
72
|
files: coverage/coverage.json
|
|
64
73
|
|
|
74
|
+
# The upload step succeeds even when Codecov later fails to process the
|
|
75
|
+
# report (state "error", 0% coverage), so poll the API and fail loudly.
|
|
76
|
+
- name: Verify Codecov processed the report
|
|
77
|
+
if: matrix.ruby == '3.4' && github.event.pull_request.head.repo.fork != true
|
|
78
|
+
env:
|
|
79
|
+
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
80
|
+
run: |
|
|
81
|
+
owner="${GITHUB_REPOSITORY%%/*}"
|
|
82
|
+
repo="${GITHUB_REPOSITORY#*/}"
|
|
83
|
+
url="https://api.codecov.io/api/v2/github/${owner}/repos/${repo}/commits/${SHA}/"
|
|
84
|
+
for attempt in $(seq 1 18); do
|
|
85
|
+
state=$(curl -fsS "$url" | jq -r '.state' || true)
|
|
86
|
+
echo "Codecov state for ${SHA}: ${state:-unknown} (attempt ${attempt})"
|
|
87
|
+
case "$state" in
|
|
88
|
+
complete) exit 0 ;;
|
|
89
|
+
error)
|
|
90
|
+
echo "::error::Codecov failed to process the coverage report for ${SHA}"
|
|
91
|
+
exit 1 ;;
|
|
92
|
+
esac
|
|
93
|
+
sleep 10
|
|
94
|
+
done
|
|
95
|
+
echo "::warning::Timed out waiting for Codecov to process ${SHA}"
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,22 @@ 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.1] - 2026-10-26
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `ractor_safe: true` could hang forever on Ruby 3.x when several threads in the same Ractor called memoized methods (or `reset_all_ractor_memos` and friends) at once. Ruby can lose a wakeup when multiple threads wait in `Ractor.receive_if` on the Ractor's single incoming queue, leaving one caller blocked indefinitely. Each request/reply round trip to the supervisor is now serialized per Ractor; throughput is unchanged since the supervisor already handles requests one at a time.
|
|
16
|
+
- `ractor_safe: true` now works on Ruby 4.0. Ruby 4.0 removed `Ractor.receive_if` and `Ractor#take`, so every call raised `NoMethodError`. On 4.0 each request now gets its own `Ractor::Port` for the supervisor's reply; Ruby 3.x behaviour is unchanged. The `ractor_safe` specs previously skipped themselves on 4.0 and now run there.
|
|
17
|
+
|
|
18
|
+
## [1.7.0] - 2026-06-02
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `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.
|
|
23
|
+
- `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.
|
|
24
|
+
- `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`.
|
|
25
|
+
- `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.
|
|
26
|
+
|
|
11
27
|
## [1.6.0] - 2026-06-02
|
|
12
28
|
|
|
13
29
|
### Added
|
|
@@ -259,7 +275,18 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
|
|
|
259
275
|
|
|
260
276
|
- Initial release
|
|
261
277
|
|
|
262
|
-
[Unreleased]: https://github.com/eclectic-coding/safe_memoize/compare/
|
|
278
|
+
[Unreleased]: https://github.com/eclectic-coding/safe_memoize/compare/v1.7.1...HEAD
|
|
279
|
+
[1.7.1]: https://github.com/eclectic-coding/safe_memoize/compare/v1.7.0...v1.7.1
|
|
280
|
+
[1.7.0]: https://github.com/eclectic-coding/safe_memoize/compare/v1.6.0...v1.7.0
|
|
281
|
+
[1.6.0]: https://github.com/eclectic-coding/safe_memoize/compare/v1.5.0...v1.6.0
|
|
282
|
+
[1.5.0]: https://github.com/eclectic-coding/safe_memoize/compare/v1.4.0...v1.5.0
|
|
283
|
+
[1.4.0]: https://github.com/eclectic-coding/safe_memoize/compare/v1.3.0...v1.4.0
|
|
284
|
+
[1.3.0]: https://github.com/eclectic-coding/safe_memoize/compare/v1.2.0...v1.3.0
|
|
285
|
+
[1.2.0]: https://github.com/eclectic-coding/safe_memoize/compare/v1.1.0...v1.2.0
|
|
286
|
+
[1.1.0]: https://github.com/eclectic-coding/safe_memoize/compare/v1.0.0...v1.1.0
|
|
287
|
+
[1.0.0]: https://github.com/eclectic-coding/safe_memoize/compare/v0.9.0...v1.0.0
|
|
288
|
+
[0.9.0]: https://github.com/eclectic-coding/safe_memoize/compare/v0.8.0...v0.9.0
|
|
289
|
+
[0.8.0]: https://github.com/eclectic-coding/safe_memoize/compare/v0.7.0...v0.8.0
|
|
263
290
|
[0.7.0]: https://github.com/eclectic-coding/safe_memoize/compare/v0.6.3...v0.7.0
|
|
264
291
|
[0.6.3]: https://github.com/eclectic-coding/safe_memoize/compare/v0.6.2...v0.6.3
|
|
265
292
|
[0.6.2]: https://github.com/eclectic-coding/safe_memoize/compare/v0.6.1...v0.6.2
|
data/README.md
CHANGED
|
@@ -12,6 +12,57 @@ SafeMemoize is a production-ready, zero-dependency memoization library for Ruby.
|
|
|
12
12
|
|
|
13
13
|
Beyond the basics, SafeMemoize ships with TTL expiration (including sliding window refresh via `ttl_refresh:`), LRU cache size capping, conditional caching via `if:`/`unless:` predicates, lifecycle hooks for cache hits, evictions, and expirations, per-instance metrics (hit rate, miss rate, average computation time), targeted and bulk cache invalidation, custom cache key generators, and rich introspection helpers (`memoized?`, `memo_count`, `memo_keys`, `memo_values`, `memo_ttl_remaining`). It preserves method visibility (public, protected, and private) and requires no runtime dependencies.
|
|
14
14
|
|
|
15
|
+
## Table of Contents
|
|
16
|
+
|
|
17
|
+
- [The Problem](#the-problem)
|
|
18
|
+
- [How It Works](#how-it-works)
|
|
19
|
+
- [Installation](#installation)
|
|
20
|
+
- [Usage](#usage)
|
|
21
|
+
- [Basic memoization](#basic-memoization)
|
|
22
|
+
- [With arguments](#with-arguments)
|
|
23
|
+
- [Nil and false safety](#nil-and-false-safety)
|
|
24
|
+
- [Works with private methods](#works-with-private-methods)
|
|
25
|
+
- [Cache reset](#cache-reset)
|
|
26
|
+
- [Cache invalidation groups](#cache-invalidation-groups)
|
|
27
|
+
- [Lifecycle hooks](#lifecycle-hooks)
|
|
28
|
+
- [TTL expiration](#ttl-expiration)
|
|
29
|
+
- [Sliding window TTL](#sliding-window-ttl)
|
|
30
|
+
- [LRU cache size limit](#lru-cache-size-limit)
|
|
31
|
+
- [Conditional caching](#conditional-caching)
|
|
32
|
+
- [Cache warm-up and persistence](#cache-warm-up-and-persistence)
|
|
33
|
+
- [Shared cache](#shared-cache)
|
|
34
|
+
- [Fiber-local memoization](#fiber-local-memoization)
|
|
35
|
+
- [Bulk memoization](#bulk-memoization)
|
|
36
|
+
- [Custom cache keys](#custom-cache-keys)
|
|
37
|
+
- [Cache inspection](#cache-inspection)
|
|
38
|
+
- [Cache metrics](#cache-metrics)
|
|
39
|
+
- [Plugin / extension architecture](#plugin--extension-architecture)
|
|
40
|
+
- [Automatic cache busting](#automatic-cache-busting)
|
|
41
|
+
- [Named shared caches](#named-shared-caches)
|
|
42
|
+
- [Cache namespacing](#cache-namespacing)
|
|
43
|
+
- [Global configuration](#global-configuration)
|
|
44
|
+
- [ActiveSupport::Notifications](#activesupportnotifications)
|
|
45
|
+
- [StatsD](#statsd)
|
|
46
|
+
- [OpenTelemetry](#opentelemetry)
|
|
47
|
+
- [Rails request-scope](#rails-request-scope)
|
|
48
|
+
- [Pluggable cache stores](#pluggable-cache-stores)
|
|
49
|
+
- [Deprecation](#deprecation)
|
|
50
|
+
- [Circuit breaker for external stores](#circuit-breaker-for-external-stores)
|
|
51
|
+
- [Multi-level caching](#multi-level-caching)
|
|
52
|
+
- [Stampede protection](#stampede-protection)
|
|
53
|
+
- [Per-class default options](#per-class-default-options-safe_memoize_options)
|
|
54
|
+
- [Copy-on-read](#copy-on-read)
|
|
55
|
+
- [Ractor-safe shared cache](#ractor-safe-shared-cache)
|
|
56
|
+
- [Ractor compatibility](#ractor-compatibility)
|
|
57
|
+
- [Development](#development)
|
|
58
|
+
- [Releasing](#releasing)
|
|
59
|
+
- [Public API and versioning guarantee](#public-api-and-versioning-guarantee)
|
|
60
|
+
- [Ruby version support](#ruby-version-support)
|
|
61
|
+
- [Benchmarks](benchmarks/BENCHMARK.md)
|
|
62
|
+
- [Roadmap](#roadmap)
|
|
63
|
+
- [Contributing](#contributing)
|
|
64
|
+
- [License](#license)
|
|
65
|
+
|
|
15
66
|
## The Problem
|
|
16
67
|
|
|
17
68
|
Ruby's common memoization pattern breaks with falsy values:
|
|
@@ -28,60 +79,6 @@ SafeMemoize uses `Hash#key?` to distinguish "not yet cached" from "cached nil/fa
|
|
|
28
79
|
|
|
29
80
|
SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name`, it creates an anonymous module with a wrapper method and prepends it onto your class. The wrapper calls `super` to invoke the original method and stores the result in a per-instance hash. Thread safety is achieved with a per-instance `Mutex` using double-check locking.
|
|
30
81
|
|
|
31
|
-
## Features
|
|
32
|
-
|
|
33
|
-
- [Correctly memoizes `nil` and `false` return values](#nil-and-false-safety)
|
|
34
|
-
- [Caches per unique arguments (positional and keyword)](#with-arguments)
|
|
35
|
-
- [Thread-safe via double-check locking](#how-it-works)
|
|
36
|
-
- [Simple `prepend` + `memoize` API](#usage)
|
|
37
|
-
- [Preserves public, protected, and private method visibility](#works-with-private-methods)
|
|
38
|
-
- [Supports targeted cache invalidation by argument combination](#cache-reset)
|
|
39
|
-
- [Includes a `memoized?` helper for cache inspection](#cache-inspection)
|
|
40
|
-
- [Includes a `memo_count` helper for cache size stats](#cache-inspection)
|
|
41
|
-
- [Includes a `memo_keys` helper for inspecting cached signatures](#cache-inspection)
|
|
42
|
-
- [Includes a `memo_values` helper for inspecting cached signatures and values](#cache-inspection)
|
|
43
|
-
- [Optional TTL expiration support for cached entries](#ttl-expiration)
|
|
44
|
-
- [Sliding window TTL via `ttl_refresh: true`](#sliding-window-ttl)
|
|
45
|
-
- [Optional LRU cache size limit per method via `max_size:`](#lru-cache-size-limit)
|
|
46
|
-
- [Conditional caching via `if:` and `unless:` predicates](#conditional-caching)
|
|
47
|
-
- [Lifecycle hooks for hit, miss, eviction, and expiration events](#lifecycle-hooks)
|
|
48
|
-
- [Per-instance cache metrics (hit rate, miss rate, computation time)](#cache-metrics)
|
|
49
|
-
- [Cache warm-up, export, and restore (`warm_memo`, `dump_memo`, `load_memo`)](#cache-warm-up-and-persistence)
|
|
50
|
-
- [Class-level shared cache via `shared: true` with optional LRU](#shared-cache)
|
|
51
|
-
- [Bulk memoization via `memoize_all` (public, protected, and private)](#bulk-memoization)
|
|
52
|
-
- [Custom cache key generation per method](#custom-cache-keys)
|
|
53
|
-
- [TTL introspection via `memo_ttl_remaining`](#cache-inspection)
|
|
54
|
-
- [Deep single-entry inspection via `memo_inspect`](#cache-inspection)
|
|
55
|
-
- [`ArgumentError` at definition time when memoizing an undefined method](#basic-memoization)
|
|
56
|
-
- [Hook error isolation — hook exceptions never propagate to callers](#lifecycle-hooks)
|
|
57
|
-
- [Deprecation infrastructure for gem authors](#deprecation)
|
|
58
|
-
- [Optional `ActiveSupport::Notifications` integration for Rails observability](#activesupportnotifications)
|
|
59
|
-
- [Optional StatsD adapter for metrics pipelines](#statsd)
|
|
60
|
-
- [Optional OpenTelemetry adapter for distributed tracing](#opentelemetry)
|
|
61
|
-
- [Rails request-scope helpers for controllers and service objects](#rails-request-scope)
|
|
62
|
-
- [Batch cache warm-up via `memo_preload`](#cache-warm-up-and-persistence)
|
|
63
|
-
- [`on_memo_store` hook fires on every cache write](#lifecycle-hooks)
|
|
64
|
-
- [Global default TTL and max size via `SafeMemoize.configure`](#global-configuration)
|
|
65
|
-
- [`memo_touch` resets the expiry clock without recomputing](#ttl-expiration)
|
|
66
|
-
- [`memo_refresh` force-recomputes and re-caches in one call](#cache-inspection)
|
|
67
|
-
- [`memo_age` and `memo_stale?` for TTL introspection](#cache-inspection)
|
|
68
|
-
- [Class-level `key:` option for shared cache key generation](#custom-cache-keys)
|
|
69
|
-
- [`shared_memo_age` and `shared_memo_stale?` for shared cache TTL inspection](#shared-cache)
|
|
70
|
-
- [Pluggable external cache stores — Redis, Rails.cache, or any custom adapter](#pluggable-cache-stores)
|
|
71
|
-
- [Global default store via `Configuration#default_store`](#pluggable-cache-stores)
|
|
72
|
-
- [`SafeMemoize::Adapters::ConcurrentRuby` — optional `concurrent-ruby` store with parallel-read locking](#concurrent-ruby-adapter)
|
|
73
|
-
- [Class-level `.safe_memoize_store=` — set a per-class default store without touching global config](#class-level-default-store-safe_memoize_store)
|
|
74
|
-
- [Fiber-local memoization via `fiber_local: true` — isolated per-fiber cache, no mutex, works with Async/Falcon](#fiber-local-memoization)
|
|
75
|
-
- [Ractor-safe shared cache via `ractor_safe: true` — supervisor Ractor replaces the Mutex; worker Ractors can call the memoized method directly](#ractor-safe-shared-cache)
|
|
76
|
-
- [Cache namespacing — per-method `namespace:`, class-level `.safe_memoize_namespace=`, and global `Configuration#namespace` for multi-tenant and versioned deployments](#cache-namespacing)
|
|
77
|
-
- [Named shared caches via `shared_cache: "name"` — cross-class cache sharing backed by a globally-registered store](#named-shared-caches)
|
|
78
|
-
- [Automatic cache busting via `cache_bust:` — version-token-based invalidation; works with ActiveRecord `updated_at` and any comparable value](#automatic-cache-busting)
|
|
79
|
-
- [Plugin / extension architecture — `SafeMemoize::Extension` DSL for adding custom `memoize` options and global lifecycle handlers without monkey-patching](#plugin--extension-architecture)
|
|
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
|
-
- [Copy-on-read via `copy_on_read: true` — returns a `dup`/`deep_dup` on every cache read to protect shared cached state from caller mutation](#copy-on-read)
|
|
82
|
-
- [Cache invalidation groups via `group:` — tag related methods with a group name and bust them all with a single `reset_memo_group` call](#cache-invalidation-groups)
|
|
83
|
-
- [Circuit breaker for external stores — `Stores::CircuitBreaker` wraps any store adapter and falls back to the per-instance cache when the store is down; configurable error threshold and probe interval](#circuit-breaker-for-external-stores)
|
|
84
|
-
|
|
85
82
|
## Installation
|
|
86
83
|
|
|
87
84
|
Add to your Gemfile:
|
|
@@ -120,7 +117,7 @@ end
|
|
|
120
117
|
|
|
121
118
|
Calling `memoize` on a method name that does not exist raises `ArgumentError` immediately at class definition time rather than at the first runtime call.
|
|
122
119
|
|
|
123
|
-
[↑ Back to
|
|
120
|
+
[↑ Back to top](#table-of-contents)
|
|
124
121
|
|
|
125
122
|
### With arguments
|
|
126
123
|
|
|
@@ -145,7 +142,7 @@ calc.compute(3, 4) # computes and caches (different args)
|
|
|
145
142
|
|
|
146
143
|
Argument arrays, hashes, and strings are deep-frozen into an independent copy when the cache key is built, so mutating arguments after a call cannot corrupt or miss a cached entry.
|
|
147
144
|
|
|
148
|
-
[↑ Back to
|
|
145
|
+
[↑ Back to top](#table-of-contents)
|
|
149
146
|
|
|
150
147
|
### Nil and false safety
|
|
151
148
|
|
|
@@ -161,7 +158,7 @@ class Config
|
|
|
161
158
|
end
|
|
162
159
|
```
|
|
163
160
|
|
|
164
|
-
[↑ Back to
|
|
161
|
+
[↑ Back to top](#table-of-contents)
|
|
165
162
|
|
|
166
163
|
### Works with private methods
|
|
167
164
|
|
|
@@ -182,7 +179,7 @@ class TokenProvider
|
|
|
182
179
|
end
|
|
183
180
|
```
|
|
184
181
|
|
|
185
|
-
[↑ Back to
|
|
182
|
+
[↑ Back to top](#table-of-contents)
|
|
186
183
|
|
|
187
184
|
### Cache reset
|
|
188
185
|
|
|
@@ -194,7 +191,7 @@ obj.reset_memo(:search, "ruby", page: 2) # Clears one positional/keyword c
|
|
|
194
191
|
obj.reset_all_memos # Clears all memoized values
|
|
195
192
|
```
|
|
196
193
|
|
|
197
|
-
[↑ Back to
|
|
194
|
+
[↑ Back to top](#table-of-contents)
|
|
198
195
|
|
|
199
196
|
### Cache invalidation groups
|
|
200
197
|
|
|
@@ -267,7 +264,7 @@ end
|
|
|
267
264
|
|
|
268
265
|
A method belongs to at most one group at a time; re-memoizing with a different `group:` moves it.
|
|
269
266
|
|
|
270
|
-
[↑ Back to
|
|
267
|
+
[↑ Back to top](#table-of-contents)
|
|
271
268
|
|
|
272
269
|
### Lifecycle hooks
|
|
273
270
|
|
|
@@ -345,7 +342,7 @@ end
|
|
|
345
342
|
|
|
346
343
|
Set `c.on_hook_error = :raise` to re-raise exceptions instead of swallowing them.
|
|
347
344
|
|
|
348
|
-
[↑ Back to
|
|
345
|
+
[↑ Back to top](#table-of-contents)
|
|
349
346
|
|
|
350
347
|
### TTL expiration
|
|
351
348
|
|
|
@@ -377,7 +374,7 @@ obj.memo_refresh(:current_quote) # Recomputes and re-caches
|
|
|
377
374
|
obj.memo_refresh(:find, 42) # Recomputes for one argument combination
|
|
378
375
|
```
|
|
379
376
|
|
|
380
|
-
[↑ Back to
|
|
377
|
+
[↑ Back to top](#table-of-contents)
|
|
381
378
|
|
|
382
379
|
### Sliding window TTL
|
|
383
380
|
|
|
@@ -396,7 +393,7 @@ end
|
|
|
396
393
|
|
|
397
394
|
Without `ttl_refresh:`, the entry expires 300 seconds after it was first cached. With it, the clock resets on every read — the entry is evicted only if the method goes 300 seconds without being called. `ttl_refresh: true` requires `ttl:` to be set and works with both per-instance and `shared: true` memoization.
|
|
398
395
|
|
|
399
|
-
[↑ Back to
|
|
396
|
+
[↑ Back to top](#table-of-contents)
|
|
400
397
|
|
|
401
398
|
### LRU cache size limit
|
|
402
399
|
|
|
@@ -431,7 +428,7 @@ memoize :find, max_size: 50, ttl: 300
|
|
|
431
428
|
|
|
432
429
|
The `on_evict` hook fires for LRU-evicted entries the same way it does for manual `reset_memo` calls.
|
|
433
430
|
|
|
434
|
-
[↑ Back to
|
|
431
|
+
[↑ Back to top](#table-of-contents)
|
|
435
432
|
|
|
436
433
|
### Conditional caching
|
|
437
434
|
|
|
@@ -467,7 +464,7 @@ Both options accept any callable and compose with `ttl:` and `max_size:`:
|
|
|
467
464
|
memoize :find, if: ->(result) { !result.nil? }, ttl: 60, max_size: 500
|
|
468
465
|
```
|
|
469
466
|
|
|
470
|
-
[↑ Back to
|
|
467
|
+
[↑ Back to top](#table-of-contents)
|
|
471
468
|
|
|
472
469
|
### Cache warm-up and persistence
|
|
473
470
|
|
|
@@ -533,7 +530,7 @@ obj.load_memo(Marshal.load(raw)) if raw
|
|
|
533
530
|
|
|
534
531
|
Loaded entries have no TTL — they persist until explicitly reset. Expired entries are excluded from `dump_memo` output, so snapshots never contain stale data.
|
|
535
532
|
|
|
536
|
-
[↑ Back to
|
|
533
|
+
[↑ Back to top](#table-of-contents)
|
|
537
534
|
|
|
538
535
|
### Shared cache
|
|
539
536
|
|
|
@@ -583,7 +580,7 @@ memoize :find, shared: true, max_size: 500
|
|
|
583
580
|
|
|
584
581
|
Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`, `on_memo_evict`) fire on the calling instance as usual.
|
|
585
582
|
|
|
586
|
-
[↑ Back to
|
|
583
|
+
[↑ Back to top](#table-of-contents)
|
|
587
584
|
|
|
588
585
|
### Fiber-local memoization
|
|
589
586
|
|
|
@@ -625,7 +622,7 @@ obj.reset_all_fiber_memos # clear all fiber-local entries for thi
|
|
|
625
622
|
|
|
626
623
|
Lifecycle hooks and cache metrics work the same as for regular memoization. The existing `memoized?`, `reset_memo`, and `memo_count` methods operate on the instance-variable cache; use the `fiber_local_*` / `reset_fiber_*` API for fiber-local entries.
|
|
627
624
|
|
|
628
|
-
[↑ Back to
|
|
625
|
+
[↑ Back to top](#table-of-contents)
|
|
629
626
|
|
|
630
627
|
### Bulk memoization
|
|
631
628
|
|
|
@@ -683,7 +680,7 @@ memoize_all include_protected: true, include_private: true
|
|
|
683
680
|
|
|
684
681
|
Inherited methods are never affected regardless of visibility.
|
|
685
682
|
|
|
686
|
-
[↑ Back to
|
|
683
|
+
[↑ Back to top](#table-of-contents)
|
|
687
684
|
|
|
688
685
|
### Custom cache keys
|
|
689
686
|
|
|
@@ -732,7 +729,7 @@ svc.clear_custom_keys(:generate) # Remove generator for one method
|
|
|
732
729
|
svc.clear_custom_keys # Remove all custom key generators
|
|
733
730
|
```
|
|
734
731
|
|
|
735
|
-
[↑ Back to
|
|
732
|
+
[↑ Back to top](#table-of-contents)
|
|
736
733
|
|
|
737
734
|
### Cache inspection
|
|
738
735
|
|
|
@@ -778,7 +775,7 @@ obj.memo_inspect(:find, 42)
|
|
|
778
775
|
|
|
779
776
|
Returns `nil` when the entry is not cached.
|
|
780
777
|
|
|
781
|
-
[↑ Back to
|
|
778
|
+
[↑ Back to top](#table-of-contents)
|
|
782
779
|
|
|
783
780
|
### Cache metrics
|
|
784
781
|
|
|
@@ -808,7 +805,7 @@ obj.cache_metrics_reset(:find) # Clears metrics for one method only
|
|
|
808
805
|
|
|
809
806
|
Metrics are per-instance and reset independently from the cache itself — clearing metrics does not evict cached values.
|
|
810
807
|
|
|
811
|
-
[↑ Back to
|
|
808
|
+
[↑ Back to top](#table-of-contents)
|
|
812
809
|
|
|
813
810
|
### Plugin / extension architecture
|
|
814
811
|
|
|
@@ -891,7 +888,7 @@ An extension does not need to `extend SafeMemoize::Extension`. Any object respon
|
|
|
891
888
|
- Unknown `memoize` keywords raise `ArgumentError` unless a registered extension claims them — typos are still caught.
|
|
892
889
|
- `on_cache_event` handlers run on the main Ractor only; they are silently skipped from worker Ractors.
|
|
893
890
|
|
|
894
|
-
[↑ Back to
|
|
891
|
+
[↑ Back to top](#table-of-contents)
|
|
895
892
|
|
|
896
893
|
### Automatic cache busting
|
|
897
894
|
|
|
@@ -952,7 +949,7 @@ obj.reset_memo(:summary) # clears ALL versions
|
|
|
952
949
|
- Incompatible with `key:` — both define the cache key shape; raises `ArgumentError` at `memoize` time.
|
|
953
950
|
- Composes with `namespace:`, `ttl:`, `if:`, `unless:`, and `shared_cache:`.
|
|
954
951
|
|
|
955
|
-
[↑ Back to
|
|
952
|
+
[↑ Back to top](#table-of-contents)
|
|
956
953
|
|
|
957
954
|
### Named shared caches
|
|
958
955
|
|
|
@@ -1023,7 +1020,7 @@ memoize :find, shared_cache: "orders", namespace: "presenter" # [:"presenter:fi
|
|
|
1023
1020
|
- `register_shared_cache` must be called before the class that uses the name is defined.
|
|
1024
1021
|
- Test suites should call `SafeMemoize.reset_shared_caches!` in an `after` hook to prevent state leaking between examples.
|
|
1025
1022
|
|
|
1026
|
-
[↑ Back to
|
|
1023
|
+
[↑ Back to top](#table-of-contents)
|
|
1027
1024
|
|
|
1028
1025
|
### Cache namespacing
|
|
1029
1026
|
|
|
@@ -1084,7 +1081,7 @@ end
|
|
|
1084
1081
|
- Namespacing works with all memoize paths (standard, `store:`, `fiber_local:`, `shared:`, `ractor_safe:`).
|
|
1085
1082
|
- Adding or changing a namespace changes the cache keys, so existing entries become unreachable (they expire naturally or can be cleared by `reset_all_memos`).
|
|
1086
1083
|
|
|
1087
|
-
[↑ Back to
|
|
1084
|
+
[↑ Back to top](#table-of-contents)
|
|
1088
1085
|
|
|
1089
1086
|
### Global configuration
|
|
1090
1087
|
|
|
@@ -1105,7 +1102,7 @@ SafeMemoize.reset_configuration!
|
|
|
1105
1102
|
|
|
1106
1103
|
The configure block also accepts `on_hook_error`, `on_deprecation`, `active_support_notifications`, `statsd_client`, `default_store`, and `namespace` (covered in [Hook error isolation](#hook-error-isolation), [Deprecation](#deprecation), [ActiveSupport::Notifications](#activesupportnotifications), [StatsD](#statsd), [Pluggable cache stores](#pluggable-cache-stores), and [Cache namespacing](#cache-namespacing)).
|
|
1107
1104
|
|
|
1108
|
-
[↑ Back to
|
|
1105
|
+
[↑ Back to top](#table-of-contents)
|
|
1109
1106
|
|
|
1110
1107
|
### ActiveSupport::Notifications
|
|
1111
1108
|
|
|
@@ -1147,7 +1144,7 @@ end
|
|
|
1147
1144
|
|
|
1148
1145
|
The integration is a no-op when ActiveSupport is not loaded — there is no overhead for non-Rails projects. `active_support_notifications` defaults to `false`.
|
|
1149
1146
|
|
|
1150
|
-
[↑ Back to
|
|
1147
|
+
[↑ Back to top](#table-of-contents)
|
|
1151
1148
|
|
|
1152
1149
|
### StatsD
|
|
1153
1150
|
|
|
@@ -1173,7 +1170,7 @@ Each call includes two tags: `method:method_name` and `class:ClassName`. The cli
|
|
|
1173
1170
|
|
|
1174
1171
|
If the client raises, the error is rescued and a warning is emitted to stderr rather than propagated to the caller. `statsd_client` defaults to `nil` (disabled).
|
|
1175
1172
|
|
|
1176
|
-
[↑ Back to
|
|
1173
|
+
[↑ Back to top](#table-of-contents)
|
|
1177
1174
|
|
|
1178
1175
|
### OpenTelemetry
|
|
1179
1176
|
|
|
@@ -1198,7 +1195,7 @@ SafeMemoize then wraps every cache miss (the actual method call, not cache hits)
|
|
|
1198
1195
|
|
|
1199
1196
|
Cache hits produce no spans, so tracing overhead is zero for cached calls. The adapter is compatible with any tracer that responds to `in_span(name, attributes:, &block)` — the interface provided by `opentelemetry-sdk`, `opentelemetry-api`, and no-op tracers alike. If `opentelemetry_tracer` is `nil` (the default), the adapter is completely bypassed.
|
|
1200
1197
|
|
|
1201
|
-
[↑ Back to
|
|
1198
|
+
[↑ Back to top](#table-of-contents)
|
|
1202
1199
|
|
|
1203
1200
|
### Rails request-scope
|
|
1204
1201
|
|
|
@@ -1267,7 +1264,7 @@ end
|
|
|
1267
1264
|
|
|
1268
1265
|
`SafeMemoize::Rails::Middleware` calls `reset_all_memos` on every tracked instance in the current thread at the end of the request, even if the app raises. Tracking is thread-local, so concurrent requests never interfere. The tracked list is cleared automatically after each reset.
|
|
1269
1266
|
|
|
1270
|
-
[↑ Back to
|
|
1267
|
+
[↑ Back to top](#table-of-contents)
|
|
1271
1268
|
|
|
1272
1269
|
### Pluggable cache stores
|
|
1273
1270
|
|
|
@@ -1416,7 +1413,7 @@ A per-method `store:` option always takes precedence. Methods using `max_size:`
|
|
|
1416
1413
|
|
|
1417
1414
|
The `store:` option composes with `ttl:`, `ttl_refresh:`, `if:`, `unless:`, lifecycle hooks, and cache metrics. It is incompatible with `max_size:` (use the store adapter's own eviction) and `shared:` (raise `ArgumentError` if combined).
|
|
1418
1415
|
|
|
1419
|
-
[↑ Back to
|
|
1416
|
+
[↑ Back to top](#table-of-contents)
|
|
1420
1417
|
|
|
1421
1418
|
### Deprecation
|
|
1422
1419
|
|
|
@@ -1447,7 +1444,7 @@ SafeMemoize.configure do |c|
|
|
|
1447
1444
|
end
|
|
1448
1445
|
```
|
|
1449
1446
|
|
|
1450
|
-
[↑ Back to
|
|
1447
|
+
[↑ Back to top](#table-of-contents)
|
|
1451
1448
|
|
|
1452
1449
|
## Circuit breaker for external stores
|
|
1453
1450
|
|
|
@@ -1531,7 +1528,103 @@ class ApiService
|
|
|
1531
1528
|
end
|
|
1532
1529
|
```
|
|
1533
1530
|
|
|
1534
|
-
[↑ Back to
|
|
1531
|
+
[↑ Back to top](#table-of-contents)
|
|
1532
|
+
|
|
1533
|
+
## Multi-level caching
|
|
1534
|
+
|
|
1535
|
+
`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.
|
|
1536
|
+
|
|
1537
|
+
```ruby
|
|
1538
|
+
l1 = SafeMemoize::Stores::Memory.new # fast, in-process
|
|
1539
|
+
l2 = MyRedisStore.new # slower, cross-process
|
|
1540
|
+
|
|
1541
|
+
class ProductService
|
|
1542
|
+
prepend SafeMemoize
|
|
1543
|
+
|
|
1544
|
+
def catalog = fetch_catalog_from_db
|
|
1545
|
+
memoize :catalog, store: [l1, l2], ttl: 300 # Array shorthand
|
|
1546
|
+
end
|
|
1547
|
+
```
|
|
1548
|
+
|
|
1549
|
+
The `store: [l1, l2]` shorthand is equivalent to `store: Stores::Multilevel.new(l1, l2)`.
|
|
1550
|
+
|
|
1551
|
+
### Promotion TTL
|
|
1552
|
+
|
|
1553
|
+
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:
|
|
1554
|
+
|
|
1555
|
+
```ruby
|
|
1556
|
+
store = SafeMemoize::Stores::Multilevel.new(l1, l2, promote_expires_in: 60)
|
|
1557
|
+
memoize :catalog, store: store, ttl: 300
|
|
1558
|
+
```
|
|
1559
|
+
|
|
1560
|
+
### Composition
|
|
1561
|
+
|
|
1562
|
+
`Multilevel` composes with `CircuitBreaker` and `XFetch`:
|
|
1563
|
+
|
|
1564
|
+
```ruby
|
|
1565
|
+
safe_l2 = SafeMemoize::Stores::CircuitBreaker.new(MyRedisStore.new)
|
|
1566
|
+
store = SafeMemoize::Stores::Multilevel.new(SafeMemoize::Stores::Memory.new, safe_l2)
|
|
1567
|
+
memoize :catalog, store: store, ttl: 300
|
|
1568
|
+
```
|
|
1569
|
+
|
|
1570
|
+
[↑ Back to top](#table-of-contents)
|
|
1571
|
+
|
|
1572
|
+
## Stampede protection
|
|
1573
|
+
|
|
1574
|
+
Cache stampedes (a.k.a. thundering-herd) happen when a popular entry expires and many processes simultaneously recompute it. SafeMemoize offers two mechanisms:
|
|
1575
|
+
|
|
1576
|
+
### `stampede_protection:` — per-instance cache
|
|
1577
|
+
|
|
1578
|
+
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:
|
|
1579
|
+
|
|
1580
|
+
```ruby
|
|
1581
|
+
class ApiClient
|
|
1582
|
+
prepend SafeMemoize
|
|
1583
|
+
|
|
1584
|
+
def catalog = fetch_catalog
|
|
1585
|
+
memoize :catalog, ttl: 300, stampede_protection: true # default beta=1.0
|
|
1586
|
+
# or
|
|
1587
|
+
memoize :catalog, ttl: 300, stampede_protection: 2.0 # custom beta (more aggressive)
|
|
1588
|
+
end
|
|
1589
|
+
```
|
|
1590
|
+
|
|
1591
|
+
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.
|
|
1592
|
+
|
|
1593
|
+
**Requirements:** `ttl:` must be set. Incompatible with `store:` (see `Stores::XFetch` below) and `ractor_safe:`.
|
|
1594
|
+
|
|
1595
|
+
### `Stores::XFetch` — external stores
|
|
1596
|
+
|
|
1597
|
+
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:
|
|
1598
|
+
|
|
1599
|
+
```ruby
|
|
1600
|
+
store = SafeMemoize::Stores::XFetch.new(
|
|
1601
|
+
MyRedisStore.new,
|
|
1602
|
+
delta: 0.2, # estimated typical computation time (seconds)
|
|
1603
|
+
beta: 1.5 # aggressiveness scalar
|
|
1604
|
+
)
|
|
1605
|
+
|
|
1606
|
+
class CatalogService
|
|
1607
|
+
prepend SafeMemoize
|
|
1608
|
+
|
|
1609
|
+
def products = db_fetch
|
|
1610
|
+
memoize :products, store: store, ttl: 300
|
|
1611
|
+
end
|
|
1612
|
+
```
|
|
1613
|
+
|
|
1614
|
+
**XFetch formula:** `now − (delta × beta × log(rand)) ≥ expires_at`
|
|
1615
|
+
|
|
1616
|
+
A higher `beta` triggers early recomputation more aggressively. A larger `delta` (longer computation) also increases the recomputation window.
|
|
1617
|
+
|
|
1618
|
+
`Stores::XFetch` composes with `Multilevel` and `CircuitBreaker`:
|
|
1619
|
+
|
|
1620
|
+
```ruby
|
|
1621
|
+
store = SafeMemoize::Stores::XFetch.new(
|
|
1622
|
+
SafeMemoize::Stores::CircuitBreaker.new(MyRedisStore.new),
|
|
1623
|
+
delta: 0.1
|
|
1624
|
+
)
|
|
1625
|
+
```
|
|
1626
|
+
|
|
1627
|
+
[↑ Back to top](#table-of-contents)
|
|
1535
1628
|
|
|
1536
1629
|
## Per-class default options (`safe_memoize_options`)
|
|
1537
1630
|
|
|
@@ -1570,7 +1663,7 @@ Call with no arguments to clear all class-level defaults:
|
|
|
1570
1663
|
MyClass.safe_memoize_options # clears — subsequent memoize calls use global config or per-call options only
|
|
1571
1664
|
```
|
|
1572
1665
|
|
|
1573
|
-
[↑ Back to
|
|
1666
|
+
[↑ Back to top](#table-of-contents)
|
|
1574
1667
|
|
|
1575
1668
|
## Copy-on-read
|
|
1576
1669
|
|
|
@@ -1608,7 +1701,7 @@ class ReportService
|
|
|
1608
1701
|
end
|
|
1609
1702
|
```
|
|
1610
1703
|
|
|
1611
|
-
[↑ Back to
|
|
1704
|
+
[↑ Back to top](#table-of-contents)
|
|
1612
1705
|
|
|
1613
1706
|
## Ractor-safe shared cache
|
|
1614
1707
|
|
|
@@ -1629,7 +1722,7 @@ end
|
|
|
1629
1722
|
20.times.map { Thread.new { PriceService.new.fetch_price(42) } }.map(&:value)
|
|
1630
1723
|
|
|
1631
1724
|
# Worker Ractors also read from and write to the same supervisor cache
|
|
1632
|
-
result = Ractor.new(PriceService) { |s| s.new.fetch_price(42) }.take
|
|
1725
|
+
result = Ractor.new(PriceService) { |s| s.new.fetch_price(42) }.value # .take on Ruby 3.x
|
|
1633
1726
|
```
|
|
1634
1727
|
|
|
1635
1728
|
### How it works
|
|
@@ -1637,7 +1730,7 @@ result = Ractor.new(PriceService) { |s| s.new.fetch_price(42) }.take
|
|
|
1637
1730
|
- A **supervisor `Ractor`** is created once per class the first time a `ractor_safe: true` method is memoized. It owns a plain Ruby `Hash` and responds to `:fetch`, `:store`, `:delete_all`, `:delete_one`, `:clear`, `:memoized`, and `:count` messages.
|
|
1638
1731
|
- The memoize **wrapper Proc** is frozen via `Ractor.make_shareable` before being registered with `define_method`, so the class can be passed directly into `Ractor.new` blocks.
|
|
1639
1732
|
- Cached values are deep-frozen via `Ractor.make_shareable`. Values that cannot be made shareable (e.g. a `Mutex`) raise `ArgumentError`.
|
|
1640
|
-
- **Thread safety** inside the main Ractor (multiple threads)
|
|
1733
|
+
- **Thread safety** inside the main Ractor (multiple threads): on Ruby 4.0+ each call replies through its own `Ractor::Port`, so callers never share a queue. On Ruby 3.x, replies are matched by per-call tags (`Thread.current.object_id`) with `Ractor.receive_if`, and each send/receive pair is serialized per Ractor because concurrent `receive_if` waiters can otherwise lose a wakeup and hang.
|
|
1641
1734
|
- `ttl:` is supported. Expired entries are skipped by the supervisor's `:fetch` handler.
|
|
1642
1735
|
|
|
1643
1736
|
### Constraints
|
|
@@ -1663,7 +1756,7 @@ PriceService.reset_ractor_memo(:fetch_price) # → clear all entries for
|
|
|
1663
1756
|
PriceService.reset_all_ractor_memos # → clear entire shared cache
|
|
1664
1757
|
```
|
|
1665
1758
|
|
|
1666
|
-
[↑ Back to
|
|
1759
|
+
[↑ Back to top](#table-of-contents)
|
|
1667
1760
|
|
|
1668
1761
|
## Ractor compatibility
|
|
1669
1762
|
|
|
@@ -1681,7 +1774,7 @@ Regular `memoize` (without `ractor_safe: true`) is **not Ractor-compatible**. Pa
|
|
|
1681
1774
|
|
|
1682
1775
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt.
|
|
1683
1776
|
|
|
1684
|
-
To run the benchmark suite: `bundle exec ruby benchmarks/benchmark.rb`.
|
|
1777
|
+
To run the benchmark suite: `bundle exec ruby benchmarks/benchmark.rb`. Pre-recorded results and analysis are in [benchmarks/BENCHMARK.md](benchmarks/BENCHMARK.md).
|
|
1685
1778
|
|
|
1686
1779
|
To generate API documentation locally: `bundle exec rake doc`. Output is written to `doc/` (gitignored). The online reference is published automatically to [RubyDoc.info](https://rubydoc.info/gems/safe_memoize) on every release. Install `memery` and `memo_wise` first if you want comparison columns against those gems.
|
|
1687
1780
|
|
|
@@ -1772,6 +1865,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
|
|
|
1772
1865
|
| `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
1866
|
| `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
1867
|
| `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 |
|
|
1868
|
+
| `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
1869
|
| *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
|
|
1776
1870
|
|
|
1777
1871
|
### `memoize_all` options (class method)
|
|
@@ -1789,7 +1883,7 @@ All `memoize` option keys above, plus:
|
|
|
1789
1883
|
|
|
1790
1884
|
| Option key | Type | Default | Notes |
|
|
1791
1885
|
|---|---|---|---|
|
|
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:` |
|
|
1886
|
+
| 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
1887
|
|
|
1794
1888
|
### Instance methods (public)
|
|
1795
1889
|
|
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.*
|