safe_memoize 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62f1dae5d57d99d59fe20d981bca17d04bd1ff5b9dd09175770a51bf3d49dd03
4
- data.tar.gz: fade31de5124ed4b4f5d88f8bd62aaf750ec03cb22bdf97889fed2283ad3a58d
3
+ metadata.gz: 0b47b6031a8f395991376eec0407b93d1cf02caecad23f4d77e4d68234ef87b5
4
+ data.tar.gz: 7542678912a0a39425a75e3addfc718f2abb35068e28e0cdc0444c294dd4c4c1
5
5
  SHA512:
6
- metadata.gz: e742795adaef41bc07e32d1ffb0a1f78335eae46daca57dca3b6b27cf4fc1eb6a58e1636e05d21814336ab3e54468b6ffbe0a4ea52f017c00b0df1a70b98eaeb
7
- data.tar.gz: 92b1cb813ed3c54e18408fed0f79f2e9f4983672660f6ee1cf6c0ec9628577fbebf1055797e9d9a4c53ca5b293427c66d9c946a770a443e77b98c77c0937d244
6
+ metadata.gz: a304040bf86b1bc359c91f3d687a9f45e020bdaddded53b82a87e473553491aa9daba1ac45f8abadffb8ede8af270479f73762ab47357486aef9b46043afacde
7
+ data.tar.gz: 79d6a552f98f9f2ef1482832c211cc7b0a58f6481c989320c19db63b870a1723b482304955a133c661a0082a07c17de539258a9f869da0b5cbd0aaa2acacd96a
data/CHANGELOG.md CHANGED
@@ -8,6 +8,17 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.9.0] - 2026-05-22
12
+
13
+ ### Added
14
+
15
+ - `ActiveSupport::Notifications` integration — opt-in via `SafeMemoize.configure { |c| c.active_support_notifications = true }`; emits `cache_hit.safe_memoize`, `cache_miss.safe_memoize`, `cache_evict.safe_memoize`, `cache_expire.safe_memoize`, and `cache_store.safe_memoize` events; each payload includes `:method`, `:key`, and `:class`; zero overhead when ActiveSupport is not loaded
16
+ - `SafeMemoize::Adapters::StatsD` — thin optional adapter that routes lifecycle events to any StatsD client via `SafeMemoize.configure { |c| c.statsd_client = my_client }`; emits `safe_memoize.hit`, `safe_memoize.miss`, `safe_memoize.evict`, `safe_memoize.expire`, and `safe_memoize.store` with `method:` and `class:` tags; client errors are rescued and warned rather than raised
17
+ - Formal benchmark suite (`benchmarks/benchmark.rb`) — six scenarios covering zero-arg cache hit/miss, with-argument hit, fast vs locked path, shared vs instance cache, and concurrent throughput under 8-thread contention; optional comparisons against `memery` and `memo_wise`; run with `bundle exec ruby benchmarks/benchmark.rb`
18
+ - Concurrency stress test suite (`spec/concurrency_spec.rb`) — 18 barrier-synchronized examples hammering the fast path, locked path, and shared cache under 30 concurrent threads; covers exactly-once computation, LRU size invariant, hook count integrity, metric accuracy, TTL pruning, and deadlock detection (10-second timeout per run)
19
+ - `SafeMemoize::Adapters::OpenTelemetry` — optional adapter that wraps each cache-miss computation in an OpenTelemetry span; configure via `SafeMemoize.configure { |c| c.opentelemetry_tracer = OpenTelemetry.tracer_provider.tracer("safe_memoize") }`; span name is `"safe_memoize.compute"` with attributes `safe_memoize.method`, `safe_memoize.class`, and `safe_memoize.cache_hit`; falls back to untraced execution when the tracer is absent or does not respond to `in_span`
20
+ - `SafeMemoize::Rails` — opt-in request-scope helpers (`require "safe_memoize/rails"`): `SafeMemoize::Rails::RequestScoped` concern auto-registers `after_action :reset_all_memos` in controllers and exposes `reset_request_memos` elsewhere; `SafeMemoize::Rails::Middleware` Rack middleware resets all thread-tracked instances (`SafeMemoize::Rails.track(self)`) at the end of each request even on error
21
+
11
22
  ## [0.8.0] - 2026-05-21
12
23
 
13
24
  ### Added
