safe_memoize 0.6.0 → 0.6.2

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: 2de4904a414a2af75e49e3e2b86016c96cb293b31b2e42cc2494c3bc4183a0a9
4
+ data.tar.gz: 8a9cd0b7d63453a60098f119c11239220ef41fa88fdd12c8dbeaed132a0754ba
5
5
  SHA512:
6
- metadata.gz: 8185afdd3cf81fec90dc3dd02656f394d2d60385d6b6b015b66034c898600c8c68cd20712c5e4ad51ec0957f4bf168d01b5694b1cae036823d01ac17b9d87ffb
7
- data.tar.gz: 2e37ee4fcf6b57d5deb2fa252b140cb1d1e20ff67d4ad06444ceae6e075eb7699a662010254a22d5322121febe7cc72a520947432ee16bf6077729f71fe758fb
6
+ metadata.gz: 1d30bce8de6b1324d7a0e453f489e08c7ed90df83a7c0de1dd08c4decbceaf658d75d037c47c9fe3abb04df32e12ce1ce130a52a961c17eaeae37c6cd8402e19
7
+ data.tar.gz: 97530abba5c99442016afdfcb0413fc88149c5bd31dae253acbedf6bca05ddafae9ba2ee0809d72967348ad272ff20c38556c6f978d04f1dfc212aaa2f269c01
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.2] - 2026-05-18
4
+
5
+ - Achieve 100% line coverage across all lib files
6
+ - Add SimpleCov filter to exclude `/spec` from coverage reporting
7
+ - Add tests for `memo_ttl` in `CacheRecordMethods` covering nil, valid numeric, negative, and non-numeric inputs
8
+ - Add tests for private `memo_cache_read` in `CacheStoreMethods` covering nil cache, live hit, and expired entry
9
+ - Add tests for `memo_keys` / `memo_values` with custom-key entries, covering the `custom_key:` projection branch in `InspectionMethods`
10
+ - Add missing error-case tests for `ReleaseTooling.update_version_file` (no VERSION constant) and `finalize_changelog` (no Unreleased heading)
11
+
12
+ ## [0.6.1] - 2026-05-17
13
+
14
+ - Fix `memo_keys` and `memo_values` showing `args: custom_key, kwargs: nil` for methods using `memoize_with_custom_key` — now surfaces as `custom_key:`
15
+ - Refactor `cache_stats` / `cache_stats_for` to share aggregation logic via private helpers
16
+
3
17
  ## [0.6.0] - 2026-05-17
4
18
 
5
19
  - Fix TTL clock starting at `memoize` definition time instead of first method call
@@ -13,7 +27,7 @@
13
27
 
14
28
  ## [0.5.0] - 2026-05-17
15
29
 
16
- - Drop support for Ruby 3.2 (EOL); minimum required version is now Ruby 3.3
30
+ - Drop support for Ruby 3.2 (EOL); minimum required version is now Ruby 3.3
17
31
 
18
32
  ## [0.4.0] - 2026-05-17
19
33
 
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
 
@@ -18,6 +18,10 @@ end
18
18
 
19
19
  SafeMemoize uses `Hash#key?` to distinguish "not yet cached" from "cached nil/false", so your methods are only computed once regardless of return value.
20
20
 
21
+ ## How It Works
22
+
23
+ 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.
24
+
21
25
  ## Features
22
26
 
