safe_memoize 0.6.0 → 0.6.1

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