data/README.md CHANGED
@@ -14,7 +14,7 @@ Beyond the basics, SafeMemoize ships with TTL expiration (including sliding wind
14
14
 
15
15
  ## The Problem
16
16
 
17
- Ruby's common memoization pattern breaks with falsy values:
17
+ Ruby's common memoization pattern breaks with falsy values:
18
18
 
19
19
  ```ruby
20
20
  def user
@@ -51,6 +51,22 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
51
51
  - [Bulk memoization via `memoize_all` (public, protected, and private)](#bulk-memoization)
52
52
  - [Custom cache key generation per method](#custom-cache-keys)
53
53
  - [TTL introspection via `memo_ttl_remaining`](#cache-inspection)
54
+ - [Deep single-entry inspection via `memo_inspect`](#cache-inspection)
55
+ - [`ArgumentError` at definition time when memoizing an undefined method](#basic-memoization)
56
+ - [Hook error isolation — hook exceptions never propagate to callers](#lifecycle-hooks)
57
+ - [Deprecation infrastructure for gem authors](#deprecation)
58
+ - [Optional `ActiveSupport::Notifications` integration for Rails observability](#activesupportnotifications)
59
+ - [Optional StatsD adapter for metrics pipelines](#statsd)
60
+ - [Optional OpenTelemetry adapter for distributed tracing](#opentelemetry)
61
+ - [Rails request-scope helpers for controllers and service objects](#rails-request-scope)
62
+ - [Batch cache warm-up via `memo_preload`](#cache-warm-up-and-persistence)
63
+ - [`on_memo_store` hook fires on every cache write](#lifecycle-hooks)
64
+ - [Global default TTL and max size via `SafeMemoize.configure`](#global-configuration)
65
+ - [`memo_touch` resets the expiry clock without recomputing](#ttl-expiration)
66
+ - [`memo_refresh` force-recomputes and re-caches in one call](#cache-inspection)
67
+ - [`memo_age` and `memo_stale?` for TTL introspection](#cache-inspection)
68
+ - [Class-level `key:` option for shared cache key generation](#custom-cache-keys)
69
+ - [`shared_memo_age` and `shared_memo_stale?` for shared cache TTL inspection](#shared-cache)
54
70
 
55
71
  ## Installation
56
72
 
@@ -88,6 +104,10 @@ class UserService
88
104
  end
89
105
  ```
90
106
 
107
+ Calling `memoize` on a method name that does not exist raises `ArgumentError` immediately at class definition time rather than at the first runtime call.
108
+
109
+ [↑ Back to features](#features)
110
+
91
111
  ### With arguments
92
112
 
93
113
  Results are cached per unique argument combination:
@@ -109,6 +129,10 @@ calc.compute(1, 2) # returns cached result
109
129
  calc.compute(3, 4) # computes and caches (different args)
110
130
  ```
111
131
 
132
+ Argument arrays, hashes, and strings are deep-frozen into an independent copy when the cache key is built, so mutating arguments after a call cannot corrupt or miss a cached entry.
133
+
134
+ [↑ Back to features](#features)
135
+
112
136
  ### Nil and false safety
113
137
 
114
138
  ```ruby
@@ -123,6 +147,8 @@ class Config
123
147
  end
124
148
  ```
125
149
 
150
+ [↑ Back to features](#features)
151
+
126
152
  ### Works with private methods
127
153
 
128
154
  ```ruby
@@ -142,6 +168,8 @@ class TokenProvider
142
168
  end
143
169
  ```
144
170
 
171
+ [↑ Back to features](#features)
172
+
145
173
  ### Cache reset
146
174
 
147
175
  ```ruby
@@ -152,6 +180,8 @@ obj.reset_memo(:search, "ruby", page: 2) # Clears one positional/keyword c
152
180
  obj.reset_all_memos # Clears all memoized values
153
181
  ```
154
182
 
183
+ [↑ Back to features](#features)
184
+
155
185
  ### Lifecycle hooks
156
186
 
157
187
  Register callbacks that fire when cached entries are evicted or expire.
@@ -188,6 +218,14 @@ obj.on_memo_expire do |cache_key, record|
188
218
  end
189
219
  ```
190
220
 
221
+ **`on_memo_store`** fires whenever a value is written to the cache — on a miss, via `warm_memo`, or via `load_memo`:
222
+
223
+ ```ruby
224
+ obj.on_memo_store do |cache_key, record|
225
+ Rails.logger.debug("Stored #{cache_key[0]}: #{record[:value].inspect}")
226
+ end
227
+ ```
228
+
191
229
  Multiple hooks of the same type can be registered and all will fire. Remove them with `clear_memo_hooks`:
192
230
 
193
231
  ```ruby
@@ -200,6 +238,28 @@ obj.clear_memo_hooks # Clears all hooks
200
238
 
201
239
  Hooks are per-instance and do not affect other objects of the same class.
202
240
 
241
+ #### Hook error isolation
242
+
243
+ Exceptions raised inside a hook never propagate to the caller. By default a warning is emitted to stderr:
244
+
245
+ ```
246
+ [SafeMemoize] Hook error in on_miss: undefined method `log' for nil
247
+ ```
248
+
249
+ Configure a custom handler via `SafeMemoize.configure`:
250
+
251
+ ```ruby
252
+ SafeMemoize.configure do |c|
253
+ c.on_hook_error = ->(error, hook_type, cache_key) {
254
+ MyErrorTracker.capture(error, context: { hook: hook_type, key: cache_key })
255
+ }
256
+ end
257
+ ```
258
+
259
+ Set `c.on_hook_error = :raise` to re-raise exceptions instead of swallowing them.
260
+
261
+ [↑ Back to features](#features)
262
+
203
263
  ### TTL expiration
204
264
 
205
265
  ```ruby
@@ -215,6 +275,23 @@ end
215
275
 
216
276
  With a TTL, cached values expire automatically after the given number of seconds. The next call recomputes and refreshes the cache.
217
277
 
278
+ Use `memo_touch` to reset the expiry clock on a cached entry without recomputing its value:
279
+
280
+ ```ruby
281
+ obj.memo_touch(:current_quote) # Resets TTL to the original duration
282
+ obj.memo_touch(:current_quote, ttl: 120) # Resets TTL to a new duration
283
+ # => true on success, false if the entry is not cached or already expired
284
+ ```
285
+
286
+ Use `memo_refresh` to force-recompute a cached entry immediately and store the new value:
287
+
288
+ ```ruby
289
+ obj.memo_refresh(:current_quote) # Recomputes and re-caches
290
+ obj.memo_refresh(:find, 42) # Recomputes for one argument combination
291
+ ```
292
+
293
+ [↑ Back to features](#features)
294
+
218
295
  ### Sliding window TTL
219
296
 
220
297
  Add `ttl_refresh: true` to reset the expiry clock on every cache hit, so the entry only expires after a full TTL of inactivity:
@@ -232,6 +309,8 @@ end
232
309
 
233
310
  Without `ttl_refresh:`, the entry expires 300 seconds after it was first cached. With it, the clock resets on every read — the entry is evicted only if the method goes 300 seconds without being called. `ttl_refresh: true` requires `ttl:` to be set and works with both per-instance and `shared: true` memoization.
234
311
 
312
+ [↑ Back to features](#features)
313
+
235
314
  ### LRU cache size limit
236
315
 
237
316
  Pass `max_size:` to cap how many entries a method can hold. When the limit is reached the least-recently-used entry is evicted to make room:
@@ -265,6 +344,8 @@ memoize :find, max_size: 50, ttl: 300
265
344
 
266
345
  The `on_evict` hook fires for LRU-evicted entries the same way it does for manual `reset_memo` calls.
267
346
 
347
+ [↑ Back to features](#features)
348
+
268
349
  ### Conditional caching
269
350
 
270
351
  Use `if:` to cache a result only when the predicate returns truthy, or `unless:` to skip caching when it returns truthy. Calls that don't satisfy the condition recompute every time until they do.
@@ -299,8 +380,25 @@ Both options accept any callable and compose with `ttl:` and `max_size:`:
299
380
  memoize :find, if: ->(result) { !result.nil? }, ttl: 60, max_size: 500
300
381
  ```
301
382
 
383
+ [↑ Back to features](#features)
384
+
302
385
  ### Cache warm-up and persistence
303
386
 
387
+ #### Batch warm-up via `memo_preload`
388
+
389
+ Use `memo_preload` to warm multiple argument combinations in one call. It calls the memoized method for each argument set, caches all results, and returns them in input order:
390
+
391
+ ```ruby
392
+ obj.memo_preload(:find, [1], [2], [3])
393
+ # => [<User id=1>, <User id=2>, <User id=3>]
394
+ ```
395
+
396
+ Each element is a separate argument list passed to the method, so keyword arguments work too:
397
+
398
+ ```ruby
399
+ obj.memo_preload(:search, ["ruby"], ["rails"], ["rspec"])
400
+ ```
401
+
304
402
  #### Warming individual entries
305
403
 
306
404
  Use `warm_memo` to pre-populate a cache entry without calling the method. The block provides the value:
@@ -348,6 +446,8 @@ obj.load_memo(Marshal.load(raw)) if raw
348
446
 
349
447
  Loaded entries have no TTL — they persist until explicitly reset. Expired entries are excluded from `dump_memo` output, so snapshots never contain stale data.
350
448
 
449
+ [↑ Back to features](#features)
450
+
351
451
  ### Shared cache
352
452
 
353
453
  Pass `shared: true` to store results on the class instead of per-instance. All instances share one cache, so the method is computed only once regardless of how many objects exist.
@@ -382,6 +482,8 @@ ConfigService.shared_memoized?(:database_url) # => true
382
482
  ConfigService.shared_memoized?(:find, user_id) # Checks one argument combination
383
483
  ConfigService.shared_memo_count # Total shared cached entries
384
484
  ConfigService.shared_memo_count(:find) # Entries for one method
485
+ ConfigService.shared_memo_age(:feature_flags) # => 42.1 (seconds since cached)
486
+ ConfigService.shared_memo_stale?(:feature_flags) # => false (TTL not yet elapsed)
385
487
  ```
386
488
 
387
489
  `shared: true` supports `ttl:`, `ttl_refresh:`, `if:`, `unless:`, and `max_size:` options.
@@ -394,6 +496,8 @@ memoize :find, shared: true, max_size: 500
394
496
 
395
497
  Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`, `on_memo_evict`) fire on the calling instance as usual.
396
498
 
499
+ [↑ Back to features](#features)
500
+
397
501
  ### Bulk memoization
398
502
 
399
503
  Use `memoize_all` to memoize every public method defined on the class in one call:
@@ -432,6 +536,14 @@ Use `except:` to skip specific methods:
432
536
  memoize_all except: [:version, :name]
433
537
  ```
434
538
 
539
+ Use `only:` to explicitly list the methods to memoize and skip all others:
540
+
541
+ ```ruby
542
+ memoize_all only: [:database_url, :redis_url]
543
+ ```
544
+
545
+ `only:` and `except:` are mutually exclusive — passing both raises `ArgumentError`.
546
+
435
547
  By default only public methods defined directly on the class are memoized. Use `include_protected:` or `include_private:` to opt those visibilities in:
436
548
 
437
549
  ```ruby
@@ -442,9 +554,11 @@ memoize_all include_protected: true, include_private: true
442
554
 
443
555
  Inherited methods are never affected regardless of visibility.
444
556
 
557
+ [↑ Back to features](#features)
558
+
445
559
  ### Custom cache keys
446
560
 
447
- By default the cache key is derived from the method name and all arguments. Use `memoize_with_custom_key` on an instance to control exactly what makes two calls equivalent:
561
+ By default the cache key is derived from the method name and all arguments. Use the `key:` option on `memoize` to set a class-level key generator that applies to every instance:
448
562
 
449
563
  ```ruby
450
564
  class ReportService
@@ -453,9 +567,18 @@ class ReportService
453
567
  def generate(user_id, options)
454
568
  build_report(user_id, options)
455
569
  end
456
- memoize :generate
570
+ memoize :generate, key: ->(user_id, _options) { user_id }
457
571
  end
458
572
 
573
+ # All instances share the same key logic — calls with the same user_id share one cache entry
574
+ svc = ReportService.new
575
+ svc.generate(42, {format: :pdf}) # computes and caches under key 42
576
+ svc.generate(42, {format: :csv}) # cache hit — same key
577
+ ```
578
+
579
+ For per-instance key overrides, use `memoize_with_custom_key` on an instance (takes priority over the class-level `key:` option):
580
+
581
+ ```ruby
459
582
  svc = ReportService.new
460
583
 
461
584
  # Cache only by user_id — ignore the options hash entirely
@@ -480,6 +603,8 @@ svc.clear_custom_keys(:generate) # Remove generator for one method
480
603
  svc.clear_custom_keys # Remove all custom key generators
481
604
  ```
482
605
 
606
+ [↑ Back to features](#features)
607
+
483
608
  ### Cache inspection
484
609
 
485
610
  ```ruby
@@ -500,8 +625,32 @@ obj.memo_values(:search) # Cached signatures and values for one
500
625
  obj.memo_ttl_remaining(:current_quote) # => 47.231 (seconds until expiry)
501
626
  obj.memo_ttl_remaining(:current_user) # => nil (no TTL set)
502
627
  obj.memo_ttl_remaining(:find, 42) # => 0 (not cached or already expired)
628
+
629
+ obj.memo_age(:current_quote) # => 12.8 (seconds since cached; nil if not cached)
630
+ obj.memo_stale?(:current_quote) # => false (cached but TTL not yet elapsed)
631
+ obj.memo_stale?(:current_user) # => false (no TTL — never stale)
503
632
  ```
504
633
 
634
+ `memo_inspect` returns all metadata for one cached entry in a single mutex-held read:
635
+
636
+ ```ruby
637
+ obj.memo_inspect(:find, 42)
638
+ # => {
639
+ # cached: true,
640
+ # value: <result>,
641
+ # hits: 5,
642
+ # misses: 1,
643
+ # ttl_remaining: 47.2,
644
+ # age: 12.8,
645
+ # custom_key: nil,
646
+ # lru_position: 1
647
+ # }
648
+ ```
649
+
650
+ Returns `nil` when the entry is not cached.
651
+
652
+ [↑ Back to features](#features)
653
+
505
654
  ### Cache metrics
506
655
 
507
656
  Each instance tracks hits, misses, and computation time automatically.
@@ -521,18 +670,237 @@ obj.cache_stats
521
670
  # ]
522
671
  # }
523
672
 
524
- obj.cache_stats_for(:find) # Stats scoped to one method
525
- obj.cache_hit_rate # => 84.0 (percentage)
526
- obj.cache_miss_rate # => 16.0 (percentage)
527
- obj.cache_metrics_reset # Clears all collected metrics
673
+ obj.cache_stats_for(:find) # Stats scoped to one method
674
+ obj.cache_hit_rate # => 84.0 (percentage)
675
+ obj.cache_miss_rate # => 16.0 (percentage)
676
+ obj.cache_metrics_reset # Clears all collected metrics
677
+ obj.cache_metrics_reset(:find) # Clears metrics for one method only
528
678
  ```
529
679
 
530
680
  Metrics are per-instance and reset independently from the cache itself — clearing metrics does not evict cached values.
531
681
 
682
+ [↑ Back to features](#features)
683
+
684
+ ### Global configuration
685
+
686
+ Use `SafeMemoize.configure` to set defaults that apply to all subsequently memoized methods. Per-call options always take precedence over global defaults.
687
+
688
+ ```ruby
689
+ SafeMemoize.configure do |c|
690
+ c.default_ttl = 300 # All memoized methods expire after 5 minutes unless overridden
691
+ c.default_max_size = 100 # All memoized methods cap at 100 entries unless overridden
692
+ end
693
+ ```
694
+
695
+ Both settings apply at definition time — methods already memoized before `configure` is called are not affected. Reset all defaults back to `nil` with:
696
+
697
+ ```ruby
698
+ SafeMemoize.reset_configuration!
699
+ ```
700
+
701
+ The configure block also accepts `on_hook_error`, `on_deprecation`, `active_support_notifications`, and `statsd_client` (covered in [Hook error isolation](#hook-error-isolation), [Deprecation](#deprecation), [ActiveSupport::Notifications](#activesupportnotifications), and [StatsD](#statsd)).
702
+
703
+ [↑ Back to features](#features)
704
+
705
+ ### ActiveSupport::Notifications
706
+
707
+ Enable opt-in integration with `ActiveSupport::Notifications` for Rails and other ActiveSupport-based stacks:
708
+
709
+ ```ruby
710
+ SafeMemoize.configure do |c|
711
+ c.active_support_notifications = true
712
+ end
713
+ ```
714
+
715
+ Once enabled, SafeMemoize emits the following events through the standard notification pipeline:
716
+
717
+ | Event | Fires when |
718
+ |---|---|
719
+ | `cache_hit.safe_memoize` | A cached value is returned |
720
+ | `cache_miss.safe_memoize` | The method is called and no cached value exists |
721
+ | `cache_store.safe_memoize` | A value is written to the cache (miss, `warm_memo`, or `load_memo`) |
722
+ | `cache_evict.safe_memoize` | An entry is removed via `reset_memo`, `reset_all_memos`, or LRU eviction |
723
+ | `cache_expire.safe_memoize` | An expired TTL entry is pruned |
724
+
725
+ Each event payload includes:
726
+
727
+ ```ruby
728
+ {
729
+ method: :method_name, # Symbol
730
+ key: cache_key, # Array — the full cache key
731
+ class: "ClassName" # String — the host class name
732
+ }
733
+ ```
734
+
735
+ Subscribe to all SafeMemoize events via the standard ActiveSupport pattern:
736
+
737
+ ```ruby
738
+ ActiveSupport::Notifications.subscribe(/\.safe_memoize$/) do |event|
739
+ Rails.logger.debug("[cache] #{event.name} #{event.payload[:class]}##{event.payload[:method]}")
740
+ end
741
+ ```
742
+
743
+ The integration is a no-op when ActiveSupport is not loaded — there is no overhead for non-Rails projects. `active_support_notifications` defaults to `false`.
744
+
745
+ [↑ Back to features](#features)
746
+
747
+ ### StatsD
748
+
749
+ Route cache lifecycle events to any StatsD-compatible client via `SafeMemoize::Adapters::StatsD`. Assign the client once in your initializer:
750
+
751
+ ```ruby
752
+ SafeMemoize.configure do |c|
753
+ c.statsd_client = Datadog::Statsd.new("localhost", 8125)
754
+ end
755
+ ```
756
+
757
+ SafeMemoize then calls `client.increment(metric, tags: [...])` on every cache event:
758
+
759
+ | Metric | Fires when |
760
+ |---|---|
761
+ | `safe_memoize.hit` | A cached value is returned |
762
+ | `safe_memoize.miss` | The method is called and no cached value exists |
763
+ | `safe_memoize.store` | A value is written to the cache (miss, `warm_memo`, or `load_memo`) |
764
+ | `safe_memoize.evict` | An entry is removed via `reset_memo`, `reset_all_memos`, or LRU eviction |
765
+ | `safe_memoize.expire` | An expired TTL entry is pruned |
766
+
767
+ Each call includes two tags: `method:method_name` and `class:ClassName`. The client must respond to `increment(metric, tags: [...])` — the interface used by `dogstatsd-ruby`, `statsd-instrument`, and most modern StatsD clients.
768
+
769
+ If the client raises, the error is rescued and a warning is emitted to stderr rather than propagated to the caller. `statsd_client` defaults to `nil` (disabled).
770
+
771
+ [↑ Back to features](#features)
772
+
773
+ ### OpenTelemetry
774
+
775
+ `SafeMemoize::Adapters::OpenTelemetry` wraps the computation on each cache miss in an OpenTelemetry span, making memoized call costs visible in distributed traces. Assign a tracer once in your initializer:
776
+
777
+ ```ruby
778
+ SafeMemoize.configure do |c|
779
+ c.opentelemetry_tracer = OpenTelemetry.tracer_provider.tracer(
780
+ "safe_memoize",
781
+ SafeMemoize::VERSION
782
+ )
783
+ end
784
+ ```
785
+
786
+ SafeMemoize then wraps every cache miss (the actual method call, not cache hits) in a span named `"safe_memoize.compute"` with the following attributes:
787
+
788
+ | Attribute | Value |
789
+ |---|---|
790
+ | `safe_memoize.method` | Name of the memoized method |
791
+ | `safe_memoize.class` | Name of the host class |
792
+ | `safe_memoize.cache_hit` | Always `false` — only misses are traced |
793
+
794
+ Cache hits produce no spans, so tracing overhead is zero for cached calls. The adapter is compatible with any tracer that responds to `in_span(name, attributes:, &block)` — the interface provided by `opentelemetry-sdk`, `opentelemetry-api`, and no-op tracers alike. If `opentelemetry_tracer` is `nil` (the default), the adapter is completely bypassed.
795
+
796
+ [↑ Back to features](#features)
797
+
798
+ ### Rails request-scope
799
+
800
+ SafeMemoize ships optional Rails integration as a separate require (zero overhead in non-Rails apps):
801
+
802
+ ```ruby
803
+ require "safe_memoize/rails"
804
+ ```
805
+
806
+ #### Controller concern
807
+
808
+ Include `SafeMemoize::Rails::RequestScoped` in any Rails controller that also `prepend SafeMemoize`. It automatically registers `after_action :reset_all_memos` so every instance memo is cleared at the end of each request — preventing state from leaking between requests when the controller object is reused:
809
+
810
+ ```ruby
811
+ class ApplicationController < ActionController::Base
812
+ prepend SafeMemoize
813
+ include SafeMemoize::Rails::RequestScoped
814
+
815
+ memoize :current_user
816
+ end
817
+ ```
818
+
819
+ #### Service objects and non-controller classes
820
+
821
+ In plain classes (service objects, Active Model objects), include `RequestScoped` to gain `reset_request_memos` and call it manually at the appropriate point:
822
+
823
+ ```ruby
824
+ class ReportService
825
+ prepend SafeMemoize
826
+ include SafeMemoize::Rails::RequestScoped
827
+
828
+ def summary(id)
829
+ # ...
830
+ end
831
+ memoize :summary
832
+ end
833
+
834
+ svc = ReportService.new
835
+ svc.summary(1)
836
+ svc.reset_request_memos # clears all instance memos
837
+ ```
838
+
839
+ #### Middleware for tracked instances
840
+
841
+ For service objects that should be reset automatically at request boundaries, use the Rack middleware together with `SafeMemoize::Rails.track`:
842
+
843
+ ```ruby
844
+ # config/application.rb
845
+ config.middleware.use SafeMemoize::Rails::Middleware
846
+ ```
847
+
848
+ ```ruby
849
+ class ReportService
850
+ prepend SafeMemoize
851
+
852
+ def initialize
853
+ SafeMemoize::Rails.track(self) # register for auto-reset
854
+ end
855
+
856
+ def summary(id)
857
+ # ...
858
+ end
859
+ memoize :summary
860
+ end
861
+ ```
862
+
863
+ `SafeMemoize::Rails::Middleware` calls `reset_all_memos` on every tracked instance in the current thread at the end of the request, even if the app raises. Tracking is thread-local, so concurrent requests never interfere. The tracked list is cleared automatically after each reset.
864
+
865
+ [↑ Back to features](#features)
866
+
867
+ ### Deprecation
868
+
869
+ SafeMemoize ships a structured deprecation helper for gem authors who build on top of it:
870
+
871
+ ```ruby
872
+ SafeMemoize.deprecate(
873
+ "MyGem::OldHelper",
874
+ message: "Use MyGem::NewHelper instead",
875
+ horizon: "2.0.0"
876
+ )
877
+ # => [SafeMemoize] DEPRECATED: MyGem::OldHelper — Use MyGem::NewHelper instead (removal horizon: 2.0.0)
878
+ ```
879
+
880
+ The warning is emitted to stderr by default. Configure a custom handler via `SafeMemoize.configure`:
881
+
882
+ ```ruby
883
+ SafeMemoize.configure do |c|
884
+ c.on_deprecation = ->(msg) { Rails.logger.warn(msg) }
885
+ end
886
+ ```
887
+
888
+ To raise on deprecation warnings in test environments:
889
+
890
+ ```ruby
891
+ SafeMemoize.configure do |c|
892
+ c.on_deprecation = ->(msg) { raise msg }
893
+ end
894
+ ```
895
+
896
+ [↑ Back to features](#features)
897
+
532
898
  ## Development
533
899
 
534
900
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt.
535
901
 
902
+ To run the benchmark suite: `bundle exec ruby benchmarks/benchmark.rb`. Install `memery` and `memo_wise` first if you want comparison columns against those gems.
903
+
536
904
  GitHub Actions also runs the full `bundle exec rake` suite automatically for pull requests, manual workflow runs, and pushes to `main` via `.github/workflows/ci.yml`.
537
905
 
538
906
  ## Releasing
data/ROADMAP.md CHANGED
@@ -10,12 +10,12 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
10
10
 
11
11
  | Feature | Description | Status |
12
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 |
13
+ | ActiveSupport::Notifications integration | Emit `cache.hit`, `cache.miss`, `cache.evict`, and `cache.expire` events when ActiveSupport is available (opt-in via configuration) | Shipped |
14
+ | StatsD adapter | Thin optional module (`SafeMemoize::Adapters::StatsD`) that routes lifecycle hooks to a StatsD client with sensible metric names and tags | Shipped |
15
+ | OpenTelemetry spans | Optional adapter (`SafeMemoize::Adapters::OpenTelemetry`) wrapping computation time in a trace span for distributed tracing pipelines | Shipped |
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) | Shipped |
17
+ | Formal benchmark suite | `benchmarks/` directory with comparisons against `memery`, `memo_wise`, and raw `||=`, covering single-threaded throughput and contention under concurrent load | Shipped |
18
+ | Concurrency stress tests | Dedicated spec suite hammering shared-cache paths and LRU eviction under high thread counts to surface race conditions | Shipped |
19
19
 
20
20
  ---
21
21
 
@@ -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
@@ -88,7 +88,7 @@ module SafeMemoize
88
88
  call_memo_hooks(:on_expire, cache_key, record) if record && !record_live
89
89
 
90
90
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
- value = super(*args, **kwargs)
91
+ value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, klass.name) { super(*args, **kwargs) }
92
92
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
93
93
 
94
94
  new_record = memo_record(value, expires_at: memo_expires_at(ttl))
@@ -147,7 +147,7 @@ module SafeMemoize
147
147
  memo_record_value(record)
148
148
  else
149
149
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
150
- value = super(*args, **kwargs)
150
+ value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
151
151
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
152
152
 
153
153
  new_record = memo_record(value, expires_at: memo_expires_at(ttl))
@@ -174,7 +174,9 @@ module SafeMemoize
174
174
 
175
175
  # Cache miss - compute and store
176
176
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
177
- 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
178
180
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
179
181
 
180
182
  with_memo_lock do
@@ -2,13 +2,17 @@
2
2
 
3
3
  module SafeMemoize
4
4
  class Configuration
5
- attr_accessor :default_ttl, :default_max_size, :on_deprecation, :on_hook_error
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
10
11
  @on_deprecation = nil
11
12
  @on_hook_error = nil
13
+ @active_support_notifications = false
14
+ @statsd_client = nil
15
+ @opentelemetry_tracer = nil
12
16
  end
13
17
  end
14
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
@@ -29,6 +37,28 @@ module SafeMemoize
29
37
  warn "[SafeMemoize] Hook error in #{hook_type}: #{error.message}"
30
38
  end
31
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
+ })
32
62
  end
33
63
 
34
64
  def _clear_memo_hooks(hook_type = nil)
@@ -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.8.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"
data/sig/safe_memoize.rbs CHANGED
@@ -7,6 +7,7 @@ module SafeMemoize
7
7
  type memo_key = default_memo_key | custom_memo_key
8
8
  type memo_record = { value: untyped, expires_at: Float?, cached_at: Float }
9
9
 
10
+ @configuration: Configuration?
10
11
  @__safe_memo_cache__: Hash[memo_key, memo_record]?
11
12
  @__safe_memo_mutex__: Mutex?
12
13
  @__safe_memo_shared_cache__: Hash[memo_key, memo_record]?
@@ -24,6 +25,9 @@ module SafeMemoize
24
25
  attr_accessor default_max_size: Integer?
25
26
  attr_accessor on_deprecation: (^(String message) -> void)?
26
27
  attr_accessor on_hook_error: (^(Exception error, Symbol hook_type, untyped cache_key) -> void)?
28
+ attr_accessor active_support_notifications: bool
29
+ attr_accessor statsd_client: untyped
30
+ attr_accessor opentelemetry_tracer: untyped
27
31
 
28
32
  def initialize: () -> void
29
33
  end
@@ -190,5 +194,27 @@ module SafeMemoize
190
194
  include PublicCustomKeyMethods
191
195
  include LruMethods
192
196
  end
197
+
198
+ module Adapters
199
+ module OpenTelemetry
200
+ SPAN_NAME: String
201
+ def self.trace: (untyped tracer, Symbol | String method_name, String? class_name) { () -> untyped } -> untyped
202
+ end
203
+ end
204
+
205
+ module Rails
206
+ def self.track: (untyped instance) -> void
207
+ def self.reset_tracked!: () -> void
208
+
209
+ module RequestScoped
210
+ def self.included: (Module base) -> void
211
+ def reset_request_memos: () -> void
212
+ end
213
+
214
+ class Middleware
215
+ def initialize: (untyped app) -> void
216
+ def call: (untyped env) -> untyped
217
+ end
218
+ end
193
219
  end
194
220
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: safe_memoize
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -46,7 +46,12 @@ files:
46
46
  - README.md
47
47
  - ROADMAP.md
48
48
  - Rakefile
49
+ - benchmarks/README.md
50
+ - benchmarks/benchmark.rb
51
+ - codecov.yml
49
52
  - lib/safe_memoize.rb
53
+ - lib/safe_memoize/adapters/opentelemetry.rb
54
+ - lib/safe_memoize/adapters/statsd.rb
50
55
  - lib/safe_memoize/cache_metrics_methods.rb
51
56
  - lib/safe_memoize/cache_record_methods.rb
52
57
  - lib/safe_memoize/cache_store_methods.rb
@@ -60,6 +65,9 @@ files:
60
65
  - lib/safe_memoize/public_custom_key_methods.rb
61
66
  - lib/safe_memoize/public_methods.rb
62
67
  - lib/safe_memoize/public_metrics_methods.rb
68
+ - lib/safe_memoize/rails.rb
69
+ - lib/safe_memoize/rails/middleware.rb
70
+ - lib/safe_memoize/rails/request_scoped.rb
63
71
  - lib/safe_memoize/release_tooling.rb
64
72
  - lib/safe_memoize/version.rb
65
73
  - sig/safe_memoize.rbs