23
27
  - [Correctly memoizes `nil` and `false` return values](#nil-and-false-safety)
@@ -31,14 +35,16 @@ SafeMemoize uses `Hash#key?` to distinguish "not yet cached" from "cached nil/fa
31
35
  - [Includes a `memo_keys` helper for inspecting cached signatures](#cache-inspection)
32
36
  - [Includes a `memo_values` helper for inspecting cached signatures and values](#cache-inspection)
33
37
  - [Optional TTL expiration support for cached entries](#ttl-expiration)
38
+ - [Sliding window TTL via `ttl_refresh: true`](#sliding-window-ttl)
34
39
  - [Optional LRU cache size limit per method via `max_size:`](#lru-cache-size-limit)
35
40
  - [Conditional caching via `if:` and `unless:` predicates](#conditional-caching)
36
41
  - [Lifecycle hooks for hit, miss, eviction, and expiration events](#lifecycle-hooks)
37
42
  - [Per-instance cache metrics (hit rate, miss rate, computation time)](#cache-metrics)
38
43
  - [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)
44
+ - [Class-level shared cache via `shared: true` with optional LRU](#shared-cache)
45
+ - [Bulk memoization via `memoize_all` (public, protected, and private)](#bulk-memoization)
41
46
  - [Custom cache key generation per method](#custom-cache-keys)
47
+ - [TTL introspection via `memo_ttl_remaining`](#cache-inspection)
42
48
 
43
49
  ## Installation
44
50
 
@@ -203,6 +209,23 @@ end
203
209
 
204
210
  With a TTL, cached values expire automatically after the given number of seconds. The next call recomputes and refreshes the cache.
205
211
 
212
+ ### Sliding window TTL
213
+
214
+ Add `ttl_refresh: true` to reset the expiry clock on every cache hit, so the entry only expires after a full TTL of inactivity:
215
+
216
+ ```ruby
217
+ class SessionService
218
+ prepend SafeMemoize
219
+
220
+ def user_data(user_id)
221
+ fetch_from_db(user_id)
222
+ end
223
+ memoize :user_data, ttl: 300, ttl_refresh: true
224
+ end
225
+ ```
226
+
227
+ 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.
228
+
206
229
  ### LRU cache size limit
207
230
 
208
231
  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 +305,12 @@ obj.warm_memo(:find, 42) { cached_user }
282
305
  obj.warm_memo(:search, "ruby", page: 2) { cached_results }
283
306
  ```
284
307
 
308
+ Pass `ttl:` to give the warmed entry an expiry:
309
+
310
+ ```ruby
311
+ obj.warm_memo(:current_quote, ttl: 60) { cached_quote }
312
+ ```
313
+
285
314
  Useful for seeding the cache from a persistent store on startup, or overriding a cached value in tests.
286
315
 
287
316
  #### Exporting and restoring the cache
@@ -349,9 +378,15 @@ ConfigService.shared_memo_count # Total shared cached entr
349
378
  ConfigService.shared_memo_count(:find) # Entries for one method
350
379
  ```
351
380
 
352
- `shared: true` supports `ttl:`, `if:`, and `unless:` options. `max_size:` is not supported (shared LRU may be added in a future release).
381
+ `shared: true` supports `ttl:`, `ttl_refresh:`, `if:`, `unless:`, and `max_size:` options.
382
+
383
+ Pass `max_size:` to cap how many entries are kept across all instances. Eviction is LRU, tracked at the class level:
384
+
385
+ ```ruby
386
+ memoize :find, shared: true, max_size: 500
387
+ ```
353
388
 
354
- Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance as usual.
389
+ Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`, `on_memo_evict`) fire on the calling instance as usual.
355
390
 
356
391
  ### Bulk memoization
357
392
 
@@ -391,7 +426,15 @@ Use `except:` to skip specific methods:
391
426
  memoize_all except: [:version, :name]
392
427
  ```
393
428
 
394
- Only public methods defined directly on the class are memoized inherited, private, and protected methods are not affected.
429
+ By default only public methods defined directly on the class are memoized. Use `include_protected:` or `include_private:` to opt those visibilities in:
430
+
431
+ ```ruby
432
+ memoize_all include_protected: true
433
+ memoize_all include_private: true
434
+ memoize_all include_protected: true, include_private: true
435
+ ```
436
+
437
+ Inherited methods are never affected regardless of visibility.
395
438
 
396
439
  ### Custom cache keys
397
440
 
@@ -447,6 +490,10 @@ obj.memo_keys # All cached signatures with method, a
447
490
  obj.memo_keys(:search) # Cached signatures for one method
448
491
  obj.memo_values # Cached signatures and values for all methods
449
492
  obj.memo_values(:search) # Cached signatures and values for one method
493
+
494
+ obj.memo_ttl_remaining(:current_quote) # => 47.231 (seconds until expiry)
495
+ obj.memo_ttl_remaining(:current_user) # => nil (no TTL set)
496
+ obj.memo_ttl_remaining(:find, 42) # => 0 (not cached or already expired)
450
497
  ```
451
498
 
452
499
  ### Cache metrics
@@ -476,10 +523,6 @@ obj.cache_metrics_reset # Clears all collected metrics
476
523
 
477
524
  Metrics are per-instance and reset independently from the cache itself — clearing metrics does not evict cached values.
478
525
 
479
- ## How It Works
480
-
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.
482
-
483
526
  ## Development
484
527
 
485
528
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt.
@@ -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.2"
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith