safe_memoize 0.1.2 → 0.2.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: 00d20095844739078f88cd287d4389b749f9953b9b6055939d733f2da139c19d
4
- data.tar.gz: 5c4336dbe5e3ee1e1e2b822b92a3abd4f6d5452dedd36d99706c54eb8528ed7a
3
+ metadata.gz: 945c98062bb51a6f19e3f229da2f8315680f1aea9ac290d1e9d60924ff6a6b6d
4
+ data.tar.gz: aa74ec52245abaf6fb6835446546cb269b919e07d98088b5f15f589543c1c2f7
5
5
  SHA512:
6
- metadata.gz: 5d14486d2a280e2e243e2c54184875d175447f8dc921ff9e152374e9f8327061b0b4ce6de0e9213871000c0950a8cab146db7d303a0de8242d081c30e54c53ce
7
- data.tar.gz: 67be0eb15e01782f7dc6b0a35cb79c24677f8d56ebf042a9dfdd49d9ec88a44c08711af24063150688c0b1ad9047c70a6c3754fd1cc662ba54a83000c1c5247a
6
+ metadata.gz: a2fd80a5146bf93a91a36e4449abc4e83d3687d6dbbf91b6323150847b5909b6f69ff25ef75e1804c2217a2652ca148357eaf19dccd7cdc3c2cf60ef255a5121
7
+ data.tar.gz: d415cc760c97f1e8d3e50f33802279c065c6c56e7f89cc553717538824663b47ff974a9cbe57ccb064638d630892a837eb10cd7602db1a8c50618a52e040fe05
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-05-14
4
+
5
+ - Add optional TTL expiration support for memoized entries
6
+ - Add cache invalidation/expiration hooks for custom handlers
7
+ - `on_memo_expire` hook fires when TTL entries expire
8
+ - `on_memo_evict` hook fires when manually resetting cache entries
9
+ - `clear_memo_hooks` to remove registered hooks
10
+ - Add cache statistics and monitoring capabilities
11
+ - `cache_stats` for comprehensive cache metrics
12
+ - `cache_stats_for(method_name)` for per-method statistics
13
+ - `cache_hit_rate` and `cache_miss_rate` for performance analysis
14
+ - `cache_metrics_reset` to clear collected metrics
15
+ - Add manual cache key generation support
16
+ - `memoize_with_custom_key` to define custom cache key logic
17
+ - `clear_custom_keys` to remove custom key generators
18
+ - Support for complex and computed keys based on arguments
19
+
3
20
  ## [0.1.2] - 2026-05-13
4
21
 
5
22
  - Preserve public, protected, and private visibility for memoized methods
data/README.md CHANGED
@@ -27,6 +27,7 @@ SafeMemoize uses `Hash#key?` to distinguish "not yet cached" from "cached nil/fa
27
27
  - Includes a `memo_count` helper for cache size stats
28
28
  - Includes a `memo_keys` helper for inspecting cached signatures
29
29
  - Includes a `memo_values` helper for inspecting cached signatures and values
