safe_memoize 0.5.0 → 0.6.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: 64bc56c4cd1fc255353c579f2e740b88516bafc0bfbd9365f605b1ef8af36b6f
4
- data.tar.gz: b37f9d850b6d599fda50ef47f4a3e03b21484040a9efabaabba63d9891d0be6f
3
+ metadata.gz: c378e971e08b2de42905d7cb00f76b4312c7c7fd596857eb051ee311a0690aed
4
+ data.tar.gz: c0bae56bbdca32caa3b9fa4ff8f0707728e9571298a8dfcb377f9937fe0b8586
5
5
  SHA512:
6
- metadata.gz: aab068ebb5b277b6b74f79ea2226c81efe3eb620a3b54630339238c3fd536797261af12e84ed446ccd325cf5902180f3c9bd2b00c2ba591a589c829d6223ea30
7
- data.tar.gz: c75b60edcc7a189d0e062eb470b64874de56a916ac8369ceb1a7992bc23e7ec1a2303550f620da0be35fc75ad2d28419faba0ddc0c089a5875203004af73272e
6
+ metadata.gz: 8185afdd3cf81fec90dc3dd02656f394d2d60385d6b6b015b66034c898600c8c68cd20712c5e4ad51ec0957f4bf168d01b5694b1cae036823d01ac17b9d87ffb
7
+ data.tar.gz: 2e37ee4fcf6b57d5deb2fa252b140cb1d1e20ff67d4ad06444ceae6e075eb7699a662010254a22d5322121febe7cc72a520947432ee16bf6077729f71fe758fb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-05-17
4
+
5
+ - Fix TTL clock starting at `memoize` definition time instead of first method call
6
+ - Fix metrics key silently dropping kwargs, causing methods that differ only in kwargs to share a metrics bucket
7
+ - Fix stale LRU references remaining after expired entries are pruned
8
+ - Add `ttl:` option to `warm_memo` so warmed entries can be given an expiry
9
+ - Add `max_size:` support for `shared: true` memoization (class-level LRU eviction)
10
+ - Add `ttl_refresh: true` option on `memoize` for sliding window TTL — resets expiry on every cache hit
11
+ - Add `include_protected:` and `include_private:` options to `memoize_all`
12
+ - Add `memo_ttl_remaining` for TTL introspection — returns seconds until expiry, `nil` for no TTL, `0` for uncached/expired
13
+
3
14
  ## [0.5.0] - 2026-05-17
4
15
 
5
16
  - Drop support for Ruby 3.2 (EOL); minimum required version is now Ruby 3.3
@@ -15,7 +26,7 @@
15
26
  - All instances share one cache; the method is computed only once regardless of how many objects exist
16
27
  - Class-level invalidation: `reset_shared_memo`, `reset_all_shared_memos`
17
28
  - Class-level inspection: `shared_memoized?`, `shared_memo_count`
18
- - Supports `ttl:`, `if:`, and `unless:` options
29
+ - Supports `ttl:`, `if:`, and `unless:` options
19
30
  - Instance hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance
20
31
  - Add `memoize_all` to memoize every public method defined on the class in one call
21
32
  - Accepts all options supported by `memoize` (`ttl:`, `max_size:`, `if:`, `unless:`)
@@ -8,15 +8,15 @@ module SafeMemoize
8
8
  @__safe_memo_metrics__ ||= {}
9
9
  end
10
10
 
11
- def record_cache_hit(method_name, args)
12
- cache_key = safe_memo_cache_key(method_name, args, {})
11
+ def record_cache_hit(method_name, args, kwargs)
12
+ cache_key = safe_memo_cache_key(method_name, args, kwargs)
13
13
  metrics = memo_metrics_store
14
14
  metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
15
15
  metrics[cache_key][:hits] += 1
16
16
  end
17
17
 
18
- def record_cache_miss(method_name, args, computation_time)
19
- cache_key = safe_memo_cache_key(method_name, args, {})
18
+ def record_cache_miss(method_name, args, kwargs, computation_time)
19
+ cache_key = safe_memo_cache_key(method_name, args, kwargs)
20
20
  metrics = memo_metrics_store
