safe_memoize 0.2.0 → 0.3.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: 85a5a69a8dbeb570065995485ece70d230e18cbf8b39fa204f261174707d7525
4
+ data.tar.gz: f83e4d9b0efb5552e6bf81d2924d0098caddef6748a734c382d0bae10b27ad06
5
5
  SHA512:
6
- metadata.gz: a2fd80a5146bf93a91a36e4449abc4e83d3687d6dbbf91b6323150847b5909b6f69ff25ef75e1804c2217a2652ca148357eaf19dccd7cdc3c2cf60ef255a5121
7
- data.tar.gz: d415cc760c97f1e8d3e50f33802279c065c6c56e7f89cc553717538824663b47ff974a9cbe57ccb064638d630892a837eb10cd7602db1a8c50618a52e040fe05
6
+ metadata.gz: 0a0b5d2017ddd2061ced9215747b1f38ebbaa408f5f7196e5db4c0fd9a4fb5b25c33cdd7766c001dad6596b07f93d3f6c9211bdcdccc4fc9643d1f824b849d51
7
+ data.tar.gz: '09df76d2d919373d9c7a31c77698378454bb996c39554296105dfd3ffc47d7e907ede4cfd6259d3e43ffbf9dda30296564f480b004e1b3676293d7a0f9fd2fd4'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-05-15
4
+
5
+ - Add `on_memo_hit` hook that fires on every cache hit, completing the lifecycle API alongside `on_memo_expire` and `on_memo_evict`
6
+ - Add conditional memoization via `if:` and `unless:` options on `memoize`
7
+ - `if: ->(result) { ... }` — only caches when the lambda returns truthy
8
+ - `unless: ->(result) { ... }` — skips caching when the lambda returns truthy
9
+ - Uncached calls recompute on every invocation until the condition is met
10
+ - Compatible with `ttl:`, `max_size:`, hooks, and all inspection APIs
11
+ - Add LRU cache size limit via `max_size:` option on `memoize`
12
+ - Evicts the least-recently-used entry per method when the limit is reached
13
+ - Cache hits promote entries to most-recently-used, preventing premature eviction
14
+ - Fires the existing `on_evict` hook for LRU-evicted entries
15
+ - Self-healing: stale LRU references left by `reset_memo` are pruned automatically
16
+ - Compatible with `ttl:` option and all existing inspection/reset APIs
17
+ - Thread-safe under concurrent access
18
+
3
19
  ## [0.2.0] - 2026-05-14
4
20
 
5
21
  - Add optional TTL expiration support for memoized entries
data/README.md CHANGED
@@ -28,6 +28,11 @@ SafeMemoize uses `Hash#key?` to distinguish "not yet cached" from "cached nil/fa
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
30
  - Optional TTL expiration support for cached entries
31
+ - Optional LRU cache size limit per method via `max_size:`
32
+ - Conditional caching via `if:` and `unless:` predicates
33
+ - Lifecycle hooks for hit, eviction, and expiration events
34
+ - Per-instance cache metrics (hit rate, miss rate, computation time)
35
+ - Custom cache key generation per method
31
36
  - Block arguments bypass cache (blocks aren't comparable)
32
37
 
33
38
  ## Installation
@@ -130,6 +135,44 @@ obj.reset_memo(:search, "ruby", page: 2) # Clears one positional/keyword c
130
135
  obj.reset_all_memos # Clears all memoized values
131
136
  ```
132
137
 
138
+ ### Lifecycle hooks
139
+
140
+ Register callbacks that fire when cached entries are evicted or expire.
141
+
142
+ **`on_memo_evict`** fires when an entry is removed via `reset_memo`, `reset_all_memos`, or LRU eviction:
143
+
144
+ ```ruby
145
+ obj.on_memo_evict do |cache_key, record|
146
+ Rails.logger.info("Evicted #{cache_key[0]}(#{cache_key[1].join(", ")}), was: #{record[:value].inspect}")
147
+ end
148
+ ```
149
+
150
+ **`on_memo_hit`** fires on every cache hit:
151
+
152
+ ```ruby
153
+ obj.on_memo_hit do |cache_key, record|
154
+ StatsD.increment("cache.hit", tags: ["method:#{cache_key[0]}"])
155
+ end
156
+ ```
157
+
158
+ **`on_memo_expire`** fires when a TTL entry is detected as expired (on the next call or during inspection):
159
+
160
+ ```ruby
161
+ obj.on_memo_expire do |cache_key, record|
162
+ Rails.logger.debug("TTL expired: #{cache_key[0]}")
163
+ end
164
+ ```
165
+
166
+ Multiple hooks of the same type can be registered and all will fire. Remove them with `clear_memo_hooks`:
167
+
168
+ ```ruby
169
+ obj.clear_memo_hooks(:on_evict) # Clears evict hooks only
170
+ obj.clear_memo_hooks(:on_expire) # Clears expire hooks only
171
+ obj.clear_memo_hooks # Clears all hooks
172
+ ```
173
+
174
+ Hooks are per-instance and do not affect other objects of the same class.
175
+
133
176
  ### TTL expiration
134
177
 
135
178
  ```ruby
@@ -145,6 +188,111 @@ end
145
188
 
146
189
  With a TTL, cached values expire automatically after the given number of seconds. The next call recomputes and refreshes the cache.
147
190
 
191
+ ### LRU cache size limit
192
+
193
+ 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:
194
+
195
+ ```ruby
196
+ class ProductService
197
+ prepend SafeMemoize
198
+
199
+ def find(id)
200
+ Product.find(id)
201
+ end
202
+ memoize :find, max_size: 100
203
+ end
204
+ ```
205
+
206
+ Cache hits count as recent access, so a frequently-read entry will never be the one evicted:
207
+
208
+ ```ruby
209
+ svc = ProductService.new
210
+ svc.find(1) # miss — cached
211
+ svc.find(2) # miss — cached
212
+ svc.find(1) # hit — promotes 1 to most-recently-used; 2 is now LRU
213
+ svc.find(3) # miss — evicts 2 (LRU), caches 3
214
+ ```
215
+
216
+ `max_size:` combines with `ttl:` — LRU eviction applies within the TTL window, and entries also expire normally when the TTL elapses:
217
+
218
+ ```ruby
219
+ memoize :find, max_size: 50, ttl: 300
220
+ ```
221
+
222
+ The `on_evict` hook fires for LRU-evicted entries the same way it does for manual `reset_memo` calls.
223
+
224
+ ### Conditional caching
225
+
226
+ 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.
227
+
228
+ ```ruby
229
+ class UserService
230
+ prepend SafeMemoize
231
+
232
+ # Don't cache nil — retries on every call until a user is found
233
+ def find(id)
234
+ User.find_by(id: id)
235
+ end
236
+ memoize :find, if: ->(result) { !result.nil? }
237
+ end
238
+ ```
239
+
240
+ ```ruby
241
+ class DataService
242
+ prepend SafeMemoize
243
+
244
+ # Don't cache error responses
245
+ def fetch(key)
246
+ api_client.get(key)
247
+ end
248
+ memoize :fetch, unless: ->(result) { result.is_a?(ErrorResponse) }
249
+ end
250
+ ```
251
+
252
+ Both options accept any callable and compose with `ttl:` and `max_size:`:
253
+
254
+ ```ruby
255
+ memoize :find, if: ->(result) { !result.nil? }, ttl: 60, max_size: 500
256
+ ```
257
+
258
+ ### Custom cache keys
259
+
260
+ 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:
261
+
262
+ ```ruby
263
+ class ReportService
264
+ prepend SafeMemoize
265
+
266
+ def generate(user_id, options)
267
+ build_report(user_id, options)
268
+ end
269
+ memoize :generate
270
+ end
271
+
272
+ svc = ReportService.new
273
+
274
+ # Cache only by user_id — ignore the options hash entirely
275
+ svc.memoize_with_custom_key(:generate) { |user_id, _options| user_id }
276
+
277
+ svc.generate(42, {format: :pdf}) # computes and caches
278
+ svc.generate(42, {format: :csv}) # cache hit — same user_id, options ignored
279
+ ```
280
+
281
+ The block can return any comparable value — a scalar, array, or hash:
282
+
283
+ ```ruby
284
+ svc.memoize_with_custom_key(:generate) do |user_id, options|
285
+ {user: user_id, locale: options[:locale]}
286
+ end
287
+ ```
288
+
289
+ Custom key generators are per-instance and can be cleared at any time:
290
+
291
+ ```ruby
292
+ svc.clear_custom_keys(:generate) # Remove generator for one method
293
+ svc.clear_custom_keys # Remove all custom key generators
294
+ ```
295
+
148
296
  ### Cache inspection
149
297
 
150
298
  ```ruby
@@ -163,6 +311,33 @@ obj.memo_values # Cached signatures and values for all
163
311
  obj.memo_values(:search) # Cached signatures and values for one method
164
312
  ```
165
313
 
314
+ ### Cache metrics
315
+
316
+ Each instance tracks hits, misses, and computation time automatically.
317
+
318
+ ```ruby
319
+ obj.cache_stats
320
+ # => {
321
+ # total_hits: 42,
322
+ # total_misses: 8,
323
+ # hit_rate: 84.0,
324
+ # miss_rate: 16.0,
325
+ # average_computation_time: 0.012345,
326
+ # entries: [
327
+ # { method: :find, args: [1], hits: 10, misses: 1,
328
+ # hit_rate: 90.91, computation_time: 0.005 },
329
+ # ...
330
+ # ]
331
+ # }
332
+
333
+ obj.cache_stats_for(:find) # Stats scoped to one method
334
+ obj.cache_hit_rate # => 84.0 (percentage)
335
+ obj.cache_miss_rate # => 16.0 (percentage)
336
+ obj.cache_metrics_reset # Clears all collected metrics
337
+ ```
338
+
339
+ Metrics are per-instance and reset independently from the cache itself — clearing metrics does not evict cached values.
340
+
166
341
  ## How It Works
167
342
 
168
343
  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)
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,6 +18,28 @@ 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
+ if cond_if && cond_unless
31
+ raise ArgumentError, "cannot specify both :if and :unless"
32
+ end
33
+ raise ArgumentError, ":if must be callable" if cond_if && !cond_if.respond_to?(:call)
34
+ raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call)
35
+
36
+ # Normalize to a single "should cache?" predicate
37
+ condition = if cond_if
38
+ cond_if
39
+ elsif cond_unless
40
+ ->(result) { !cond_unless.call(result) }
41
+ end
42
+
18
43
  expires_at = ttl && Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
19
44
 
20
45
  mod = Module.new do
@@ -24,22 +49,50 @@ module SafeMemoize
24
49
 
25
50
  cache_key = compute_cache_key(method_name, args, kwargs)
26
51
 
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
52
+ if max_size || condition
53
+ # Locked path: used when LRU tracking or conditional storage is needed.
54
+ memo_mutex!.synchronize do
55
+ record = memo_cache_record(cache_key)
56
+ if record
57
+ lru_touch(method_name, cache_key) if max_size
58
+ record_cache_hit(method_name, args)
59
+ call_memo_hooks(:on_hit, cache_key, record)
60
+ memo_record_value(record)
61
+ else
62
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
+ value = super(*args, **kwargs)
64
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
32
65
 
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
66
+ if !condition || condition.call(value)
67
+ lru_evict_if_over_limit(method_name, max_size) if max_size
68
+ @__safe_memo_cache__ ||= {}
69
+ @__safe_memo_cache__[cache_key] = memo_record(value, expires_at: expires_at)
70
+ lru_touch(method_name, cache_key) if max_size
71
+ end
72
+ record_cache_miss(method_name, args, elapsed_time)
37
73
 
38
- with_memo_lock do
39
- record_cache_miss(method_name, args, elapsed_time)
40
- end
74
+ value
75
+ end
76
+ end
77
+ else
78
+ # Fast path: check without lock
79
+ if (record = memo_cache_record(cache_key))
80
+ record_cache_hit(method_name, args)
81
+ call_memo_hooks(:on_hit, cache_key, record)
82
+ return memo_record_value(record)
83
+ end
41
84
 
