safe_memoize 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +10 -0
- data/CHANGELOG.md +23 -0
- data/README.md +555 -8
- data/ROADMAP.md +7 -22
- data/Rakefile +5 -0
- data/UPGRADING.md +197 -0
- data/benchmarks/README.md +68 -0
- data/benchmarks/benchmark.rb +225 -0
- data/codecov.yml +17 -0
- data/lib/safe_memoize/adapters/opentelemetry.rb +44 -0
- data/lib/safe_memoize/adapters/statsd.rb +56 -0
- data/lib/safe_memoize/cache_metrics_methods.rb +1 -0
- data/lib/safe_memoize/cache_record_methods.rb +1 -0
- data/lib/safe_memoize/cache_store_methods.rb +1 -0
- data/lib/safe_memoize/class_methods.rb +106 -21
- data/lib/safe_memoize/configuration.rb +43 -1
- data/lib/safe_memoize/custom_key_methods.rb +1 -0
- data/lib/safe_memoize/hooks_methods.rb +31 -0
- data/lib/safe_memoize/inspection_methods.rb +2 -1
- data/lib/safe_memoize/instance_methods.rb +1 -0
- data/lib/safe_memoize/lru_methods.rb +1 -0
- data/lib/safe_memoize/public_custom_key_methods.rb +23 -0
- data/lib/safe_memoize/public_methods.rb +162 -5
- data/lib/safe_memoize/public_metrics_methods.rb +20 -0
- data/lib/safe_memoize/rails/middleware.rb +25 -0
- data/lib/safe_memoize/rails/request_scoped.rb +40 -0
- data/lib/safe_memoize/rails.rb +28 -0
- data/lib/safe_memoize/release_tooling.rb +1 -0
- data/lib/safe_memoize/version.rb +2 -1
- data/lib/safe_memoize.rb +56 -0
- data/rbi/safe_memoize.rbi +245 -0
- data/sig/safe_memoize.rbs +41 -5
- metadata +12 -1
data/ROADMAP.md
CHANGED
|
@@ -4,34 +4,19 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## v0.9.0 — Observability & Ecosystem Integration
|
|
8
|
-
|
|
9
|
-
*Goal: make SafeMemoize a first-class citizen in Rails/ActiveSupport stacks and in observability pipelines.*
|
|
10
|
-
|
|
11
|
-
| Feature | Description | Status |
|
|
12
|
-
|---|---|---|
|
|
13
|
-
| ActiveSupport::Notifications integration | Emit `cache.hit`, `cache.miss`, `cache.evict`, and `cache.expire` events when ActiveSupport is available (opt-in via configuration) | Planned |
|
|
14
|
-
| StatsD adapter | Thin optional module (`SafeMemoize::Adapters::StatsD`) that routes lifecycle hooks to a StatsD client with sensible metric names and tags | Planned |
|
|
15
|
-
| OpenTelemetry spans | Optional adapter (`SafeMemoize::Adapters::OpenTelemetry`) wrapping computation time in a trace span for distributed tracing pipelines | Planned |
|
|
16
|
-
| Rails request-scope helper | Guide + optional mixin for resetting instance memos at the end of each request (controller concern, middleware, or Active Model pattern) | Planned |
|
|
17
|
-
| Formal benchmark suite | `benchmarks/` directory with comparisons against `memery`, `memo_wise`, and raw `||=`, covering single-threaded throughput and contention under concurrent load | Planned |
|
|
18
|
-
| Concurrency stress tests | Dedicated spec suite hammering shared-cache paths and LRU eviction under high thread counts to surface race conditions | Planned |
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
7
|
## v1.0.0 — Stable API
|
|
23
8
|
|
|
24
9
|
*Goal: declare a stable, semver-governed public API that downstream code can depend on with confidence.*
|
|
25
10
|
|
|
26
11
|
| Feature | Description | Status |
|
|
27
12
|
|---|---|---|
|
|
28
|
-
| Semantic versioning guarantee | Document which constants, methods, and option keys are public API; breaking changes require a major bump henceforth |
|
|
29
|
-
| Complete RBS + Sorbet signatures | Cover all public methods including overloads for optional keyword arguments; publish `.rbi` stubs as a companion package if demand warrants |
|
|
30
|
-
| Full API reference | Generated documentation hosted on RubyDoc or a dedicated docs site; all public methods documented with parameter types, return values, and usage examples |
|
|
31
|
-
| Ractor compatibility audit | Investigate and either support Ractor-compatible operation (Mutex replacement, shared-cache storage) or document the limitation clearly |
|
|
32
|
-
| Ruby version policy | Formalise the supported Ruby version window and cadence for dropping EOL versions |
|
|
33
|
-
| Deprecation sweep | Resolve or formally deprecate any unstable internal APIs before the stable release |
|
|
34
|
-
| Upgrade guide | Document all breaking changes from 0.x and provide a migration path for users of deprecated behaviour |
|
|
13
|
+
| Semantic versioning guarantee | Document which constants, methods, and option keys are public API; breaking changes require a major bump henceforth | Shipped |
|
|
14
|
+
| Complete RBS + Sorbet signatures | Cover all public methods including overloads for optional keyword arguments; publish `.rbi` stubs as a companion package if demand warrants | Shipped |
|
|
15
|
+
| Full API reference | Generated documentation hosted on RubyDoc or a dedicated docs site; all public methods documented with parameter types, return values, and usage examples | Shipped |
|
|
16
|
+
| Ractor compatibility audit | Investigate and either support Ractor-compatible operation (Mutex replacement, shared-cache storage) or document the limitation clearly | Shipped |
|
|
17
|
+
| Ruby version policy | Formalise the supported Ruby version window and cadence for dropping EOL versions | Shipped |
|
|
18
|
+
| Deprecation sweep | Resolve or formally deprecate any unstable internal APIs before the stable release | Shipped |
|
|
19
|
+
| Upgrade guide | Document all breaking changes from 0.x and provide a migration path for users of deprecated behaviour | Shipped |
|
|
35
20
|
|
|
36
21
|
---
|
|
37
22
|
|
data/Rakefile
CHANGED
data/UPGRADING.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Upgrading to SafeMemoize 1.0.0
|
|
2
|
+
|
|
3
|
+
This guide covers every behavioral change introduced across the 0.x series that could affect code written against an earlier release. Work through the sections that apply to your starting version.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Summary of breaking changes
|
|
8
|
+
|
|
9
|
+
| Change | Introduced | Impact |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| Ruby 3.2 dropped | v0.5.0 | High if still on Ruby 3.2 |
|
|
12
|
+
| TTL clock starts at first call, not definition | v0.6.0 | Medium — affects TTL precision |
|
|
13
|
+
| `memo_keys`/`memo_values` shape for custom keys | v0.6.1 | Low — only if inspecting key metadata |
|
|
14
|
+
| `memoize` raises at definition time for undefined methods | v0.8.0 | Medium — affects dynamic class construction |
|
|
15
|
+
| Argument mutation no longer corrupts cache | v0.8.0 | Low — was silent bug; may surface latent test issues |
|
|
16
|
+
| Hook exceptions no longer propagate | v0.8.0 | Medium — only if catching hook exceptions |
|
|
17
|
+
| `memoized?` / introspection methods now respect custom keys | v1.0.0 | Low — bug fix; only if using custom keys |
|
|
18
|
+
| `reset_memo` with args and `memo_refresh` respect custom keys | v1.0.0 | Low — bug fix; only if using custom keys |
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Upgrading from 0.1.x or 0.2.x
|
|
23
|
+
|
|
24
|
+
Follow every section below in order.
|
|
25
|
+
|
|
26
|
+
## Upgrading from 0.3.x – 0.4.x
|
|
27
|
+
|
|
28
|
+
Follow sections starting from [Ruby 3.2 dropped](#ruby-32-dropped-v050).
|
|
29
|
+
|
|
30
|
+
## Upgrading from 0.5.x
|
|
31
|
+
|
|
32
|
+
Follow sections starting from [TTL clock fix](#ttl-clock-now-starts-at-first-call-v060).
|
|
33
|
+
|
|
34
|
+
## Upgrading from 0.6.x
|
|
35
|
+
|
|
36
|
+
Follow sections starting from [memoize on undefined methods](#memoize-on-an-undefined-method-raises-at-definition-time-v080).
|
|
37
|
+
|
|
38
|
+
## Upgrading from 0.7.x – 0.9.x
|
|
39
|
+
|
|
40
|
+
Follow sections starting from [custom key introspection fix](#introspection-methods-now-respect-custom-keys-v100).
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Ruby 3.2 dropped (v0.5.0)
|
|
45
|
+
|
|
46
|
+
**Impact:** High — the gem will not install on Ruby 3.2.
|
|
47
|
+
|
|
48
|
+
Ruby 3.2 reached end-of-life and was removed from the supported matrix in v0.5.0. The gemspec now enforces `ruby >= 3.3.0`.
|
|
49
|
+
|
|
50
|
+
**Migration:** Upgrade to Ruby 3.3 or later before updating the gem.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## TTL clock now starts at first call, not definition (v0.6.0)
|
|
55
|
+
|
|
56
|
+
**Impact:** Medium — affects how long entries actually live in the cache.
|
|
57
|
+
|
|
58
|
+
Before v0.6.0, the TTL countdown began when `memoize :method, ttl: N` was evaluated (class load time). If a class was loaded 30 seconds before the first call, entries would expire 30 seconds earlier than expected.
|
|
59
|
+
|
|
60
|
+
From v0.6.0 onwards, the clock starts on the first cache write — the entry lives for exactly `ttl` seconds after it is populated.
|
|
61
|
+
|
|
62
|
+
**Migration:** No code change required. Entries now live for the duration you specified. If you intentionally set very short TTLs and relied on the early-start behaviour (unlikely), reduce your TTL value accordingly.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## `memo_keys`/`memo_values` shape change for custom-keyed entries (v0.6.1)
|
|
67
|
+
|
|
68
|
+
**Impact:** Low — only affects code that reads the hashes returned by these methods.
|
|
69
|
+
|
|
70
|
+
Before v0.6.1, entries stored via `memoize_with_custom_key` were surfaced in `memo_keys` and `memo_values` as:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
{ args: <the_custom_key>, kwargs: nil }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
From v0.6.1 onwards they are correctly surfaced as:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
{ custom_key: <the_custom_key> }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Migration:** If your code inspects the hash returned by `memo_keys` or `memo_values` and checks for an `:args` key to detect custom-keyed entries, update it to check for `:custom_key` instead:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# Before
|
|
86
|
+
entry[:args] # custom key was smuggled here
|
|
87
|
+
|
|
88
|
+
# After
|
|
89
|
+
entry[:custom_key] # explicit field
|
|
90
|
+
entry[:args] # only present for default-keyed entries
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## `memoize` on an undefined method raises at definition time (v0.8.0)
|
|
96
|
+
|
|
97
|
+
**Impact:** Medium — affects any code that calls `memoize` before the method is defined,
|
|
98
|
+
or that dynamically defines methods after `memoize` is called.
|
|
99
|
+
|
|
100
|
+
Before v0.8.0, calling `memoize :missing_method` silently succeeded at class load time. The error only appeared at runtime when the memoized wrapper tried to call `super` and found nothing to call.
|
|
101
|
+
|
|
102
|
+
From v0.8.0 onwards, `memoize` raises `ArgumentError` immediately if the named method does not exist at the time of the call.
|
|
103
|
+
|
|
104
|
+
**Migration:** Ensure `memoize` is always called *after* the method it wraps:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# Wrong — memoize called before def (raises ArgumentError in v0.8.0+)
|
|
108
|
+
memoize :compute
|
|
109
|
+
def compute = expensive_work
|
|
110
|
+
|
|
111
|
+
# Correct
|
|
112
|
+
def compute = expensive_work
|
|
113
|
+
memoize :compute
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
If you use `Module#prepend` or `include` to add methods dynamically, make sure `memoize` is called after the module is prepended/included:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
include MyMethods # defines :compute
|
|
120
|
+
memoize :compute # now safe
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Argument mutation no longer corrupts the cache (v0.8.0)
|
|
126
|
+
|
|
127
|
+
**Impact:** Low — this was a silent bug. Existing code is unlikely to rely on it, but test suites that mutate arguments after a call and expect a cache miss may need updating.
|
|
128
|
+
|
|
129
|
+
Before v0.8.0, mutable cache keys (arrays, hashes, strings passed as arguments) were stored by reference. Mutating an argument after a call could cause the cache to behave unpredictably — sometimes missing on identical arguments, sometimes returning stale values.
|
|
130
|
+
|
|
131
|
+
From v0.8.0 onwards, argument arrays, hashes, and strings are deep-frozen into an independent copy when the cache key is built. Callers can mutate their arguments after a call without affecting the cache.
|
|
132
|
+
|
|
133
|
+
**Migration:** No migration required for production code. If a test mutates arguments after a memoized call and expects a cache miss, the test was relying on the buggy behaviour — update it to use distinct argument values instead.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Hook exceptions no longer propagate to callers (v0.8.0)
|
|
138
|
+
|
|
139
|
+
**Impact:** Medium — only affects code that wraps memoized calls in `rescue` to catch errors raised by hooks.
|
|
140
|
+
|
|
141
|
+
Before v0.8.0, an exception raised inside an `on_memo_hit`, `on_memo_miss`, `on_memo_store`, `on_memo_expire`, or `on_memo_evict` hook would propagate through the memoized method call and be visible to the caller.
|
|
142
|
+
|
|
143
|
+
From v0.8.0 onwards, hook exceptions are isolated. By default a `[SafeMemoize] Hook error in <type>: <message>` warning is written to `$stderr` and execution continues normally.
|
|
144
|
+
|
|
145
|
+
**Migration:** If you need hook exceptions to propagate (e.g. in a strict test environment), configure `on_hook_error` to re-raise:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
SafeMemoize.configure do |c|
|
|
149
|
+
c.on_hook_error = ->(error, hook_type, cache_key) { raise error }
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Or to route them to your error tracker without raising:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
SafeMemoize.configure do |c|
|
|
157
|
+
c.on_hook_error = ->(error, _type, _key) { Bugsnag.notify(error) }
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Introspection methods now respect custom keys (v1.0.0)
|
|
164
|
+
|
|
165
|
+
**Impact:** Low — only affects code that uses `memoize_with_custom_key` or the `key:` option together with any of the introspection methods listed below.
|
|
166
|
+
|
|
167
|
+
Before v1.0.0, the following methods looked up cache entries using the *default* key (derived from raw arguments) rather than the *custom* key generator. This meant they always returned incorrect results when a custom key was active:
|
|
168
|
+
|
|
169
|
+
- `memoized?`
|
|
170
|
+
- `memo_ttl_remaining`
|
|
171
|
+
- `memo_touch`
|
|
172
|
+
- `memo_age`
|
|
173
|
+
- `memo_stale?`
|
|
174
|
+
|
|
175
|
+
From v1.0.0 onwards, all five methods correctly call `compute_cache_key`, which checks for a custom key generator first.
|
|
176
|
+
|
|
177
|
+
**Migration:** No code change required — the methods now return correct results. If your code had workarounds that bypassed these methods (e.g. manually inspecting `memo_keys` to determine if an entry existed), you can simplify them to use `memoized?` directly.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## `reset_memo` with args and `memo_refresh` respect custom keys (v1.0.0)
|
|
182
|
+
|
|
183
|
+
**Impact:** Low — only affects code that uses custom keys and calls `reset_memo` with specific arguments, or calls `memo_refresh`.
|
|
184
|
+
|
|
185
|
+
Before v1.0.0, `reset_memo(:method, *args)` with explicit arguments built a default key from raw args to match entries. When the method used a custom key generator, no entry was found and nothing was cleared. `memo_refresh` inherited the same flaw — the old entry survived and `refresh` returned the cached (stale) value instead of recomputing.
|
|
186
|
+
|
|
187
|
+
From v1.0.0 onwards, both methods resolve the cache key through `compute_cache_key`.
|
|
188
|
+
|
|
189
|
+
**Migration:** No code change required — the methods now behave correctly. Note that `reset_memo(:method)` with *no* arguments still clears all entries for the method regardless of key format; this behaviour is unchanged.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## New stable API
|
|
194
|
+
|
|
195
|
+
All symbols listed in the README `## Public API and versioning guarantee` section are now covered by [Semantic Versioning](https://semver.org/) from v1.0.0 onwards. Breaking changes to any of those symbols require a major version bump.
|
|
196
|
+
|
|
197
|
+
Opt-in extensions (`SafeMemoize::Rails`, `SafeMemoize::Adapters::StatsD`, `SafeMemoize::Adapters::OpenTelemetry`) are available but are *not* included in the v1.0.0 semver guarantee; they will be stabilised in a subsequent minor release.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SafeMemoize Benchmarks
|
|
2
|
+
|
|
3
|
+
Measures throughput for cache hits, cache misses, argument-keyed lookups, and concurrent access. Optional comparison against `memery` and `memo_wise`.
|
|
4
|
+
|
|
5
|
+
## Running
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bundle exec ruby benchmarks/benchmark.rb
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### With comparison gems
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gem install memery memo_wise
|
|
15
|
+
bundle exec ruby benchmarks/benchmark.rb
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Sections
|
|
19
|
+
|
|
20
|
+
| # | Scenario | What it measures |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| 1 | Zero-arg cache hit | Steady-state throughput on a primed cache |
|
|
23
|
+
| 2 | Zero-arg cache miss | First-call overhead (new instance per iteration) |
|
|
24
|
+
| 3 | With-argument cache hit | Key construction + hash lookup with one positional arg |
|
|
25
|
+
| 4 | Fast path vs locked path | Cost of `max_size:` (adds a Mutex for LRU promotion) |
|
|
26
|
+
| 5 | Shared vs instance cache | Class-level vs per-instance cache throughput |
|
|
27
|
+
| 6 | Concurrent cache hits | 8 threads × 50 000 iterations under contention |
|
|
28
|
+
|
|
29
|
+
## Interpreting results
|
|
30
|
+
|
|
31
|
+
**Cache hits are ~50–70× slower than raw `||=`** on a single thread — an expected trade-off. SafeMemoize does significantly more work per call: prepended-module dispatch, deep-frozen key construction, hook dispatch, and metrics tracking. The `||=` pattern is also incorrect for `nil`/`false` return values, which is the whole reason this gem exists.
|
|
32
|
+
|
|
33
|
+
**The fast path vs locked path gap is ~1.3×.** The locked path (used when `max_size:`, `if:`, or `ttl_refresh:` is set) holds the Mutex for the full read-compute-write cycle; the fast path only acquires it for the write step.
|
|
34
|
+
|
|
35
|
+
**Shared and instance caches are effectively identical in throughput.** Both paths go through the same Mutex; the class-level cache has one shared Mutex rather than one per instance.
|
|
36
|
+
|
|
37
|
+
**Concurrent throughput is bounded by the Mutex.** Under 8-thread contention, all threads compete for the per-instance Mutex on every read, serialising access. This is the cost of correctness under concurrent writes; in read-heavy workloads where the cache is pre-warmed the contention is minimal.
|
|
38
|
+
|
|
39
|
+
## Representative results (Apple M-series, Ruby 3.4, MRI)
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
1. Zero-arg cache HIT (primed cache)
|
|
43
|
+
raw safe ivar ~22 M i/s
|
|
44
|
+
safe_memoize ~335 K i/s (65× slower than raw)
|
|
45
|
+
|
|
46
|
+
2. Zero-arg cache MISS (new instance each iteration)
|
|
47
|
+
raw safe ivar ~8 M i/s
|
|
48
|
+
safe_memoize ~227 K i/s (36× slower than raw)
|
|
49
|
+
|
|
50
|
+
3. With-argument cache HIT
|
|
51
|
+
raw safe ivar ~15 M i/s
|
|
52
|
+
safe_memoize ~314 K i/s (47× slower than raw)
|
|
53
|
+
|
|
54
|
+
4. Fast path vs locked path
|
|
55
|
+
fast path ~310 K i/s
|
|
56
|
+
max_size: 100 ~234 K i/s (1.32× slower)
|
|
57
|
+
|
|
58
|
+
5. Shared vs instance cache
|
|
59
|
+
instance cache ~323 K i/s
|
|
60
|
+
shared cache ~324 K i/s (same-ish)
|
|
61
|
+
|
|
62
|
+
6. Concurrent (8 threads × 50 000 iterations)
|
|
63
|
+
raw safe ivar ~12 M i/s
|
|
64
|
+
safe_memoize (fast) ~327 K i/s
|
|
65
|
+
safe_memoize (shared) ~323 K i/s
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Results vary by hardware, Ruby version, and GVL scheduling. Run on your own hardware for authoritative numbers.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Run with: bundle exec ruby benchmarks/benchmark.rb
|
|
5
|
+
#
|
|
6
|
+
# Optional comparison gems (install separately if desired):
|
|
7
|
+
# gem install memery memo_wise
|
|
8
|
+
#
|
|
9
|
+
# Each section prints iterations/second and a comparison table.
|
|
10
|
+
# Higher i/s is better.
|
|
11
|
+
|
|
12
|
+
require "benchmark/ips"
|
|
13
|
+
require_relative "../lib/safe_memoize"
|
|
14
|
+
|
|
15
|
+
# ── Optional comparison gems ──────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
HAS_MEMERY = begin
|
|
18
|
+
require "memery"
|
|
19
|
+
true
|
|
20
|
+
rescue LoadError
|
|
21
|
+
warn " [skip] memery not installed (gem install memery to include)"
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
HAS_MEMO_WISE = begin
|
|
26
|
+
require "memo_wise"
|
|
27
|
+
true
|
|
28
|
+
rescue LoadError
|
|
29
|
+
warn " [skip] memo_wise not installed (gem install memo_wise to include)"
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# ── Subject classes ───────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
# Raw patterns — no gem, for baseline reference
|
|
36
|
+
class RawIvarUnsafe
|
|
37
|
+
# Classic ||= — fast but silently broken for nil/false return values
|
|
38
|
+
def compute = (@compute ||= 42)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class RawIvarSafe
|
|
42
|
+
# Safe raw pattern — correct but verbose
|
|
43
|
+
def compute
|
|
44
|
+
return @compute if defined?(@compute)
|
|
45
|
+
@compute = 42
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fetch(n)
|
|
49
|
+
@fetch ||= {}
|
|
50
|
+
return @fetch[n] if @fetch.key?(n)
|
|
51
|
+
@fetch[n] = n * 2
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# SafeMemoize
|
|
56
|
+
class SmZeroArg
|
|
57
|
+
prepend SafeMemoize
|
|
58
|
+
def compute = 42
|
|
59
|
+
memoize :compute
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class SmWithArg
|
|
63
|
+
prepend SafeMemoize
|
|
64
|
+
def fetch(n) = n * 2
|
|
65
|
+
memoize :fetch
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class SmLruPath
|
|
69
|
+
prepend SafeMemoize
|
|
70
|
+
def fetch(n) = n * 2
|
|
71
|
+
memoize :fetch, max_size: 100
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class SmShared
|
|
75
|
+
prepend SafeMemoize
|
|
76
|
+
def compute = 42
|
|
77
|
+
memoize :compute, shared: true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# memery
|
|
81
|
+
if HAS_MEMERY
|
|
82
|
+
class MemeryZeroArg
|
|
83
|
+
include Memery
|
|
84
|
+
memoize def compute = 42
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class MemeryWithArg
|
|
88
|
+
include Memery
|
|
89
|
+
memoize def fetch(n) = n * 2
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# memo_wise
|
|
94
|
+
if HAS_MEMO_WISE
|
|
95
|
+
class MemoWiseZeroArg
|
|
96
|
+
prepend MemoWise
|
|
97
|
+
def compute = 42
|
|
98
|
+
memo_wise :compute
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class MemoWiseWithArg
|
|
102
|
+
prepend MemoWise
|
|
103
|
+
def fetch(n) = n * 2
|
|
104
|
+
memo_wise :fetch
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
IPS_CONFIG = {time: 5, warmup: 2}
|
|
111
|
+
|
|
112
|
+
def section(title)
|
|
113
|
+
puts
|
|
114
|
+
puts "=" * 62
|
|
115
|
+
puts " #{title}"
|
|
116
|
+
puts "=" * 62
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ── 1. Zero-arg cache HIT — steady-state throughput ──────────────────────────
|
|
120
|
+
|
|
121
|
+
section "1. Zero-arg cache HIT (steady-state, primed cache)"
|
|
122
|
+
|
|
123
|
+
raw_unsafe = RawIvarUnsafe.new.tap(&:compute)
|
|
124
|
+
raw_safe = RawIvarSafe.new.tap(&:compute)
|
|
125
|
+
sm_zero = SmZeroArg.new.tap(&:compute)
|
|
126
|
+
mem_zero = MemeryZeroArg.new.tap(&:compute) if HAS_MEMERY
|
|
127
|
+
mw_zero = MemoWiseZeroArg.new.tap(&:compute) if HAS_MEMO_WISE
|
|
128
|
+
|
|
129
|
+
Benchmark.ips do |x|
|
|
130
|
+
x.config(**IPS_CONFIG)
|
|
131
|
+
x.report("raw ||= (unsafe)") { raw_unsafe.compute }
|
|
132
|
+
x.report("raw safe ivar") { raw_safe.compute }
|
|
133
|
+
x.report("safe_memoize") { sm_zero.compute }
|
|
134
|
+
x.report("memery") { mem_zero.compute } if HAS_MEMERY
|
|
135
|
+
x.report("memo_wise") { mw_zero.compute } if HAS_MEMO_WISE
|
|
136
|
+
x.compare!
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# ── 2. Zero-arg cache MISS — first-call overhead ──────────────────────────────
|
|
140
|
+
|
|
141
|
+
section "2. Zero-arg cache MISS (new instance each iteration)"
|
|
142
|
+
|
|
143
|
+
Benchmark.ips do |x|
|
|
144
|
+
x.config(**IPS_CONFIG)
|
|
145
|
+
x.report("raw safe ivar") { RawIvarSafe.new.compute }
|
|
146
|
+
x.report("safe_memoize") { SmZeroArg.new.compute }
|
|
147
|
+
x.report("memery") { MemeryZeroArg.new.compute } if HAS_MEMERY
|
|
148
|
+
x.report("memo_wise") { MemoWiseZeroArg.new.compute } if HAS_MEMO_WISE
|
|
149
|
+
x.compare!
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# ── 3. With-argument cache HIT ────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
section "3. With-argument cache HIT (single fixed argument)"
|
|
155
|
+
|
|
156
|
+
raw_arg = RawIvarSafe.new.tap { |o| o.fetch(1) }
|
|
157
|
+
sm_arg = SmWithArg.new.tap { |o| o.fetch(1) }
|
|
158
|
+
mem_arg = MemeryWithArg.new.tap { |o| o.fetch(1) } if HAS_MEMERY
|
|
159
|
+
mw_arg = MemoWiseWithArg.new.tap { |o| o.fetch(1) } if HAS_MEMO_WISE
|
|
160
|
+
|
|
161
|
+
Benchmark.ips do |x|
|
|
162
|
+
x.config(**IPS_CONFIG)
|
|
163
|
+
x.report("raw safe ivar") { raw_arg.fetch(1) }
|
|
164
|
+
x.report("safe_memoize") { sm_arg.fetch(1) }
|
|
165
|
+
x.report("memery") { mem_arg.fetch(1) } if HAS_MEMERY
|
|
166
|
+
x.report("memo_wise") { mw_arg.fetch(1) } if HAS_MEMO_WISE
|
|
167
|
+
x.compare!
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# ── 4. Fast path vs locked path ───────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
section "4. Fast path vs locked path (max_size: adds mutex for LRU)"
|
|
173
|
+
|
|
174
|
+
sm_fast = SmWithArg.new.tap { |o| o.fetch(1) }
|
|
175
|
+
sm_lru = SmLruPath.new.tap { |o| o.fetch(1) }
|
|
176
|
+
|
|
177
|
+
Benchmark.ips do |x|
|
|
178
|
+
x.config(**IPS_CONFIG)
|
|
179
|
+
x.report("fast path (no max_size)") { sm_fast.fetch(1) }
|
|
180
|
+
x.report("locked path (max_size:100)") { sm_lru.fetch(1) }
|
|
181
|
+
x.compare!
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ── 5. Shared cache vs instance cache ────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
section "5. Shared cache vs instance cache (class-level vs instance-level)"
|
|
187
|
+
|
|
188
|
+
sm_inst = SmZeroArg.new.tap(&:compute)
|
|
189
|
+
sm_shared = SmShared.new.tap(&:compute)
|
|
190
|
+
|
|
191
|
+
Benchmark.ips do |x|
|
|
192
|
+
x.config(**IPS_CONFIG)
|
|
193
|
+
x.report("instance cache") { sm_inst.compute }
|
|
194
|
+
x.report("shared cache") { sm_shared.compute }
|
|
195
|
+
x.compare!
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# ── 6. Concurrent cache hits under thread contention ─────────────────────────
|
|
199
|
+
|
|
200
|
+
section "6. Concurrent cache hits (8 threads × 50_000 iterations)"
|
|
201
|
+
|
|
202
|
+
THREADS = 8
|
|
203
|
+
ITERS = 50_000
|
|
204
|
+
TOTAL = THREADS * ITERS
|
|
205
|
+
|
|
206
|
+
def bench_threaded(label, obj, method, *args)
|
|
207
|
+
ts = THREADS.times.map { Thread.new { ITERS.times { obj.public_send(method, *args) } } }
|
|
208
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
209
|
+
ts.each(&:join)
|
|
210
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
211
|
+
ips = TOTAL / elapsed
|
|
212
|
+
label_str = ips >= 1_000_000 ? format("%.2fM", ips / 1_000_000.0) : format("%.1fK", ips / 1_000.0)
|
|
213
|
+
printf " %-34s %6.3fs (%s i/s)\n", label, elapsed, label_str
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
puts
|
|
217
|
+
|
|
218
|
+
bench_threaded("raw safe ivar", raw_safe, :compute)
|
|
219
|
+
bench_threaded("safe_memoize (fast)", sm_inst, :compute)
|
|
220
|
+
bench_threaded("safe_memoize (shared)", sm_shared, :compute)
|
|
221
|
+
bench_threaded("memery", mem_zero, :compute) if HAS_MEMERY
|
|
222
|
+
bench_threaded("memo_wise", mw_zero, :compute) if HAS_MEMO_WISE
|
|
223
|
+
|
|
224
|
+
puts
|
|
225
|
+
puts "Done."
|
data/codecov.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Configure Pull Request Bot Comments
|
|
2
|
+
comment:
|
|
3
|
+
layout: "reach, diff, flags, files"
|
|
4
|
+
behavior: default
|
|
5
|
+
# Only post or update the comment if the coverage drops
|
|
6
|
+
require_changes: "coverage_drop"
|
|
7
|
+
|
|
8
|
+
# Configure Commit Status Checks (the green checkmark/red X list)
|
|
9
|
+
coverage:
|
|
10
|
+
status:
|
|
11
|
+
project:
|
|
12
|
+
default:
|
|
13
|
+
# Prevent "Informational" mode so a drop causes an actual red failure check
|
|
14
|
+
informational: false
|
|
15
|
+
patch:
|
|
16
|
+
default:
|
|
17
|
+
informational: false
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
# Optional adapters for external observability systems.
|
|
5
|
+
# None are auto-required; load them explicitly when needed.
|
|
6
|
+
module Adapters
|
|
7
|
+
# Optional OpenTelemetry adapter.
|
|
8
|
+
#
|
|
9
|
+
# Wraps each cache-miss computation in an OpenTelemetry span so the time
|
|
10
|
+
# spent computing uncached values is visible in distributed traces.
|
|
11
|
+
#
|
|
12
|
+
# Configure via {Configuration#opentelemetry_tracer}:
|
|
13
|
+
#
|
|
14
|
+
# SafeMemoize.configure do |c|
|
|
15
|
+
# c.opentelemetry_tracer = OpenTelemetry.tracer_provider.tracer("safe_memoize")
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Each span is named {SPAN_NAME} and carries the attributes
|
|
19
|
+
# +safe_memoize.method+, +safe_memoize.class+, and +safe_memoize.cache_hit+.
|
|
20
|
+
# Falls back to untraced execution when no tracer is configured or the tracer
|
|
21
|
+
# does not respond to +#in_span+.
|
|
22
|
+
module OpenTelemetry
|
|
23
|
+
# The name given to every span created by this adapter.
|
|
24
|
+
SPAN_NAME = "safe_memoize.compute"
|
|
25
|
+
|
|
26
|
+
# Wraps the block in a span if a tracer is available; otherwise yields directly.
|
|
27
|
+
#
|
|
28
|
+
# @param tracer [Object, nil] an object responding to +#in_span+
|
|
29
|
+
# @param method_name [Symbol, String]
|
|
30
|
+
# @param class_name [String, nil]
|
|
31
|
+
# @yield the computation block (cache-miss path)
|
|
32
|
+
# @return [Object] the result of the block
|
|
33
|
+
def self.trace(tracer, method_name, class_name)
|
|
34
|
+
return yield unless tracer&.respond_to?(:in_span)
|
|
35
|
+
|
|
36
|
+
tracer.in_span(SPAN_NAME, attributes: {
|
|
37
|
+
"safe_memoize.method" => method_name.to_s,
|
|
38
|
+
"safe_memoize.class" => class_name.to_s,
|
|
39
|
+
"safe_memoize.cache_hit" => false
|
|
40
|
+
}) { yield }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module Adapters
|
|
5
|
+
# Optional StatsD adapter.
|
|
6
|
+
#
|
|
7
|
+
# Routes SafeMemoize lifecycle events to any StatsD-compatible client
|
|
8
|
+
# (any object that responds to +#increment+).
|
|
9
|
+
#
|
|
10
|
+
# Configure via {Configuration#statsd_client}:
|
|
11
|
+
#
|
|
12
|
+
# SafeMemoize.configure do |c|
|
|
13
|
+
# c.statsd_client = Datadog::Statsd.new
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Emitted metrics:
|
|
17
|
+
#
|
|
18
|
+
# | Metric | Fires on |
|
|
19
|
+
# |---|---|
|
|
20
|
+
# | +safe_memoize.hit+ | cache hit |
|
|
21
|
+
# | +safe_memoize.miss+ | cache miss |
|
|
22
|
+
# | +safe_memoize.store+ | value written |
|
|
23
|
+
# | +safe_memoize.evict+ | LRU eviction |
|
|
24
|
+
# | +safe_memoize.expire+ | TTL expiration |
|
|
25
|
+
#
|
|
26
|
+
# Every metric is tagged with +method:<name>+ and +class:<name>+.
|
|
27
|
+
# Client errors are rescued and warned rather than raised.
|
|
28
|
+
module StatsD
|
|
29
|
+
# @api private
|
|
30
|
+
METRIC_NAMES = {
|
|
31
|
+
on_hit: "safe_memoize.hit",
|
|
32
|
+
on_miss: "safe_memoize.miss",
|
|
33
|
+
on_evict: "safe_memoize.evict",
|
|
34
|
+
on_expire: "safe_memoize.expire",
|
|
35
|
+
on_store: "safe_memoize.store"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Dispatches a lifecycle event to the StatsD client.
|
|
39
|
+
#
|
|
40
|
+
# @param client [Object] a StatsD-compatible client responding to +#increment+
|
|
41
|
+
# @param hook_type [Symbol] one of the keys in {METRIC_NAMES}
|
|
42
|
+
# @param cache_key [Array] the internal cache key (first element is the method name)
|
|
43
|
+
# @param class_name [String, nil]
|
|
44
|
+
# @return [void]
|
|
45
|
+
def self.dispatch(client, hook_type, cache_key, class_name)
|
|
46
|
+
metric = METRIC_NAMES[hook_type]
|
|
47
|
+
return unless metric
|
|
48
|
+
|
|
49
|
+
tags = ["method:#{cache_key[0]}", "class:#{class_name}"]
|
|
50
|
+
client.increment(metric, tags: tags)
|
|
51
|
+
rescue => error
|
|
52
|
+
warn "[SafeMemoize] StatsD dispatch error: #{error.message}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|