safe_memoize 0.2.0 → 0.4.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 +35 -0
- data/README.md +326 -13
- data/lib/safe_memoize/class_methods.rb +180 -14
- data/lib/safe_memoize/hooks_methods.rb +3 -3
- data/lib/safe_memoize/instance_methods.rb +1 -0
- data/lib/safe_memoize/lru_methods.rb +46 -0
- data/lib/safe_memoize/public_methods.rb +52 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +1 -0
- data/sig/safe_memoize.rbs +31 -3
- metadata +12 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb68ddebb035ad2262b063ca010d757692802d7c4ab42a8c99beffabbcc01eb2
|
|
4
|
+
data.tar.gz: 7d154bb103d0659956959ab7e6943e6959f01738d8491e5bbe2a911ef07e43e6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca92fa2b915bd9a2b921143c1ce3ee1044509456688a57fc225319ee99ab4cd01c2d5ae9036f810d2a9df518127c55c114e72987fb604458ec051967b30bd367
|
|
7
|
+
data.tar.gz: d5e6296c817536ef739b05cbc9b550c8e429450d8558bf65307188cfc2f778246f9d7318ffd39e7525278fa584b069a6143c3f6b1ed349ab4bc7d34e5ba67507
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-05-17
|
|
4
|
+
|
|
5
|
+
- Add `warm_memo`, `dump_memo`, and `load_memo` for cache warm-up and persistence
|
|
6
|
+
- `warm_memo(:method, *args, **kwargs) { value }` — pre-populates a cache entry via block without calling the method
|
|
7
|
+
- `dump_memo` / `dump_memo(:method)` — exports live cached entries as a plain `{[method, args, kwargs] => value}` hash
|
|
8
|
+
- `load_memo(snapshot)` — merges a snapshot into the cache; loaded entries have no TTL
|
|
9
|
+
- Expired entries are excluded from `dump_memo` output
|
|
10
|
+
- Add `shared: true` option on `memoize` to store results on the class instead of per-instance
|
|
11
|
+
- All instances share one cache; the method is computed only once regardless of how many objects exist
|
|
12
|
+
- Class-level invalidation: `reset_shared_memo`, `reset_all_shared_memos`
|
|
13
|
+
- Class-level inspection: `shared_memoized?`, `shared_memo_count`
|
|
14
|
+
- Supports `ttl:`, `if:`, and `unless:` options
|
|
15
|
+
- Instance hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance
|
|
16
|
+
- Add `memoize_all` to memoize every public method defined on the class in one call
|
|
17
|
+
- Accepts all options supported by `memoize` (`ttl:`, `max_size:`, `if:`, `unless:`)
|
|
18
|
+
- `except:` option to skip specific methods by name
|
|
19
|
+
- Only affects public methods defined directly on the class
|
|
20
|
+
- Add `on_memo_miss` hook that fires on every cache miss, completing the full lifecycle hook set alongside `on_memo_hit`, `on_memo_evict`, and `on_memo_expire`
|
|
21
|
+
|
|
22
|
+
## [0.3.0] - 2026-05-15
|
|
23
|
+
|
|
24
|
+
- Add `on_memo_hit` hook that fires on every cache hit, completing the lifecycle API alongside `on_memo_expire` and `on_memo_evict`
|
|
25
|
+
- Add conditional memoization via `if:` and `unless:` options on `memoize`
|
|
26
|
+
- `if: ->(result) { ... }` — only caches when the lambda returns truthy
|
|
27
|
+
- `unless: ->(result) { ... }` — skips caching when the lambda returns truthy
|
|
28
|
+
- Uncached calls recompute on every invocation until the condition is met
|
|
29
|
+
- Compatible with `ttl:`, `max_size:`, hooks, and all inspection APIs
|
|
30
|
+
- Add LRU cache size limit via `max_size:` option on `memoize`
|
|
31
|
+
- Evicts the least-recently-used entry per method when the limit is reached
|
|
32
|
+
- Cache hits promote entries to most-recently-used, preventing premature eviction
|
|
33
|
+
- Fires the existing `on_evict` hook for LRU-evicted entries
|
|
34
|
+
- Self-healing: stale LRU references left by `reset_memo` are pruned automatically
|
|
35
|
+
- Compatible with `ttl:` option and all existing inspection/reset APIs
|
|
36
|
+
- Thread-safe under concurrent access
|
|
37
|
+
|
|
3
38
|
## [0.2.0] - 2026-05-14
|
|
4
39
|
|
|
5
40
|
- Add optional TTL expiration support for memoized entries
|
data/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Thread-safe memoization for Ruby that correctly handles `nil` and `false` values.
|
|
4
4
|
|
|
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
|
+
|
|
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.
|
|
8
|
+
|
|
5
9
|
## The Problem
|
|
6
10
|
|
|
7
11
|
Ruby's common memoization pattern breaks with falsy values:
|
|
@@ -16,19 +20,25 @@ SafeMemoize uses `Hash#key?` to distinguish "not yet cached" from "cached nil/fa
|
|
|
16
20
|
|
|
17
21
|
## Features
|
|
18
22
|
|
|
19
|
-
- Correctly memoizes `nil` and `false` return values
|
|
20
|
-
- Caches per unique arguments (positional and keyword)
|
|
21
|
-
- Thread-safe via double-check locking
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
- Includes a `
|
|
27
|
-
- Includes a `
|
|
28
|
-
- Includes a `
|
|
29
|
-
-
|
|
30
|
-
- Optional
|
|
31
|
-
-
|
|
23
|
+
- [Correctly memoizes `nil` and `false` return values](#nil-and-false-safety)
|
|
24
|
+
- [Caches per unique arguments (positional and keyword)](#with-arguments)
|
|
25
|
+
- [Thread-safe via double-check locking](#how-it-works)
|
|
26
|
+
- [Simple `prepend` + `memoize` API](#usage)
|
|
27
|
+
- [Preserves public, protected, and private method visibility](#works-with-private-methods)
|
|
28
|
+
- [Supports targeted cache invalidation by argument combination](#cache-reset)
|
|
29
|
+
- [Includes a `memoized?` helper for cache inspection](#cache-inspection)
|
|
30
|
+
- [Includes a `memo_count` helper for cache size stats](#cache-inspection)
|
|
31
|
+
- [Includes a `memo_keys` helper for inspecting cached signatures](#cache-inspection)
|
|
32
|
+
- [Includes a `memo_values` helper for inspecting cached signatures and values](#cache-inspection)
|
|
33
|
+
- [Optional TTL expiration support for cached entries](#ttl-expiration)
|
|
34
|
+
- [Optional LRU cache size limit per method via `max_size:`](#lru-cache-size-limit)
|
|
35
|
+
- [Conditional caching via `if:` and `unless:` predicates](#conditional-caching)
|
|
36
|
+
- [Lifecycle hooks for hit, miss, eviction, and expiration events](#lifecycle-hooks)
|
|
37
|
+
- [Per-instance cache metrics (hit rate, miss rate, computation time)](#cache-metrics)
|
|
38
|
+
- [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)
|
|
41
|
+
- [Custom cache key generation per method](#custom-cache-keys)
|
|
32
42
|
|
|
33
43
|
## Installation
|
|
34
44
|
|
|
@@ -130,6 +140,54 @@ obj.reset_memo(:search, "ruby", page: 2) # Clears one positional/keyword c
|
|
|
130
140
|
obj.reset_all_memos # Clears all memoized values
|
|
131
141
|
```
|
|
132
142
|
|
|
143
|
+
### Lifecycle hooks
|
|
144
|
+
|
|
145
|
+
Register callbacks that fire when cached entries are evicted or expire.
|
|
146
|
+
|
|
147
|
+
**`on_memo_evict`** fires when an entry is removed via `reset_memo`, `reset_all_memos`, or LRU eviction:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
obj.on_memo_evict do |cache_key, record|
|
|
151
|
+
Rails.logger.info("Evicted #{cache_key[0]}(#{cache_key[1].join(", ")}), was: #{record[:value].inspect}")
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**`on_memo_miss`** fires on every cache miss (i.e. the first call or after invalidation):
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
obj.on_memo_miss do |cache_key, record|
|
|
159
|
+
Rails.logger.debug("Cache miss: #{cache_key[0]}(#{cache_key[1].join(", ")})")
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**`on_memo_hit`** fires on every cache hit:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
obj.on_memo_hit do |cache_key, record|
|
|
167
|
+
StatsD.increment("cache.hit", tags: ["method:#{cache_key[0]}"])
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**`on_memo_expire`** fires when a TTL entry is detected as expired (on the next call or during inspection):
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
obj.on_memo_expire do |cache_key, record|
|
|
175
|
+
Rails.logger.debug("TTL expired: #{cache_key[0]}")
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Multiple hooks of the same type can be registered and all will fire. Remove them with `clear_memo_hooks`:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
obj.clear_memo_hooks(:on_miss) # Clears miss hooks only
|
|
183
|
+
obj.clear_memo_hooks(:on_hit) # Clears hit hooks only
|
|
184
|
+
obj.clear_memo_hooks(:on_evict) # Clears evict hooks only
|
|
185
|
+
obj.clear_memo_hooks(:on_expire) # Clears expire hooks only
|
|
186
|
+
obj.clear_memo_hooks # Clears all hooks
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Hooks are per-instance and do not affect other objects of the same class.
|
|
190
|
+
|
|
133
191
|
### TTL expiration
|
|
134
192
|
|
|
135
193
|
```ruby
|
|
@@ -145,6 +203,234 @@ end
|
|
|
145
203
|
|
|
146
204
|
With a TTL, cached values expire automatically after the given number of seconds. The next call recomputes and refreshes the cache.
|
|
147
205
|
|
|
206
|
+
### LRU cache size limit
|
|
207
|
+
|
|
208
|
+
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:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
class ProductService
|
|
212
|
+
prepend SafeMemoize
|
|
213
|
+
|
|
214
|
+
def find(id)
|
|
215
|
+
Product.find(id)
|
|
216
|
+
end
|
|
217
|
+
memoize :find, max_size: 100
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Cache hits count as recent access, so a frequently-read entry will never be the one evicted:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
svc = ProductService.new
|
|
225
|
+
svc.find(1) # miss — cached
|
|
226
|
+
svc.find(2) # miss — cached
|
|
227
|
+
svc.find(1) # hit — promotes 1 to most-recently-used; 2 is now LRU
|
|
228
|
+
svc.find(3) # miss — evicts 2 (LRU), caches 3
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`max_size:` combines with `ttl:` — LRU eviction applies within the TTL window, and entries also expire normally when the TTL elapses:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
memoize :find, max_size: 50, ttl: 300
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The `on_evict` hook fires for LRU-evicted entries the same way it does for manual `reset_memo` calls.
|
|
238
|
+
|
|
239
|
+
### Conditional caching
|
|
240
|
+
|
|
241
|
+
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.
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
class UserService
|
|
245
|
+
prepend SafeMemoize
|
|
246
|
+
|
|
247
|
+
# Don't cache nil — retries on every call until a user is found
|
|
248
|
+
def find(id)
|
|
249
|
+
User.find_by(id: id)
|
|
250
|
+
end
|
|
251
|
+
memoize :find, if: ->(result) { !result.nil? }
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
class DataService
|
|
257
|
+
prepend SafeMemoize
|
|
258
|
+
|
|
259
|
+
# Don't cache error responses
|
|
260
|
+
def fetch(key)
|
|
261
|
+
api_client.get(key)
|
|
262
|
+
end
|
|
263
|
+
memoize :fetch, unless: ->(result) { result.is_a?(ErrorResponse) }
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Both options accept any callable and compose with `ttl:` and `max_size:`:
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
memoize :find, if: ->(result) { !result.nil? }, ttl: 60, max_size: 500
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Cache warm-up and persistence
|
|
274
|
+
|
|
275
|
+
#### Warming individual entries
|
|
276
|
+
|
|
277
|
+
Use `warm_memo` to pre-populate a cache entry without calling the method. The block provides the value:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
obj.warm_memo(:current_user) { User.find(session[:user_id]) }
|
|
281
|
+
obj.warm_memo(:find, 42) { cached_user }
|
|
282
|
+
obj.warm_memo(:search, "ruby", page: 2) { cached_results }
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Useful for seeding the cache from a persistent store on startup, or overriding a cached value in tests.
|
|
286
|
+
|
|
287
|
+
#### Exporting and restoring the cache
|
|
288
|
+
|
|
289
|
+
`dump_memo` exports all live cached entries as a plain hash keyed by `[method, args, kwargs]`:
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
snapshot = obj.dump_memo # All methods
|
|
293
|
+
snapshot = obj.dump_memo(:find) # One method only
|
|
294
|
+
# => { [:find, [1], {}] => <User>, [:find, [2], {}] => <User>, ... }
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`load_memo` restores entries from a snapshot — merging into the existing cache without evicting unrelated entries:
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
obj.load_memo(snapshot)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Together they enable cross-request or cross-process cache persistence:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
# On shutdown — save to Redis
|
|
307
|
+
redis.set("cache:#{user_id}", Marshal.dump(obj.dump_memo))
|
|
308
|
+
|
|
309
|
+
# On boot — restore from Redis
|
|
310
|
+
raw = redis.get("cache:#{user_id}")
|
|
311
|
+
obj.load_memo(Marshal.load(raw)) if raw
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Loaded entries have no TTL — they persist until explicitly reset. Expired entries are excluded from `dump_memo` output, so snapshots never contain stale data.
|
|
315
|
+
|
|
316
|
+
### Shared cache
|
|
317
|
+
|
|
318
|
+
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.
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
class ConfigService
|
|
322
|
+
prepend SafeMemoize
|
|
323
|
+
|
|
324
|
+
def database_url
|
|
325
|
+
ENV.fetch("DATABASE_URL")
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def feature_flags
|
|
329
|
+
fetch_flags_from_api
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
memoize :database_url, shared: true
|
|
333
|
+
memoize :feature_flags, shared: true, ttl: 300
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
ConfigService.new.database_url # computes
|
|
337
|
+
ConfigService.new.database_url # returns cached — no recomputation
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Class-level invalidation and inspection:
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
ConfigService.reset_shared_memo(:feature_flags) # Clears all entries for one method
|
|
344
|
+
ConfigService.reset_shared_memo(:find, user_id) # Clears one argument combination
|
|
345
|
+
ConfigService.reset_all_shared_memos # Clears all shared cached entries
|
|
346
|
+
ConfigService.shared_memoized?(:database_url) # => true
|
|
347
|
+
ConfigService.shared_memoized?(:find, user_id) # Checks one argument combination
|
|
348
|
+
ConfigService.shared_memo_count # Total shared cached entries
|
|
349
|
+
ConfigService.shared_memo_count(:find) # Entries for one method
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`shared: true` supports `ttl:`, `if:`, and `unless:` options. `max_size:` is not supported (shared LRU may be added in a future release).
|
|
353
|
+
|
|
354
|
+
Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance as usual.
|
|
355
|
+
|
|
356
|
+
### Bulk memoization
|
|
357
|
+
|
|
358
|
+
Use `memoize_all` to memoize every public method defined on the class in one call:
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
class ConfigService
|
|
362
|
+
prepend SafeMemoize
|
|
363
|
+
|
|
364
|
+
def database_url
|
|
365
|
+
ENV.fetch("DATABASE_URL")
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def redis_url
|
|
369
|
+
ENV.fetch("REDIS_URL")
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def feature_flags
|
|
373
|
+
fetch_flags_from_api
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
memoize_all
|
|
377
|
+
end
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
All options accepted by `memoize` can be passed as shared options:
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
memoize_all ttl: 60
|
|
384
|
+
memoize_all max_size: 100
|
|
385
|
+
memoize_all if: ->(result) { !result.nil? }
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Use `except:` to skip specific methods:
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
memoize_all except: [:version, :name]
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Only public methods defined directly on the class are memoized — inherited, private, and protected methods are not affected.
|
|
395
|
+
|
|
396
|
+
### Custom cache keys
|
|
397
|
+
|
|
398
|
+
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:
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
class ReportService
|
|
402
|
+
prepend SafeMemoize
|
|
403
|
+
|
|
404
|
+
def generate(user_id, options)
|
|
405
|
+
build_report(user_id, options)
|
|
406
|
+
end
|
|
407
|
+
memoize :generate
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
svc = ReportService.new
|
|
411
|
+
|
|
412
|
+
# Cache only by user_id — ignore the options hash entirely
|
|
413
|
+
svc.memoize_with_custom_key(:generate) { |user_id, _options| user_id }
|
|
414
|
+
|
|
415
|
+
svc.generate(42, {format: :pdf}) # computes and caches
|
|
416
|
+
svc.generate(42, {format: :csv}) # cache hit — same user_id, options ignored
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
The block can return any comparable value — a scalar, array, or hash:
|
|
420
|
+
|
|
421
|
+
```ruby
|
|
422
|
+
svc.memoize_with_custom_key(:generate) do |user_id, options|
|
|
423
|
+
{user: user_id, locale: options[:locale]}
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Custom key generators are per-instance and can be cleared at any time:
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
svc.clear_custom_keys(:generate) # Remove generator for one method
|
|
431
|
+
svc.clear_custom_keys # Remove all custom key generators
|
|
432
|
+
```
|
|
433
|
+
|
|
148
434
|
### Cache inspection
|
|
149
435
|
|
|
150
436
|
```ruby
|
|
@@ -163,6 +449,33 @@ obj.memo_values # Cached signatures and values for all
|
|
|
163
449
|
obj.memo_values(:search) # Cached signatures and values for one method
|
|
164
450
|
```
|
|
165
451
|
|
|
452
|
+
### Cache metrics
|
|
453
|
+
|
|
454
|
+
Each instance tracks hits, misses, and computation time automatically.
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
obj.cache_stats
|
|
458
|
+
# => {
|
|
459
|
+
# total_hits: 42,
|
|
460
|
+
# total_misses: 8,
|
|
461
|
+
# hit_rate: 84.0,
|
|
462
|
+
# miss_rate: 16.0,
|
|
463
|
+
# average_computation_time: 0.012345,
|
|
464
|
+
# entries: [
|
|
465
|
+
# { method: :find, args: [1], hits: 10, misses: 1,
|
|
466
|
+
# hit_rate: 90.91, computation_time: 0.005 },
|
|
467
|
+
# ...
|
|
468
|
+
# ]
|
|
469
|
+
# }
|
|
470
|
+
|
|
471
|
+
obj.cache_stats_for(:find) # Stats scoped to one method
|
|
472
|
+
obj.cache_hit_rate # => 84.0 (percentage)
|
|
473
|
+
obj.cache_miss_rate # => 16.0 (percentage)
|
|
474
|
+
obj.cache_metrics_reset # Clears all collected metrics
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Metrics are per-instance and reset independently from the cache itself — clearing metrics does not evict cached values.
|
|
478
|
+
|
|
166
479
|
## How It Works
|
|
167
480
|
|
|
168
481
|
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.
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module SafeMemoize
|
|
4
4
|
module ClassMethods
|
|
5
|
-
def memoize(method_name, ttl: nil)
|
|
5
|
+
def memoize(method_name, ttl: nil, max_size: nil, 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
|
+
cond_if = binding.local_variable_get(:if)
|
|
10
|
+
cond_unless = binding.local_variable_get(:unless)
|
|
11
|
+
|
|
9
12
|
ttl = if ttl.nil?
|
|
10
13
|
nil
|
|
11
14
|
else
|
|
@@ -15,8 +18,78 @@ module SafeMemoize
|
|
|
15
18
|
ttl
|
|
16
19
|
end
|
|
17
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
|
+
raise ArgumentError, "max_size: is not supported with shared: true" if shared && max_size
|
|
31
|
+
|
|
32
|
+
if cond_if && cond_unless
|
|
33
|
+
raise ArgumentError, "cannot specify both :if and :unless"
|
|
34
|
+
end
|
|
35
|
+
raise ArgumentError, ":if must be callable" if cond_if && !cond_if.respond_to?(:call)
|
|
36
|
+
raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call)
|
|
37
|
+
|
|
38
|
+
# Normalize to a single "should cache?" predicate
|
|
39
|
+
condition = if cond_if
|
|
40
|
+
cond_if
|
|
41
|
+
elsif cond_unless
|
|
42
|
+
->(result) { !cond_unless.call(result) }
|
|
43
|
+
end
|
|
44
|
+
|
|
18
45
|
expires_at = ttl && Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
|
|
19
46
|
|
|
47
|
+
if shared
|
|
48
|
+
klass = self
|
|
49
|
+
shared_mutex = klass.send(:__safe_memo_shared_mutex__)
|
|
50
|
+
|
|
51
|
+
mod = Module.new do
|
|
52
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
53
|
+
return super(*args, **kwargs, &block) if block
|
|
54
|
+
|
|
55
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
56
|
+
|
|
57
|
+
shared_mutex.synchronize do
|
|
58
|
+
shared_cache = klass.send(:__safe_memo_shared_cache__)
|
|
59
|
+
record = shared_cache[cache_key]
|
|
60
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
|
+
record_live = record && (record[:expires_at].nil? || record[:expires_at] > now)
|
|
62
|
+
|
|
63
|
+
if record_live
|
|
64
|
+
record_cache_hit(method_name, args)
|
|
65
|
+
call_memo_hooks(:on_hit, cache_key, record)
|
|
66
|
+
record[:value]
|
|
67
|
+
else
|
|
68
|
+
call_memo_hooks(:on_expire, cache_key, record) if record && !record_live
|
|
69
|
+
|
|
70
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
71
|
+
value = super(*args, **kwargs)
|
|
72
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
73
|
+
|
|
74
|
+
new_record = {value: value, expires_at: expires_at}
|
|
75
|
+
shared_cache[cache_key] = new_record unless condition && !condition.call(value)
|
|
76
|
+
|
|
77
|
+
record_cache_miss(method_name, args, elapsed_time)
|
|
78
|
+
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
79
|
+
|
|
80
|
+
value
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
send(visibility, method_name)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
prepend mod
|
|
89
|
+
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
20
93
|
mod = Module.new do
|
|
21
94
|
define_method(method_name) do |*args, **kwargs, &block|
|
|
22
95
|
# Blocks bypass cache entirely — they aren't comparable
|
|
@@ -24,22 +97,54 @@ module SafeMemoize
|
|
|
24
97
|
|
|
25
98
|
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
26
99
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
100
|
+
if max_size || condition
|
|
101
|
+
# Locked path: used when LRU tracking or conditional storage is needed.
|
|
102
|
+
memo_mutex!.synchronize do
|
|
103
|
+
record = memo_cache_record(cache_key)
|
|
104
|
+
if record
|
|
105
|
+
lru_touch(method_name, cache_key) if max_size
|
|
106
|
+
record_cache_hit(method_name, args)
|
|
107
|
+
call_memo_hooks(:on_hit, cache_key, record)
|
|
108
|
+
memo_record_value(record)
|
|
109
|
+
else
|
|
110
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
111
|
+
value = super(*args, **kwargs)
|
|
112
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
32
113
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
114
|
+
new_record = memo_record(value, expires_at: expires_at)
|
|
115
|
+
if !condition || condition.call(value)
|
|
116
|
+
lru_evict_if_over_limit(method_name, max_size) if max_size
|
|
117
|
+
@__safe_memo_cache__ ||= {}
|
|
118
|
+
@__safe_memo_cache__[cache_key] = new_record
|
|
119
|
+
lru_touch(method_name, cache_key) if max_size
|
|
120
|
+
end
|
|
121
|
+
record_cache_miss(method_name, args, elapsed_time)
|
|
122
|
+
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
37
123
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
124
|
+
value
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
else
|
|
128
|
+
# Fast path: check without lock
|
|
129
|
+
if (record = memo_cache_record(cache_key))
|
|
130
|
+
record_cache_hit(method_name, args)
|
|
131
|
+
call_memo_hooks(:on_hit, cache_key, record)
|
|
132
|
+
return memo_record_value(record)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Cache miss - compute and store
|
|
136
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
137
|
+
result = memo_fetch_or_store(cache_key, expires_at: expires_at) { super(*args, **kwargs) }
|
|
138
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
139
|
+
|
|
140
|
+
with_memo_lock do
|
|
141
|
+
record_cache_miss(method_name, args, elapsed_time)
|
|
142
|
+
new_record = memo_cache_record(cache_key)
|
|
143
|
+
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
144
|
+
end
|
|
41
145
|
|
|
42
|
-
|
|
146
|
+
result
|
|
147
|
+
end
|
|
43
148
|
end
|
|
44
149
|
|
|
45
150
|
send(visibility, method_name)
|
|
@@ -48,8 +153,69 @@ module SafeMemoize
|
|
|
48
153
|
prepend mod
|
|
49
154
|
end
|
|
50
155
|
|
|
156
|
+
def reset_shared_memo(method_name, *args, **kwargs)
|
|
157
|
+
method_name = method_name.to_sym
|
|
158
|
+
matcher = if args.empty? && kwargs.empty?
|
|
159
|
+
->(key) { key[0] == method_name }
|
|
160
|
+
else
|
|
161
|
+
cache_key = [method_name, args, kwargs]
|
|
162
|
+
->(key) { key == cache_key }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
__safe_memo_shared_mutex__.synchronize do
|
|
166
|
+
__safe_memo_shared_cache__.delete_if { |key, _| matcher.call(key) }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def reset_all_shared_memos
|
|
171
|
+
__safe_memo_shared_mutex__.synchronize do
|
|
172
|
+
@__safe_memo_shared_cache__ = {}
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def shared_memoized?(method_name, *args, **kwargs)
|
|
177
|
+
method_name = method_name.to_sym
|
|
178
|
+
cache_key = [method_name, args, kwargs]
|
|
179
|
+
|
|
180
|
+
__safe_memo_shared_mutex__.synchronize do
|
|
181
|
+
cache = @__safe_memo_shared_cache__
|
|
182
|
+
return false unless cache
|
|
183
|
+
|
|
184
|
+
record = cache[cache_key]
|
|
185
|
+
return false unless record
|
|
186
|
+
|
|
187
|
+
record[:expires_at].nil? || record[:expires_at] > Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def shared_memo_count(method_name = nil)
|
|
192
|
+
__safe_memo_shared_mutex__.synchronize do
|
|
193
|
+
cache = @__safe_memo_shared_cache__ || {}
|
|
194
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
195
|
+
live = cache.reject { |_, r| r[:expires_at] && r[:expires_at] <= now }
|
|
196
|
+
method_name ? live.count { |key, _| key[0] == method_name.to_sym } : live.count
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def memoize_all(except: [], **options)
|
|
201
|
+
excluded = Array(except).map(&:to_sym)
|
|
202
|
+
public_instance_methods(false).each do |method_name|
|
|
203
|
+
next if excluded.include?(method_name)
|
|
204
|
+
|
|
205
|
+
memoize(method_name, **options)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
51
209
|
private
|
|
52
210
|
|
|
211
|
+
def __safe_memo_shared_cache__
|
|
212
|
+
@__safe_memo_shared_cache__ ||= {}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def __safe_memo_shared_mutex__
|
|
216
|
+
@__safe_memo_shared_mutex__ ||= Mutex.new
|
|
217
|
+
end
|
|
218
|
+
|
|
53
219
|
def memoized_method_visibility(method_name)
|
|
54
220
|
return :private if private_method_defined?(method_name)
|
|
55
221
|
return :protected if protected_method_defined?(method_name)
|
|
@@ -5,13 +5,13 @@ module SafeMemoize
|
|
|
5
5
|
private
|
|
6
6
|
|
|
7
7
|
def memo_hook_store
|
|
8
|
-
@__safe_memo_hooks__ ||= {on_expire: [], on_evict: []}
|
|
8
|
+
@__safe_memo_hooks__ ||= {on_expire: [], on_evict: [], on_hit: [], on_miss: []}
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def register_memo_hook(hook_type, &block)
|
|
12
12
|
raise ArgumentError, "block required" unless block
|
|
13
13
|
|
|
14
|
-
valid_hooks = [:on_expire, :on_evict]
|
|
14
|
+
valid_hooks = [:on_expire, :on_evict, :on_hit, :on_miss]
|
|
15
15
|
raise ArgumentError, "invalid hook type: #{hook_type}" unless valid_hooks.include?(hook_type)
|
|
16
16
|
|
|
17
17
|
memo_hook_store[hook_type] << block
|
|
@@ -26,7 +26,7 @@ module SafeMemoize
|
|
|
26
26
|
if hook_type
|
|
27
27
|
memo_hook_store[hook_type] = []
|
|
28
28
|
else
|
|
29
|
-
@__safe_memo_hooks__ = {on_expire: [], on_evict: []}
|
|
29
|
+
@__safe_memo_hooks__ = {on_expire: [], on_evict: [], on_hit: [], on_miss: []}
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module LruMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# Per-method LRU order: { method_name => { cache_key => true, ... } }
|
|
8
|
+
# Ruby Hash insertion order gives LRU for free: oldest key first, newest last.
|
|
9
|
+
def lru_order_store
|
|
10
|
+
@__safe_memo_lru_order__ ||= {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Mark +cache_key+ as most recently used for +method_name+.
|
|
14
|
+
def lru_touch(method_name, cache_key)
|
|
15
|
+
method_store = lru_order_store[method_name] ||= {}
|
|
16
|
+
method_store.delete(cache_key)
|
|
17
|
+
method_store[cache_key] = true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Evict the least-recently-used entry for +method_name+ when at +max_size+.
|
|
21
|
+
# Must be called while holding the mutex.
|
|
22
|
+
def lru_evict_if_over_limit(method_name, max_size)
|
|
23
|
+
method_store = lru_order_store[method_name]
|
|
24
|
+
return unless method_store && !method_store.empty?
|
|
25
|
+
|
|
26
|
+
cache = @__safe_memo_cache__
|
|
27
|
+
|
|
28
|
+
# Prune stale LRU references left behind by reset_memo calls.
|
|
29
|
+
method_store.delete_if { |key, _| !cache&.key?(key) }
|
|
30
|
+
|
|
31
|
+
return if method_store.size < max_size
|
|
32
|
+
|
|
33
|
+
lru_cache_key = method_store.keys.first
|
|
34
|
+
return unless lru_cache_key
|
|
35
|
+
|
|
36
|
+
method_store.delete(lru_cache_key)
|
|
37
|
+
record = cache&.delete(lru_cache_key)
|
|
38
|
+
call_memo_hooks(:on_evict, lru_cache_key, record) if record
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Clear all LRU tracking state. Called by reset_all_memos.
|
|
42
|
+
def lru_clear_all
|
|
43
|
+
@__safe_memo_lru_order__ = {}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -48,12 +48,63 @@ module SafeMemoize
|
|
|
48
48
|
register_memo_hook(:on_evict, &block)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
def on_memo_hit(&block)
|
|
52
|
+
raise ArgumentError, "block required" unless block
|
|
53
|
+
|
|
54
|
+
register_memo_hook(:on_hit, &block)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def on_memo_miss(&block)
|
|
58
|
+
raise ArgumentError, "block required" unless block
|
|
59
|
+
|
|
60
|
+
register_memo_hook(:on_miss, &block)
|
|
61
|
+
end
|
|
62
|
+
|
|
51
63
|
def clear_memo_hooks(hook_type = nil)
|
|
52
64
|
with_memo_lock do
|
|
53
65
|
_clear_memo_hooks(hook_type)
|
|
54
66
|
end
|
|
55
67
|
end
|
|
56
68
|
|
|
69
|
+
def warm_memo(method_name, *args, **kwargs, &block)
|
|
70
|
+
raise ArgumentError, "block required" unless block
|
|
71
|
+
|
|
72
|
+
method_name = method_name.to_sym
|
|
73
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
74
|
+
value = block.call
|
|
75
|
+
|
|
76
|
+
with_memo_lock do
|
|
77
|
+
@__safe_memo_cache__ ||= {}
|
|
78
|
+
@__safe_memo_cache__[cache_key] = memo_record(value, expires_at: nil)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def dump_memo(method_name = nil)
|
|
85
|
+
method_name = method_name&.to_sym
|
|
86
|
+
|
|
87
|
+
with_memo_lock do
|
|
88
|
+
cache = memo_cache_or_nil || {}
|
|
89
|
+
entries = method_name ? cache.select { |key, _| key[0] == method_name } : cache.dup
|
|
90
|
+
entries.select! { |_, record| memo_record_live?(record) }
|
|
91
|
+
entries.transform_values { |record| memo_record_value(record) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def load_memo(snapshot)
|
|
96
|
+
raise ArgumentError, "snapshot must be a Hash" unless snapshot.is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
with_memo_lock do
|
|
99
|
+
@__safe_memo_cache__ ||= {}
|
|
100
|
+
snapshot.each do |cache_key, value|
|
|
101
|
+
@__safe_memo_cache__[cache_key] = memo_record(value, expires_at: nil)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
57
108
|
def reset_memo(method_name, *args, **kwargs)
|
|
58
109
|
method_name = method_name.to_sym
|
|
59
110
|
|
|
@@ -81,6 +132,7 @@ module SafeMemoize
|
|
|
81
132
|
end
|
|
82
133
|
end
|
|
83
134
|
@__safe_memo_cache__ = {}
|
|
135
|
+
lru_clear_all
|
|
84
136
|
end
|
|
85
137
|
end
|
|
86
138
|
end
|
data/lib/safe_memoize/version.rb
CHANGED
data/lib/safe_memoize.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "safe_memoize/cache_metrics_methods"
|
|
|
11
11
|
require_relative "safe_memoize/public_metrics_methods"
|
|
12
12
|
require_relative "safe_memoize/custom_key_methods"
|
|
13
13
|
require_relative "safe_memoize/public_custom_key_methods"
|
|
14
|
+
require_relative "safe_memoize/lru_methods"
|
|
14
15
|
require_relative "safe_memoize/instance_methods"
|
|
15
16
|
|
|
16
17
|
module SafeMemoize
|
data/sig/safe_memoize.rbs
CHANGED
|
@@ -9,25 +9,41 @@ module SafeMemoize
|
|
|
9
9
|
|
|
10
10
|
@__safe_memo_cache__: Hash[memo_key, memo_record]?
|
|
11
11
|
@__safe_memo_mutex__: Mutex?
|
|
12
|
+
@__safe_memo_shared_cache__: Hash[memo_key, memo_record]?
|
|
13
|
+
@__safe_memo_shared_mutex__: Mutex?
|
|
12
14
|
|
|
13
15
|
def self.prepended: (Class base) -> void
|
|
14
16
|
|
|
15
17
|
module ClassMethods
|
|
16
|
-
def memoize: (Symbol | String method_name, ?ttl: Numeric?) -> void
|
|
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
|
|
20
|
+
def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
21
|
+
def reset_all_shared_memos: () -> void
|
|
22
|
+
def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
|
|
23
|
+
def shared_memo_count: (?Symbol | String method_name) -> Integer
|
|
17
24
|
|
|
18
25
|
private
|
|
19
26
|
|
|
27
|
+
def __safe_memo_shared_cache__: () -> Hash[memo_key, memo_record]
|
|
28
|
+
def __safe_memo_shared_mutex__: () -> Mutex
|
|
20
29
|
def memoized_method_visibility: (Symbol method_name) -> Symbol
|
|
21
30
|
end
|
|
22
31
|
|
|
23
32
|
module PublicMethods
|
|
33
|
+
@__safe_memo_cache__: Hash[memo_key, memo_record]?
|
|
34
|
+
|
|
24
35
|
def memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) ?{ () -> untyped } -> bool
|
|
25
36
|
def memo_count: (*untyped method_name) -> Integer
|
|
26
37
|
def memo_keys: (*untyped method_name) -> Array[untyped]
|
|
27
38
|
def memo_values: (*untyped method_name) -> Array[untyped]
|
|
28
39
|
def on_memo_expire: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
29
40
|
def on_memo_evict: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
41
|
+
def on_memo_hit: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
42
|
+
def on_memo_miss: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
30
43
|
def clear_memo_hooks: (Symbol? hook_type) -> void
|
|
44
|
+
def warm_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) { () -> untyped } -> untyped
|
|
45
|
+
def dump_memo: (?Symbol | String method_name) -> Hash[memo_key, untyped]
|
|
46
|
+
def load_memo: (Hash[memo_key, untyped] snapshot) -> nil
|
|
31
47
|
def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
32
48
|
def reset_all_memos: () -> void
|
|
33
49
|
end
|
|
@@ -73,11 +89,11 @@ module SafeMemoize
|
|
|
73
89
|
end
|
|
74
90
|
|
|
75
91
|
module HooksMethods
|
|
76
|
-
@__safe_memo_hooks__: { on_expire: Array[Proc], on_evict: Array[Proc] }?
|
|
92
|
+
@__safe_memo_hooks__: { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc], on_miss: Array[Proc] }?
|
|
77
93
|
|
|
78
94
|
private
|
|
79
95
|
|
|
80
|
-
def memo_hook_store: () -> { on_expire: Array[Proc], on_evict: Array[Proc] }
|
|
96
|
+
def memo_hook_store: () -> { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc], on_miss: Array[Proc] }
|
|
81
97
|
def register_memo_hook: (Symbol hook_type) { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
82
98
|
def call_memo_hooks: (Symbol hook_type, memo_key cache_key, memo_record record) -> void
|
|
83
99
|
def _clear_memo_hooks: (Symbol? hook_type) -> void
|
|
@@ -118,6 +134,17 @@ module SafeMemoize
|
|
|
118
134
|
def clear_custom_keys: (Symbol | String? method_name) -> void
|
|
119
135
|
end
|
|
120
136
|
|
|
137
|
+
module LruMethods
|
|
138
|
+
@__safe_memo_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def lru_order_store: () -> Hash[Symbol, Hash[memo_key, true]]
|
|
143
|
+
def lru_touch: (Symbol method_name, memo_key cache_key) -> void
|
|
144
|
+
def lru_evict_if_over_limit: (Symbol method_name, Integer max_size) -> void
|
|
145
|
+
def lru_clear_all: () -> void
|
|
146
|
+
end
|
|
147
|
+
|
|
121
148
|
module InstanceMethods
|
|
122
149
|
include PublicMethods
|
|
123
150
|
include CacheStoreMethods
|
|
@@ -128,6 +155,7 @@ module SafeMemoize
|
|
|
128
155
|
include PublicMetricsMethods
|
|
129
156
|
include CustomKeyMethods
|
|
130
157
|
include PublicCustomKeyMethods
|
|
158
|
+
include LruMethods
|
|
131
159
|
end
|
|
132
160
|
end
|
|
133
161
|
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -9,9 +9,16 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
|
-
description:
|
|
13
|
-
|
|
14
|
-
locking
|
|
12
|
+
description: 'SafeMemoize is a production-ready, zero-dependency memoization library
|
|
13
|
+
for Ruby. It uses Ruby''s prepend mechanism to wrap methods with a thread-safe cache
|
|
14
|
+
(Mutex + double-check locking) that correctly handles nil and false return values
|
|
15
|
+
— fixing the silent bug in the common ||= pattern. Results are cached per unique
|
|
16
|
+
argument combination, so parameterized methods only compute each variant once. Additional
|
|
17
|
+
features include TTL expiration, LRU cache size limiting, conditional caching via
|
|
18
|
+
if:/unless: predicates, lifecycle hooks for hit/eviction/expiration events, per-instance
|
|
19
|
+
metrics (hit rate, miss rate, computation time), targeted cache invalidation, custom
|
|
20
|
+
cache key generators, and introspection helpers. Method visibility (public, protected,
|
|
21
|
+
private) is fully preserved.'
|
|
15
22
|
email:
|
|
16
23
|
- eclectic-coding@users.noreply.github.com
|
|
17
24
|
executables: []
|
|
@@ -33,6 +40,7 @@ files:
|
|
|
33
40
|
- lib/safe_memoize/hooks_methods.rb
|
|
34
41
|
- lib/safe_memoize/inspection_methods.rb
|
|
35
42
|
- lib/safe_memoize/instance_methods.rb
|
|
43
|
+
- lib/safe_memoize/lru_methods.rb
|
|
36
44
|
- lib/safe_memoize/public_custom_key_methods.rb
|
|
37
45
|
- lib/safe_memoize/public_methods.rb
|
|
38
46
|
- lib/safe_memoize/public_metrics_methods.rb
|