30
+ - Optional TTL expiration support for cached entries
30
31
  - Block arguments bypass cache (blocks aren't comparable)
31
32
 
32
33
  ## Installation
@@ -129,6 +130,21 @@ obj.reset_memo(:search, "ruby", page: 2) # Clears one positional/keyword c
129
130
  obj.reset_all_memos # Clears all memoized values
130
131
  ```
131
132
 
133
+ ### TTL expiration
134
+
135
+ ```ruby
136
+ class QuoteService
137
+ prepend SafeMemoize
138
+
139
+ def current_quote
140
+ fetch_quote_from_api
141
+ end
142
+ memoize :current_quote, ttl: 60
143
+ end
144
+ ```
145
+
146
+ With a TTL, cached values expire automatically after the given number of seconds. The next call recomputes and refreshes the cache.
147
+
132
148
  ### Cache inspection
133
149
 
134
150
  ```ruby
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module CacheMetricsMethods
5
+ private
6
+
7
+ def memo_metrics_store
8
+ @__safe_memo_metrics__ ||= {}
9
+ end
10
+
11
+ def record_cache_hit(method_name, args)
12
+ cache_key = safe_memo_cache_key(method_name, args, {})
13
+ metrics = memo_metrics_store
14
+ metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
15
+ metrics[cache_key][:hits] += 1
16
+ end
17
+
18
+ def record_cache_miss(method_name, args, computation_time)
19
+ cache_key = safe_memo_cache_key(method_name, args, {})
20
+ metrics = memo_metrics_store
21
+ metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
22
+ metrics[cache_key][:misses] += 1
23
+ metrics[cache_key][:total_time] += computation_time
24
+ end
25
+
26
+ def _reset_cache_metrics
27
+ @__safe_memo_metrics__ = {}
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module CacheRecordMethods
5
+ private
6
+
7
+ def memo_ttl(ttl)
8
+ return nil if ttl.nil?
9
+
10
+ ttl = Float(ttl)
11
+ raise ArgumentError, "ttl must be non-negative" if ttl < 0
12
+
13
+ ttl
14
+ rescue ArgumentError, TypeError
15
+ raise ArgumentError, "ttl must be a non-negative number"
16
+ end
17
+
18
+ def memo_expires_at(ttl)
19
+ return nil unless ttl
20
+
21
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
22
+ end
23
+
24
+ def memo_record(value, expires_at:)
25
+ {value: value, expires_at: expires_at}
26
+ end
27
+
28
+ def memo_record_value(record)
29
+ record[:value]
30
+ end
31
+
32
+ def memo_record_live?(record)
33
+ return false unless record
34
+
35
+ expires_at = record[:expires_at]
36
+ return true unless expires_at
37
+
38
+ expires_at > Process.clock_gettime(Process::CLOCK_MONOTONIC)
39
+ end
40
+
41
+ def memo_prune_expired_entries!(cache)
42
+ cache.delete_if do |cache_key, record|
43
+ if !memo_record_live?(record)
44
+ call_memo_hooks(:on_expire, cache_key, record)
45
+ true
46
+ else
47
+ false
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module CacheStoreMethods
5
+ private
6
+
7
+ def with_memo_lock
8
+ if defined?(@__safe_memo_mutex__) && @__safe_memo_mutex__
9
+ @__safe_memo_mutex__.synchronize { yield }
10
+ else
11
+ yield
12
+ end
13
+ end
14
+
15
+ def memo_cache_or_nil
16
+ return nil unless defined?(@__safe_memo_cache__)
17
+
18
+ @__safe_memo_cache__
19
+ end
20
+
21
+ def memo_cache_hit?(cache_key)
22
+ !!memo_cache_record(cache_key)
23
+ end
24
+
25
+ def memo_cache_record(cache_key)
26
+ cache = memo_cache_or_nil
27
+ return nil unless cache
28
+
29
+ record = cache[cache_key]
30
+ return nil unless memo_record_live?(record)
31
+
32
+ record
33
+ end
34
+
35
+ def memo_cache_read(cache_key)
36
+ record = memo_cache_record(cache_key)
37
+ return nil unless record
38
+
39
+ memo_record_value(record)
40
+ end
41
+
42
+ def memo_fetch_or_store(cache_key, expires_at: nil)
43
+ memo_mutex!.synchronize do
44
+ @__safe_memo_cache__ ||= {}
45
+
46
+ record = @__safe_memo_cache__[cache_key]
47
+
48
+ if memo_record_live?(record)
49
+ memo_record_value(record)
50
+ else
51
+ value = yield
52
+ @__safe_memo_cache__[cache_key] = memo_record(value, expires_at: expires_at)
53
+
54
+ value
55
+ end
56
+ end
57
+ end
58
+
59
+ def memo_mutex!
60
+ @__safe_memo_mutex__ ||= Mutex.new
61
+ end
62
+
63
+ def with_memo_cache
64
+ cache = memo_cache_or_nil
65
+ return nil unless cache
66
+
67
+ yield cache
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module ClassMethods
5
+ def memoize(method_name, ttl: nil)
6
+ method_name = method_name.to_sym
7
+ visibility = memoized_method_visibility(method_name)
8
+
9
+ ttl = if ttl.nil?
10
+ nil
11
+ else
12
+ ttl = Float(ttl)
13
+ raise ArgumentError, "ttl must be non-negative" if ttl < 0
14
+
15
+ ttl
16
+ end
17
+
18
+ expires_at = ttl && Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
19
+
20
+ mod = Module.new do
21
+ define_method(method_name) do |*args, **kwargs, &block|
22
+ # Blocks bypass cache entirely — they aren't comparable
23
+ return super(*args, **kwargs, &block) if block
24
+
25
+ cache_key = compute_cache_key(method_name, args, kwargs)
26
+
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
32
+
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
37
+
38
+ with_memo_lock do
39
+ record_cache_miss(method_name, args, elapsed_time)
40
+ end
41
+
42
+ result
43
+ end
44
+
45
+ send(visibility, method_name)
46
+ end
47
+
48
+ prepend mod
49
+ end
50
+
51
+ private
52
+
53
+ def memoized_method_visibility(method_name)
54
+ return :private if private_method_defined?(method_name)
55
+ return :protected if protected_method_defined?(method_name)
56
+
57
+ :public
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module CustomKeyMethods
5
+ private
6
+
7
+ def custom_key_store
8
+ @__safe_memo_custom_keys__ ||= {}
9
+ end
10
+
11
+ def register_custom_key(method_name, &block)
12
+ raise ArgumentError, "block required" unless block
13
+
14
+ method_name = method_name.to_sym
15
+ custom_key_store[method_name] = block
16
+ end
17
+
18
+ def compute_cache_key(method_name, args, kwargs)
19
+ method_name = method_name.to_sym
20
+
21
+ # Check if a custom key generator is registered
22
+ custom_key_block = custom_key_store[method_name]
23
+
24
+ if custom_key_block
25
+ # Call the custom key generator with args and kwargs
26
+ custom_key = custom_key_block.call(*args, **kwargs)
27
+ # Wrap in a standard format: [method, custom_key]
28
+ [method_name, custom_key]
29
+ else
30
+ # Use default key generation
31
+ safe_memo_cache_key(method_name, args, kwargs)
32
+ end
33
+ end
34
+
35
+ def _clear_custom_keys
36
+ @__safe_memo_custom_keys__ = {}
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module HooksMethods
5
+ private
6
+
7
+ def memo_hook_store
8
+ @__safe_memo_hooks__ ||= {on_expire: [], on_evict: []}
9
+ end
10
+
11
+ def register_memo_hook(hook_type, &block)
12
+ raise ArgumentError, "block required" unless block
13
+
14
+ valid_hooks = [:on_expire, :on_evict]
15
+ raise ArgumentError, "invalid hook type: #{hook_type}" unless valid_hooks.include?(hook_type)
16
+
17
+ memo_hook_store[hook_type] << block
18
+ end
19
+
20
+ def call_memo_hooks(hook_type, cache_key, record)
21
+ hooks = memo_hook_store[hook_type] || []
22
+ hooks.each { |hook| hook.call(cache_key, record) }
23
+ end
24
+
25
+ def _clear_memo_hooks(hook_type = nil)
26
+ if hook_type
27
+ memo_hook_store[hook_type] = []
28
+ else
29
+ @__safe_memo_hooks__ = {on_expire: [], on_evict: []}
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module InspectionMethods
5
+ private
6
+
7
+ def safe_memo_scoped_method(method_name)
8
+ raise ArgumentError, "expected 0 or 1 arguments" if method_name.length > 1
9
+
10
+ method_name.first&.to_sym
11
+ end
12
+
13
+ def memo_matcher_for(method_name, args, kwargs)
14
+ if args.empty? && kwargs.empty?
15
+ ->(key) { key[0] == method_name }
16
+ else
17
+ cache_key = safe_memo_cache_key(method_name, args, kwargs)
18
+ ->(key) { key == cache_key }
19
+ end
20
+ end
21
+
22
+ def memo_entries_for(method_name)
23
+ cache = memo_cache_or_nil
24
+ return [] unless cache
25
+
26
+ memo_prune_expired_entries!(cache)
27
+ entries = cache.to_a
28
+ return entries unless method_name
29
+
30
+ entries.select { |(cache_key, _)| cache_key[0] == method_name }
31
+ end
32
+
33
+ def safe_memo_count_for(method_name)
34
+ memo_entries_for(method_name).length
35
+ end
36
+
37
+ def safe_memo_keys_for(method_name)
38
+ entries = memo_entries_for(method_name)
39
+ include_method = method_name.nil?
40
+
41
+ entries.map do |(cache_key, value)|
42
+ memo_projection(cache_key, value, include_method: include_method, include_value: false)
43
+ end
44
+ end
45
+
46
+ def safe_memo_values_for(method_name)
47
+ entries = memo_entries_for(method_name)
48
+ include_method = method_name.nil?
49
+
50
+ entries.map do |(cache_key, value)|
51
+ memo_projection(cache_key, value, include_method: include_method, include_value: true)
52
+ end
53
+ end
54
+
55
+ def memo_projection(cache_key, value, include_method:, include_value:)
56
+ method_name, args, kwargs = cache_key
57
+
58
+ payload = {args: args, kwargs: kwargs}
59
+ payload[:method] = method_name if include_method
60
+ payload[:value] = memo_record_value(value) if include_value
61
+ payload
62
+ end
63
+
64
+ def safe_memo_cache_key(method_name, args, kwargs)
65
+ [method_name.to_sym, args, kwargs]
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module InstanceMethods
5
+ include PublicMethods
6
+ include CacheStoreMethods
7
+ include CacheRecordMethods
8
+ include InspectionMethods
9
+ include HooksMethods
10
+ include CacheMetricsMethods
11
+ include PublicMetricsMethods
12
+ include CustomKeyMethods
13
+ include PublicCustomKeyMethods
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module PublicCustomKeyMethods
5
+ def memoize_with_custom_key(method_name, &key_generator)
6
+ raise ArgumentError, "block required for key generation" unless key_generator
7
+
8
+ register_custom_key(method_name, &key_generator)
9
+ end
10
+
11
+ def clear_custom_keys(method_name = nil)
12
+ if method_name
13
+ with_memo_lock do
14
+ custom_key_store.delete(method_name.to_sym)
15
+ end
16
+ else
17
+ with_memo_lock do
18
+ _clear_custom_keys
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module PublicMethods
5
+ def memoized?(method_name, *args, **kwargs, &block)
6
+ return false if block
7
+
8
+ cache_key = safe_memo_cache_key(method_name, args, kwargs)
9
+
10
+ with_memo_lock do
11
+ memo_cache_hit?(cache_key)
12
+ end
13
+ end
14
+
15
+ def memo_count(*method_name)
16
+ scoped_method = safe_memo_scoped_method(method_name)
17
+
18
+ with_memo_lock do
19
+ safe_memo_count_for(scoped_method)
20
+ end
21
+ end
22
+
23
+ def memo_keys(*method_name)
24
+ scoped_method = safe_memo_scoped_method(method_name)
25
+
26
+ with_memo_lock do
27
+ safe_memo_keys_for(scoped_method)
28
+ end
29
+ end
30
+
31
+ def memo_values(*method_name)
32
+ scoped_method = safe_memo_scoped_method(method_name)
33
+
34
+ with_memo_lock do
35
+ safe_memo_values_for(scoped_method)
36
+ end
37
+ end
38
+
39
+ def on_memo_expire(&block)
40
+ raise ArgumentError, "block required" unless block
41
+
42
+ register_memo_hook(:on_expire, &block)
43
+ end
44
+
45
+ def on_memo_evict(&block)
46
+ raise ArgumentError, "block required" unless block
47
+
48
+ register_memo_hook(:on_evict, &block)
49
+ end
50
+
51
+ def clear_memo_hooks(hook_type = nil)
52
+ with_memo_lock do
53
+ _clear_memo_hooks(hook_type)
54
+ end
55
+ end
56
+
57
+ def reset_memo(method_name, *args, **kwargs)
58
+ method_name = method_name.to_sym
59
+
60
+ matcher = memo_matcher_for(method_name, args, kwargs)
61
+
62
+ with_memo_lock do
63
+ with_memo_cache do |cache|
64
+ cache.delete_if do |key, record|
65
+ if matcher.call(key)
66
+ call_memo_hooks(:on_evict, key, record)
67
+ true
68
+ else
69
+ false
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ def reset_all_memos
77
+ with_memo_lock do
78
+ if defined?(@__safe_memo_cache__) && @__safe_memo_cache__
79
+ @__safe_memo_cache__.each do |key, record|
80
+ call_memo_hooks(:on_evict, key, record)
81
+ end
82
+ end
83
+ @__safe_memo_cache__ = {}
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module PublicMetricsMethods
5
+ def cache_stats
6
+ with_memo_lock do
7
+ metrics = memo_metrics_store
8
+
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
+ }
55
+ end
56
+ end
57
+
58
+ def cache_stats_for(method_name)
59
+ method_name = method_name.to_sym
60
+
61
+ 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
102
+
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
+ }
112
+ end
113
+ end
114
+
115
+ def cache_hit_rate
116
+ stats = cache_stats
117
+ stats[:hit_rate]
118
+ end
119
+
120
+ def cache_miss_rate
121
+ stats = cache_stats
122
+ stats[:miss_rate]
123
+ end
124
+
125
+ def cache_metrics_reset
126
+ with_memo_lock do
127
+ _reset_cache_metrics
128
+ end
129
+ end
130
+ end
131
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/safe_memoize.rb CHANGED
@@ -1,206 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "safe_memoize/version"
4
+ require_relative "safe_memoize/class_methods"
5
+ require_relative "safe_memoize/public_methods"
6
+ require_relative "safe_memoize/cache_store_methods"
7
+ require_relative "safe_memoize/cache_record_methods"
8
+ require_relative "safe_memoize/inspection_methods"
9
+ require_relative "safe_memoize/hooks_methods"
10
+ require_relative "safe_memoize/cache_metrics_methods"
11
+ require_relative "safe_memoize/public_metrics_methods"
12
+ require_relative "safe_memoize/custom_key_methods"
13
+ require_relative "safe_memoize/public_custom_key_methods"
14
+ require_relative "safe_memoize/instance_methods"
4
15
 
5
16
  module SafeMemoize
6
17
  class Error < StandardError; end
7
18
 
19
+ include InstanceMethods
20
+
8
21
  def self.prepended(base)
9
22
  base.extend(ClassMethods)
10
23
  end
11
-
12
- module ClassMethods
13
- def memoize(method_name)
14
- method_name = method_name.to_sym
15
- visibility = memoized_method_visibility(method_name)
16
-
17
- mod = Module.new do
18
- define_method(method_name) do |*args, **kwargs, &block|
19
- # Blocks bypass cache entirely — they aren't comparable
20
- return super(*args, **kwargs, &block) if block
21
-
22
- cache_key = safe_memo_cache_key(method_name, args, kwargs)
23
-
24
- # Fast path: check without lock
25
- return memo_cache_read(cache_key) if memo_cache_hit?(cache_key)
26
-
27
- memo_fetch_or_store(cache_key) { super(*args, **kwargs) }
28
- end
29
-
30
- send(visibility, method_name)
31
- end
32
-
33
- prepend mod
34
- end
35
-
36
- private
37
-
38
- def memoized_method_visibility(method_name)
39
- return :private if private_method_defined?(method_name)
40
- return :protected if protected_method_defined?(method_name)
41
-
42
- :public
43
- end
44
- end
45
-
46
- def memoized?(method_name, *args, **kwargs, &block)
47
- return false if block
48
-
49
- cache_key = safe_memo_cache_key(method_name, args, kwargs)
50
-
51
- with_memo_lock do
52
- with_memo_cache { |cache| cache.key?(cache_key) } || false
53
- end
54
- end
55
-
56
- def memo_count(*method_name)
57
- scoped_method = safe_memo_scoped_method(method_name)
58
-
59
- with_memo_lock do
60
- safe_memo_count_for(scoped_method)
61
- end
62
- end
63
-
64
- def memo_keys(*method_name)
65
- scoped_method = safe_memo_scoped_method(method_name)
66
-
67
- with_memo_lock do
68
- safe_memo_keys_for(scoped_method)
69
- end
70
- end
71
-
72
- def memo_values(*method_name)
73
- scoped_method = safe_memo_scoped_method(method_name)
74
-
75
- with_memo_lock do
76
- safe_memo_values_for(scoped_method)
77
- end
78
- end
79
-
80
- def reset_memo(method_name, *args, **kwargs)
81
- method_name = method_name.to_sym
82
-
83
- matcher = memo_matcher_for(method_name, args, kwargs)
84
-
85
- with_memo_lock do
86
- with_memo_cache do |cache|
87
- cache.delete_if { |key, _| matcher.call(key) }
88
- end
89
- end
90
- end
91
-
92
- def reset_all_memos
93
- with_memo_lock do
94
- @__safe_memo_cache__ = {}
95
- end
96
- end
97
-
98
- private
99
-
100
- def safe_memo_scoped_method(method_name)
101
- raise ArgumentError, "expected 0 or 1 arguments" if method_name.length > 1
102
-
103
- method_name.first&.to_sym
104
- end
105
-
106
- def with_memo_lock
107
- if defined?(@__safe_memo_mutex__) && @__safe_memo_mutex__
108
- @__safe_memo_mutex__.synchronize { yield }
109
- else
110
- yield
111
- end
112
- end
113
-
114
- def memo_cache_or_nil
115
- return nil unless defined?(@__safe_memo_cache__)
116
-
117
- @__safe_memo_cache__
118
- end
119
-
120
- def memo_cache_hit?(cache_key)
121
- cache = memo_cache_or_nil
122
- cache&.key?(cache_key)
123
- end
124
-
125
- def memo_cache_read(cache_key)
126
- cache = memo_cache_or_nil
127
- cache && cache[cache_key]
128
- end
129
-
130
- def memo_fetch_or_store(cache_key)
131
- memo_mutex!.synchronize do
132
- @__safe_memo_cache__ ||= {}
133
-
134
- if @__safe_memo_cache__.key?(cache_key)
135
- @__safe_memo_cache__[cache_key]
136
- else
137
- @__safe_memo_cache__[cache_key] = yield
138
- end
139
- end
140
- end
141
-
142
- def memo_mutex!
143
- @__safe_memo_mutex__ ||= Mutex.new
144
- end
145
-
146
- def with_memo_cache
147
- cache = memo_cache_or_nil
148
- return nil unless cache
149
-
150
- yield cache
151
- end
152
-
153
- def memo_matcher_for(method_name, args, kwargs)
154
- if args.empty? && kwargs.empty?
155
- ->(key) { key[0] == method_name }
156
- else
157
- cache_key = safe_memo_cache_key(method_name, args, kwargs)
158
- ->(key) { key == cache_key }
159
- end
160
- end
161
-
162
- def memo_entries_for(method_name)
163
- cache = memo_cache_or_nil
164
- return [] unless cache
165
-
166
- entries = cache.to_a
167
- return entries unless method_name
168
-
169
- entries.select { |(cache_key, _)| cache_key[0] == method_name }
170
- end
171
-
172
- def safe_memo_count_for(method_name)
173
- memo_entries_for(method_name).length
174
- end
175
-
176
- def safe_memo_keys_for(method_name)
177
- entries = memo_entries_for(method_name)
178
- include_method = method_name.nil?
179
-
180
- entries.map do |(cache_key, value)|
181
- memo_projection(cache_key, value, include_method: include_method, include_value: false)
182
- end
183
- end
184
-
185
- def safe_memo_values_for(method_name)
186
- entries = memo_entries_for(method_name)
187
- include_method = method_name.nil?
188
-
189
- entries.map do |(cache_key, value)|
190
- memo_projection(cache_key, value, include_method: include_method, include_value: true)
191
- end
192
- end
193
-
194
- def memo_projection(cache_key, value, include_method:, include_value:)
195
- method_name, args, kwargs = cache_key
196
-
197
- payload = {args: args, kwargs: kwargs}
198
- payload[:method] = method_name if include_method
199
- payload[:value] = value if include_value
200
- payload
201
- end
202
-
203
- def safe_memo_cache_key(method_name, args, kwargs)
204
- [method_name.to_sym, args, kwargs]
205
- end
206
24
  end
