safe_memoize 0.7.0 → 0.9.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/.github/workflows/ci.yml +29 -2
- data/.github/workflows/release.yml +5 -1
- data/CHANGELOG.md +119 -100
- data/README.md +385 -7
- data/ROADMAP.md +92 -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 +19 -0
- data/lib/safe_memoize/adapters/statsd.rb +25 -0
- data/lib/safe_memoize/class_methods.rb +15 -4
- data/lib/safe_memoize/configuration.rb +7 -1
- data/lib/safe_memoize/hooks_methods.rb +40 -1
- data/lib/safe_memoize/inspection_methods.rb +14 -1
- data/lib/safe_memoize/public_methods.rb +43 -0
- data/lib/safe_memoize/rails/middleware.rb +23 -0
- data/lib/safe_memoize/rails/request_scoped.rb +37 -0
- data/lib/safe_memoize/rails.rb +28 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +8 -0
- data/sig/safe_memoize.rbs +32 -1
- metadata +11 -2
|
@@ -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,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module Adapters
|
|
5
|
+
module OpenTelemetry
|
|
6
|
+
SPAN_NAME = "safe_memoize.compute"
|
|
7
|
+
|
|
8
|
+
def self.trace(tracer, method_name, class_name)
|
|
9
|
+
return yield unless tracer&.respond_to?(:in_span)
|
|
10
|
+
|
|
11
|
+
tracer.in_span(SPAN_NAME, attributes: {
|
|
12
|
+
"safe_memoize.method" => method_name.to_s,
|
|
13
|
+
"safe_memoize.class" => class_name.to_s,
|
|
14
|
+
"safe_memoize.cache_hit" => false
|
|
15
|
+
}) { yield }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module Adapters
|
|
5
|
+
module StatsD
|
|
6
|
+
METRIC_NAMES = {
|
|
7
|
+
on_hit: "safe_memoize.hit",
|
|
8
|
+
on_miss: "safe_memoize.miss",
|
|
9
|
+
on_evict: "safe_memoize.evict",
|
|
10
|
+
on_expire: "safe_memoize.expire",
|
|
11
|
+
on_store: "safe_memoize.store"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.dispatch(client, hook_type, cache_key, class_name)
|
|
15
|
+
metric = METRIC_NAMES[hook_type]
|
|
16
|
+
return unless metric
|
|
17
|
+
|
|
18
|
+
tags = ["method:#{cache_key[0]}", "class:#{class_name}"]
|
|
19
|
+
client.increment(metric, tags: tags)
|
|
20
|
+
rescue => error
|
|
21
|
+
warn "[SafeMemoize] StatsD dispatch error: #{error.message}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -4,6 +4,11 @@ module SafeMemoize
|
|
|
4
4
|
module ClassMethods
|
|
5
5
|
def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil)
|
|
6
6
|
method_name = method_name.to_sym
|
|
7
|
+
|
|
8
|
+
unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
|
|
9
|
+
raise ArgumentError, "cannot memoize :#{method_name} — no instance method with that name is defined on #{self}"
|
|
10
|
+
end
|
|
11
|
+
|
|
7
12
|
visibility = memoized_method_visibility(method_name)
|
|
8
13
|
|
|
9
14
|
config = SafeMemoize.configuration
|
|
@@ -83,7 +88,7 @@ module SafeMemoize
|
|
|
83
88
|
call_memo_hooks(:on_expire, cache_key, record) if record && !record_live
|
|
84
89
|
|
|
85
90
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
86
|
-
value = super(*args, **kwargs)
|
|
91
|
+
value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, klass.name) { super(*args, **kwargs) }
|
|
87
92
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
88
93
|
|
|
89
94
|
new_record = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
@@ -142,7 +147,7 @@ module SafeMemoize
|
|
|
142
147
|
memo_record_value(record)
|
|
143
148
|
else
|
|
144
149
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
145
|
-
value = super(*args, **kwargs)
|
|
150
|
+
value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
|
|
146
151
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
147
152
|
|
|
148
153
|
new_record = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
@@ -169,7 +174,9 @@ module SafeMemoize
|
|
|
169
174
|
|
|
170
175
|
# Cache miss - compute and store
|
|
171
176
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
172
|
-
result = memo_fetch_or_store(cache_key, ttl: ttl)
|
|
177
|
+
result = memo_fetch_or_store(cache_key, ttl: ttl) do
|
|
178
|
+
Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
|
|
179
|
+
end
|
|
173
180
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
174
181
|
|
|
175
182
|
with_memo_lock do
|
|
@@ -274,8 +281,11 @@ module SafeMemoize
|
|
|
274
281
|
end
|
|
275
282
|
end
|
|
276
283
|
|
|
277
|
-
def memoize_all(except: [], include_protected: false, include_private: false, **options)
|
|
284
|
+
def memoize_all(except: [], only: [], include_protected: false, include_private: false, **options)
|
|
285
|
+
raise ArgumentError, "cannot specify both :only and :except" if only.any? && except.any?
|
|
286
|
+
|
|
278
287
|
excluded = Array(except).map(&:to_sym)
|
|
288
|
+
included = Array(only).map(&:to_sym)
|
|
279
289
|
|
|
280
290
|
methods = public_instance_methods(false)
|
|
281
291
|
methods |= protected_instance_methods(false) if include_protected
|
|
@@ -283,6 +293,7 @@ module SafeMemoize
|
|
|
283
293
|
|
|
284
294
|
methods.each do |method_name|
|
|
285
295
|
next if excluded.include?(method_name)
|
|
296
|
+
next if included.any? && !included.include?(method_name)
|
|
286
297
|
|
|
287
298
|
memoize(method_name, **options)
|
|
288
299
|
end
|
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module SafeMemoize
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :default_ttl, :default_max_size
|
|
5
|
+
attr_accessor :default_ttl, :default_max_size, :on_deprecation, :on_hook_error,
|
|
6
|
+
:active_support_notifications, :statsd_client, :opentelemetry_tracer
|
|
6
7
|
|
|
7
8
|
def initialize
|
|
8
9
|
@default_ttl = nil
|
|
9
10
|
@default_max_size = nil
|
|
11
|
+
@on_deprecation = nil
|
|
12
|
+
@on_hook_error = nil
|
|
13
|
+
@active_support_notifications = false
|
|
14
|
+
@statsd_client = nil
|
|
15
|
+
@opentelemetry_tracer = nil
|
|
10
16
|
end
|
|
11
17
|
end
|
|
12
18
|
end
|
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module SafeMemoize
|
|
4
4
|
module HooksMethods
|
|
5
|
+
NOTIFICATION_EVENT_NAMES = {
|
|
6
|
+
on_hit: "cache_hit.safe_memoize",
|
|
7
|
+
on_miss: "cache_miss.safe_memoize",
|
|
8
|
+
on_evict: "cache_evict.safe_memoize",
|
|
9
|
+
on_expire: "cache_expire.safe_memoize",
|
|
10
|
+
on_store: "cache_store.safe_memoize"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
5
13
|
private
|
|
6
14
|
|
|
7
15
|
def memo_hook_store
|
|
@@ -19,7 +27,38 @@ module SafeMemoize
|
|
|
19
27
|
|
|
20
28
|
def call_memo_hooks(hook_type, cache_key, record)
|
|
21
29
|
hooks = memo_hook_store[hook_type] || []
|
|
22
|
-
hooks.each
|
|
30
|
+
hooks.each do |hook|
|
|
31
|
+
hook.call(cache_key, record)
|
|
32
|
+
rescue => error
|
|
33
|
+
handler = SafeMemoize.configuration.on_hook_error
|
|
34
|
+
if handler
|
|
35
|
+
handler.call(error, hook_type, cache_key)
|
|
36
|
+
else
|
|
37
|
+
warn "[SafeMemoize] Hook error in #{hook_type}: #{error.message}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
safe_memo_notify(hook_type, cache_key) if SafeMemoize.configuration.active_support_notifications
|
|
42
|
+
|
|
43
|
+
if (client = SafeMemoize.configuration.statsd_client)
|
|
44
|
+
Adapters::StatsD.dispatch(client, hook_type, cache_key, self.class.name)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def safe_memo_notify(hook_type, cache_key)
|
|
49
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
50
|
+
|
|
51
|
+
asn = ActiveSupport::Notifications
|
|
52
|
+
return unless asn.respond_to?(:instrument)
|
|
53
|
+
|
|
54
|
+
event = NOTIFICATION_EVENT_NAMES[hook_type]
|
|
55
|
+
return unless event
|
|
56
|
+
|
|
57
|
+
asn.instrument(event, {
|
|
58
|
+
method: cache_key[0],
|
|
59
|
+
key: cache_key,
|
|
60
|
+
class: self.class.name
|
|
61
|
+
})
|
|
23
62
|
end
|
|
24
63
|
|
|
25
64
|
def _clear_memo_hooks(hook_type = nil)
|
|
@@ -69,7 +69,20 @@ module SafeMemoize
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def safe_memo_cache_key(method_name, args, kwargs)
|
|
72
|
-
[method_name.to_sym, args, kwargs]
|
|
72
|
+
[method_name.to_sym, deep_freeze_copy(args), deep_freeze_copy(kwargs)]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deep_freeze_copy(obj)
|
|
76
|
+
case obj
|
|
77
|
+
when Array
|
|
78
|
+
obj.map { |e| deep_freeze_copy(e) }.freeze
|
|
79
|
+
when Hash
|
|
80
|
+
obj.each_with_object({}) { |(k, v), h| h[deep_freeze_copy(k)] = deep_freeze_copy(v) }.freeze
|
|
81
|
+
when String
|
|
82
|
+
-obj
|
|
83
|
+
else
|
|
84
|
+
obj
|
|
85
|
+
end
|
|
73
86
|
end
|
|
74
87
|
end
|
|
75
88
|
end
|
|
@@ -226,5 +226,48 @@ module SafeMemoize
|
|
|
226
226
|
lru_clear_all
|
|
227
227
|
end
|
|
228
228
|
end
|
|
229
|
+
|
|
230
|
+
def memo_inspect(method_name, *args, **kwargs)
|
|
231
|
+
method_name = method_name.to_sym
|
|
232
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
233
|
+
|
|
234
|
+
with_memo_lock do
|
|
235
|
+
record = memo_cache_record(cache_key)
|
|
236
|
+
return nil unless record
|
|
237
|
+
|
|
238
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
239
|
+
|
|
240
|
+
ttl_remaining = if record[:expires_at]
|
|
241
|
+
remaining = record[:expires_at] - now
|
|
242
|
+
(remaining > 0) ? remaining.round(6) : 0
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
age = (now - record[:cached_at]).round(6) if record[:cached_at]
|
|
246
|
+
|
|
247
|
+
metrics_key = safe_memo_cache_key(method_name, args, kwargs)
|
|
248
|
+
entry_metrics = memo_metrics_store[metrics_key] || {hits: 0, misses: 0}
|
|
249
|
+
|
|
250
|
+
custom_key = (cache_key.length == 2) ? cache_key[1] : nil
|
|
251
|
+
|
|
252
|
+
lru_position = begin
|
|
253
|
+
method_lru = lru_order_store[method_name]
|
|
254
|
+
if method_lru&.key?(cache_key)
|
|
255
|
+
keys = method_lru.keys
|
|
256
|
+
keys.length - keys.index(cache_key)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
{
|
|
261
|
+
cached: true,
|
|
262
|
+
value: memo_record_value(record),
|
|
263
|
+
hits: entry_metrics[:hits],
|
|
264
|
+
misses: entry_metrics[:misses],
|
|
265
|
+
ttl_remaining: ttl_remaining,
|
|
266
|
+
age: age,
|
|
267
|
+
custom_key: custom_key,
|
|
268
|
+
lru_position: lru_position
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
end
|
|
229
272
|
end
|
|
230
273
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module Rails
|
|
5
|
+
# Rack middleware that resets all thread-tracked memoized instances at the
|
|
6
|
+
# end of each request. Useful for service objects that are instantiated
|
|
7
|
+
# per-request and register themselves via `SafeMemoize::Rails.track(self)`.
|
|
8
|
+
#
|
|
9
|
+
# Add to your Rack stack in config/application.rb:
|
|
10
|
+
# config.middleware.use SafeMemoize::Rails::Middleware
|
|
11
|
+
class Middleware
|
|
12
|
+
def initialize(app)
|
|
13
|
+
@app = app
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
@app.call(env)
|
|
18
|
+
ensure
|
|
19
|
+
SafeMemoize::Rails.reset_tracked!
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module Rails
|
|
5
|
+
# Include in a Rails controller to automatically reset instance memos after
|
|
6
|
+
# each request. In non-controller classes (service objects, models), include
|
|
7
|
+
# it to gain `reset_request_memos` and call it manually at the end of a
|
|
8
|
+
# request or job.
|
|
9
|
+
#
|
|
10
|
+
# The class must also `prepend SafeMemoize` for `reset_all_memos` to exist.
|
|
11
|
+
#
|
|
12
|
+
# Example — controller:
|
|
13
|
+
# class ApplicationController < ActionController::Base
|
|
14
|
+
# prepend SafeMemoize
|
|
15
|
+
# include SafeMemoize::Rails::RequestScoped
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Example — service object with middleware tracking:
|
|
19
|
+
# class ReportService
|
|
20
|
+
# prepend SafeMemoize
|
|
21
|
+
# include SafeMemoize::Rails::RequestScoped
|
|
22
|
+
#
|
|
23
|
+
# def initialize
|
|
24
|
+
# SafeMemoize::Rails.track(self)
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
module RequestScoped
|
|
28
|
+
def self.included(base)
|
|
29
|
+
base.after_action :reset_all_memos if base.respond_to?(:after_action)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reset_request_memos
|
|
33
|
+
reset_all_memos
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "safe_memoize"
|
|
4
|
+
require_relative "rails/request_scoped"
|
|
5
|
+
require_relative "rails/middleware"
|
|
6
|
+
|
|
7
|
+
module SafeMemoize
|
|
8
|
+
# Optional Rails integration. Not auto-required — add to your initializer:
|
|
9
|
+
# require "safe_memoize/rails"
|
|
10
|
+
module Rails
|
|
11
|
+
# Register an instance to have its memos reset at the end of the current
|
|
12
|
+
# request (via Middleware). Thread-local; each thread maintains its own list.
|
|
13
|
+
def self.track(instance)
|
|
14
|
+
(Thread.current[:safe_memoize_tracked] ||= []) << instance
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Reset all tracked instances and clear the list. Called automatically by
|
|
18
|
+
# Middleware after each request. Safe to call with an empty list.
|
|
19
|
+
def self.reset_tracked!
|
|
20
|
+
instances = Thread.current[:safe_memoize_tracked] || []
|
|
21
|
+
instances.each do |instance|
|
|
22
|
+
instance.reset_all_memos if instance.respond_to?(:reset_all_memos)
|
|
23
|
+
end
|
|
24
|
+
ensure
|
|
25
|
+
Thread.current[:safe_memoize_tracked] = []
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/safe_memoize/version.rb
CHANGED
data/lib/safe_memoize.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "safe_memoize/version"
|
|
4
4
|
require_relative "safe_memoize/configuration"
|
|
5
|
+
require_relative "safe_memoize/adapters/statsd"
|
|
6
|
+
require_relative "safe_memoize/adapters/opentelemetry"
|
|
5
7
|
require_relative "safe_memoize/class_methods"
|
|
6
8
|
require_relative "safe_memoize/public_methods"
|
|
7
9
|
require_relative "safe_memoize/cache_store_methods"
|
|
@@ -35,4 +37,10 @@ module SafeMemoize
|
|
|
35
37
|
def self.reset_configuration!
|
|
36
38
|
@configuration = Configuration.new
|
|
37
39
|
end
|
|
40
|
+
|
|
41
|
+
def self.deprecate(subject, message:, horizon:)
|
|
42
|
+
text = "[SafeMemoize] #{subject} is deprecated and will be removed in #{horizon}. #{message}"
|
|
43
|
+
handler = configuration.on_deprecation
|
|
44
|
+
handler ? handler.call(text) : warn(text)
|
|
45
|
+
end
|
|
38
46
|
end
|