safe_memoize 1.7.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 +19 -1
- data/README.md +89 -94
- data/benchmarks/BENCHMARK.md +114 -0
- data/benchmarks/README.md +3 -1
- data/lib/safe_memoize/class_methods.rb +2 -5
- data/lib/safe_memoize/ractor_shared_methods.rb +59 -8
- data/lib/safe_memoize/release_tooling.rb +19 -1
- data/lib/safe_memoize/version.rb +1 -1
- data/sig/safe_memoize.rbs +8 -0
- metadata +3 -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,13 @@ 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
|
+
|
|
11
18
|
## [1.7.0] - 2026-06-02
|
|
12
19
|
|
|
13
20
|
### Added
|
|
@@ -268,7 +275,18 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
|
|
|
268
275
|
|
|
269
276
|
- Initial release
|
|
270
277
|
|
|
271
|
-
[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
|
|
272
290
|
[0.7.0]: https://github.com/eclectic-coding/safe_memoize/compare/v0.6.3...v0.7.0
|
|
273
291
|
[0.6.3]: https://github.com/eclectic-coding/safe_memoize/compare/v0.6.2...v0.6.3
|
|
274
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,62 +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
|
-
- [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)
|
|
86
|
-
|
|
87
82
|
## Installation
|
|
88
83
|
|
|
89
84
|
Add to your Gemfile:
|
|
@@ -122,7 +117,7 @@ end
|
|
|
122
117
|
|
|
123
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.
|
|
124
119
|
|
|
125
|
-
[↑ Back to
|
|
120
|
+
[↑ Back to top](#table-of-contents)
|
|
126
121
|
|
|
127
122
|
### With arguments
|
|
128
123
|
|
|
@@ -147,7 +142,7 @@ calc.compute(3, 4) # computes and caches (different args)
|
|
|
147
142
|
|
|
148
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.
|
|
149
144
|
|
|
150
|
-
[↑ Back to
|
|
145
|
+
[↑ Back to top](#table-of-contents)
|
|
151
146
|
|
|
152
147
|
### Nil and false safety
|
|
153
148
|
|
|
@@ -163,7 +158,7 @@ class Config
|
|
|
163
158
|
end
|
|
164
159
|
```
|
|
165
160
|
|
|
166
|
-
[↑ Back to
|
|
161
|
+
[↑ Back to top](#table-of-contents)
|
|
167
162
|
|
|
168
163
|
### Works with private methods
|
|
169
164
|
|
|
@@ -184,7 +179,7 @@ class TokenProvider
|
|
|
184
179
|
end
|
|
185
180
|
```
|
|
186
181
|
|
|
187
|
-
[↑ Back to
|
|
182
|
+
[↑ Back to top](#table-of-contents)
|
|
188
183
|
|
|
189
184
|
### Cache reset
|
|
190
185
|
|
|
@@ -196,7 +191,7 @@ obj.reset_memo(:search, "ruby", page: 2) # Clears one positional/keyword c
|
|
|
196
191
|
obj.reset_all_memos # Clears all memoized values
|
|
197
192
|
```
|
|
198
193
|
|
|
199
|
-
[↑ Back to
|
|
194
|
+
[↑ Back to top](#table-of-contents)
|
|
200
195
|
|
|
201
196
|
### Cache invalidation groups
|
|
202
197
|
|
|
@@ -269,7 +264,7 @@ end
|
|
|
269
264
|
|
|
270
265
|
A method belongs to at most one group at a time; re-memoizing with a different `group:` moves it.
|
|
271
266
|
|
|
272
|
-
[↑ Back to
|
|
267
|
+
[↑ Back to top](#table-of-contents)
|
|
273
268
|
|
|
274
269
|
### Lifecycle hooks
|
|
275
270
|
|
|
@@ -347,7 +342,7 @@ end
|
|
|
347
342
|
|
|
348
343
|
Set `c.on_hook_error = :raise` to re-raise exceptions instead of swallowing them.
|
|
349
344
|
|
|
350
|
-
[↑ Back to
|
|
345
|
+
[↑ Back to top](#table-of-contents)
|
|
351
346
|
|
|
352
347
|
### TTL expiration
|
|
353
348
|
|
|
@@ -379,7 +374,7 @@ obj.memo_refresh(:current_quote) # Recomputes and re-caches
|
|
|
379
374
|
obj.memo_refresh(:find, 42) # Recomputes for one argument combination
|
|
380
375
|
```
|
|
381
376
|
|
|
382
|
-
[↑ Back to
|
|
377
|
+
[↑ Back to top](#table-of-contents)
|
|
383
378
|
|
|
384
379
|
### Sliding window TTL
|
|
385
380
|
|
|
@@ -398,7 +393,7 @@ end
|
|
|
398
393
|
|
|
399
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.
|
|
400
395
|
|
|
401
|
-
[↑ Back to
|
|
396
|
+
[↑ Back to top](#table-of-contents)
|
|
402
397
|
|
|
403
398
|
### LRU cache size limit
|
|
404
399
|
|
|
@@ -433,7 +428,7 @@ memoize :find, max_size: 50, ttl: 300
|
|
|
433
428
|
|
|
434
429
|
The `on_evict` hook fires for LRU-evicted entries the same way it does for manual `reset_memo` calls.
|
|
435
430
|
|
|
436
|
-
[↑ Back to
|
|
431
|
+
[↑ Back to top](#table-of-contents)
|
|
437
432
|
|
|
438
433
|
### Conditional caching
|
|
439
434
|
|
|
@@ -469,7 +464,7 @@ Both options accept any callable and compose with `ttl:` and `max_size:`:
|
|
|
469
464
|
memoize :find, if: ->(result) { !result.nil? }, ttl: 60, max_size: 500
|
|
470
465
|
```
|
|
471
466
|
|
|
472
|
-
[↑ Back to
|
|
467
|
+
[↑ Back to top](#table-of-contents)
|
|
473
468
|
|
|
474
469
|
### Cache warm-up and persistence
|
|
475
470
|
|
|
@@ -535,7 +530,7 @@ obj.load_memo(Marshal.load(raw)) if raw
|
|
|
535
530
|
|
|
536
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.
|
|
537
532
|
|
|
538
|
-
[↑ Back to
|
|
533
|
+
[↑ Back to top](#table-of-contents)
|
|
539
534
|
|
|
540
535
|
### Shared cache
|
|
541
536
|
|
|
@@ -585,7 +580,7 @@ memoize :find, shared: true, max_size: 500
|
|
|
585
580
|
|
|
586
581
|
Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`, `on_memo_evict`) fire on the calling instance as usual.
|
|
587
582
|
|
|
588
|
-
[↑ Back to
|
|
583
|
+
[↑ Back to top](#table-of-contents)
|
|
589
584
|
|
|
590
585
|
### Fiber-local memoization
|
|
591
586
|
|
|
@@ -627,7 +622,7 @@ obj.reset_all_fiber_memos # clear all fiber-local entries for thi
|
|
|
627
622
|
|
|
628
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.
|
|
629
624
|
|
|
630
|
-
[↑ Back to
|
|
625
|
+
[↑ Back to top](#table-of-contents)
|
|
631
626
|
|
|
632
627
|
### Bulk memoization
|
|
633
628
|
|
|
@@ -685,7 +680,7 @@ memoize_all include_protected: true, include_private: true
|
|
|
685
680
|
|
|
686
681
|
Inherited methods are never affected regardless of visibility.
|
|
687
682
|
|
|
688
|
-
[↑ Back to
|
|
683
|
+
[↑ Back to top](#table-of-contents)
|
|
689
684
|
|
|
690
685
|
### Custom cache keys
|
|
691
686
|
|
|
@@ -734,7 +729,7 @@ svc.clear_custom_keys(:generate) # Remove generator for one method
|
|
|
734
729
|
svc.clear_custom_keys # Remove all custom key generators
|
|
735
730
|
```
|
|
736
731
|
|
|
737
|
-
[↑ Back to
|
|
732
|
+
[↑ Back to top](#table-of-contents)
|
|
738
733
|
|
|
739
734
|
### Cache inspection
|
|
740
735
|
|
|
@@ -780,7 +775,7 @@ obj.memo_inspect(:find, 42)
|
|
|
780
775
|
|
|
781
776
|
Returns `nil` when the entry is not cached.
|
|
782
777
|
|
|
783
|
-
[↑ Back to
|
|
778
|
+
[↑ Back to top](#table-of-contents)
|
|
784
779
|
|
|
785
780
|
### Cache metrics
|
|
786
781
|
|
|
@@ -810,7 +805,7 @@ obj.cache_metrics_reset(:find) # Clears metrics for one method only
|
|
|
810
805
|
|
|
811
806
|
Metrics are per-instance and reset independently from the cache itself — clearing metrics does not evict cached values.
|
|
812
807
|
|
|
813
|
-
[↑ Back to
|
|
808
|
+
[↑ Back to top](#table-of-contents)
|
|
814
809
|
|
|
815
810
|
### Plugin / extension architecture
|
|
816
811
|
|
|
@@ -893,7 +888,7 @@ An extension does not need to `extend SafeMemoize::Extension`. Any object respon
|
|
|
893
888
|
- Unknown `memoize` keywords raise `ArgumentError` unless a registered extension claims them — typos are still caught.
|
|
894
889
|
- `on_cache_event` handlers run on the main Ractor only; they are silently skipped from worker Ractors.
|
|
895
890
|
|
|
896
|
-
[↑ Back to
|
|
891
|
+
[↑ Back to top](#table-of-contents)
|
|
897
892
|
|
|
898
893
|
### Automatic cache busting
|
|
899
894
|
|
|
@@ -954,7 +949,7 @@ obj.reset_memo(:summary) # clears ALL versions
|
|
|
954
949
|
- Incompatible with `key:` — both define the cache key shape; raises `ArgumentError` at `memoize` time.
|
|
955
950
|
- Composes with `namespace:`, `ttl:`, `if:`, `unless:`, and `shared_cache:`.
|
|
956
951
|
|
|
957
|
-
[↑ Back to
|
|
952
|
+
[↑ Back to top](#table-of-contents)
|
|
958
953
|
|
|
959
954
|
### Named shared caches
|
|
960
955
|
|
|
@@ -1025,7 +1020,7 @@ memoize :find, shared_cache: "orders", namespace: "presenter" # [:"presenter:fi
|
|
|
1025
1020
|
- `register_shared_cache` must be called before the class that uses the name is defined.
|
|
1026
1021
|
- Test suites should call `SafeMemoize.reset_shared_caches!` in an `after` hook to prevent state leaking between examples.
|
|
1027
1022
|
|
|
1028
|
-
[↑ Back to
|
|
1023
|
+
[↑ Back to top](#table-of-contents)
|
|
1029
1024
|
|
|
1030
1025
|
### Cache namespacing
|
|
1031
1026
|
|
|
@@ -1086,7 +1081,7 @@ end
|
|
|
1086
1081
|
- Namespacing works with all memoize paths (standard, `store:`, `fiber_local:`, `shared:`, `ractor_safe:`).
|
|
1087
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`).
|
|
1088
1083
|
|
|
1089
|
-
[↑ Back to
|
|
1084
|
+
[↑ Back to top](#table-of-contents)
|
|
1090
1085
|
|
|
1091
1086
|
### Global configuration
|
|
1092
1087
|
|
|
@@ -1107,7 +1102,7 @@ SafeMemoize.reset_configuration!
|
|
|
1107
1102
|
|
|
1108
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)).
|
|
1109
1104
|
|
|
1110
|
-
[↑ Back to
|
|
1105
|
+
[↑ Back to top](#table-of-contents)
|
|
1111
1106
|
|
|
1112
1107
|
### ActiveSupport::Notifications
|
|
1113
1108
|
|
|
@@ -1149,7 +1144,7 @@ end
|
|
|
1149
1144
|
|
|
1150
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`.
|
|
1151
1146
|
|
|
1152
|
-
[↑ Back to
|
|
1147
|
+
[↑ Back to top](#table-of-contents)
|
|
1153
1148
|
|
|
1154
1149
|
### StatsD
|
|
1155
1150
|
|
|
@@ -1175,7 +1170,7 @@ Each call includes two tags: `method:method_name` and `class:ClassName`. The cli
|
|
|
1175
1170
|
|
|
1176
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).
|
|
1177
1172
|
|
|
1178
|
-
[↑ Back to
|
|
1173
|
+
[↑ Back to top](#table-of-contents)
|
|
1179
1174
|
|
|
1180
1175
|
### OpenTelemetry
|
|
1181
1176
|
|
|
@@ -1200,7 +1195,7 @@ SafeMemoize then wraps every cache miss (the actual method call, not cache hits)
|
|
|
1200
1195
|
|
|
1201
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.
|
|
1202
1197
|
|
|
1203
|
-
[↑ Back to
|
|
1198
|
+
[↑ Back to top](#table-of-contents)
|
|
1204
1199
|
|
|
1205
1200
|
### Rails request-scope
|
|
1206
1201
|
|
|
@@ -1269,7 +1264,7 @@ end
|
|
|
1269
1264
|
|
|
1270
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.
|
|
1271
1266
|
|
|
1272
|
-
[↑ Back to
|
|
1267
|
+
[↑ Back to top](#table-of-contents)
|
|
1273
1268
|
|
|
1274
1269
|
### Pluggable cache stores
|
|
1275
1270
|
|
|
@@ -1418,7 +1413,7 @@ A per-method `store:` option always takes precedence. Methods using `max_size:`
|
|
|
1418
1413
|
|
|
1419
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).
|
|
1420
1415
|
|
|
1421
|
-
[↑ Back to
|
|
1416
|
+
[↑ Back to top](#table-of-contents)
|
|
1422
1417
|
|
|
1423
1418
|
### Deprecation
|
|
1424
1419
|
|
|
@@ -1449,7 +1444,7 @@ SafeMemoize.configure do |c|
|
|
|
1449
1444
|
end
|
|
1450
1445
|
```
|
|
1451
1446
|
|
|
1452
|
-
[↑ Back to
|
|
1447
|
+
[↑ Back to top](#table-of-contents)
|
|
1453
1448
|
|
|
1454
1449
|
## Circuit breaker for external stores
|
|
1455
1450
|
|
|
@@ -1533,7 +1528,7 @@ class ApiService
|
|
|
1533
1528
|
end
|
|
1534
1529
|
```
|
|
1535
1530
|
|
|
1536
|
-
[↑ Back to
|
|
1531
|
+
[↑ Back to top](#table-of-contents)
|
|
1537
1532
|
|
|
1538
1533
|
## Multi-level caching
|
|
1539
1534
|
|
|
@@ -1572,7 +1567,7 @@ store = SafeMemoize::Stores::Multilevel.new(SafeMemoize::Stores::Memory.new, s
|
|
|
1572
1567
|
memoize :catalog, store: store, ttl: 300
|
|
1573
1568
|
```
|
|
1574
1569
|
|
|
1575
|
-
[↑ Back to
|
|
1570
|
+
[↑ Back to top](#table-of-contents)
|
|
1576
1571
|
|
|
1577
1572
|
## Stampede protection
|
|
1578
1573
|
|
|
@@ -1629,7 +1624,7 @@ store = SafeMemoize::Stores::XFetch.new(
|
|
|
1629
1624
|
)
|
|
1630
1625
|
```
|
|
1631
1626
|
|
|
1632
|
-
[↑ Back to
|
|
1627
|
+
[↑ Back to top](#table-of-contents)
|
|
1633
1628
|
|
|
1634
1629
|
## Per-class default options (`safe_memoize_options`)
|
|
1635
1630
|
|
|
@@ -1668,7 +1663,7 @@ Call with no arguments to clear all class-level defaults:
|
|
|
1668
1663
|
MyClass.safe_memoize_options # clears — subsequent memoize calls use global config or per-call options only
|
|
1669
1664
|
```
|
|
1670
1665
|
|
|
1671
|
-
[↑ Back to
|
|
1666
|
+
[↑ Back to top](#table-of-contents)
|
|
1672
1667
|
|
|
1673
1668
|
## Copy-on-read
|
|
1674
1669
|
|
|
@@ -1706,7 +1701,7 @@ class ReportService
|
|
|
1706
1701
|
end
|
|
1707
1702
|
```
|
|
1708
1703
|
|
|
1709
|
-
[↑ Back to
|
|
1704
|
+
[↑ Back to top](#table-of-contents)
|
|
1710
1705
|
|
|
1711
1706
|
## Ractor-safe shared cache
|
|
1712
1707
|
|
|
@@ -1727,7 +1722,7 @@ end
|
|
|
1727
1722
|
20.times.map { Thread.new { PriceService.new.fetch_price(42) } }.map(&:value)
|
|
1728
1723
|
|
|
1729
1724
|
# Worker Ractors also read from and write to the same supervisor cache
|
|
1730
|
-
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
|
|
1731
1726
|
```
|
|
1732
1727
|
|
|
1733
1728
|
### How it works
|
|
@@ -1735,7 +1730,7 @@ result = Ractor.new(PriceService) { |s| s.new.fetch_price(42) }.take
|
|
|
1735
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.
|
|
1736
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.
|
|
1737
1732
|
- Cached values are deep-frozen via `Ractor.make_shareable`. Values that cannot be made shareable (e.g. a `Mutex`) raise `ArgumentError`.
|
|
1738
|
-
- **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.
|
|
1739
1734
|
- `ttl:` is supported. Expired entries are skipped by the supervisor's `:fetch` handler.
|
|
1740
1735
|
|
|
1741
1736
|
### Constraints
|
|
@@ -1761,7 +1756,7 @@ PriceService.reset_ractor_memo(:fetch_price) # → clear all entries for
|
|
|
1761
1756
|
PriceService.reset_all_ractor_memos # → clear entire shared cache
|
|
1762
1757
|
```
|
|
1763
1758
|
|
|
1764
|
-
[↑ Back to
|
|
1759
|
+
[↑ Back to top](#table-of-contents)
|
|
1765
1760
|
|
|
1766
1761
|
## Ractor compatibility
|
|
1767
1762
|
|
|
@@ -1779,7 +1774,7 @@ Regular `memoize` (without `ractor_safe: true`) is **not Ractor-compatible**. Pa
|
|
|
1779
1774
|
|
|
1780
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.
|
|
1781
1776
|
|
|
1782
|
-
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).
|
|
1783
1778
|
|
|
1784
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.
|
|
1785
1780
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# SafeMemoize Benchmark Results
|
|
2
|
+
|
|
3
|
+
Run with `bundle exec ruby benchmarks/benchmark.rb`.
|
|
4
|
+
|
|
5
|
+
**Environment:** Ruby 3.4.8 (arm64-darwin25, PRISM) · `benchmark-ips` 5 s measurement / 2 s warmup
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What is being measured
|
|
10
|
+
|
|
11
|
+
SafeMemoize is compared against two vanilla Ruby baselines:
|
|
12
|
+
|
|
13
|
+
| Label | Pattern | Correctness |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `raw \|\|= (unsafe)` | `@v \|\|= compute` | Broken — re-runs if result is `nil` or `false` |
|
|
16
|
+
| `raw safe ivar` | `return @v if defined?(@v); @v = compute` | Correct |
|
|
17
|
+
| `safe_memoize` | `prepend SafeMemoize` + `memoize :method` | Correct |
|
|
18
|
+
|
|
19
|
+
The raw patterns are intentionally simple and carry none of SafeMemoize's feature overhead (thread safety, argument keying, TTL, hooks, metrics). The numbers below quantify exactly what that feature set costs.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Results
|
|
24
|
+
|
|
25
|
+
### 1. Zero-arg cache HIT — steady-state throughput
|
|
26
|
+
|
|
27
|
+
A primed object called repeatedly. This is the hot path.
|
|
28
|
+
|
|
29
|
+
| Implementation | i/s | ns/call | vs fastest |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| `raw safe ivar` | 21.51 M | 46.5 | — |
|
|
32
|
+
| `raw \|\|= (unsafe)` | 16.69 M | 59.9 | 1.29× slower |
|
|
33
|
+
| `safe_memoize` | 296.8 K | 3,370 | **72×** slower |
|
|
34
|
+
|
|
35
|
+
**Takeaway:** SafeMemoize's hit path costs ~3.4 µs per call — the price of a hash lookup, mutex check, and key construction. For methods that are called millions of times per second in a tight loop, raw ivars win. For methods that do real work (DB queries, HTTP, serialization), this overhead is negligible.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
### 2. Zero-arg cache MISS — first-call overhead
|
|
40
|
+
|
|
41
|
+
A fresh instance is created each iteration so every call is a miss.
|
|
42
|
+
|
|
43
|
+
| Implementation | i/s | µs/call | vs fastest |
|
|
44
|
+
|---|---|---|---|
|
|
45
|
+
| `raw safe ivar` | 8.47 M | 0.12 | — |
|
|
46
|
+
| `safe_memoize` | 189.6 K | 5.28 | **45×** slower |
|
|
47
|
+
|
|
48
|
+
**Takeaway:** The miss path allocates the cache hash, builds the key, and acquires the mutex — ~5 µs. Still fast in absolute terms; any method body doing more than a trivial computation will dominate.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
### 3. With-argument cache HIT — keyed lookup
|
|
53
|
+
|
|
54
|
+
A primed object called with the same argument each iteration.
|
|
55
|
+
|
|
56
|
+
| Implementation | i/s | ns/call | vs fastest |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `raw safe ivar` | 14.17 M | 70.6 | — |
|
|
59
|
+
| `safe_memoize` | 289.2 K | 3,460 | **49×** slower |
|
|
60
|
+
|
|
61
|
+
**Takeaway:** The argument hash lookup adds roughly the same overhead as the zero-arg case. Key construction (deep-freeze copy of args) dominates, not the hash lookup itself.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
### 4. Fast path vs locked path (internal)
|
|
66
|
+
|
|
67
|
+
Both are `safe_memoize`. Adding `max_size:` enables LRU tracking, which holds the mutex for the full read/write cycle.
|
|
68
|
+
|
|
69
|
+
| Configuration | i/s | µs/call | vs fast path |
|
|
70
|
+
|---|---|---|---|
|
|
71
|
+
| No `max_size:` (fast path) | 287.7 K | 3.48 | — |
|
|
72
|
+
| `max_size: 100` (locked path) | 218.0 K | 4.59 | 1.32× slower |
|
|
73
|
+
|
|
74
|
+
**Takeaway:** LRU costs an extra ~1.1 µs per hit. Use it when eviction matters; skip it for unbounded caches.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
### 5. Shared cache vs instance cache (internal)
|
|
79
|
+
|
|
80
|
+
Both are `safe_memoize`. `shared: true` stores results on the class rather than the instance.
|
|
81
|
+
|
|
82
|
+
| Configuration | i/s | µs/call | difference |
|
|
83
|
+
|---|---|---|---|
|
|
84
|
+
| Instance cache | 292.5 K | 3.42 | — |
|
|
85
|
+
| Shared cache | 275.3 K | 3.63 | within margin of error |
|
|
86
|
+
|
|
87
|
+
**Takeaway:** Class-level shared storage is essentially free compared to per-instance caching. The mutex contention at the class level does not measurably affect single-threaded throughput.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
### 6. Concurrent throughput — 8 threads × 50,000 iterations
|
|
92
|
+
|
|
93
|
+
| Implementation | Total time | i/s |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `raw safe ivar` | 0.033 s | 12.01 M |
|
|
96
|
+
| `safe_memoize` (instance cache) | 1.363 s | 293.4 K |
|
|
97
|
+
| `safe_memoize` (shared cache) | 1.393 s | 287.1 K |
|
|
98
|
+
|
|
99
|
+
**Takeaway:** Under real thread contention the raw ivar baseline has no locking at all — it is technically unsafe here. SafeMemoize's per-instance mutex serialises writes correctly at ~293 K i/s; the class-level shared mutex performs identically at ~287 K i/s.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Summary
|
|
104
|
+
|
|
105
|
+
| Scenario | SafeMemoize overhead |
|
|
106
|
+
|---|---|
|
|
107
|
+
| Hot cache hit (zero-arg) | ~3.4 µs — 72× vs raw ivar |
|
|
108
|
+
| Hot cache hit (with args) | ~3.5 µs — 49× vs raw ivar |
|
|
109
|
+
| First call / cache miss | ~5.3 µs — 45× vs raw ivar |
|
|
110
|
+
| LRU enabled (`max_size:`) | +1.1 µs vs fast path |
|
|
111
|
+
| Shared vs instance cache | no measurable difference |
|
|
112
|
+
| Concurrent throughput | ~293 K i/s (thread-safe) |
|
|
113
|
+
|
|
114
|
+
SafeMemoize is slower than a raw instance variable in absolute terms, but the overhead is measured in **microseconds**, not milliseconds. Any method that does I/O, calls a database, hits an external API, or performs non-trivial computation will completely dominate the memoization overhead — and that is precisely the use case SafeMemoize is designed for.
|
data/benchmarks/README.md
CHANGED
|
@@ -65,4 +65,6 @@ bundle exec ruby benchmarks/benchmark.rb
|
|
|
65
65
|
safe_memoize (shared) ~323 K i/s
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
Results vary by hardware, Ruby version, and GVL scheduling. Run on your own hardware for authoritative numbers.
|
|
68
|
+
Results vary by hardware, Ruby version, and GVL scheduling. Run on your own hardware for authoritative numbers.
|
|
69
|
+
|
|
70
|
+
For a full recorded run with analysis, see [BENCHMARK.md](BENCHMARK.md).
|
|
@@ -958,9 +958,7 @@ module SafeMemoize
|
|
|
958
958
|
|
|
959
959
|
cache_key = Ractor.make_shareable([method_name, deep_freeze_copy(args), deep_freeze_copy(kwargs)])
|
|
960
960
|
|
|
961
|
-
|
|
962
|
-
supervisor.send(Ractor.make_shareable([Ractor.current, tag, :fetch, cache_key]))
|
|
963
|
-
response = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
961
|
+
response = RactorSharedMethods.round_trip(supervisor, :fetch, cache_key)
|
|
964
962
|
|
|
965
963
|
if response[:hit]
|
|
966
964
|
record_cache_hit(cache_key)
|
|
@@ -985,8 +983,7 @@ module SafeMemoize
|
|
|
985
983
|
cached_at: now
|
|
986
984
|
})
|
|
987
985
|
|
|
988
|
-
|
|
989
|
-
stored = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
986
|
+
stored = RactorSharedMethods.round_trip(supervisor, :store, cache_key, record)
|
|
990
987
|
stored_record = stored[:stored]
|
|
991
988
|
|
|
992
989
|
record_cache_miss(cache_key, elapsed_time)
|
|
@@ -15,9 +15,65 @@ module SafeMemoize
|
|
|
15
15
|
# - +if:+, +unless:+, +max_size:+, +ttl_refresh:+, +key:+, and +store:+ are
|
|
16
16
|
# incompatible and raise +ArgumentError+ at +memoize+ time.
|
|
17
17
|
# - When calling a ractor-safe memoized method from the main Ractor with multiple
|
|
18
|
-
# threads, responses are matched by thread identity
|
|
19
|
-
# consume each other's
|
|
18
|
+
# threads, responses are matched by thread identity and each send/receive pair
|
|
19
|
+
# is serialized per Ractor, so concurrent callers do not consume each other's
|
|
20
|
+
# replies or lose a wakeup.
|
|
20
21
|
module RactorSharedMethods
|
|
22
|
+
MUTEX_KEY = :__safe_memo_ractor_mutex__
|
|
23
|
+
|
|
24
|
+
# Ruby 4.0 replaced +Ractor.receive_if+ with +Ractor::Port+.
|
|
25
|
+
PORTS = defined?(Ractor::Port) ? true : false
|
|
26
|
+
|
|
27
|
+
# Sends a request to +supervisor+ and blocks until its reply arrives,
|
|
28
|
+
# returning the reply payload.
|
|
29
|
+
def self.round_trip(supervisor, op, *args)
|
|
30
|
+
# :nocov:
|
|
31
|
+
return round_trip_via_port(supervisor, op, *args) if PORTS
|
|
32
|
+
# :nocov:
|
|
33
|
+
|
|
34
|
+
round_trip_via_queue(supervisor, op, *args)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Ruby 4.0+: each call gets its own port, so callers never share a queue.
|
|
38
|
+
# :nocov:
|
|
39
|
+
def self.round_trip_via_port(supervisor, op, *args)
|
|
40
|
+
port = Ractor::Port.new
|
|
41
|
+
supervisor.send(Ractor.make_shareable([port, Thread.current.object_id, op, *args]))
|
|
42
|
+
port.receive[1]
|
|
43
|
+
ensure
|
|
44
|
+
port&.close
|
|
45
|
+
end
|
|
46
|
+
# :nocov:
|
|
47
|
+
|
|
48
|
+
# Ruby 3.x: replies are matched by thread identity, but every thread in a
|
|
49
|
+
# Ractor waits on that Ractor's single incoming queue. Ruby 3.x can lose a
|
|
50
|
+
# wakeup when several threads sit in +Ractor.receive_if+ at once, leaving one
|
|
51
|
+
# waiting forever, so the send/receive pair is serialized per Ractor. The
|
|
52
|
+
# supervisor handles requests one at a time anyway, so this costs no
|
|
53
|
+
# throughput.
|
|
54
|
+
def self.round_trip_via_queue(supervisor, op, *args)
|
|
55
|
+
tag = Thread.current.object_id
|
|
56
|
+
msg = Ractor.make_shareable([Ractor.current, tag, op, *args])
|
|
57
|
+
|
|
58
|
+
ractor_mutex.synchronize do
|
|
59
|
+
supervisor.send(msg)
|
|
60
|
+
Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Per-Ractor mutex held in Ractor-local storage (a plain +Mutex+ constant is
|
|
65
|
+
# not Ractor-shareable).
|
|
66
|
+
def self.ractor_mutex
|
|
67
|
+
Ractor.current[MUTEX_KEY] ||
|
|
68
|
+
if Ractor.respond_to?(:store_if_absent)
|
|
69
|
+
Ractor.store_if_absent(MUTEX_KEY) { Mutex.new }
|
|
70
|
+
else
|
|
71
|
+
# :nocov:
|
|
72
|
+
Ractor.current[MUTEX_KEY] ||= Mutex.new # Ruby 3.3 lacks store_if_absent
|
|
73
|
+
# :nocov:
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
21
77
|
# Clears one or all entries from the Ractor-safe shared cache.
|
|
22
78
|
#
|
|
23
79
|
# @param method_name [Symbol, String]
|
|
@@ -76,13 +132,8 @@ module SafeMemoize
|
|
|
76
132
|
private
|
|
77
133
|
|
|
78
134
|
# Sends a message to the supervisor and blocks until the tagged response arrives.
|
|
79
|
-
# Uses Thread.current.object_id as a per-call tag so concurrent threads in the
|
|
80
|
-
# main Ractor do not steal each other's replies.
|
|
81
135
|
def __ractor_cache_send__(supervisor, op, *args)
|
|
82
|
-
|
|
83
|
-
msg = Ractor.make_shareable([Ractor.current, tag, op, *args])
|
|
84
|
-
supervisor.send(msg)
|
|
85
|
-
Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
136
|
+
RactorSharedMethods.round_trip(supervisor, op, *args)
|
|
86
137
|
end
|
|
87
138
|
|
|
88
139
|
# Creates the supervisor Ractor that owns this class's Ractor-safe shared cache.
|
|
@@ -10,6 +10,7 @@ module SafeMemoize
|
|
|
10
10
|
VERSION_PATTERN = /VERSION = "[^"]+"/
|
|
11
11
|
SEMVER_PATTERN = /\A\d+\.\d+\.\d+(?:[-.][0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?\z/
|
|
12
12
|
UNRELEASED_HEADING = "## [Unreleased]"
|
|
13
|
+
UNRELEASED_LINK = %r{^\[Unreleased\]: (?<base>\S+)/compare/v(?<previous>[^.\s]\S*?)\.\.\.HEAD$}
|
|
13
14
|
|
|
14
15
|
def normalize_version(version)
|
|
15
16
|
normalized_version = version.to_s.sub(/\Av/, "")
|
|
@@ -43,7 +44,24 @@ module SafeMemoize
|
|
|
43
44
|
raise ArgumentError, "CHANGELOG.md already contains #{normalized_version}"
|
|
44
45
|
end
|
|
45
46
|
|
|
46
|
-
contents.sub(UNRELEASED_HEADING, "#{UNRELEASED_HEADING}\n\n#{release_heading}")
|
|
47
|
+
contents = contents.sub(UNRELEASED_HEADING, "#{UNRELEASED_HEADING}\n\n#{release_heading}")
|
|
48
|
+
update_version_links(contents, normalized_version)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Keeps the reference-style link definitions at the bottom of the CHANGELOG
|
|
52
|
+
# in sync: points [Unreleased] at the new tag and adds a compare link for
|
|
53
|
+
# the new version. Left untouched when no [Unreleased] link is present.
|
|
54
|
+
def update_version_links(contents, version)
|
|
55
|
+
match = contents.match(UNRELEASED_LINK)
|
|
56
|
+
return contents unless match
|
|
57
|
+
|
|
58
|
+
base = match[:base]
|
|
59
|
+
replacement = [
|
|
60
|
+
"[Unreleased]: #{base}/compare/v#{version}...HEAD",
|
|
61
|
+
"[#{version}]: #{base}/compare/v#{match[:previous]}...v#{version}"
|
|
62
|
+
].join("\n")
|
|
63
|
+
|
|
64
|
+
contents.sub(UNRELEASED_LINK) { replacement }
|
|
47
65
|
end
|
|
48
66
|
|
|
49
67
|
# Removes milestone sections from ROADMAP.md where every feature row has
|
data/lib/safe_memoize/version.rb
CHANGED
data/sig/safe_memoize.rbs
CHANGED
|
@@ -251,6 +251,14 @@ module SafeMemoize
|
|
|
251
251
|
end
|
|
252
252
|
|
|
253
253
|
module RactorSharedMethods
|
|
254
|
+
MUTEX_KEY: Symbol
|
|
255
|
+
PORTS: bool
|
|
256
|
+
|
|
257
|
+
def self.round_trip: (untyped supervisor, Symbol op, *untyped args) -> untyped
|
|
258
|
+
def self.round_trip_via_port: (untyped supervisor, Symbol op, *untyped args) -> untyped
|
|
259
|
+
def self.round_trip_via_queue: (untyped supervisor, Symbol op, *untyped args) -> untyped
|
|
260
|
+
def self.ractor_mutex: () -> Thread::Mutex
|
|
261
|
+
|
|
254
262
|
def reset_ractor_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
255
263
|
def reset_all_ractor_memos: () -> void
|
|
256
264
|
def ractor_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
|
metadata
CHANGED
|
@@ -1,28 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: safe_memoize
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.7.
|
|
4
|
+
version: 1.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: simplecov
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - "~>"
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.22'
|
|
19
|
-
type: :development
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - "~>"
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.22'
|
|
11
|
+
dependencies: []
|
|
26
12
|
description: 'SafeMemoize is a production-ready, zero-dependency memoization library
|
|
27
13
|
for Ruby. It uses Ruby''s prepend mechanism to wrap methods with a thread-safe cache
|
|
28
14
|
(Mutex + double-check locking) that correctly handles nil and false return values
|
|
@@ -49,6 +35,7 @@ files:
|
|
|
49
35
|
- ROADMAP.md
|
|
50
36
|
- Rakefile
|
|
51
37
|
- UPGRADING.md
|
|
38
|
+
- benchmarks/BENCHMARK.md
|
|
52
39
|
- benchmarks/README.md
|
|
53
40
|
- benchmarks/benchmark.rb
|
|
54
41
|
- codecov.yml
|