data/sig/safe_memoize.rbs CHANGED
@@ -1,47 +1,133 @@
1
1
  module SafeMemoize
2
2
  VERSION: String
3
+ include InstanceMethods
3
4
 
4
- type memo_key = [Symbol, Array[untyped], Hash[Symbol, untyped]]
5
- type memo_entry = [memo_key, untyped]
5
+ type default_memo_key = [Symbol, Array[untyped], Hash[Symbol, untyped]]
6
+ type custom_memo_key = [Symbol, untyped]
7
+ type memo_key = default_memo_key | custom_memo_key
8
+ type memo_record = { value: untyped, expires_at: Float? }
6
9
 
7
- @__safe_memo_cache__: Hash[memo_key, untyped]?
10
+ @__safe_memo_cache__: Hash[memo_key, memo_record]?
8
11
  @__safe_memo_mutex__: Mutex?
9
12
 
10
13
  def self.prepended: (Class base) -> void
11
14
 
12
- def memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
13
- def memo_count: () -> Integer
14
- def memo_count: (Symbol | String method_name) -> Integer
15
- def memo_keys: () -> Array[untyped]
16
- def memo_keys: (Symbol | String method_name) -> Array[untyped]
17
- def memo_values: () -> Array[untyped]
18
- def memo_values: (Symbol | String method_name) -> Array[untyped]
19
- def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
20
- def reset_all_memos: () -> void
21
-
22
- private
23
-
24
- def safe_memo_scoped_method: (Array[untyped] method_name) -> Symbol?
25
- def with_memo_lock: { () -> untyped } -> untyped
26
- def memo_cache_or_nil: () -> Hash[memo_key, untyped]?
27
- def memo_cache_hit?: (memo_key cache_key) -> bool
28
- def memo_cache_read: (memo_key cache_key) -> untyped?
29
- def memo_fetch_or_store: (memo_key cache_key) { () -> untyped } -> untyped
30
- def memo_mutex!: () -> Mutex
31
- def with_memo_cache: { (Hash[memo_key, untyped] cache) -> untyped } -> untyped?
32
- def memo_matcher_for: (Symbol method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs) -> ((memo_key) -> bool)
33
- def memo_entries_for: (Symbol? method_name) -> Array[memo_entry]
34
- def safe_memo_count_for: (Symbol? method_name) -> Integer
35
- def safe_memo_keys_for: (Symbol? method_name) -> Array[untyped]
36
- def safe_memo_values_for: (Symbol? method_name) -> Array[untyped]
37
- def memo_projection: (memo_key cache_key, untyped value, include_method: bool, include_value: bool) -> Hash[Symbol, untyped]
38
- def safe_memo_cache_key: (Symbol | String method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs) -> memo_key
39
-
40
15
  module ClassMethods
41
- def memoize: (Symbol | String method_name) -> void
16
+ def memoize: (Symbol | String method_name, ?ttl: Numeric?) -> void
17
+
18
+ private
19
+
20
+ def memoized_method_visibility: (Symbol method_name) -> Symbol
21
+ end
22
+
23
+ module PublicMethods
24
+ def memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) ?{ () -> untyped } -> bool
25
+ def memo_count: (*untyped method_name) -> Integer
26
+ def memo_keys: (*untyped method_name) -> Array[untyped]
27
+ def memo_values: (*untyped method_name) -> Array[untyped]
28
+ def on_memo_expire: { (memo_key cache_key, memo_record record) -> untyped } -> void
29
+ def on_memo_evict: { (memo_key cache_key, memo_record record) -> untyped } -> void
30
+ def clear_memo_hooks: (Symbol? hook_type) -> void
31
+ def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
32
+ def reset_all_memos: () -> void
33
+ end
34
+
35
+ module CacheStoreMethods
36
+ @__safe_memo_cache__: Hash[memo_key, memo_record]?
37
+ @__safe_memo_mutex__: Mutex?
38
+
39
+ private
40
+
41
+ def with_memo_lock: { () -> untyped } -> untyped
42
+ def memo_cache_or_nil: () -> Hash[memo_key, memo_record]?
43
+ def memo_cache_hit?: (memo_key cache_key) -> bool
44
+ def memo_cache_record: (memo_key cache_key) -> memo_record?
45
+ def memo_cache_read: (memo_key cache_key) -> untyped?
46
+ def memo_fetch_or_store: (memo_key cache_key) { () -> untyped } -> untyped
47
+ def memo_mutex!: () -> Mutex
48
+ def with_memo_cache: { (Hash[memo_key, memo_record] cache) -> untyped } -> untyped?
49
+ end
50
+
51
+ module CacheRecordMethods
52
+ private
53
+
54
+ def memo_ttl: (Numeric? ttl) -> Float?
55
+ def memo_expires_at: (Float? ttl) -> Float?
56
+ def memo_record: (untyped value, expires_at: Float?) -> memo_record
57
+ def memo_record_value: (memo_record record) -> untyped
58
+ def memo_record_live?: (memo_record? record) -> bool
59
+ def memo_prune_expired_entries!: (Hash[memo_key, memo_record] cache) -> void
60
+ end
61
+
62
+ module InspectionMethods
63
+ private
64
+
65
+ def safe_memo_scoped_method: (Array[untyped] method_name) -> Symbol?
66
+ def memo_matcher_for: (Symbol method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs) -> untyped
67
+ def memo_entries_for: (Symbol? method_name) -> Array[untyped]
68
+ def safe_memo_count_for: (Symbol? method_name) -> Integer
69
+ def safe_memo_keys_for: (Symbol? method_name) -> Array[untyped]
70
+ def safe_memo_values_for: (Symbol? method_name) -> Array[untyped]
71
+ def memo_projection: (memo_key cache_key, memo_record value, include_method: bool, include_value: bool) -> Hash[Symbol, untyped]
72
+ def safe_memo_cache_key: (Symbol | String method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs) -> default_memo_key
73
+ end
74
+
75
+ module HooksMethods
76
+ @__safe_memo_hooks__: { on_expire: Array[Proc], on_evict: Array[Proc] }?
77
+
78
+ private
79
+
80
+ def memo_hook_store: () -> { on_expire: Array[Proc], on_evict: Array[Proc] }
81
+ def register_memo_hook: (Symbol hook_type) { (memo_key cache_key, memo_record record) -> untyped } -> void
82
+ def call_memo_hooks: (Symbol hook_type, memo_key cache_key, memo_record record) -> void
83
+ def _clear_memo_hooks: (Symbol? hook_type) -> void
84
+ end
85
+
86
+ module CacheMetricsMethods
87
+ @__safe_memo_metrics__: Hash[memo_key, { hits: Integer, misses: Integer, total_time: Float }]?
42
88
 
43
89
  private
44
90
 
45
- def memoized_method_visibility: (Symbol method_name) -> (:private | :protected | :public)
91
+ def memo_metrics_store: () -> Hash[memo_key, { hits: Integer, misses: Integer, total_time: Float }]
92
+ def record_cache_hit: (Symbol method_name, Array[untyped] args) -> void
93
+ def record_cache_miss: (Symbol method_name, Array[untyped] args, Float computation_time) -> void
94
+ def _reset_cache_metrics: () -> void
95
+ end
96
+
97
+ module PublicMetricsMethods
98
+ def cache_stats: () -> Hash[Symbol, untyped]
99
+ def cache_stats_for: (Symbol | String method_name) -> Hash[Symbol, untyped]
100
+ def cache_hit_rate: () -> Float
101
+ def cache_miss_rate: () -> Float
102
+ def cache_metrics_reset: () -> void
103
+ end
104
+
105
+ module CustomKeyMethods
106
+ @__safe_memo_custom_keys__: Hash[Symbol, Proc]?
107
+
108
+ private
109
+
110
+ def custom_key_store: () -> Hash[Symbol, Proc]
111
+ def register_custom_key: (Symbol | String method_name) { (*untyped args, **untyped kwargs) -> untyped } -> void
112
+ def compute_cache_key: (Symbol | String method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs) -> memo_key
113
+ def _clear_custom_keys: () -> void
114
+ end
115
+
116
+ module PublicCustomKeyMethods
117
+ def memoize_with_custom_key: (Symbol | String method_name) { (*untyped args, **untyped kwargs) -> untyped } -> void
118
+ def clear_custom_keys: (Symbol | String? method_name) -> void
119
+ end
120
+
121
+ module InstanceMethods
122
+ include PublicMethods
123
+ include CacheStoreMethods
124
+ include CacheRecordMethods
125
+ include InspectionMethods
126
+ include HooksMethods
127
+ include CacheMetricsMethods
128
+ include PublicMetricsMethods
129
+ include CustomKeyMethods
130
+ include PublicCustomKeyMethods
46
131
  end
47
132
  end
133
+
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.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -25,6 +25,17 @@ files:
25
25
  - README.md
26
26
  - Rakefile
27
27
  - lib/safe_memoize.rb
28
+ - lib/safe_memoize/cache_metrics_methods.rb
29
+ - lib/safe_memoize/cache_record_methods.rb
30
+ - lib/safe_memoize/cache_store_methods.rb
31
+ - lib/safe_memoize/class_methods.rb
32
+ - lib/safe_memoize/custom_key_methods.rb
33
+ - lib/safe_memoize/hooks_methods.rb
34
+ - lib/safe_memoize/inspection_methods.rb
35
+ - lib/safe_memoize/instance_methods.rb
36
+ - lib/safe_memoize/public_custom_key_methods.rb
37
+ - lib/safe_memoize/public_methods.rb
38
+ - lib/safe_memoize/public_metrics_methods.rb
28
39
  - lib/safe_memoize/release_tooling.rb
29
40
  - lib/safe_memoize/version.rb
30
41
  - sig/safe_memoize.rbs