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
data/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# SafeMemoize
|
|
2
2
|
|
|
3
|
+
[](https://github.com/eclectic-coding/safe_memoize/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/safe_memoize)
|
|
5
|
+
[](https://rubygems.org/gems/safe_memoize)
|
|
6
|
+
[](https://www.ruby-lang.org)
|
|
7
|
+
[](https://codecov.io/gh/eclectic-coding/safe_memoize)
|
|
8
|
+
|
|
3
9
|
Thread-safe memoization for Ruby that correctly handles `nil` and `false` values.
|
|
4
10
|
|
|
5
11
|
SafeMemoize is a production-ready, zero-dependency memoization library for Ruby. It wraps methods with a `prepend`-based cache that handles everything the standard `||=` idiom gets wrong: `nil` and `false` return values are cached correctly, per-argument result maps eliminate redundant computation for parameterized methods, and a per-instance `Mutex` with double-check locking makes the whole thing safe under concurrent load.
|
|
@@ -8,7 +14,7 @@ Beyond the basics, SafeMemoize ships with TTL expiration (including sliding wind
|
|
|
8
14
|
|
|
9
15
|
## The Problem
|
|
10
16
|
|
|
11
|
-
Ruby's common memoization pattern breaks with falsy values:
|
|
17
|
+
Ruby's common memoization pattern breaks with falsy values:
|
|
12
18
|
|
|
13
19
|
```ruby
|
|
14
20
|
def user
|
|
@@ -45,6 +51,22 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
|
|
|
45
51
|
- [Bulk memoization via `memoize_all` (public, protected, and private)](#bulk-memoization)
|
|
46
52
|
- [Custom cache key generation per method](#custom-cache-keys)
|
|
47
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)
|
|
48
70
|
|
|
49
71
|
## Installation
|
|
50
72
|
|
|
@@ -82,6 +104,10 @@ class UserService
|
|
|
82
104
|
end
|
|
83
105
|
```
|
|
84
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
|
+
|
|
85
111
|
### With arguments
|
|
86
112
|
|
|
87
113
|
Results are cached per unique argument combination:
|
|
@@ -103,6 +129,10 @@ calc.compute(1, 2) # returns cached result
|
|
|
103
129
|
calc.compute(3, 4) # computes and caches (different args)
|
|
104
130
|
```
|
|
105
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
|
+
|
|
106
136
|
### Nil and false safety
|
|
107
137
|
|
|
108
138
|
```ruby
|
|
@@ -117,6 +147,8 @@ class Config
|
|
|
117
147
|
end
|
|
118
148
|
```
|
|
119
149
|
|
|
150
|
+
[↑ Back to features](#features)
|
|
151
|
+
|
|
120
152
|
### Works with private methods
|
|
121
153
|
|
|
122
154
|
```ruby
|
|
@@ -136,6 +168,8 @@ class TokenProvider
|
|
|
136
168
|
end
|
|
137
169
|
```
|
|
138
170
|
|
|
171
|
+
[↑ Back to features](#features)
|
|
172
|
+
|
|
139
173
|
### Cache reset
|
|
140
174
|
|
|
141
175
|
```ruby
|
|
@@ -146,6 +180,8 @@ obj.reset_memo(:search, "ruby", page: 2) # Clears one positional/keyword c
|
|
|
146
180
|
obj.reset_all_memos # Clears all memoized values
|
|
147
181
|
```
|
|
148
182
|
|
|
183
|
+
[↑ Back to features](#features)
|
|
184
|
+
|
|
149
185
|
### Lifecycle hooks
|
|
150
186
|
|
|
151
187
|
Register callbacks that fire when cached entries are evicted or expire.
|
|
@@ -182,6 +218,14 @@ obj.on_memo_expire do |cache_key, record|
|
|
|
182
218
|
end
|
|
183
219
|
```
|
|
184
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
|
+
|
|
185
229
|
Multiple hooks of the same type can be registered and all will fire. Remove them with `clear_memo_hooks`:
|
|
186
230
|
|
|
187
231
|
```ruby
|
|
@@ -194,6 +238,28 @@ obj.clear_memo_hooks # Clears all hooks
|
|
|
194
238
|
|
|
195
239
|
Hooks are per-instance and do not affect other objects of the same class.
|
|
196
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
|
+
|
|
197
263
|
### TTL expiration
|
|
198
264
|
|
|
199
265
|
```ruby
|
|
@@ -209,6 +275,23 @@ end
|
|
|
209
275
|
|
|
210
276
|
With a TTL, cached values expire automatically after the given number of seconds. The next call recomputes and refreshes the cache.
|
|
211
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
|
+
|
|
212
295
|
### Sliding window TTL
|
|
213
296
|
|
|
214
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:
|
|
@@ -226,6 +309,8 @@ end
|
|
|
226
309
|
|
|
227
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.
|
|
228
311
|
|
|
312
|
+
[↑ Back to features](#features)
|
|
313
|
+
|
|
229
314
|
### LRU cache size limit
|
|
230
315
|
|
|
231
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:
|
|
@@ -259,6 +344,8 @@ memoize :find, max_size: 50, ttl: 300
|
|
|
259
344
|
|
|
260
345
|
The `on_evict` hook fires for LRU-evicted entries the same way it does for manual `reset_memo` calls.
|
|
261
346
|
|
|
347
|
+
[↑ Back to features](#features)
|
|
348
|
+
|
|
262
349
|
### Conditional caching
|
|
263
350
|
|
|
264
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.
|
|
@@ -293,8 +380,25 @@ Both options accept any callable and compose with `ttl:` and `max_size:`:
|
|
|
293
380
|
memoize :find, if: ->(result) { !result.nil? }, ttl: 60, max_size: 500
|
|
294
381
|
```
|
|
295
382
|
|
|
383
|
+
[↑ Back to features](#features)
|
|
384
|
+
|
|
296
385
|
### Cache warm-up and persistence
|
|
297
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
|
+
|
|
298
402
|
#### Warming individual entries
|
|
299
403
|
|
|
300
404
|
Use `warm_memo` to pre-populate a cache entry without calling the method. The block provides the value:
|
|
@@ -342,6 +446,8 @@ obj.load_memo(Marshal.load(raw)) if raw
|
|
|
342
446
|
|
|
343
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.
|
|
344
448
|
|
|
449
|
+
[↑ Back to features](#features)
|
|
450
|
+
|
|
345
451
|
### Shared cache
|
|
346
452
|
|
|
347
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.
|
|
@@ -376,6 +482,8 @@ ConfigService.shared_memoized?(:database_url) # => true
|
|
|
376
482
|
ConfigService.shared_memoized?(:find, user_id) # Checks one argument combination
|
|
377
483
|
ConfigService.shared_memo_count # Total shared cached entries
|
|
378
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)
|
|
379
487
|
```
|
|
380
488
|
|
|
381
489
|
`shared: true` supports `ttl:`, `ttl_refresh:`, `if:`, `unless:`, and `max_size:` options.
|
|
@@ -388,6 +496,8 @@ memoize :find, shared: true, max_size: 500
|
|
|
388
496
|
|
|
389
497
|
Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`, `on_memo_evict`) fire on the calling instance as usual.
|
|
390
498
|
|
|
499
|
+
[↑ Back to features](#features)
|
|
500
|
+
|
|
391
501
|
### Bulk memoization
|
|
392
502
|
|
|
393
503
|
Use `memoize_all` to memoize every public method defined on the class in one call:
|
|
@@ -426,6 +536,14 @@ Use `except:` to skip specific methods:
|
|
|
426
536
|
memoize_all except: [:version, :name]
|
|
427
537
|
```
|
|
428
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
|
+
|
|
429
547
|
By default only public methods defined directly on the class are memoized. Use `include_protected:` or `include_private:` to opt those visibilities in:
|
|
430
548
|
|
|
431
549
|
```ruby
|
|
@@ -436,9 +554,11 @@ memoize_all include_protected: true, include_private: true
|
|
|
436
554
|
|
|
437
555
|
Inherited methods are never affected regardless of visibility.
|
|
438
556
|
|
|
557
|
+
[↑ Back to features](#features)
|
|
558
|
+
|
|
439
559
|
### Custom cache keys
|
|
440
560
|
|
|
441
|
-
By default the cache key is derived from the method name and all arguments. Use `
|
|
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:
|
|
442
562
|
|
|
443
563
|
```ruby
|
|
444
564
|
class ReportService
|
|
@@ -447,9 +567,18 @@ class ReportService
|
|
|
447
567
|
def generate(user_id, options)
|
|
448
568
|
build_report(user_id, options)
|
|
449
569
|
end
|
|
450
|
-
memoize :generate
|
|
570
|
+
memoize :generate, key: ->(user_id, _options) { user_id }
|
|
451
571
|
end
|
|
452
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
|
|
453
582
|
svc = ReportService.new
|
|
454
583
|
|
|
455
584
|
# Cache only by user_id — ignore the options hash entirely
|
|
@@ -474,6 +603,8 @@ svc.clear_custom_keys(:generate) # Remove generator for one method
|
|
|
474
603
|
svc.clear_custom_keys # Remove all custom key generators
|
|
475
604
|
```
|
|
476
605
|
|
|
606
|
+
[↑ Back to features](#features)
|
|
607
|
+
|
|
477
608
|
### Cache inspection
|
|
478
609
|
|
|
479
610
|
```ruby
|
|
@@ -494,8 +625,32 @@ obj.memo_values(:search) # Cached signatures and values for one
|
|
|
494
625
|
obj.memo_ttl_remaining(:current_quote) # => 47.231 (seconds until expiry)
|
|
495
626
|
obj.memo_ttl_remaining(:current_user) # => nil (no TTL set)
|
|
496
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)
|
|
497
632
|
```
|
|
498
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
|
+
|
|
499
654
|
### Cache metrics
|
|
500
655
|
|
|
501
656
|
Each instance tracks hits, misses, and computation time automatically.
|
|
@@ -515,18 +670,237 @@ obj.cache_stats
|
|
|
515
670
|
# ]
|
|
516
671
|
# }
|
|
517
672
|
|
|
518
|
-
obj.cache_stats_for(:find)
|
|
519
|
-
obj.cache_hit_rate
|
|
520
|
-
obj.cache_miss_rate
|
|
521
|
-
obj.cache_metrics_reset
|
|
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
|
|
522
678
|
```
|
|
523
679
|
|
|
524
680
|
Metrics are per-instance and reset independently from the cache itself — clearing metrics does not evict cached values.
|
|
525
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
|
+
|
|
526
898
|
## Development
|
|
527
899
|
|
|
528
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.
|
|
529
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
|
+
|
|
530
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`.
|
|
531
905
|
|
|
532
906
|
## Releasing
|
|
@@ -562,6 +936,10 @@ To preview the changelog/version update without changing anything, use:
|
|
|
562
936
|
bin/release 0.1.1 --dry-run
|
|
563
937
|
```
|
|
564
938
|
|
|
939
|
+
## Roadmap
|
|
940
|
+
|
|
941
|
+
See [ROADMAP.md](ROADMAP.md) for the planned path from v0.7.0 to v1.0.0 and beyond, including upcoming features, API stability goals, and the versioning policy.
|
|
942
|
+
|
|
565
943
|
## Contributing
|
|
566
944
|
|
|
567
945
|
Bug reports and pull requests are welcome on GitHub at https://github.com/eclectic-coding/safe_memoize.
|
data/ROADMAP.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SafeMemoize Roadmap
|
|
2
|
+
|
|
3
|
+
This document tracks the planned evolution of SafeMemoize through v1.0.0 and beyond. Items are grouped by release milestone; ordering within a milestone reflects priority, not a strict implementation sequence.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## v0.9.0 — Observability & Ecosystem Integration
|
|
8
|
+
|
|
9
|
+
*Goal: make SafeMemoize a first-class citizen in Rails/ActiveSupport stacks and in observability pipelines.*
|
|
10
|
+
|
|
11
|
+
| Feature | Description | Status |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| ActiveSupport::Notifications integration | Emit `cache.hit`, `cache.miss`, `cache.evict`, and `cache.expire` events when ActiveSupport is available (opt-in via configuration) | 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
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## v1.0.0 — Stable API
|
|
23
|
+
|
|
24
|
+
*Goal: declare a stable, semver-governed public API that downstream code can depend on with confidence.*
|
|
25
|
+
|
|
26
|
+
| Feature | Description | Status |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Semantic versioning guarantee | Document which constants, methods, and option keys are public API; breaking changes require a major bump henceforth | Planned |
|
|
29
|
+
| Complete RBS + Sorbet signatures | Cover all public methods including overloads for optional keyword arguments; publish `.rbi` stubs as a companion package if demand warrants | Planned |
|
|
30
|
+
| Full API reference | Generated documentation hosted on RubyDoc or a dedicated docs site; all public methods documented with parameter types, return values, and usage examples | Planned |
|
|
31
|
+
| Ractor compatibility audit | Investigate and either support Ractor-compatible operation (Mutex replacement, shared-cache storage) or document the limitation clearly | Planned |
|
|
32
|
+
| Ruby version policy | Formalise the supported Ruby version window and cadence for dropping EOL versions | Planned |
|
|
33
|
+
| Deprecation sweep | Resolve or formally deprecate any unstable internal APIs before the stable release | Planned |
|
|
34
|
+
| Upgrade guide | Document all breaking changes from 0.x and provide a migration path for users of deprecated behaviour | Planned |
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## v1.1.0 — Pluggable Cache Stores
|
|
39
|
+
|
|
40
|
+
*Goal: allow the in-process hash cache to be swapped for an external store, enabling cross-process and distributed memoization.*
|
|
41
|
+
|
|
42
|
+
| Feature | Description | Status |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| Cache store adapter interface | Define a minimal read/write/delete/clear/keys contract that external backends must implement | Planned |
|
|
45
|
+
| `store:` option on `memoize` | Accept any store adapter object; defaults to the existing in-process hash store | Planned |
|
|
46
|
+
| Redis adapter | Reference implementation (`SafeMemoize::Stores::Redis`) with TTL, LRU-like expiry, and serialization handled transparently | Planned |
|
|
47
|
+
| Rails.cache adapter | Thin wrapper around `ActiveSupport::Cache::Store` for projects already using a configured Rails cache | Planned |
|
|
48
|
+
| Global default store | Set via `SafeMemoize.configure` — applies a default store to every memoized method without per-call configuration | Planned |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## v1.2.0 — Async & Fiber-Safe Memoization
|
|
53
|
+
|
|
54
|
+
*Goal: first-class support for Fiber-based concurrency frameworks (Async, Falcon, Rails async controllers).*
|
|
55
|
+
|
|
56
|
+
| Feature | Description | Status |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| Fiber-local memoization mode | `memoize :method, fiber_local: true` stores results in `Fiber[:safe_memoize_cache]` rather than instance variables, giving each fiber its own isolated cache automatically reset when the fiber terminates | Planned |
|
|
59
|
+
| Ractor-compatible shared cache | Revisit `shared: true` using `Ractor::TVar` or shareable frozen objects so class-level caches work across Ractors | Planned |
|
|
60
|
+
| concurrent-ruby integration | Optional adapter using `Concurrent::Map` and `Concurrent::ReentrantReadWriteLock` as a drop-in replacement for `Mutex` where higher read-concurrency is desirable | Planned |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## v2.0.0 — Next Generation (Long Horizon)
|
|
65
|
+
|
|
66
|
+
*Goal: incorporate real-world usage feedback, clean up accumulated API surface, and open a path for advanced extension.*
|
|
67
|
+
|
|
68
|
+
| Feature | Description | Status |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| Plugin / extension architecture | A formal `SafeMemoize::Extension` API so third-party gems can add new options, hooks, or store adapters without monkey-patching | Planned |
|
|
71
|
+
| DSL refinements | Evaluate alternative syntax proposals (`memoize_method`, block form, annotation approach) based on community feedback; introduce the preferred form with a migration path from the current API | Planned |
|
|
72
|
+
| Cross-instance cache sharing | Beyond the class-level `shared: true`, support explicitly named shared caches that span unrelated classes | Planned |
|
|
73
|
+
| Cache namespacing | Allow a namespace prefix on all keys for multi-tenant or versioned deployments (especially useful with external stores) | Planned |
|
|
74
|
+
| Automatic cache busting | Optional integration with ActiveRecord's `updated_at` timestamp so object mutations automatically invalidate their own cached entries | Planned |
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Versioning policy
|
|
79
|
+
|
|
80
|
+
SafeMemoize follows [Semantic Versioning](https://semver.org/) from v1.0.0 onwards:
|
|
81
|
+
|
|
82
|
+
- **Patch** (1.x.**y**) — bug fixes; no API changes
|
|
83
|
+
- **Minor** (1.**x**.0) — additive features; backward-compatible
|
|
84
|
+
- **Major** (**x**.0.0) — breaking changes; migration guide published
|
|
85
|
+
|
|
86
|
+
0.x releases may include breaking changes between minor versions.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Contributing
|
|
91
|
+
|
|
92
|
+
Ideas, bug reports, and pull requests are welcome. Open an issue at <https://github.com/eclectic-coding/safe_memoize/issues> to discuss a feature before building it. If you are picking up a roadmap item, mention the milestone in your PR so it can be tracked against this document.
|