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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +375 -7
- data/ROADMAP.md +6 -6
- 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 +5 -3
- data/lib/safe_memoize/configuration.rb +5 -1
- data/lib/safe_memoize/hooks_methods.rb +30 -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 +2 -0
- data/sig/safe_memoize.rbs +26 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b47b6031a8f395991376eec0407b93d1cf02caecad23f4d77e4d68234ef87b5
|
|
4
|
+
data.tar.gz: 7542678912a0a39425a75e3addfc718f2abb35068e28e0cdc0444c294dd4c4c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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)
|
|
525
|
-
obj.cache_hit_rate
|
|
526
|
-
obj.cache_miss_rate
|
|
527
|
-
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
|
|
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) |
|
|
14
|
-
| StatsD adapter | Thin optional module (`SafeMemoize::Adapters::StatsD`) that routes lifecycle hooks to a StatsD client with sensible metric names and tags |
|
|
15
|
-
| OpenTelemetry spans | Optional adapter (`SafeMemoize::Adapters::OpenTelemetry`) wrapping computation time in a trace span for distributed tracing pipelines |
|
|
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) |
|
|
17
|
-
| Formal benchmark suite | `benchmarks/` directory with comparisons against `memery`, `memo_wise`, and raw `||=`, covering single-threaded throughput and contention under concurrent load |
|
|
18
|
-
| Concurrency stress tests | Dedicated spec suite hammering shared-cache paths and LRU eviction under high thread counts to surface race conditions |
|
|
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)
|
|
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
|
data/lib/safe_memoize/version.rb
CHANGED
data/lib/safe_memoize.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "safe_memoize/version"
|
|
4
4
|
require_relative "safe_memoize/configuration"
|
|
5
|
+
require_relative "safe_memoize/adapters/statsd"
|
|
6
|
+
require_relative "safe_memoize/adapters/opentelemetry"
|
|
5
7
|
require_relative "safe_memoize/class_methods"
|
|
6
8
|
require_relative "safe_memoize/public_methods"
|
|
7
9
|
require_relative "safe_memoize/cache_store_methods"
|
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.
|
|
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
|