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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1920c31de4a855b818c5a4f37f35260f38489b7ff6ffe221b43d91e862133bde
4
- data.tar.gz: 948979dfc49f4e42b16d21926344869fc06a39da3252ab1033697a5748a7362a
3
+ metadata.gz: 0b2558ad027e3b8bb09ec956d8a18eed759b56c9baa1234a173fdebd7e5e9913
4
+ data.tar.gz: ae5302c29efe49c336a323bc7a6ad8dcd4a6b10cebc394e786fccd12c00a66ab
5
5
  SHA512:
6
- metadata.gz: 415c4095ce702b5823a887858f342fdf5742887394dc2cc113ccf711ef1f5789430e036d69ee3660d663a2153f34375f3622f1ff92d73143072e2376d2ae087b
7
- data.tar.gz: 1fed071b0feb6591d4b95c69604f4c14440c88cefbdb13ce9f2e50a2eba5fad4723b28c1584f2cb7aaa33f7ba4e2140b5a16c45b99ba0cd9b13f47b2a16b0d58
6
+ metadata.gz: 5822061de749e6001cee636a687c19bfd59253ed075b4d9e82123aa88954578a3a8f2dcd0a5a9cbfb29cad8d86b6768a5461a454210dfd7bf56ba624e4e6df6c
7
+ data.tar.gz: 89e21d16cb9e37a00333f98ec12adf7b9ec6a3e746bb1a9f8377cfafd16c27abbd92de5a79074e207f51fcacaa25a0e9c58092da94a82798be501d35a677489c
@@ -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/v0.7.0...HEAD
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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 features](#features)
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) is handled by per-call tags (`Thread.current.object_id`) combined with `Ractor.receive_if`, so concurrent threads never consume each other's replies.
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 features](#features)
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.*