21
21
  metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
22
22
  metrics[cache_key][:misses] += 1
@@ -42,6 +42,7 @@ module SafeMemoize
42
42
  cache.delete_if do |cache_key, record|
43
43
  if !memo_record_live?(record)
44
44
  call_memo_hooks(:on_expire, cache_key, record)
45
+ lru_remove(cache_key[0], cache_key)
45
46
  true
46
47
  else
47
48
  false
@@ -39,7 +39,7 @@ module SafeMemoize
39
39
  memo_record_value(record)
40
40
  end
41
41
 
42
- def memo_fetch_or_store(cache_key, expires_at: nil)
42
+ def memo_fetch_or_store(cache_key, ttl: nil)
43
43
  memo_mutex!.synchronize do
44
44
  @__safe_memo_cache__ ||= {}
45
45
 
@@ -49,7 +49,7 @@ module SafeMemoize
49
49
  memo_record_value(record)
50
50
  else
51
51
  value = yield
52
- @__safe_memo_cache__[cache_key] = memo_record(value, expires_at: expires_at)
52
+ @__safe_memo_cache__[cache_key] = memo_record(value, expires_at: memo_expires_at(ttl))
53
53
 
54
54
  value
55
55
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SafeMemoize
4
4
  module ClassMethods
5
- def memoize(method_name, ttl: nil, max_size: nil, if: nil, unless: nil, shared: false)
5
+ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false)
6
6
  method_name = method_name.to_sym
7
7
  visibility = memoized_method_visibility(method_name)
8
8
 
@@ -12,6 +12,7 @@ module SafeMemoize
12
12
  ttl = if ttl.nil?
13
13
  nil
14
14
  else
15
+
15
16
  ttl = Float(ttl)
16
17
  raise ArgumentError, "ttl must be non-negative" if ttl < 0
17
18
 
@@ -27,7 +28,7 @@ module SafeMemoize
27
28
  max_size
28
29
  end
29
30
 
30
- raise ArgumentError, "max_size: is not supported with shared: true" if shared && max_size
31
+ raise ArgumentError, "ttl_refresh: requires a ttl: to be set" if ttl_refresh && ttl.nil?
31
32
 
32
33
  if cond_if && cond_unless
33
34
  raise ArgumentError, "cannot specify both :if and :unless"
@@ -42,8 +43,6 @@ module SafeMemoize
42
43
  ->(result) { !cond_unless.call(result) }
43
44
  end
44
45
 
45
- expires_at = ttl && Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
46
-
47
46
  if shared
48
47
  klass = self
49
48
  shared_mutex = klass.send(:__safe_memo_shared_mutex__)
@@ -61,7 +60,13 @@ module SafeMemoize
61
60
  record_live = record && (record[:expires_at].nil? || record[:expires_at] > now)
62
61
 
63
62
  if record_live
64
- record_cache_hit(method_name, args)
63
+ if max_size
64
+ lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
65
+ lru.delete(cache_key)
66
+ lru[cache_key] = true
67
+ end
68
+ record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
69
+ record_cache_hit(method_name, args, kwargs)
65
70
  call_memo_hooks(:on_hit, cache_key, record)
66
71
  record[:value]
67
72
  else
@@ -71,10 +76,27 @@ module SafeMemoize
71
76
  value = super(*args, **kwargs)
72
77
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
73
78
 
74
- new_record = {value: value, expires_at: expires_at}
75
- shared_cache[cache_key] = new_record unless condition && !condition.call(value)
79
+ new_record = {value: value, expires_at: memo_expires_at(ttl)}
76
80
 
77
- record_cache_miss(method_name, args, elapsed_time)
81
+ if !condition || condition.call(value)
82
+ if max_size
83
+ lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
84
+ lru.delete_if { |key, _| !shared_cache.key?(key) }
85
+ if lru.size >= max_size
86
+ lru_key = lru.keys.first
87
+ lru.delete(lru_key)
88
+ evicted = shared_cache.delete(lru_key)
89
+ call_memo_hooks(:on_evict, lru_key, evicted) if evicted
90
+ end
91
+ end
92
+ shared_cache[cache_key] = new_record
93
+ if max_size
94
+ lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
95
+ lru[cache_key] = true
96
+ end
97
+ end
98
+
99
+ record_cache_miss(method_name, args, kwargs, elapsed_time)
78
100
  call_memo_hooks(:on_miss, cache_key, new_record)
79
101
 
80
102
  value
@@ -97,13 +119,14 @@ module SafeMemoize
97
119
 
98
120
  cache_key = compute_cache_key(method_name, args, kwargs)
99
121
 
100
- if max_size || condition
101
- # Locked path: used when LRU tracking or conditional storage is needed.
122
+ if max_size || condition || ttl_refresh
123
+ # Locked path: used when LRU tracking, conditional storage, or TTL refresh is needed.
102
124
  memo_mutex!.synchronize do
103
125
  record = memo_cache_record(cache_key)
104
126
  if record
105
127
  lru_touch(method_name, cache_key) if max_size
106
- record_cache_hit(method_name, args)
128
+ record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
129
+ record_cache_hit(method_name, args, kwargs)
107
130
  call_memo_hooks(:on_hit, cache_key, record)
108
131
  memo_record_value(record)
109
132
  else
@@ -111,14 +134,14 @@ module SafeMemoize
111
134
  value = super(*args, **kwargs)
112
135
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
113
136
 
114
- new_record = memo_record(value, expires_at: expires_at)
137
+ new_record = memo_record(value, expires_at: memo_expires_at(ttl))
115
138
  if !condition || condition.call(value)
116
139
  lru_evict_if_over_limit(method_name, max_size) if max_size
117
140
  @__safe_memo_cache__ ||= {}
118
141
  @__safe_memo_cache__[cache_key] = new_record
119
142
  lru_touch(method_name, cache_key) if max_size
120
143
  end
121
- record_cache_miss(method_name, args, elapsed_time)
144
+ record_cache_miss(method_name, args, kwargs, elapsed_time)
122
145
  call_memo_hooks(:on_miss, cache_key, new_record)
123
146
 
124
147
  value
@@ -127,18 +150,18 @@ module SafeMemoize
127
150
  else
128
151
  # Fast path: check without lock
129
152
  if (record = memo_cache_record(cache_key))
130
- record_cache_hit(method_name, args)
153
+ record_cache_hit(method_name, args, kwargs)
131
154
  call_memo_hooks(:on_hit, cache_key, record)
132
155
  return memo_record_value(record)
133
156
  end
134
157
 
135
158
  # Cache miss - compute and store
136
159
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
- result = memo_fetch_or_store(cache_key, expires_at: expires_at) { super(*args, **kwargs) }
160
+ result = memo_fetch_or_store(cache_key, ttl: ttl) { super(*args, **kwargs) }
138
161
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
139
162
 
140
163
  with_memo_lock do
141
- record_cache_miss(method_name, args, elapsed_time)
164
+ record_cache_miss(method_name, args, kwargs, elapsed_time)
142
165
  new_record = memo_cache_record(cache_key)
143
166
  call_memo_hooks(:on_miss, cache_key, new_record)
144
167
  end
@@ -155,21 +178,23 @@ module SafeMemoize
155
178
 
156
179
  def reset_shared_memo(method_name, *args, **kwargs)
157
180
  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
181
+ specific_key = (args.empty? && kwargs.empty?) ? nil : [method_name, args, kwargs]
164
182
 
165
183
  __safe_memo_shared_mutex__.synchronize do
166
- __safe_memo_shared_cache__.delete_if { |key, _| matcher.call(key) }
184
+ if specific_key
185
+ __safe_memo_shared_cache__.delete(specific_key)
186
+ __safe_memo_shared_lru_order__[method_name]&.delete(specific_key)
187
+ else
188
+ __safe_memo_shared_cache__.delete_if { |key, _| key[0] == method_name }
189
+ __safe_memo_shared_lru_order__.delete(method_name)
190
+ end
167
191
  end
168
192
  end
169
193
 
170
194
  def reset_all_shared_memos
171
195
  __safe_memo_shared_mutex__.synchronize do
172
196
  @__safe_memo_shared_cache__ = {}
197
+ @__safe_memo_shared_lru_order__ = {}
173
198
  end
174
199
  end
175
200
 
@@ -197,9 +222,14 @@ module SafeMemoize
197
222
  end
198
223
  end
199
224
 
200
- def memoize_all(except: [], **options)
225
+ def memoize_all(except: [], include_protected: false, include_private: false, **options)
201
226
  excluded = Array(except).map(&:to_sym)
202
- public_instance_methods(false).each do |method_name|
227
+
228
+ methods = public_instance_methods(false)
229
+ methods |= protected_instance_methods(false) if include_protected
230
+ methods |= private_instance_methods(false) if include_private
231
+
232
+ methods.each do |method_name|
203
233
  next if excluded.include?(method_name)
204
234
 
205
235
  memoize(method_name, **options)
@@ -216,6 +246,10 @@ module SafeMemoize
216
246
  @__safe_memo_shared_mutex__ ||= Mutex.new
217
247
  end
218
248
 
249
+ def __safe_memo_shared_lru_order__
250
+ @__safe_memo_shared_lru_order__ ||= {}
251
+ end
252
+
219
253
  def memoized_method_visibility(method_name)
220
254
  return :private if private_method_defined?(method_name)
221
255
  return :protected if protected_method_defined?(method_name)
@@ -38,6 +38,11 @@ module SafeMemoize
38
38
  call_memo_hooks(:on_evict, lru_cache_key, record) if record
39
39
  end
40
40
 
41
+ # Remove a single cache key from LRU tracking. Called when an entry expires.
42
+ def lru_remove(method_name, cache_key)
43
+ lru_order_store[method_name]&.delete(cache_key)
44
+ end
45
+
41
46
  # Clear all LRU tracking state. Called by reset_all_memos.
42
47
  def lru_clear_all
43
48
  @__safe_memo_lru_order__ = {}
@@ -12,6 +12,21 @@ module SafeMemoize
12
12
  end
13
13
  end
14
14
 
15
+ def memo_ttl_remaining(method_name, *args, **kwargs)
16
+ cache_key = safe_memo_cache_key(method_name, args, kwargs)
17
+
18
+ with_memo_lock do
19
+ record = memo_cache_record(cache_key)
20
+ return 0 unless record
21
+
22
+ expires_at = record[:expires_at]
23
+ return nil unless expires_at
24
+
25
+ remaining = expires_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
+ (remaining > 0) ? remaining.round(6) : 0
27
+ end
28
+ end
29
+
15
30
  def memo_count(*method_name)
16
31
  scoped_method = safe_memo_scoped_method(method_name)
17
32
 
@@ -66,7 +81,7 @@ module SafeMemoize
66
81
  end
67
82
  end
68
83
 
69
- def warm_memo(method_name, *args, **kwargs, &block)
84
+ def warm_memo(method_name, *args, ttl: nil, **kwargs, &block)
70
85
  raise ArgumentError, "block required" unless block
71
86
 
72
87
  method_name = method_name.to_sym
@@ -75,7 +90,7 @@ module SafeMemoize
75
90
 
76
91
  with_memo_lock do
77
92
  @__safe_memo_cache__ ||= {}
78
- @__safe_memo_cache__[cache_key] = memo_record(value, expires_at: nil)
93
+ @__safe_memo_cache__[cache_key] = memo_record(value, expires_at: memo_expires_at(ttl))
79
94
  end
80
95
 
81
96
  value
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/sig/safe_memoize.rbs CHANGED
@@ -11,12 +11,13 @@ module SafeMemoize
11
11
  @__safe_memo_mutex__: Mutex?
12
12
  @__safe_memo_shared_cache__: Hash[memo_key, memo_record]?
13
13
  @__safe_memo_shared_mutex__: Mutex?
14
+ @__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
14
15
 
15
16
  def self.prepended: (Class base) -> void
16
17
 
17
18
  module ClassMethods
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
19
+ def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool) -> void
20
+ def memoize_all: (?except: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?) -> void
20
21
  def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
21
22
  def reset_all_shared_memos: () -> void
22
23
  def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
@@ -26,6 +27,7 @@ module SafeMemoize
26
27
 
27
28
  def __safe_memo_shared_cache__: () -> Hash[memo_key, memo_record]
28
29
  def __safe_memo_shared_mutex__: () -> Mutex
30
+ def __safe_memo_shared_lru_order__: () -> Hash[Symbol, Hash[memo_key, true]]
29
31
  def memoized_method_visibility: (Symbol method_name) -> Symbol
30
32
  end
31
33
 
@@ -33,6 +35,7 @@ module SafeMemoize
33
35
  @__safe_memo_cache__: Hash[memo_key, memo_record]?
34
36
 
35
37
  def memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) ?{ () -> untyped } -> bool
38
+ def memo_ttl_remaining: (Symbol | String method_name, *untyped args, **untyped kwargs) -> (Float | Integer | nil)
36
39
  def memo_count: (*untyped method_name) -> Integer
37
40
  def memo_keys: (*untyped method_name) -> Array[untyped]
38
41
  def memo_values: (*untyped method_name) -> Array[untyped]
@@ -41,7 +44,7 @@ module SafeMemoize
41
44
  def on_memo_hit: { (memo_key cache_key, memo_record record) -> untyped } -> void
42
45
  def on_memo_miss: { (memo_key cache_key, memo_record record) -> untyped } -> void
43
46
  def clear_memo_hooks: (Symbol? hook_type) -> void
44
- def warm_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) { () -> untyped } -> untyped
47
+ def warm_memo: (Symbol | String method_name, *untyped args, ?ttl: Numeric?, **untyped kwargs) { () -> untyped } -> untyped
45
48
  def dump_memo: (?Symbol | String method_name) -> Hash[memo_key, untyped]
46
49
  def load_memo: (Hash[memo_key, untyped] snapshot) -> nil
47
50
  def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
@@ -59,7 +62,7 @@ module SafeMemoize
59
62
  def memo_cache_hit?: (memo_key cache_key) -> bool
60
63
  def memo_cache_record: (memo_key cache_key) -> memo_record?
61
64
  def memo_cache_read: (memo_key cache_key) -> untyped?
62
- def memo_fetch_or_store: (memo_key cache_key) { () -> untyped } -> untyped
65
+ def memo_fetch_or_store: (memo_key cache_key, ?ttl: Float?) { () -> untyped } -> untyped
63
66
  def memo_mutex!: () -> Mutex
64
67
  def with_memo_cache: { (Hash[memo_key, memo_record] cache) -> untyped } -> untyped?
65
68
  end
@@ -105,8 +108,8 @@ module SafeMemoize
105
108
  private
106
109
 
107
110
  def memo_metrics_store: () -> Hash[memo_key, { hits: Integer, misses: Integer, total_time: Float }]
108
- def record_cache_hit: (Symbol method_name, Array[untyped] args) -> void
109
- def record_cache_miss: (Symbol method_name, Array[untyped] args, Float computation_time) -> void
111
+ def record_cache_hit: (Symbol method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs) -> void
112
+ def record_cache_miss: (Symbol method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs, Float computation_time) -> void
110
113
  def _reset_cache_metrics: () -> void
111
114
  end
112
115
 
@@ -142,6 +145,7 @@ module SafeMemoize
142
145
  def lru_order_store: () -> Hash[Symbol, Hash[memo_key, true]]
143
146
  def lru_touch: (Symbol method_name, memo_key cache_key) -> void
144
147
  def lru_evict_if_over_limit: (Symbol method_name, Integer max_size) -> void
148
+ def lru_remove: (Symbol method_name, memo_key cache_key) -> void
145
149
  def lru_clear_all: () -> void
146
150
  end
147
151
 
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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith