safe_memoize 0.1.2 → 0.3.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 +33 -0
- data/README.md +191 -0
- data/lib/safe_memoize/cache_metrics_methods.rb +30 -0
- data/lib/safe_memoize/cache_record_methods.rb +52 -0
- data/lib/safe_memoize/cache_store_methods.rb +70 -0
- data/lib/safe_memoize/class_methods.rb +113 -0
- data/lib/safe_memoize/custom_key_methods.rb +39 -0
- data/lib/safe_memoize/hooks_methods.rb +33 -0
- data/lib/safe_memoize/inspection_methods.rb +68 -0
- data/lib/safe_memoize/instance_methods.rb +16 -0
- data/lib/safe_memoize/lru_methods.rb +46 -0
- data/lib/safe_memoize/public_custom_key_methods.rb +23 -0
- data/lib/safe_memoize/public_methods.rb +94 -0
- data/lib/safe_memoize/public_metrics_methods.rb +131 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +14 -195
- data/sig/safe_memoize.rbs +132 -33
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85a5a69a8dbeb570065995485ece70d230e18cbf8b39fa204f261174707d7525
|
|
4
|
+
data.tar.gz: f83e4d9b0efb5552e6bf81d2924d0098caddef6748a734c382d0bae10b27ad06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a0b5d2017ddd2061ced9215747b1f38ebbaa408f5f7196e5db4c0fd9a4fb5b25c33cdd7766c001dad6596b07f93d3f6c9211bdcdccc4fc9643d1f824b849d51
|
|
7
|
+
data.tar.gz: '09df76d2d919373d9c7a31c77698378454bb996c39554296105dfd3ffc47d7e907ede4cfd6259d3e43ffbf9dda30296564f480b004e1b3676293d7a0f9fd2fd4'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-05-15
|
|
4
|
+
|
|
5
|
+
- Add `on_memo_hit` hook that fires on every cache hit, completing the lifecycle API alongside `on_memo_expire` and `on_memo_evict`
|
|
6
|
+
- Add conditional memoization via `if:` and `unless:` options on `memoize`
|
|
7
|
+
- `if: ->(result) { ... }` — only caches when the lambda returns truthy
|
|
8
|
+
- `unless: ->(result) { ... }` — skips caching when the lambda returns truthy
|
|
9
|
+
- Uncached calls recompute on every invocation until the condition is met
|
|
10
|
+
- Compatible with `ttl:`, `max_size:`, hooks, and all inspection APIs
|
|
11
|
+
- Add LRU cache size limit via `max_size:` option on `memoize`
|
|
12
|
+
- Evicts the least-recently-used entry per method when the limit is reached
|
|
13
|
+
- Cache hits promote entries to most-recently-used, preventing premature eviction
|
|
14
|
+
- Fires the existing `on_evict` hook for LRU-evicted entries
|
|
15
|
+
- Self-healing: stale LRU references left by `reset_memo` are pruned automatically
|
|
16
|
+
- Compatible with `ttl:` option and all existing inspection/reset APIs
|
|
17
|
+
- Thread-safe under concurrent access
|
|
18
|
+
|
|
19
|
+
## [0.2.0] - 2026-05-14
|
|
20
|
+
|
|
21
|
+
- Add optional TTL expiration support for memoized entries
|
|
22
|
+
- Add cache invalidation/expiration hooks for custom handlers
|
|
23
|
+
- `on_memo_expire` hook fires when TTL entries expire
|
|
24
|
+
- `on_memo_evict` hook fires when manually resetting cache entries
|
|
25
|
+
- `clear_memo_hooks` to remove registered hooks
|
|
26
|
+
- Add cache statistics and monitoring capabilities
|
|
27
|
+
- `cache_stats` for comprehensive cache metrics
|
|
28
|
+
- `cache_stats_for(method_name)` for per-method statistics
|
|
29
|
+
- `cache_hit_rate` and `cache_miss_rate` for performance analysis
|
|
30
|
+
- `cache_metrics_reset` to clear collected metrics
|
|
31
|
+
- Add manual cache key generation support
|
|
32
|
+
- `memoize_with_custom_key` to define custom cache key logic
|
|
33
|
+
- `clear_custom_keys` to remove custom key generators
|
|
34
|
+
- Support for complex and computed keys based on arguments
|
|
35
|
+
|
|
3
36
|
## [0.1.2] - 2026-05-13
|
|
4
37
|
|
|
5
38
|
- Preserve public, protected, and private visibility for memoized methods
|
data/README.md
CHANGED
|
@@ -27,6 +27,12 @@ SafeMemoize uses `Hash#key?` to distinguish "not yet cached" from "cached nil/fa
|
|
|
27
27
|
- Includes a `memo_count` helper for cache size stats
|
|
28
28
|
- Includes a `memo_keys` helper for inspecting cached signatures
|
|
29
29
|
- Includes a `memo_values` helper for inspecting cached signatures and values
|
|
30
|
+
- Optional TTL expiration support for cached entries
|
|
31
|
+
- Optional LRU cache size limit per method via `max_size:`
|
|
32
|
+
- Conditional caching via `if:` and `unless:` predicates
|
|
33
|
+
- Lifecycle hooks for hit, eviction, and expiration events
|
|
34
|
+
- Per-instance cache metrics (hit rate, miss rate, computation time)
|
|
35
|
+
- Custom cache key generation per method
|
|
30
36
|
- Block arguments bypass cache (blocks aren't comparable)
|
|
31
37
|
|
|
32
38
|
## Installation
|
|
@@ -129,6 +135,164 @@ obj.reset_memo(:search, "ruby", page: 2) # Clears one positional/keyword c
|
|
|
129
135
|
obj.reset_all_memos # Clears all memoized values
|
|
130
136
|
```
|
|
131
137
|
|
|
138
|
+
### Lifecycle hooks
|
|
139
|
+
|
|
140
|
+
Register callbacks that fire when cached entries are evicted or expire.
|
|
141
|
+
|
|
142
|
+
**`on_memo_evict`** fires when an entry is removed via `reset_memo`, `reset_all_memos`, or LRU eviction:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
obj.on_memo_evict do |cache_key, record|
|
|
146
|
+
Rails.logger.info("Evicted #{cache_key[0]}(#{cache_key[1].join(", ")}), was: #{record[:value].inspect}")
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**`on_memo_hit`** fires on every cache hit:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
obj.on_memo_hit do |cache_key, record|
|
|
154
|
+
StatsD.increment("cache.hit", tags: ["method:#{cache_key[0]}"])
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**`on_memo_expire`** fires when a TTL entry is detected as expired (on the next call or during inspection):
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
obj.on_memo_expire do |cache_key, record|
|
|
162
|
+
Rails.logger.debug("TTL expired: #{cache_key[0]}")
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Multiple hooks of the same type can be registered and all will fire. Remove them with `clear_memo_hooks`:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
obj.clear_memo_hooks(:on_evict) # Clears evict hooks only
|
|
170
|
+
obj.clear_memo_hooks(:on_expire) # Clears expire hooks only
|
|
171
|
+
obj.clear_memo_hooks # Clears all hooks
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Hooks are per-instance and do not affect other objects of the same class.
|
|
175
|
+
|
|
176
|
+
### TTL expiration
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
class QuoteService
|
|
180
|
+
prepend SafeMemoize
|
|
181
|
+
|
|
182
|
+
def current_quote
|
|
183
|
+
fetch_quote_from_api
|
|
184
|
+
end
|
|
185
|
+
memoize :current_quote, ttl: 60
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
With a TTL, cached values expire automatically after the given number of seconds. The next call recomputes and refreshes the cache.
|
|
190
|
+
|
|
191
|
+
### LRU cache size limit
|
|
192
|
+
|
|
193
|
+
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:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
class ProductService
|
|
197
|
+
prepend SafeMemoize
|
|
198
|
+
|
|
199
|
+
def find(id)
|
|
200
|
+
Product.find(id)
|
|
201
|
+
end
|
|
202
|
+
memoize :find, max_size: 100
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Cache hits count as recent access, so a frequently-read entry will never be the one evicted:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
svc = ProductService.new
|
|
210
|
+
svc.find(1) # miss — cached
|
|
211
|
+
svc.find(2) # miss — cached
|
|
212
|
+
svc.find(1) # hit — promotes 1 to most-recently-used; 2 is now LRU
|
|
213
|
+
svc.find(3) # miss — evicts 2 (LRU), caches 3
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`max_size:` combines with `ttl:` — LRU eviction applies within the TTL window, and entries also expire normally when the TTL elapses:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
memoize :find, max_size: 50, ttl: 300
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The `on_evict` hook fires for LRU-evicted entries the same way it does for manual `reset_memo` calls.
|
|
223
|
+
|
|
224
|
+
### Conditional caching
|
|
225
|
+
|
|
226
|
+
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.
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
class UserService
|
|
230
|
+
prepend SafeMemoize
|
|
231
|
+
|
|
232
|
+
# Don't cache nil — retries on every call until a user is found
|
|
233
|
+
def find(id)
|
|
234
|
+
User.find_by(id: id)
|
|
235
|
+
end
|
|
236
|
+
memoize :find, if: ->(result) { !result.nil? }
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
class DataService
|
|
242
|
+
prepend SafeMemoize
|
|
243
|
+
|
|
244
|
+
# Don't cache error responses
|
|
245
|
+
def fetch(key)
|
|
246
|
+
api_client.get(key)
|
|
247
|
+
end
|
|
248
|
+
memoize :fetch, unless: ->(result) { result.is_a?(ErrorResponse) }
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Both options accept any callable and compose with `ttl:` and `max_size:`:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
memoize :find, if: ->(result) { !result.nil? }, ttl: 60, max_size: 500
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Custom cache keys
|
|
259
|
+
|
|
260
|
+
By default the cache key is derived from the method name and all arguments. Use `memoize_with_custom_key` on an instance to control exactly what makes two calls equivalent:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
class ReportService
|
|
264
|
+
prepend SafeMemoize
|
|
265
|
+
|
|
266
|
+
def generate(user_id, options)
|
|
267
|
+
build_report(user_id, options)
|
|
268
|
+
end
|
|
269
|
+
memoize :generate
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
svc = ReportService.new
|
|
273
|
+
|
|
274
|
+
# Cache only by user_id — ignore the options hash entirely
|
|
275
|
+
svc.memoize_with_custom_key(:generate) { |user_id, _options| user_id }
|
|
276
|
+
|
|
277
|
+
svc.generate(42, {format: :pdf}) # computes and caches
|
|
278
|
+
svc.generate(42, {format: :csv}) # cache hit — same user_id, options ignored
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The block can return any comparable value — a scalar, array, or hash:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
svc.memoize_with_custom_key(:generate) do |user_id, options|
|
|
285
|
+
{user: user_id, locale: options[:locale]}
|
|
286
|
+
end
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Custom key generators are per-instance and can be cleared at any time:
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
svc.clear_custom_keys(:generate) # Remove generator for one method
|
|
293
|
+
svc.clear_custom_keys # Remove all custom key generators
|
|
294
|
+
```
|
|
295
|
+
|
|
132
296
|
### Cache inspection
|
|
133
297
|
|
|
134
298
|
```ruby
|
|
@@ -147,6 +311,33 @@ obj.memo_values # Cached signatures and values for all
|
|
|
147
311
|
obj.memo_values(:search) # Cached signatures and values for one method
|
|
148
312
|
```
|
|
149
313
|
|
|
314
|
+
### Cache metrics
|
|
315
|
+
|
|
316
|
+
Each instance tracks hits, misses, and computation time automatically.
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
obj.cache_stats
|
|
320
|
+
# => {
|
|
321
|
+
# total_hits: 42,
|
|
322
|
+
# total_misses: 8,
|
|
323
|
+
# hit_rate: 84.0,
|
|
324
|
+
# miss_rate: 16.0,
|
|
325
|
+
# average_computation_time: 0.012345,
|
|
326
|
+
# entries: [
|
|
327
|
+
# { method: :find, args: [1], hits: 10, misses: 1,
|
|
328
|
+
# hit_rate: 90.91, computation_time: 0.005 },
|
|
329
|
+
# ...
|
|
330
|
+
# ]
|
|
331
|
+
# }
|
|
332
|
+
|
|
333
|
+
obj.cache_stats_for(:find) # Stats scoped to one method
|
|
334
|
+
obj.cache_hit_rate # => 84.0 (percentage)
|
|
335
|
+
obj.cache_miss_rate # => 16.0 (percentage)
|
|
336
|
+
obj.cache_metrics_reset # Clears all collected metrics
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Metrics are per-instance and reset independently from the cache itself — clearing metrics does not evict cached values.
|
|
340
|
+
|
|
150
341
|
## How It Works
|
|
151
342
|
|
|
152
343
|
SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name`, it creates an anonymous module with a wrapper method and prepends it onto your class. The wrapper calls `super` to invoke the original method and stores the result in a per-instance hash. Thread safety is achieved with a per-instance `Mutex` using double-check locking.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module CacheMetricsMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def memo_metrics_store
|
|
8
|
+
@__safe_memo_metrics__ ||= {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def record_cache_hit(method_name, args)
|
|
12
|
+
cache_key = safe_memo_cache_key(method_name, args, {})
|
|
13
|
+
metrics = memo_metrics_store
|
|
14
|
+
metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
|
|
15
|
+
metrics[cache_key][:hits] += 1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record_cache_miss(method_name, args, computation_time)
|
|
19
|
+
cache_key = safe_memo_cache_key(method_name, args, {})
|
|
20
|
+
metrics = memo_metrics_store
|
|
21
|
+
metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
|
|
22
|
+
metrics[cache_key][:misses] += 1
|
|
23
|
+
metrics[cache_key][:total_time] += computation_time
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def _reset_cache_metrics
|
|
27
|
+
@__safe_memo_metrics__ = {}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module CacheRecordMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def memo_ttl(ttl)
|
|
8
|
+
return nil if ttl.nil?
|
|
9
|
+
|
|
10
|
+
ttl = Float(ttl)
|
|
11
|
+
raise ArgumentError, "ttl must be non-negative" if ttl < 0
|
|
12
|
+
|
|
13
|
+
ttl
|
|
14
|
+
rescue ArgumentError, TypeError
|
|
15
|
+
raise ArgumentError, "ttl must be a non-negative number"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def memo_expires_at(ttl)
|
|
19
|
+
return nil unless ttl
|
|
20
|
+
|
|
21
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def memo_record(value, expires_at:)
|
|
25
|
+
{value: value, expires_at: expires_at}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def memo_record_value(record)
|
|
29
|
+
record[:value]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def memo_record_live?(record)
|
|
33
|
+
return false unless record
|
|
34
|
+
|
|
35
|
+
expires_at = record[:expires_at]
|
|
36
|
+
return true unless expires_at
|
|
37
|
+
|
|
38
|
+
expires_at > Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def memo_prune_expired_entries!(cache)
|
|
42
|
+
cache.delete_if do |cache_key, record|
|
|
43
|
+
if !memo_record_live?(record)
|
|
44
|
+
call_memo_hooks(:on_expire, cache_key, record)
|
|
45
|
+
true
|
|
46
|
+
else
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module CacheStoreMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def with_memo_lock
|
|
8
|
+
if defined?(@__safe_memo_mutex__) && @__safe_memo_mutex__
|
|
9
|
+
@__safe_memo_mutex__.synchronize { yield }
|
|
10
|
+
else
|
|
11
|
+
yield
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def memo_cache_or_nil
|
|
16
|
+
return nil unless defined?(@__safe_memo_cache__)
|
|
17
|
+
|
|
18
|
+
@__safe_memo_cache__
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def memo_cache_hit?(cache_key)
|
|
22
|
+
!!memo_cache_record(cache_key)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def memo_cache_record(cache_key)
|
|
26
|
+
cache = memo_cache_or_nil
|
|
27
|
+
return nil unless cache
|
|
28
|
+
|
|
29
|
+
record = cache[cache_key]
|
|
30
|
+
return nil unless memo_record_live?(record)
|
|
31
|
+
|
|
32
|
+
record
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def memo_cache_read(cache_key)
|
|
36
|
+
record = memo_cache_record(cache_key)
|
|
37
|
+
return nil unless record
|
|
38
|
+
|
|
39
|
+
memo_record_value(record)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def memo_fetch_or_store(cache_key, expires_at: nil)
|
|
43
|
+
memo_mutex!.synchronize do
|
|
44
|
+
@__safe_memo_cache__ ||= {}
|
|
45
|
+
|
|
46
|
+
record = @__safe_memo_cache__[cache_key]
|
|
47
|
+
|
|
48
|
+
if memo_record_live?(record)
|
|
49
|
+
memo_record_value(record)
|
|
50
|
+
else
|
|
51
|
+
value = yield
|
|
52
|
+
@__safe_memo_cache__[cache_key] = memo_record(value, expires_at: expires_at)
|
|
53
|
+
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def memo_mutex!
|
|
60
|
+
@__safe_memo_mutex__ ||= Mutex.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def with_memo_cache
|
|
64
|
+
cache = memo_cache_or_nil
|
|
65
|
+
return nil unless cache
|
|
66
|
+
|
|
67
|
+
yield cache
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module ClassMethods
|
|
5
|
+
def memoize(method_name, ttl: nil, max_size: nil, if: nil, unless: nil)
|
|
6
|
+
method_name = method_name.to_sym
|
|
7
|
+
visibility = memoized_method_visibility(method_name)
|
|
8
|
+
|
|
9
|
+
cond_if = binding.local_variable_get(:if)
|
|
10
|
+
cond_unless = binding.local_variable_get(:unless)
|
|
11
|
+
|
|
12
|
+
ttl = if ttl.nil?
|
|
13
|
+
nil
|
|
14
|
+
else
|
|
15
|
+
ttl = Float(ttl)
|
|
16
|
+
raise ArgumentError, "ttl must be non-negative" if ttl < 0
|
|
17
|
+
|
|
18
|
+
ttl
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
max_size = if max_size.nil?
|
|
22
|
+
nil
|
|
23
|
+
else
|
|
24
|
+
raise ArgumentError, "max_size must be a positive integer" unless max_size.is_a?(Integer)
|
|
25
|
+
raise ArgumentError, "max_size must be positive" unless max_size > 0
|
|
26
|
+
|
|
27
|
+
max_size
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if cond_if && cond_unless
|
|
31
|
+
raise ArgumentError, "cannot specify both :if and :unless"
|
|
32
|
+
end
|
|
33
|
+
raise ArgumentError, ":if must be callable" if cond_if && !cond_if.respond_to?(:call)
|
|
34
|
+
raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call)
|
|
35
|
+
|
|
36
|
+
# Normalize to a single "should cache?" predicate
|
|
37
|
+
condition = if cond_if
|
|
38
|
+
cond_if
|
|
39
|
+
elsif cond_unless
|
|
40
|
+
->(result) { !cond_unless.call(result) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
expires_at = ttl && Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
|
|
44
|
+
|
|
45
|
+
mod = Module.new do
|
|
46
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
47
|
+
# Blocks bypass cache entirely — they aren't comparable
|
|
48
|
+
return super(*args, **kwargs, &block) if block
|
|
49
|
+
|
|
50
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
51
|
+
|
|
52
|
+
if max_size || condition
|
|
53
|
+
# Locked path: used when LRU tracking or conditional storage is needed.
|
|
54
|
+
memo_mutex!.synchronize do
|
|
55
|
+
record = memo_cache_record(cache_key)
|
|
56
|
+
if record
|
|
57
|
+
lru_touch(method_name, cache_key) if max_size
|
|
58
|
+
record_cache_hit(method_name, args)
|
|
59
|
+
call_memo_hooks(:on_hit, cache_key, record)
|
|
60
|
+
memo_record_value(record)
|
|
61
|
+
else
|
|
62
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
63
|
+
value = super(*args, **kwargs)
|
|
64
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
65
|
+
|
|
66
|
+
if !condition || condition.call(value)
|
|
67
|
+
lru_evict_if_over_limit(method_name, max_size) if max_size
|
|
68
|
+
@__safe_memo_cache__ ||= {}
|
|
69
|
+
@__safe_memo_cache__[cache_key] = memo_record(value, expires_at: expires_at)
|
|
70
|
+
lru_touch(method_name, cache_key) if max_size
|
|
71
|
+
end
|
|
72
|
+
record_cache_miss(method_name, args, elapsed_time)
|
|
73
|
+
|
|
74
|
+
value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
else
|
|
78
|
+
# Fast path: check without lock
|
|
79
|
+
if (record = memo_cache_record(cache_key))
|
|
80
|
+
record_cache_hit(method_name, args)
|
|
81
|
+
call_memo_hooks(:on_hit, cache_key, record)
|
|
82
|
+
return memo_record_value(record)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Cache miss - compute and store
|
|
86
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
87
|
+
result = memo_fetch_or_store(cache_key, expires_at: expires_at) { super(*args, **kwargs) }
|
|
88
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
89
|
+
|
|
90
|
+
with_memo_lock do
|
|
91
|
+
record_cache_miss(method_name, args, elapsed_time)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
result
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
send(visibility, method_name)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
prepend mod
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def memoized_method_visibility(method_name)
|
|
107
|
+
return :private if private_method_defined?(method_name)
|
|
108
|
+
return :protected if protected_method_defined?(method_name)
|
|
109
|
+
|
|
110
|
+
:public
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module CustomKeyMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def custom_key_store
|
|
8
|
+
@__safe_memo_custom_keys__ ||= {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register_custom_key(method_name, &block)
|
|
12
|
+
raise ArgumentError, "block required" unless block
|
|
13
|
+
|
|
14
|
+
method_name = method_name.to_sym
|
|
15
|
+
custom_key_store[method_name] = block
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def compute_cache_key(method_name, args, kwargs)
|
|
19
|
+
method_name = method_name.to_sym
|
|
20
|
+
|
|
21
|
+
# Check if a custom key generator is registered
|
|
22
|
+
custom_key_block = custom_key_store[method_name]
|
|
23
|
+
|
|
24
|
+
if custom_key_block
|
|
25
|
+
# Call the custom key generator with args and kwargs
|
|
26
|
+
custom_key = custom_key_block.call(*args, **kwargs)
|
|
27
|
+
# Wrap in a standard format: [method, custom_key]
|
|
28
|
+
[method_name, custom_key]
|
|
29
|
+
else
|
|
30
|
+
# Use default key generation
|
|
31
|
+
safe_memo_cache_key(method_name, args, kwargs)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def _clear_custom_keys
|
|
36
|
+
@__safe_memo_custom_keys__ = {}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module HooksMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def memo_hook_store
|
|
8
|
+
@__safe_memo_hooks__ ||= {on_expire: [], on_evict: [], on_hit: []}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register_memo_hook(hook_type, &block)
|
|
12
|
+
raise ArgumentError, "block required" unless block
|
|
13
|
+
|
|
14
|
+
valid_hooks = [:on_expire, :on_evict, :on_hit]
|
|
15
|
+
raise ArgumentError, "invalid hook type: #{hook_type}" unless valid_hooks.include?(hook_type)
|
|
16
|
+
|
|
17
|
+
memo_hook_store[hook_type] << block
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call_memo_hooks(hook_type, cache_key, record)
|
|
21
|
+
hooks = memo_hook_store[hook_type] || []
|
|
22
|
+
hooks.each { |hook| hook.call(cache_key, record) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def _clear_memo_hooks(hook_type = nil)
|
|
26
|
+
if hook_type
|
|
27
|
+
memo_hook_store[hook_type] = []
|
|
28
|
+
else
|
|
29
|
+
@__safe_memo_hooks__ = {on_expire: [], on_evict: [], on_hit: []}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module InspectionMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def safe_memo_scoped_method(method_name)
|
|
8
|
+
raise ArgumentError, "expected 0 or 1 arguments" if method_name.length > 1
|
|
9
|
+
|
|
10
|
+
method_name.first&.to_sym
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def memo_matcher_for(method_name, args, kwargs)
|
|
14
|
+
if args.empty? && kwargs.empty?
|
|
15
|
+
->(key) { key[0] == method_name }
|
|
16
|
+
else
|
|
17
|
+
cache_key = safe_memo_cache_key(method_name, args, kwargs)
|
|
18
|
+
->(key) { key == cache_key }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def memo_entries_for(method_name)
|
|
23
|
+
cache = memo_cache_or_nil
|
|
24
|
+
return [] unless cache
|
|
25
|
+
|
|
26
|
+
memo_prune_expired_entries!(cache)
|
|
27
|
+
entries = cache.to_a
|
|
28
|
+
return entries unless method_name
|
|
29
|
+
|
|
30
|
+
entries.select { |(cache_key, _)| cache_key[0] == method_name }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def safe_memo_count_for(method_name)
|
|
34
|
+
memo_entries_for(method_name).length
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def safe_memo_keys_for(method_name)
|
|
38
|
+
entries = memo_entries_for(method_name)
|
|
39
|
+
include_method = method_name.nil?
|
|
40
|
+
|
|
41
|
+
entries.map do |(cache_key, value)|
|
|
42
|
+
memo_projection(cache_key, value, include_method: include_method, include_value: false)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def safe_memo_values_for(method_name)
|
|
47
|
+
entries = memo_entries_for(method_name)
|
|
48
|
+
include_method = method_name.nil?
|
|
49
|
+
|
|
50
|
+
entries.map do |(cache_key, value)|
|
|
51
|
+
memo_projection(cache_key, value, include_method: include_method, include_value: true)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def memo_projection(cache_key, value, include_method:, include_value:)
|
|
56
|
+
method_name, args, kwargs = cache_key
|
|
57
|
+
|
|
58
|
+
payload = {args: args, kwargs: kwargs}
|
|
59
|
+
payload[:method] = method_name if include_method
|
|
60
|
+
payload[:value] = memo_record_value(value) if include_value
|
|
61
|
+
payload
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def safe_memo_cache_key(method_name, args, kwargs)
|
|
65
|
+
[method_name.to_sym, args, kwargs]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module InstanceMethods
|
|
5
|
+
include PublicMethods
|
|
6
|
+
include CacheStoreMethods
|
|
7
|
+
include CacheRecordMethods
|
|
8
|
+
include InspectionMethods
|
|
9
|
+
include HooksMethods
|
|
10
|
+
include CacheMetricsMethods
|
|
11
|
+
include PublicMetricsMethods
|
|
12
|
+
include CustomKeyMethods
|
|
13
|
+
include PublicCustomKeyMethods
|
|
14
|
+
include LruMethods
|
|
15
|
+
end
|
|
16
|
+
end
|