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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 945c98062bb51a6f19e3f229da2f8315680f1aea9ac290d1e9d60924ff6a6b6d
4
- data.tar.gz: aa74ec52245abaf6fb6835446546cb269b919e07d98088b5f15f589543c1c2f7
3
+ metadata.gz: eb68ddebb035ad2262b063ca010d757692802d7c4ab42a8c99beffabbcc01eb2
4
+ data.tar.gz: 7d154bb103d0659956959ab7e6943e6959f01738d8491e5bbe2a911ef07e43e6
5
5
  SHA512:
6
- metadata.gz: a2fd80a5146bf93a91a36e4449abc4e83d3687d6dbbf91b6323150847b5909b6f69ff25ef75e1804c2217a2652ca148357eaf19dccd7cdc3c2cf60ef255a5121
7
- data.tar.gz: d415cc760c97f1e8d3e50f33802279c065c6c56e7f89cc553717538824663b47ff974a9cbe57ccb064638d630892a837eb10cd7602db1a8c50618a52e040fe05
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
- - Zero runtime dependencies
23
- - Simple `prepend` + `memoize` API
24
- - Preserves public, protected, and private method visibility
25
- - Supports targeted cache invalidation by argument combination
26
- - Includes a `memoized?` helper for cache inspection
27
- - Includes a `memo_count` helper for cache size stats
28
- - Includes a `memo_keys` helper for inspecting cached signatures
29
- - Includes a `memo_values` helper for inspecting cached signatures and values
30
- - Optional TTL expiration support for cached entries
31
- - Block arguments bypass cache (blocks aren't comparable)
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
- # Fast path: check without lock
28
- if (record = memo_cache_record(cache_key))
29
- record_cache_hit(method_name, args)
30
- return memo_record_value(record)
31
- end
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
- # Cache miss - compute and store
34
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
- result = memo_fetch_or_store(cache_key, expires_at: expires_at) { super(*args, **kwargs) }
36
- elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
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
- with_memo_lock do
39
- record_cache_miss(method_name, args, elapsed_time)
40
- end
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
- result
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
@@ -11,5 +11,6 @@ module SafeMemoize
11
11
  include PublicMetricsMethods
12
12
  include CustomKeyMethods
13
13
  include PublicCustomKeyMethods
14
+ include LruMethods
14
15
  end
15
16
  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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
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.2.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: A prepend-based memoization module for Ruby that safely caches nil and
13
- false return values, supports method arguments, and provides thread safety via double-check
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