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.
@@ -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) { super(*args, **kwargs) }
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 { |hook| hook.call(cache_key, record) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
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