safe_memoize 0.6.0 → 0.6.1
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 +5 -0
- data/README.md +49 -6
- data/lib/safe_memoize/class_methods.rb +3 -0
- data/lib/safe_memoize/inspection_methods.rb +9 -2
- data/lib/safe_memoize/public_metrics_methods.rb +45 -99
- data/lib/safe_memoize/version.rb +1 -1
- data/sig/safe_memoize.rbs +5 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 036e876f53d96a1c6ca54ae257bb631c30b0b973a0f0bed77b6fafd92f187c93
|
|
4
|
+
data.tar.gz: 2d09472476154ff424940a59666b5a891d95156d44a829025ad9929f92fed24d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d0004ea27b6d21d3e32df92f782ddb0d4a768212d4344e3d4e4793d6eb44bb481b309a05a875bb166e1633f3be8c08e4580b006f512dd74723383e11516ff5c4
|
|
7
|
+
data.tar.gz: 6a81f21e5e4347a723305893cfcf3dbe8b4b71bdb0ef3767774276e7046893e5ec12471898b6ecfc87df135b0462691324fa16808369a5ad98fc6e54d6ce1a14
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.6.1] - 2026-05-17
|
|
4
|
+
|
|
5
|
+
- Fix `memo_keys` and `memo_values` showing `args: custom_key, kwargs: nil` for methods using `memoize_with_custom_key` — now surfaces as `custom_key:`
|
|
6
|
+
- Refactor `cache_stats` / `cache_stats_for` to share aggregation logic via private helpers
|
|
7
|
+
|
|
3
8
|
## [0.6.0] - 2026-05-17
|
|
4
9
|
|
|
5
10
|
- Fix TTL clock starting at `memoize` definition time instead of first method call
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Thread-safe memoization for Ruby that correctly handles `nil` and `false` values
|
|
|
4
4
|
|
|
5
5
|
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.
|
|
6
6
|
|
|
7
|
-
Beyond the basics, SafeMemoize ships with TTL expiration, LRU cache size capping, conditional caching via `if:`/`unless:` predicates, lifecycle hooks for cache hits, evictions, and expirations, per-instance metrics (hit rate, miss rate, average computation time), targeted and bulk cache invalidation, custom cache key generators, and rich introspection helpers (`memoized?`, `memo_count`, `memo_keys`, `memo_values`). It preserves method visibility (public, protected, and private) and requires no runtime dependencies.
|
|
7
|
+
Beyond the basics, SafeMemoize ships with TTL expiration (including sliding window refresh via `ttl_refresh:`), LRU cache size capping, conditional caching via `if:`/`unless:` predicates, lifecycle hooks for cache hits, evictions, and expirations, per-instance metrics (hit rate, miss rate, average computation time), targeted and bulk cache invalidation, custom cache key generators, and rich introspection helpers (`memoized?`, `memo_count`, `memo_keys`, `memo_values`, `memo_ttl_remaining`). It preserves method visibility (public, protected, and private) and requires no runtime dependencies.
|
|
8
8
|
|
|
9
9
|
## The Problem
|
|
10
10
|
|
|
@@ -31,14 +31,16 @@ SafeMemoize uses `Hash#key?` to distinguish "not yet cached" from "cached nil/fa
|
|
|
31
31
|
- [Includes a `memo_keys` helper for inspecting cached signatures](#cache-inspection)
|
|
32
32
|
- [Includes a `memo_values` helper for inspecting cached signatures and values](#cache-inspection)
|
|
33
33
|
- [Optional TTL expiration support for cached entries](#ttl-expiration)
|
|
34
|
+
- [Sliding window TTL via `ttl_refresh: true`](#sliding-window-ttl)
|
|
34
35
|
- [Optional LRU cache size limit per method via `max_size:`](#lru-cache-size-limit)
|
|
35
36
|
- [Conditional caching via `if:` and `unless:` predicates](#conditional-caching)
|
|
36
37
|
- [Lifecycle hooks for hit, miss, eviction, and expiration events](#lifecycle-hooks)
|
|
37
38
|
- [Per-instance cache metrics (hit rate, miss rate, computation time)](#cache-metrics)
|
|
38
39
|
- [Cache warm-up, export, and restore (`warm_memo`, `dump_memo`, `load_memo`)](#cache-warm-up-and-persistence)
|
|
39
|
-
- [Class-level shared cache via `shared: true`](#shared-cache)
|
|
40
|
-
- [Bulk memoization via `memoize_all`](#bulk-memoization)
|
|
40
|
+
- [Class-level shared cache via `shared: true` with optional LRU](#shared-cache)
|
|
41
|
+
- [Bulk memoization via `memoize_all` (public, protected, and private)](#bulk-memoization)
|
|
41
42
|
- [Custom cache key generation per method](#custom-cache-keys)
|
|
43
|
+
- [TTL introspection via `memo_ttl_remaining`](#cache-inspection)
|
|
42
44
|
|
|
43
45
|
## Installation
|
|
44
46
|
|
|
@@ -203,6 +205,23 @@ end
|
|
|
203
205
|
|
|
204
206
|
With a TTL, cached values expire automatically after the given number of seconds. The next call recomputes and refreshes the cache.
|
|
205
207
|
|
|
208
|
+
### Sliding window TTL
|
|
209
|
+
|
|
210
|
+
Add `ttl_refresh: true` to reset the expiry clock on every cache hit, so the entry only expires after a full TTL of inactivity:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
class SessionService
|
|
214
|
+
prepend SafeMemoize
|
|
215
|
+
|
|
216
|
+
def user_data(user_id)
|
|
217
|
+
fetch_from_db(user_id)
|
|
218
|
+
end
|
|
219
|
+
memoize :user_data, ttl: 300, ttl_refresh: true
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
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.
|
|
224
|
+
|
|
206
225
|
### LRU cache size limit
|
|
207
226
|
|
|
208
227
|
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:
|
|
@@ -282,6 +301,12 @@ obj.warm_memo(:find, 42) { cached_user }
|
|
|
282
301
|
obj.warm_memo(:search, "ruby", page: 2) { cached_results }
|
|
283
302
|
```
|
|
284
303
|
|
|
304
|
+
Pass `ttl:` to give the warmed entry an expiry:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
obj.warm_memo(:current_quote, ttl: 60) { cached_quote }
|
|
308
|
+
```
|
|
309
|
+
|
|
285
310
|
Useful for seeding the cache from a persistent store on startup, or overriding a cached value in tests.
|
|
286
311
|
|
|
287
312
|
#### Exporting and restoring the cache
|
|
@@ -349,9 +374,15 @@ ConfigService.shared_memo_count # Total shared cached entr
|
|
|
349
374
|
ConfigService.shared_memo_count(:find) # Entries for one method
|
|
350
375
|
```
|
|
351
376
|
|
|
352
|
-
`shared: true` supports `ttl:`, `if:`,
|
|
377
|
+
`shared: true` supports `ttl:`, `ttl_refresh:`, `if:`, `unless:`, and `max_size:` options.
|
|
378
|
+
|
|
379
|
+
Pass `max_size:` to cap how many entries are kept across all instances. Eviction is LRU, tracked at the class level:
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
memoize :find, shared: true, max_size: 500
|
|
383
|
+
```
|
|
353
384
|
|
|
354
|
-
Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance as usual.
|
|
385
|
+
Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`, `on_memo_evict`) fire on the calling instance as usual.
|
|
355
386
|
|
|
356
387
|
### Bulk memoization
|
|
357
388
|
|
|
@@ -391,7 +422,15 @@ Use `except:` to skip specific methods:
|
|
|
391
422
|
memoize_all except: [:version, :name]
|
|
392
423
|
```
|
|
393
424
|
|
|
394
|
-
|
|
425
|
+
By default only public methods defined directly on the class are memoized. Use `include_protected:` or `include_private:` to opt those visibilities in:
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
memoize_all include_protected: true
|
|
429
|
+
memoize_all include_private: true
|
|
430
|
+
memoize_all include_protected: true, include_private: true
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Inherited methods are never affected regardless of visibility.
|
|
395
434
|
|
|
396
435
|
### Custom cache keys
|
|
397
436
|
|
|
@@ -447,6 +486,10 @@ obj.memo_keys # All cached signatures with method, a
|
|
|
447
486
|
obj.memo_keys(:search) # Cached signatures for one method
|
|
448
487
|
obj.memo_values # Cached signatures and values for all methods
|
|
449
488
|
obj.memo_values(:search) # Cached signatures and values for one method
|
|
489
|
+
|
|
490
|
+
obj.memo_ttl_remaining(:current_quote) # => 47.231 (seconds until expiry)
|
|
491
|
+
obj.memo_ttl_remaining(:current_user) # => nil (no TTL set)
|
|
492
|
+
obj.memo_ttl_remaining(:find, 42) # => 0 (not cached or already expired)
|
|
450
493
|
```
|
|
451
494
|
|
|
452
495
|
### Cache metrics
|
|
@@ -6,6 +6,9 @@ module SafeMemoize
|
|
|
6
6
|
method_name = method_name.to_sym
|
|
7
7
|
visibility = memoized_method_visibility(method_name)
|
|
8
8
|
|
|
9
|
+
# :if and :unless are reserved Ruby keywords, so they can't be referenced
|
|
10
|
+
# as local variables directly. binding.local_variable_get is the only way
|
|
11
|
+
# to read keyword arguments with those names inside the method body.
|
|
9
12
|
cond_if = binding.local_variable_get(:if)
|
|
10
13
|
cond_unless = binding.local_variable_get(:unless)
|
|
11
14
|
|
|
@@ -53,9 +53,16 @@ module SafeMemoize
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def memo_projection(cache_key, value, include_method:, include_value:)
|
|
56
|
-
|
|
56
|
+
# Custom keys are [method, custom_key] (2 elements); default keys are
|
|
57
|
+
# [method, args, kwargs] (3 elements). Detect and surface accordingly.
|
|
58
|
+
if cache_key.length == 2
|
|
59
|
+
method_name, custom_key = cache_key
|
|
60
|
+
payload = {custom_key: custom_key}
|
|
61
|
+
else
|
|
62
|
+
method_name, args, kwargs = cache_key
|
|
63
|
+
payload = {args: args, kwargs: kwargs}
|
|
64
|
+
end
|
|
57
65
|
|
|
58
|
-
payload = {args: args, kwargs: kwargs}
|
|
59
66
|
payload[:method] = method_name if include_method
|
|
60
67
|
payload[:value] = memo_record_value(value) if include_value
|
|
61
68
|
payload
|
|
@@ -5,53 +5,9 @@ module SafeMemoize
|
|
|
5
5
|
def cache_stats
|
|
6
6
|
with_memo_lock do
|
|
7
7
|
metrics = memo_metrics_store
|
|
8
|
+
return empty_stats if metrics.empty?
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
return {
|
|
11
|
-
total_hits: 0,
|
|
12
|
-
total_misses: 0,
|
|
13
|
-
hit_rate: 0.0,
|
|
14
|
-
miss_rate: 0.0,
|
|
15
|
-
average_computation_time: 0.0,
|
|
16
|
-
entries: []
|
|
17
|
-
}
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
total_hits = metrics.values.sum { |m| m[:hits] }
|
|
21
|
-
total_misses = metrics.values.sum { |m| m[:misses] }
|
|
22
|
-
total_time = metrics.values.sum { |m| m[:total_time] }
|
|
23
|
-
total_calls = total_hits + total_misses
|
|
24
|
-
|
|
25
|
-
hit_rate = total_calls.zero? ? 0.0 : (total_hits.to_f / total_calls * 100).round(2)
|
|
26
|
-
miss_rate = total_calls.zero? ? 0.0 : (total_misses.to_f / total_calls * 100).round(2)
|
|
27
|
-
avg_time = total_misses.zero? ? 0.0 : (total_time / total_misses).round(6)
|
|
28
|
-
|
|
29
|
-
entries = metrics.map do |cache_key, stats|
|
|
30
|
-
method_name, args, _kwargs = cache_key
|
|
31
|
-
entry_hit_rate = if (stats[:hits] + stats[:misses]).zero?
|
|
32
|
-
0.0
|
|
33
|
-
else
|
|
34
|
-
(stats[:hits].to_f / (stats[:hits] + stats[:misses]) * 100).round(2)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
{
|
|
38
|
-
method: method_name,
|
|
39
|
-
args: args,
|
|
40
|
-
hits: stats[:hits],
|
|
41
|
-
misses: stats[:misses],
|
|
42
|
-
hit_rate: entry_hit_rate,
|
|
43
|
-
computation_time: stats[:total_time].round(6)
|
|
44
|
-
}
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
{
|
|
48
|
-
total_hits: total_hits,
|
|
49
|
-
total_misses: total_misses,
|
|
50
|
-
hit_rate: hit_rate,
|
|
51
|
-
miss_rate: miss_rate,
|
|
52
|
-
average_computation_time: avg_time,
|
|
53
|
-
entries: entries
|
|
54
|
-
}
|
|
10
|
+
aggregate_metrics(metrics, include_method: true)
|
|
55
11
|
end
|
|
56
12
|
end
|
|
57
13
|
|
|
@@ -59,67 +15,19 @@ module SafeMemoize
|
|
|
59
15
|
method_name = method_name.to_sym
|
|
60
16
|
|
|
61
17
|
with_memo_lock do
|
|
62
|
-
metrics = memo_metrics_store
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if method_metrics.empty?
|
|
66
|
-
return {
|
|
67
|
-
method: method_name,
|
|
68
|
-
total_hits: 0,
|
|
69
|
-
total_misses: 0,
|
|
70
|
-
hit_rate: 0.0,
|
|
71
|
-
miss_rate: 0.0,
|
|
72
|
-
average_computation_time: 0.0,
|
|
73
|
-
entries: []
|
|
74
|
-
}
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
total_hits = method_metrics.values.sum { |m| m[:hits] }
|
|
78
|
-
total_misses = method_metrics.values.sum { |m| m[:misses] }
|
|
79
|
-
total_time = method_metrics.values.sum { |m| m[:total_time] }
|
|
80
|
-
total_calls = total_hits + total_misses
|
|
81
|
-
|
|
82
|
-
hit_rate = total_calls.zero? ? 0.0 : (total_hits.to_f / total_calls * 100).round(2)
|
|
83
|
-
miss_rate = total_calls.zero? ? 0.0 : (total_misses.to_f / total_calls * 100).round(2)
|
|
84
|
-
avg_time = total_misses.zero? ? 0.0 : (total_time / total_misses).round(6)
|
|
85
|
-
|
|
86
|
-
entries = method_metrics.map do |cache_key, stats|
|
|
87
|
-
_method, args, _kwargs = cache_key
|
|
88
|
-
entry_hit_rate = if (stats[:hits] + stats[:misses]).zero?
|
|
89
|
-
0.0
|
|
90
|
-
else
|
|
91
|
-
(stats[:hits].to_f / (stats[:hits] + stats[:misses]) * 100).round(2)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
{
|
|
95
|
-
args: args,
|
|
96
|
-
hits: stats[:hits],
|
|
97
|
-
misses: stats[:misses],
|
|
98
|
-
hit_rate: entry_hit_rate,
|
|
99
|
-
computation_time: stats[:total_time].round(6)
|
|
100
|
-
}
|
|
101
|
-
end
|
|
18
|
+
metrics = memo_metrics_store.select { |key, _| key[0] == method_name }
|
|
19
|
+
return empty_stats.merge(method: method_name) if metrics.empty?
|
|
102
20
|
|
|
103
|
-
|
|
104
|
-
method: method_name,
|
|
105
|
-
total_hits: total_hits,
|
|
106
|
-
total_misses: total_misses,
|
|
107
|
-
hit_rate: hit_rate,
|
|
108
|
-
miss_rate: miss_rate,
|
|
109
|
-
average_computation_time: avg_time,
|
|
110
|
-
entries: entries
|
|
111
|
-
}
|
|
21
|
+
aggregate_metrics(metrics, include_method: false).merge(method: method_name)
|
|
112
22
|
end
|
|
113
23
|
end
|
|
114
24
|
|
|
115
25
|
def cache_hit_rate
|
|
116
|
-
|
|
117
|
-
stats[:hit_rate]
|
|
26
|
+
cache_stats[:hit_rate]
|
|
118
27
|
end
|
|
119
28
|
|
|
120
29
|
def cache_miss_rate
|
|
121
|
-
|
|
122
|
-
stats[:miss_rate]
|
|
30
|
+
cache_stats[:miss_rate]
|
|
123
31
|
end
|
|
124
32
|
|
|
125
33
|
def cache_metrics_reset
|
|
@@ -127,5 +35,43 @@ module SafeMemoize
|
|
|
127
35
|
_reset_cache_metrics
|
|
128
36
|
end
|
|
129
37
|
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def aggregate_metrics(metrics, include_method:)
|
|
42
|
+
total_hits = metrics.values.sum { |m| m[:hits] }
|
|
43
|
+
total_misses = metrics.values.sum { |m| m[:misses] }
|
|
44
|
+
total_time = metrics.values.sum { |m| m[:total_time] }
|
|
45
|
+
total_calls = total_hits + total_misses
|
|
46
|
+
|
|
47
|
+
hit_rate = total_calls.zero? ? 0.0 : (total_hits.to_f / total_calls * 100).round(2)
|
|
48
|
+
miss_rate = total_calls.zero? ? 0.0 : (total_misses.to_f / total_calls * 100).round(2)
|
|
49
|
+
avg_time = total_misses.zero? ? 0.0 : (total_time / total_misses).round(6)
|
|
50
|
+
|
|
51
|
+
entries = metrics.map do |cache_key, stats|
|
|
52
|
+
method_name, args, _kwargs = cache_key
|
|
53
|
+
entry_calls = stats[:hits] + stats[:misses]
|
|
54
|
+
entry_hit_rate = entry_calls.zero? ? 0.0 : (stats[:hits].to_f / entry_calls * 100).round(2)
|
|
55
|
+
|
|
56
|
+
entry = {args: args, hits: stats[:hits], misses: stats[:misses],
|
|
57
|
+
hit_rate: entry_hit_rate, computation_time: stats[:total_time].round(6)}
|
|
58
|
+
entry[:method] = method_name if include_method
|
|
59
|
+
entry
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
total_hits: total_hits,
|
|
64
|
+
total_misses: total_misses,
|
|
65
|
+
hit_rate: hit_rate,
|
|
66
|
+
miss_rate: miss_rate,
|
|
67
|
+
average_computation_time: avg_time,
|
|
68
|
+
entries: entries
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def empty_stats
|
|
73
|
+
{total_hits: 0, total_misses: 0, hit_rate: 0.0, miss_rate: 0.0,
|
|
74
|
+
average_computation_time: 0.0, entries: []}
|
|
75
|
+
end
|
|
130
76
|
end
|
|
131
77
|
end
|
data/lib/safe_memoize/version.rb
CHANGED
data/sig/safe_memoize.rbs
CHANGED
|
@@ -119,6 +119,11 @@ module SafeMemoize
|
|
|
119
119
|
def cache_hit_rate: () -> Float
|
|
120
120
|
def cache_miss_rate: () -> Float
|
|
121
121
|
def cache_metrics_reset: () -> void
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def aggregate_metrics: (Hash[memo_key, untyped] metrics, include_method: bool) -> Hash[Symbol, untyped]
|
|
126
|
+
def empty_stats: () -> Hash[Symbol, untyped]
|
|
122
127
|
end
|
|
123
128
|
|
|
124
129
|
module CustomKeyMethods
|