42
- result
85
+ # Cache miss - compute and store
86
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
87
+ result = memo_fetch_or_store(cache_key, expires_at: expires_at) { super(*args, **kwargs) }
88
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
89
+
90
+ with_memo_lock do
91
+ record_cache_miss(method_name, args, elapsed_time)
92
+ end
93
+
94
+ result
95
+ end
43
96
  end
44
97
 
45
98
  send(visibility, 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: []}
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]
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: []}
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,6 +48,12 @@ 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
+
51
57
  def clear_memo_hooks(hook_type = nil)
52
58
  with_memo_lock do
53
59
  _clear_memo_hooks(hook_type)
@@ -81,6 +87,7 @@ module SafeMemoize
81
87
  end
82
88
  end
83
89
  @__safe_memo_cache__ = {}
90
+ lru_clear_all
84
91
  end
85
92
  end
86
93
  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.3.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
@@ -13,7 +13,7 @@ module SafeMemoize
13
13
  def self.prepended: (Class base) -> void
14
14
 
15
15
  module ClassMethods
16
- def memoize: (Symbol | String method_name, ?ttl: Numeric?) -> void
16
+ def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?) -> void
17
17
 
18
18
  private
19
19
 
@@ -27,6 +27,7 @@ module SafeMemoize
27
27
  def memo_values: (*untyped method_name) -> Array[untyped]
28
28
  def on_memo_expire: { (memo_key cache_key, memo_record record) -> untyped } -> void
29
29
  def on_memo_evict: { (memo_key cache_key, memo_record record) -> untyped } -> void
30
+ def on_memo_hit: { (memo_key cache_key, memo_record record) -> untyped } -> void
30
31
  def clear_memo_hooks: (Symbol? hook_type) -> void
31
32
  def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
32
33
  def reset_all_memos: () -> void
@@ -73,11 +74,11 @@ module SafeMemoize
73
74
  end
74
75
 
75
76
  module HooksMethods
76
- @__safe_memo_hooks__: { on_expire: Array[Proc], on_evict: Array[Proc] }?
77
+ @__safe_memo_hooks__: { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc] }?
77
78
 
78
79
  private
79
80
 
80
- def memo_hook_store: () -> { on_expire: Array[Proc], on_evict: Array[Proc] }
81
+ def memo_hook_store: () -> { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc] }
81
82
  def register_memo_hook: (Symbol hook_type) { (memo_key cache_key, memo_record record) -> untyped } -> void
82
83
  def call_memo_hooks: (Symbol hook_type, memo_key cache_key, memo_record record) -> void
83
84
  def _clear_memo_hooks: (Symbol? hook_type) -> void
@@ -118,6 +119,17 @@ module SafeMemoize
118
119
  def clear_custom_keys: (Symbol | String? method_name) -> void
119
120
  end
120
121
 
122
+ module LruMethods
123
+ @__safe_memo_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
124
+
125
+ private
126
+
127
+ def lru_order_store: () -> Hash[Symbol, Hash[memo_key, true]]
128
+ def lru_touch: (Symbol method_name, memo_key cache_key) -> void
129
+ def lru_evict_if_over_limit: (Symbol method_name, Integer max_size) -> void
130
+ def lru_clear_all: () -> void
131
+ end
132
+
121
133
  module InstanceMethods
122
134
  include PublicMethods
123
135
  include CacheStoreMethods
@@ -128,6 +140,7 @@ module SafeMemoize
128
140
  include PublicMetricsMethods
129
141
  include CustomKeyMethods
130
142
  include PublicCustomKeyMethods
143
+ include LruMethods
131
144
  end
132
145
  end
133
146
 
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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -33,6 +33,7 @@ files:
33
33
  - lib/safe_memoize/hooks_methods.rb
34
34
  - lib/safe_memoize/inspection_methods.rb
35
35
  - lib/safe_memoize/instance_methods.rb
36
+ - lib/safe_memoize/lru_methods.rb
36
37
  - lib/safe_memoize/public_custom_key_methods.rb
37
38
  - lib/safe_memoize/public_methods.rb
38
39
  - lib/safe_memoize/public_metrics_methods.rb