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.
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 | Planned |
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 | Planned |
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 | Planned |
31
- | Ractor compatibility audit | Investigate and either support Ractor-compatible operation (Mutex replacement, shared-cache storage) or document the limitation clearly | Planned |
32
- | Ruby version policy | Formalise the supported Ruby version window and cadence for dropping EOL versions | Planned |
33
- | Deprecation sweep | Resolve or formally deprecate any unstable internal APIs before the stable release | Planned |
34
- | Upgrade guide | Document all breaking changes from 0.x and provide a migration path for users of deprecated behaviour | Planned |
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
@@ -7,4 +7,9 @@ RSpec::Core::RakeTask.new(:spec)
7
7
 
8
8
  require "standard/rake"
9
9
 
10
+ require "yard"
11
+ YARD::Rake::YardocTask.new(:doc) do |t|
12
+ t.options = ["--fail-on-warning"]
13
+ end
14
+
10
15
  task default: %i[spec standard]
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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module CacheMetricsMethods
5
6
  private
6
7
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module CacheRecordMethods
5
6
  private
6
7