safe_memoize 0.5.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 +17 -1
- data/README.md +49 -6
- data/lib/safe_memoize/cache_metrics_methods.rb +4 -4
- data/lib/safe_memoize/cache_record_methods.rb +1 -0
- data/lib/safe_memoize/cache_store_methods.rb +2 -2
- data/lib/safe_memoize/class_methods.rb +62 -25
- data/lib/safe_memoize/inspection_methods.rb +9 -2
- data/lib/safe_memoize/lru_methods.rb +5 -0
- data/lib/safe_memoize/public_methods.rb +17 -2
- data/lib/safe_memoize/public_metrics_methods.rb +45 -99
- data/lib/safe_memoize/version.rb +1 -1
- data/sig/safe_memoize.rbs +15 -6
- 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,21 @@
|
|
|
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
|
+
|
|
8
|
+
## [0.6.0] - 2026-05-17
|
|
9
|
+
|
|
10
|
+
- Fix TTL clock starting at `memoize` definition time instead of first method call
|
|
11
|
+
- Fix metrics key silently dropping kwargs, causing methods that differ only in kwargs to share a metrics bucket
|
|
12
|
+
- Fix stale LRU references remaining after expired entries are pruned
|
|
13
|
+
- Add `ttl:` option to `warm_memo` so warmed entries can be given an expiry
|
|
14
|
+
- Add `max_size:` support for `shared: true` memoization (class-level LRU eviction)
|
|
15
|
+
- Add `ttl_refresh: true` option on `memoize` for sliding window TTL — resets expiry on every cache hit
|
|
16
|
+
- Add `include_protected:` and `include_private:` options to `memoize_all`
|
|
17
|
+
- Add `memo_ttl_remaining` for TTL introspection — returns seconds until expiry, `nil` for no TTL, `0` for uncached/expired
|
|
18
|
+
|
|
3
19
|
## [0.5.0] - 2026-05-17
|
|
4
20
|
|
|
5
21
|
- Drop support for Ruby 3.2 (EOL); minimum required version is now Ruby 3.3
|
|
@@ -15,7 +31,7 @@
|
|
|
15
31
|
- All instances share one cache; the method is computed only once regardless of how many objects exist
|
|
16
32
|
- Class-level invalidation: `reset_shared_memo`, `reset_all_shared_memos`
|
|
17
33
|
- Class-level inspection: `shared_memoized?`, `shared_memo_count`
|
|
18
|
-
|
|
34
|
+
- Supports `ttl:`, `if:`, and `unless:` options
|
|
19
35
|
- Instance hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance
|
|
20
36
|
- Add `memoize_all` to memoize every public method defined on the class in one call
|
|
21
37
|
- Accepts all options supported by `memoize` (`ttl:`, `max_size:`, `if:`, `unless:`)
|
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
|
|
@@ -8,15 +8,15 @@ module SafeMemoize
|
|
|
8
8
|
@__safe_memo_metrics__ ||= {}
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
def record_cache_hit(method_name, args)
|
|
12
|
-
cache_key = safe_memo_cache_key(method_name, args,
|
|
11
|
+
def record_cache_hit(method_name, args, kwargs)
|
|
12
|
+
cache_key = safe_memo_cache_key(method_name, args, kwargs)
|
|
13
13
|
metrics = memo_metrics_store
|
|
14
14
|
metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
|
|
15
15
|
metrics[cache_key][:hits] += 1
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def record_cache_miss(method_name, args, computation_time)
|
|
19
|
-
cache_key = safe_memo_cache_key(method_name, args,
|
|
18
|
+
def record_cache_miss(method_name, args, kwargs, computation_time)
|
|
19
|
+
cache_key = safe_memo_cache_key(method_name, args, kwargs)
|
|
20
20
|
metrics = memo_metrics_store
|
|
21
21
|
metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
|
|
22
22
|
metrics[cache_key][:misses] += 1
|
|
@@ -39,7 +39,7 @@ module SafeMemoize
|
|
|
39
39
|
memo_record_value(record)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def memo_fetch_or_store(cache_key,
|
|
42
|
+
def memo_fetch_or_store(cache_key, ttl: nil)
|
|
43
43
|
memo_mutex!.synchronize do
|
|
44
44
|
@__safe_memo_cache__ ||= {}
|
|
45
45
|
|
|
@@ -49,7 +49,7 @@ module SafeMemoize
|
|
|
49
49
|
memo_record_value(record)
|
|
50
50
|
else
|
|
51
51
|
value = yield
|
|
52
|
-
@__safe_memo_cache__[cache_key] = memo_record(value, expires_at:
|
|
52
|
+
@__safe_memo_cache__[cache_key] = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
53
53
|
|
|
54
54
|
value
|
|
55
55
|
end
|
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module SafeMemoize
|
|
4
4
|
module ClassMethods
|
|
5
|
-
def memoize(method_name, ttl: nil, max_size: nil, if: nil, unless: nil, shared: false)
|
|
5
|
+
def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false)
|
|
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
|
|
|
12
15
|
ttl = if ttl.nil?
|
|
13
16
|
nil
|
|
14
17
|
else
|
|
18
|
+
|
|
15
19
|
ttl = Float(ttl)
|
|
16
20
|
raise ArgumentError, "ttl must be non-negative" if ttl < 0
|
|
17
21
|
|
|
@@ -27,7 +31,7 @@ module SafeMemoize
|
|
|
27
31
|
max_size
|
|
28
32
|
end
|
|
29
33
|
|
|
30
|
-
raise ArgumentError, "
|
|
34
|
+
raise ArgumentError, "ttl_refresh: requires a ttl: to be set" if ttl_refresh && ttl.nil?
|
|
31
35
|
|
|
32
36
|
if cond_if && cond_unless
|
|
33
37
|
raise ArgumentError, "cannot specify both :if and :unless"
|
|
@@ -42,8 +46,6 @@ module SafeMemoize
|
|
|
42
46
|
->(result) { !cond_unless.call(result) }
|
|
43
47
|
end
|
|
44
48
|
|
|
45
|
-
expires_at = ttl && Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
|
|
46
|
-
|
|
47
49
|
if shared
|
|
48
50
|
klass = self
|
|
49
51
|
shared_mutex = klass.send(:__safe_memo_shared_mutex__)
|
|
@@ -61,7 +63,13 @@ module SafeMemoize
|
|
|
61
63
|
record_live = record && (record[:expires_at].nil? || record[:expires_at] > now)
|
|
62
64
|
|
|
63
65
|
if record_live
|
|
64
|
-
|
|
66
|
+
if max_size
|
|
67
|
+
lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
|
|
68
|
+
lru.delete(cache_key)
|
|
69
|
+
lru[cache_key] = true
|
|
70
|
+
end
|
|
71
|
+
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
72
|
+
record_cache_hit(method_name, args, kwargs)
|
|
65
73
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
66
74
|
record[:value]
|
|
67
75
|
else
|
|
@@ -71,10 +79,27 @@ module SafeMemoize
|
|
|
71
79
|
value = super(*args, **kwargs)
|
|
72
80
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
73
81
|
|
|
74
|
-
new_record = {value: value, expires_at:
|
|
75
|
-
shared_cache[cache_key] = new_record unless condition && !condition.call(value)
|
|
82
|
+
new_record = {value: value, expires_at: memo_expires_at(ttl)}
|
|
76
83
|
|
|
77
|
-
|
|
84
|
+
if !condition || condition.call(value)
|
|
85
|
+
if max_size
|
|
86
|
+
lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
|
|
87
|
+
lru.delete_if { |key, _| !shared_cache.key?(key) }
|
|
88
|
+
if lru.size >= max_size
|
|
89
|
+
lru_key = lru.keys.first
|
|
90
|
+
lru.delete(lru_key)
|
|
91
|
+
evicted = shared_cache.delete(lru_key)
|
|
92
|
+
call_memo_hooks(:on_evict, lru_key, evicted) if evicted
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
shared_cache[cache_key] = new_record
|
|
96
|
+
if max_size
|
|
97
|
+
lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
|
|
98
|
+
lru[cache_key] = true
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
record_cache_miss(method_name, args, kwargs, elapsed_time)
|
|
78
103
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
79
104
|
|
|
80
105
|
value
|
|
@@ -97,13 +122,14 @@ module SafeMemoize
|
|
|
97
122
|
|
|
98
123
|
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
99
124
|
|
|
100
|
-
if max_size || condition
|
|
101
|
-
# Locked path: used when LRU tracking
|
|
125
|
+
if max_size || condition || ttl_refresh
|
|
126
|
+
# Locked path: used when LRU tracking, conditional storage, or TTL refresh is needed.
|
|
102
127
|
memo_mutex!.synchronize do
|
|
103
128
|
record = memo_cache_record(cache_key)
|
|
104
129
|
if record
|
|
105
130
|
lru_touch(method_name, cache_key) if max_size
|
|
106
|
-
|
|
131
|
+
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
132
|
+
record_cache_hit(method_name, args, kwargs)
|
|
107
133
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
108
134
|
memo_record_value(record)
|
|
109
135
|
else
|
|
@@ -111,14 +137,14 @@ module SafeMemoize
|
|
|
111
137
|
value = super(*args, **kwargs)
|
|
112
138
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
113
139
|
|
|
114
|
-
new_record = memo_record(value, expires_at:
|
|
140
|
+
new_record = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
115
141
|
if !condition || condition.call(value)
|
|
116
142
|
lru_evict_if_over_limit(method_name, max_size) if max_size
|
|
117
143
|
@__safe_memo_cache__ ||= {}
|
|
118
144
|
@__safe_memo_cache__[cache_key] = new_record
|
|
119
145
|
lru_touch(method_name, cache_key) if max_size
|
|
120
146
|
end
|
|
121
|
-
record_cache_miss(method_name, args, elapsed_time)
|
|
147
|
+
record_cache_miss(method_name, args, kwargs, elapsed_time)
|
|
122
148
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
123
149
|
|
|
124
150
|
value
|
|
@@ -127,18 +153,18 @@ module SafeMemoize
|
|
|
127
153
|
else
|
|
128
154
|
# Fast path: check without lock
|
|
129
155
|
if (record = memo_cache_record(cache_key))
|
|
130
|
-
record_cache_hit(method_name, args)
|
|
156
|
+
record_cache_hit(method_name, args, kwargs)
|
|
131
157
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
132
158
|
return memo_record_value(record)
|
|
133
159
|
end
|
|
134
160
|
|
|
135
161
|
# Cache miss - compute and store
|
|
136
162
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
137
|
-
result = memo_fetch_or_store(cache_key,
|
|
163
|
+
result = memo_fetch_or_store(cache_key, ttl: ttl) { super(*args, **kwargs) }
|
|
138
164
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
139
165
|
|
|
140
166
|
with_memo_lock do
|
|
141
|
-
record_cache_miss(method_name, args, elapsed_time)
|
|
167
|
+
record_cache_miss(method_name, args, kwargs, elapsed_time)
|
|
142
168
|
new_record = memo_cache_record(cache_key)
|
|
143
169
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
144
170
|
end
|
|
@@ -155,21 +181,23 @@ module SafeMemoize
|
|
|
155
181
|
|
|
156
182
|
def reset_shared_memo(method_name, *args, **kwargs)
|
|
157
183
|
method_name = method_name.to_sym
|
|
158
|
-
|
|
159
|
-
->(key) { key[0] == method_name }
|
|
160
|
-
else
|
|
161
|
-
cache_key = [method_name, args, kwargs]
|
|
162
|
-
->(key) { key == cache_key }
|
|
163
|
-
end
|
|
184
|
+
specific_key = (args.empty? && kwargs.empty?) ? nil : [method_name, args, kwargs]
|
|
164
185
|
|
|
165
186
|
__safe_memo_shared_mutex__.synchronize do
|
|
166
|
-
|
|
187
|
+
if specific_key
|
|
188
|
+
__safe_memo_shared_cache__.delete(specific_key)
|
|
189
|
+
__safe_memo_shared_lru_order__[method_name]&.delete(specific_key)
|
|
190
|
+
else
|
|
191
|
+
__safe_memo_shared_cache__.delete_if { |key, _| key[0] == method_name }
|
|
192
|
+
__safe_memo_shared_lru_order__.delete(method_name)
|
|
193
|
+
end
|
|
167
194
|
end
|
|
168
195
|
end
|
|
169
196
|
|
|
170
197
|
def reset_all_shared_memos
|
|
171
198
|
__safe_memo_shared_mutex__.synchronize do
|
|
172
199
|
@__safe_memo_shared_cache__ = {}
|
|
200
|
+
@__safe_memo_shared_lru_order__ = {}
|
|
173
201
|
end
|
|
174
202
|
end
|
|
175
203
|
|
|
@@ -197,9 +225,14 @@ module SafeMemoize
|
|
|
197
225
|
end
|
|
198
226
|
end
|
|
199
227
|
|
|
200
|
-
def memoize_all(except: [], **options)
|
|
228
|
+
def memoize_all(except: [], include_protected: false, include_private: false, **options)
|
|
201
229
|
excluded = Array(except).map(&:to_sym)
|
|
202
|
-
|
|
230
|
+
|
|
231
|
+
methods = public_instance_methods(false)
|
|
232
|
+
methods |= protected_instance_methods(false) if include_protected
|
|
233
|
+
methods |= private_instance_methods(false) if include_private
|
|
234
|
+
|
|
235
|
+
methods.each do |method_name|
|
|
203
236
|
next if excluded.include?(method_name)
|
|
204
237
|
|
|
205
238
|
memoize(method_name, **options)
|
|
@@ -216,6 +249,10 @@ module SafeMemoize
|
|
|
216
249
|
@__safe_memo_shared_mutex__ ||= Mutex.new
|
|
217
250
|
end
|
|
218
251
|
|
|
252
|
+
def __safe_memo_shared_lru_order__
|
|
253
|
+
@__safe_memo_shared_lru_order__ ||= {}
|
|
254
|
+
end
|
|
255
|
+
|
|
219
256
|
def memoized_method_visibility(method_name)
|
|
220
257
|
return :private if private_method_defined?(method_name)
|
|
221
258
|
return :protected if protected_method_defined?(method_name)
|
|
@@ -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
|
|
@@ -38,6 +38,11 @@ module SafeMemoize
|
|
|
38
38
|
call_memo_hooks(:on_evict, lru_cache_key, record) if record
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Remove a single cache key from LRU tracking. Called when an entry expires.
|
|
42
|
+
def lru_remove(method_name, cache_key)
|
|
43
|
+
lru_order_store[method_name]&.delete(cache_key)
|
|
44
|
+
end
|
|
45
|
+
|
|
41
46
|
# Clear all LRU tracking state. Called by reset_all_memos.
|
|
42
47
|
def lru_clear_all
|
|
43
48
|
@__safe_memo_lru_order__ = {}
|
|
@@ -12,6 +12,21 @@ module SafeMemoize
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
def memo_ttl_remaining(method_name, *args, **kwargs)
|
|
16
|
+
cache_key = safe_memo_cache_key(method_name, args, kwargs)
|
|
17
|
+
|
|
18
|
+
with_memo_lock do
|
|
19
|
+
record = memo_cache_record(cache_key)
|
|
20
|
+
return 0 unless record
|
|
21
|
+
|
|
22
|
+
expires_at = record[:expires_at]
|
|
23
|
+
return nil unless expires_at
|
|
24
|
+
|
|
25
|
+
remaining = expires_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
|
+
(remaining > 0) ? remaining.round(6) : 0
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
15
30
|
def memo_count(*method_name)
|
|
16
31
|
scoped_method = safe_memo_scoped_method(method_name)
|
|
17
32
|
|
|
@@ -66,7 +81,7 @@ module SafeMemoize
|
|
|
66
81
|
end
|
|
67
82
|
end
|
|
68
83
|
|
|
69
|
-
def warm_memo(method_name, *args, **kwargs, &block)
|
|
84
|
+
def warm_memo(method_name, *args, ttl: nil, **kwargs, &block)
|
|
70
85
|
raise ArgumentError, "block required" unless block
|
|
71
86
|
|
|
72
87
|
method_name = method_name.to_sym
|
|
@@ -75,7 +90,7 @@ module SafeMemoize
|
|
|
75
90
|
|
|
76
91
|
with_memo_lock do
|
|
77
92
|
@__safe_memo_cache__ ||= {}
|
|
78
|
-
@__safe_memo_cache__[cache_key] = memo_record(value, expires_at:
|
|
93
|
+
@__safe_memo_cache__[cache_key] = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
79
94
|
end
|
|
80
95
|
|
|
81
96
|
value
|
|
@@ -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
|
@@ -11,12 +11,13 @@ module SafeMemoize
|
|
|
11
11
|
@__safe_memo_mutex__: Mutex?
|
|
12
12
|
@__safe_memo_shared_cache__: Hash[memo_key, memo_record]?
|
|
13
13
|
@__safe_memo_shared_mutex__: Mutex?
|
|
14
|
+
@__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
|
|
14
15
|
|
|
15
16
|
def self.prepended: (Class base) -> void
|
|
16
17
|
|
|
17
18
|
module ClassMethods
|
|
18
|
-
def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool) -> void
|
|
19
|
-
def memoize_all: (?except: Array[Symbol | String], ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?) -> void
|
|
19
|
+
def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool) -> void
|
|
20
|
+
def memoize_all: (?except: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?) -> void
|
|
20
21
|
def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
21
22
|
def reset_all_shared_memos: () -> void
|
|
22
23
|
def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
|
|
@@ -26,6 +27,7 @@ module SafeMemoize
|
|
|
26
27
|
|
|
27
28
|
def __safe_memo_shared_cache__: () -> Hash[memo_key, memo_record]
|
|
28
29
|
def __safe_memo_shared_mutex__: () -> Mutex
|
|
30
|
+
def __safe_memo_shared_lru_order__: () -> Hash[Symbol, Hash[memo_key, true]]
|
|
29
31
|
def memoized_method_visibility: (Symbol method_name) -> Symbol
|
|
30
32
|
end
|
|
31
33
|
|
|
@@ -33,6 +35,7 @@ module SafeMemoize
|
|
|
33
35
|
@__safe_memo_cache__: Hash[memo_key, memo_record]?
|
|
34
36
|
|
|
35
37
|
def memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) ?{ () -> untyped } -> bool
|
|
38
|
+
def memo_ttl_remaining: (Symbol | String method_name, *untyped args, **untyped kwargs) -> (Float | Integer | nil)
|
|
36
39
|
def memo_count: (*untyped method_name) -> Integer
|
|
37
40
|
def memo_keys: (*untyped method_name) -> Array[untyped]
|
|
38
41
|
def memo_values: (*untyped method_name) -> Array[untyped]
|
|
@@ -41,7 +44,7 @@ module SafeMemoize
|
|
|
41
44
|
def on_memo_hit: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
42
45
|
def on_memo_miss: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
43
46
|
def clear_memo_hooks: (Symbol? hook_type) -> void
|
|
44
|
-
def warm_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) { () -> untyped } -> untyped
|
|
47
|
+
def warm_memo: (Symbol | String method_name, *untyped args, ?ttl: Numeric?, **untyped kwargs) { () -> untyped } -> untyped
|
|
45
48
|
def dump_memo: (?Symbol | String method_name) -> Hash[memo_key, untyped]
|
|
46
49
|
def load_memo: (Hash[memo_key, untyped] snapshot) -> nil
|
|
47
50
|
def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
@@ -59,7 +62,7 @@ module SafeMemoize
|
|
|
59
62
|
def memo_cache_hit?: (memo_key cache_key) -> bool
|
|
60
63
|
def memo_cache_record: (memo_key cache_key) -> memo_record?
|
|
61
64
|
def memo_cache_read: (memo_key cache_key) -> untyped?
|
|
62
|
-
def memo_fetch_or_store: (memo_key cache_key) { () -> untyped } -> untyped
|
|
65
|
+
def memo_fetch_or_store: (memo_key cache_key, ?ttl: Float?) { () -> untyped } -> untyped
|
|
63
66
|
def memo_mutex!: () -> Mutex
|
|
64
67
|
def with_memo_cache: { (Hash[memo_key, memo_record] cache) -> untyped } -> untyped?
|
|
65
68
|
end
|
|
@@ -105,8 +108,8 @@ module SafeMemoize
|
|
|
105
108
|
private
|
|
106
109
|
|
|
107
110
|
def memo_metrics_store: () -> Hash[memo_key, { hits: Integer, misses: Integer, total_time: Float }]
|
|
108
|
-
def record_cache_hit: (Symbol method_name, Array[untyped] args) -> void
|
|
109
|
-
def record_cache_miss: (Symbol method_name, Array[untyped] args, Float computation_time) -> void
|
|
111
|
+
def record_cache_hit: (Symbol method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs) -> void
|
|
112
|
+
def record_cache_miss: (Symbol method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs, Float computation_time) -> void
|
|
110
113
|
def _reset_cache_metrics: () -> void
|
|
111
114
|
end
|
|
112
115
|
|
|
@@ -116,6 +119,11 @@ module SafeMemoize
|
|
|
116
119
|
def cache_hit_rate: () -> Float
|
|
117
120
|
def cache_miss_rate: () -> Float
|
|
118
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]
|
|
119
127
|
end
|
|
120
128
|
|
|
121
129
|
module CustomKeyMethods
|
|
@@ -142,6 +150,7 @@ module SafeMemoize
|
|
|
142
150
|
def lru_order_store: () -> Hash[Symbol, Hash[memo_key, true]]
|
|
143
151
|
def lru_touch: (Symbol method_name, memo_key cache_key) -> void
|
|
144
152
|
def lru_evict_if_over_limit: (Symbol method_name, Integer max_size) -> void
|
|
153
|
+
def lru_remove: (Symbol method_name, memo_key cache_key) -> void
|
|
145
154
|
def lru_clear_all: () -> void
|
|
146
155
|
end
|
|
147
156
|
|