safe_memoize 0.3.0 → 0.4.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: 85a5a69a8dbeb570065995485ece70d230e18cbf8b39fa204f261174707d7525
4
- data.tar.gz: f83e4d9b0efb5552e6bf81d2924d0098caddef6748a734c382d0bae10b27ad06
3
+ metadata.gz: eb68ddebb035ad2262b063ca010d757692802d7c4ab42a8c99beffabbcc01eb2
4
+ data.tar.gz: 7d154bb103d0659956959ab7e6943e6959f01738d8491e5bbe2a911ef07e43e6
5
5
  SHA512:
6
- metadata.gz: 0a0b5d2017ddd2061ced9215747b1f38ebbaa408f5f7196e5db4c0fd9a4fb5b25c33cdd7766c001dad6596b07f93d3f6c9211bdcdccc4fc9643d1f824b849d51
7
- data.tar.gz: '09df76d2d919373d9c7a31c77698378454bb996c39554296105dfd3ffc47d7e907ede4cfd6259d3e43ffbf9dda30296564f480b004e1b3676293d7a0f9fd2fd4'
6
+ metadata.gz: ca92fa2b915bd9a2b921143c1ce3ee1044509456688a57fc225319ee99ab4cd01c2d5ae9036f810d2a9df518127c55c114e72987fb604458ec051967b30bd367
7
+ data.tar.gz: d5e6296c817536ef739b05cbc9b550c8e429450d8558bf65307188cfc2f778246f9d7318ffd39e7525278fa584b069a6143c3f6b1ed349ab4bc7d34e5ba67507
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-05-17
4
+
5
+ - Add `warm_memo`, `dump_memo`, and `load_memo` for cache warm-up and persistence
6
+ - `warm_memo(:method, *args, **kwargs) { value }` — pre-populates a cache entry via block without calling the method
7
+ - `dump_memo` / `dump_memo(:method)` — exports live cached entries as a plain `{[method, args, kwargs] => value}` hash
8
+ - `load_memo(snapshot)` — merges a snapshot into the cache; loaded entries have no TTL
9
+ - Expired entries are excluded from `dump_memo` output
10
+ - Add `shared: true` option on `memoize` to store results on the class instead of per-instance
11
+ - All instances share one cache; the method is computed only once regardless of how many objects exist
12
+ - Class-level invalidation: `reset_shared_memo`, `reset_all_shared_memos`
13
+ - Class-level inspection: `shared_memoized?`, `shared_memo_count`
14
+ - Supports `ttl:`, `if:`, and `unless:` options
15
+ - Instance hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance
16
+ - Add `memoize_all` to memoize every public method defined on the class in one call
17
+ - Accepts all options supported by `memoize` (`ttl:`, `max_size:`, `if:`, `unless:`)
18
+ - `except:` option to skip specific methods by name
19
+ - Only affects public methods defined directly on the class
20
+ - Add `on_memo_miss` hook that fires on every cache miss, completing the full lifecycle hook set alongside `on_memo_hit`, `on_memo_evict`, and `on_memo_expire`
21
+
3
22
  ## [0.3.0] - 2026-05-15
4
23
 
5
24
  - Add `on_memo_hit` hook that fires on every cache hit, completing the lifecycle API alongside `on_memo_expire` and `on_memo_evict`
data/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Thread-safe memoization for Ruby that correctly handles `nil` and `false` values.
4
4
 
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
+
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.
8
+
5
9
  ## The Problem
6
10
 
7
11
  Ruby's common memoization pattern breaks with falsy values:
@@ -16,24 +20,25 @@ SafeMemoize uses `Hash#key?` to distinguish "not yet cached" from "cached nil/fa
16
20
 
17
21
  ## Features
18
22
 
19
- - Correctly memoizes `nil` and `false` return values
20
- - Caches per unique arguments (positional and keyword)
21
- - Thread-safe via double-check locking
22
- - Zero runtime dependencies
23
- - Simple `prepend` + `memoize` API
24
- - Preserves public, protected, and private method visibility
25
- - Supports targeted cache invalidation by argument combination
26
- - Includes a `memoized?` helper for cache inspection
27
- - Includes a `memo_count` helper for cache size stats
28
- - Includes a `memo_keys` helper for inspecting cached signatures
29
- - Includes a `memo_values` helper for inspecting cached signatures and values
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
36
- - Block arguments bypass cache (blocks aren't comparable)
23
+ - [Correctly memoizes `nil` and `false` return values](#nil-and-false-safety)
24
+ - [Caches per unique arguments (positional and keyword)](#with-arguments)
25
+ - [Thread-safe via double-check locking](#how-it-works)
26
+ - [Simple `prepend` + `memoize` API](#usage)
27
+ - [Preserves public, protected, and private method visibility](#works-with-private-methods)
28
+ - [Supports targeted cache invalidation by argument combination](#cache-reset)
29
+ - [Includes a `memoized?` helper for cache inspection](#cache-inspection)
30
+ - [Includes a `memo_count` helper for cache size stats](#cache-inspection)
31
+ - [Includes a `memo_keys` helper for inspecting cached signatures](#cache-inspection)
32
+ - [Includes a `memo_values` helper for inspecting cached signatures and values](#cache-inspection)
33
+ - [Optional TTL expiration support for cached entries](#ttl-expiration)
34
+ - [Optional LRU cache size limit per method via `max_size:`](#lru-cache-size-limit)
35
+ - [Conditional caching via `if:` and `unless:` predicates](#conditional-caching)
36
+ - [Lifecycle hooks for hit, miss, eviction, and expiration events](#lifecycle-hooks)
37
+ - [Per-instance cache metrics (hit rate, miss rate, computation time)](#cache-metrics)
38
+ - [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)
41
+ - [Custom cache key generation per method](#custom-cache-keys)
37
42
 
38
43
  ## Installation
39
44
 
@@ -147,6 +152,14 @@ obj.on_memo_evict do |cache_key, record|
147
152
  end
148
153
  ```
149
154
 
155
+ **`on_memo_miss`** fires on every cache miss (i.e. the first call or after invalidation):
156
+
157
+ ```ruby
158
+ obj.on_memo_miss do |cache_key, record|
159
+ Rails.logger.debug("Cache miss: #{cache_key[0]}(#{cache_key[1].join(", ")})")
160
+ end
161
+ ```
162
+
150
163
  **`on_memo_hit`** fires on every cache hit:
151
164
 
152
165
  ```ruby
@@ -166,6 +179,8 @@ end
166
179
  Multiple hooks of the same type can be registered and all will fire. Remove them with `clear_memo_hooks`:
167
180
 
168
181
  ```ruby
182
+ obj.clear_memo_hooks(:on_miss) # Clears miss hooks only
183
+ obj.clear_memo_hooks(:on_hit) # Clears hit hooks only
169
184
  obj.clear_memo_hooks(:on_evict) # Clears evict hooks only
170
185
  obj.clear_memo_hooks(:on_expire) # Clears expire hooks only
171
186
  obj.clear_memo_hooks # Clears all hooks
@@ -255,6 +270,129 @@ Both options accept any callable and compose with `ttl:` and `max_size:`:
255
270
  memoize :find, if: ->(result) { !result.nil? }, ttl: 60, max_size: 500
256
271
  ```
257
272
 
273
+ ### Cache warm-up and persistence
274
+
275
+ #### Warming individual entries
276
+
277
+ Use `warm_memo` to pre-populate a cache entry without calling the method. The block provides the value:
278
+
279
+ ```ruby
280
+ obj.warm_memo(:current_user) { User.find(session[:user_id]) }
281
+ obj.warm_memo(:find, 42) { cached_user }
282
+ obj.warm_memo(:search, "ruby", page: 2) { cached_results }
283
+ ```
284
+
285
+ Useful for seeding the cache from a persistent store on startup, or overriding a cached value in tests.
286
+
287
+ #### Exporting and restoring the cache
288
+
289
+ `dump_memo` exports all live cached entries as a plain hash keyed by `[method, args, kwargs]`:
290
+
291
+ ```ruby
292
+ snapshot = obj.dump_memo # All methods
293
+ snapshot = obj.dump_memo(:find) # One method only
294
+ # => { [:find, [1], {}] => <User>, [:find, [2], {}] => <User>, ... }
295
+ ```
296
+
297
+ `load_memo` restores entries from a snapshot — merging into the existing cache without evicting unrelated entries:
298
+
299
+ ```ruby
300
+ obj.load_memo(snapshot)
301
+ ```
302
+
303
+ Together they enable cross-request or cross-process cache persistence:
304
+
305
+ ```ruby
306
+ # On shutdown — save to Redis
307
+ redis.set("cache:#{user_id}", Marshal.dump(obj.dump_memo))
308
+
309
+ # On boot — restore from Redis
310
+ raw = redis.get("cache:#{user_id}")
311
+ obj.load_memo(Marshal.load(raw)) if raw
312
+ ```
313
+
314
+ Loaded entries have no TTL — they persist until explicitly reset. Expired entries are excluded from `dump_memo` output, so snapshots never contain stale data.
315
+
316
+ ### Shared cache
317
+
318
+ Pass `shared: true` to store results on the class instead of per-instance. All instances share one cache, so the method is computed only once regardless of how many objects exist.
319
+
320
+ ```ruby
321
+ class ConfigService
322
+ prepend SafeMemoize
323
+
324
+ def database_url
325
+ ENV.fetch("DATABASE_URL")
326
+ end
327
+
328
+ def feature_flags
329
+ fetch_flags_from_api
330
+ end
331
+
332
+ memoize :database_url, shared: true
333
+ memoize :feature_flags, shared: true, ttl: 300
334
+ end
335
+
336
+ ConfigService.new.database_url # computes
337
+ ConfigService.new.database_url # returns cached — no recomputation
338
+ ```
339
+
340
+ Class-level invalidation and inspection:
341
+
342
+ ```ruby
343
+ ConfigService.reset_shared_memo(:feature_flags) # Clears all entries for one method
344
+ ConfigService.reset_shared_memo(:find, user_id) # Clears one argument combination
345
+ ConfigService.reset_all_shared_memos # Clears all shared cached entries
346
+ ConfigService.shared_memoized?(:database_url) # => true
347
+ ConfigService.shared_memoized?(:find, user_id) # Checks one argument combination
348
+ ConfigService.shared_memo_count # Total shared cached entries
349
+ ConfigService.shared_memo_count(:find) # Entries for one method
350
+ ```
351
+
352
+ `shared: true` supports `ttl:`, `if:`, and `unless:` options. `max_size:` is not supported (shared LRU may be added in a future release).
353
+
354
+ Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance as usual.
355
+
356
+ ### Bulk memoization
357
+
358
+ Use `memoize_all` to memoize every public method defined on the class in one call:
359
+
360
+ ```ruby
361
+ class ConfigService
362
+ prepend SafeMemoize
363
+
364
+ def database_url
365
+ ENV.fetch("DATABASE_URL")
366
+ end
367
+
368
+ def redis_url
369
+ ENV.fetch("REDIS_URL")
370
+ end
371
+
372
+ def feature_flags
373
+ fetch_flags_from_api
374
+ end
375
+
376
+ memoize_all
377
+ end
378
+ ```
379
+
380
+ All options accepted by `memoize` can be passed as shared options:
381
+
382
+ ```ruby
383
+ memoize_all ttl: 60
384
+ memoize_all max_size: 100
385
+ memoize_all if: ->(result) { !result.nil? }
386
+ ```
387
+
388
+ Use `except:` to skip specific methods:
389
+
390
+ ```ruby
391
+ memoize_all except: [:version, :name]
392
+ ```
393
+
394
+ Only public methods defined directly on the class are memoized — inherited, private, and protected methods are not affected.
395
+
258
396
  ### Custom cache keys
259
397
 
260
398
  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:
@@ -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)
5
+ def memoize(method_name, ttl: nil, max_size: nil, if: nil, unless: nil, shared: false)
6
6
  method_name = method_name.to_sym
7
7
  visibility = memoized_method_visibility(method_name)
8
8
 
@@ -27,6 +27,8 @@ module SafeMemoize
27
27
  max_size
28
28
  end
29
29
 
30
+ raise ArgumentError, "max_size: is not supported with shared: true" if shared && max_size
31
+
30
32
  if cond_if && cond_unless
31
33
  raise ArgumentError, "cannot specify both :if and :unless"
32
34
  end
@@ -42,6 +44,52 @@ module SafeMemoize
42
44
 
43
45
  expires_at = ttl && Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
44
46
 
47
+ if shared
48
+ klass = self
49
+ shared_mutex = klass.send(:__safe_memo_shared_mutex__)
50
+
51
+ mod = Module.new do
52
+ define_method(method_name) do |*args, **kwargs, &block|
53
+ return super(*args, **kwargs, &block) if block
54
+
55
+ cache_key = compute_cache_key(method_name, args, kwargs)
56
+
57
+ shared_mutex.synchronize do
58
+ shared_cache = klass.send(:__safe_memo_shared_cache__)
59
+ record = shared_cache[cache_key]
60
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ record_live = record && (record[:expires_at].nil? || record[:expires_at] > now)
62
+
63
+ if record_live
64
+ record_cache_hit(method_name, args)
65
+ call_memo_hooks(:on_hit, cache_key, record)
66
+ record[:value]
67
+ else
68
+ call_memo_hooks(:on_expire, cache_key, record) if record && !record_live
69
+
70
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
71
+ value = super(*args, **kwargs)
72
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
73
+
74
+ new_record = {value: value, expires_at: expires_at}
75
+ shared_cache[cache_key] = new_record unless condition && !condition.call(value)
76
+
77
+ record_cache_miss(method_name, args, elapsed_time)
78
+ call_memo_hooks(:on_miss, cache_key, new_record)
79
+
80
+ value
81
+ end
82
+ end
83
+ end
84
+
85
+ send(visibility, method_name)
86
+ end
87
+
88
+ prepend mod
89
+
90
+ return
91
+ end
92
+
45
93
  mod = Module.new do
46
94
  define_method(method_name) do |*args, **kwargs, &block|
47
95
  # Blocks bypass cache entirely — they aren't comparable
@@ -63,13 +111,15 @@ module SafeMemoize
63
111
  value = super(*args, **kwargs)
64
112
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
65
113
 
114
+ new_record = memo_record(value, expires_at: expires_at)
66
115
  if !condition || condition.call(value)
67
116
  lru_evict_if_over_limit(method_name, max_size) if max_size
68
117
  @__safe_memo_cache__ ||= {}
69
- @__safe_memo_cache__[cache_key] = memo_record(value, expires_at: expires_at)
118
+ @__safe_memo_cache__[cache_key] = new_record
70
119
  lru_touch(method_name, cache_key) if max_size
71
120
  end
72
121
  record_cache_miss(method_name, args, elapsed_time)
122
+ call_memo_hooks(:on_miss, cache_key, new_record)
73
123
 
74
124
  value
75
125
  end
@@ -89,6 +139,8 @@ module SafeMemoize
89
139
 
90
140
  with_memo_lock do
91
141
  record_cache_miss(method_name, args, elapsed_time)
142
+ new_record = memo_cache_record(cache_key)
143
+ call_memo_hooks(:on_miss, cache_key, new_record)
92
144
  end
93
145
 
94
146
  result
@@ -101,8 +153,69 @@ module SafeMemoize
101
153
  prepend mod
102
154
  end
103
155
 
156
+ def reset_shared_memo(method_name, *args, **kwargs)
157
+ 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
164
+
165
+ __safe_memo_shared_mutex__.synchronize do
166
+ __safe_memo_shared_cache__.delete_if { |key, _| matcher.call(key) }
167
+ end
168
+ end
169
+
170
+ def reset_all_shared_memos
171
+ __safe_memo_shared_mutex__.synchronize do
172
+ @__safe_memo_shared_cache__ = {}
173
+ end
174
+ end
175
+
176
+ def shared_memoized?(method_name, *args, **kwargs)
177
+ method_name = method_name.to_sym
178
+ cache_key = [method_name, args, kwargs]
179
+
180
+ __safe_memo_shared_mutex__.synchronize do
181
+ cache = @__safe_memo_shared_cache__
182
+ return false unless cache
183
+
184
+ record = cache[cache_key]
185
+ return false unless record
186
+
187
+ record[:expires_at].nil? || record[:expires_at] > Process.clock_gettime(Process::CLOCK_MONOTONIC)
188
+ end
189
+ end
190
+
191
+ def shared_memo_count(method_name = nil)
192
+ __safe_memo_shared_mutex__.synchronize do
193
+ cache = @__safe_memo_shared_cache__ || {}
194
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
195
+ live = cache.reject { |_, r| r[:expires_at] && r[:expires_at] <= now }
196
+ method_name ? live.count { |key, _| key[0] == method_name.to_sym } : live.count
197
+ end
198
+ end
199
+
200
+ def memoize_all(except: [], **options)
201
+ excluded = Array(except).map(&:to_sym)
202
+ public_instance_methods(false).each do |method_name|
203
+ next if excluded.include?(method_name)
204
+
205
+ memoize(method_name, **options)
206
+ end
207
+ end
208
+
104
209
  private
105
210
 
211
+ def __safe_memo_shared_cache__
212
+ @__safe_memo_shared_cache__ ||= {}
213
+ end
214
+
215
+ def __safe_memo_shared_mutex__
216
+ @__safe_memo_shared_mutex__ ||= Mutex.new
217
+ end
218
+
106
219
  def memoized_method_visibility(method_name)
107
220
  return :private if private_method_defined?(method_name)
108
221
  return :protected if protected_method_defined?(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: [], on_hit: []}
8
+ @__safe_memo_hooks__ ||= {on_expire: [], on_evict: [], on_hit: [], on_miss: []}
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, :on_hit]
14
+ valid_hooks = [:on_expire, :on_evict, :on_hit, :on_miss]
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: [], on_hit: []}
29
+ @__safe_memo_hooks__ = {on_expire: [], on_evict: [], on_hit: [], on_miss: []}
30
30
  end
31
31
  end
32
32
  end
@@ -54,12 +54,57 @@ module SafeMemoize
54
54
  register_memo_hook(:on_hit, &block)
55
55
  end
56
56
 
57
+ def on_memo_miss(&block)
58
+ raise ArgumentError, "block required" unless block
59
+
60
+ register_memo_hook(:on_miss, &block)
61
+ end
62
+
57
63
  def clear_memo_hooks(hook_type = nil)
58
64
  with_memo_lock do
59
65
  _clear_memo_hooks(hook_type)
60
66
  end
61
67
  end
62
68
 
69
+ def warm_memo(method_name, *args, **kwargs, &block)
70
+ raise ArgumentError, "block required" unless block
71
+
72
+ method_name = method_name.to_sym
73
+ cache_key = compute_cache_key(method_name, args, kwargs)
74
+ value = block.call
75
+
76
+ with_memo_lock do
77
+ @__safe_memo_cache__ ||= {}
78
+ @__safe_memo_cache__[cache_key] = memo_record(value, expires_at: nil)
79
+ end
80
+
81
+ value
82
+ end
83
+
84
+ def dump_memo(method_name = nil)
85
+ method_name = method_name&.to_sym
86
+
87
+ with_memo_lock do
88
+ cache = memo_cache_or_nil || {}
89
+ entries = method_name ? cache.select { |key, _| key[0] == method_name } : cache.dup
90
+ entries.select! { |_, record| memo_record_live?(record) }
91
+ entries.transform_values { |record| memo_record_value(record) }
92
+ end
93
+ end
94
+
95
+ def load_memo(snapshot)
96
+ raise ArgumentError, "snapshot must be a Hash" unless snapshot.is_a?(Hash)
97
+
98
+ with_memo_lock do
99
+ @__safe_memo_cache__ ||= {}
100
+ snapshot.each do |cache_key, value|
101
+ @__safe_memo_cache__[cache_key] = memo_record(value, expires_at: nil)
102
+ end
103
+ end
104
+
105
+ nil
106
+ end
107
+
63
108
  def reset_memo(method_name, *args, **kwargs)
64
109
  method_name = method_name.to_sym
65
110
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/sig/safe_memoize.rbs CHANGED
@@ -9,18 +9,29 @@ module SafeMemoize
9
9
 
10
10
  @__safe_memo_cache__: Hash[memo_key, memo_record]?
11
11
  @__safe_memo_mutex__: Mutex?
12
+ @__safe_memo_shared_cache__: Hash[memo_key, memo_record]?
13
+ @__safe_memo_shared_mutex__: Mutex?
12
14
 
13
15
  def self.prepended: (Class base) -> void
14
16
 
15
17
  module ClassMethods
16
- def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?) -> void
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
20
+ def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
21
+ def reset_all_shared_memos: () -> void
22
+ def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
23
+ def shared_memo_count: (?Symbol | String method_name) -> Integer
17
24
 
18
25
  private
19
26
 
27
+ def __safe_memo_shared_cache__: () -> Hash[memo_key, memo_record]
28
+ def __safe_memo_shared_mutex__: () -> Mutex
20
29
  def memoized_method_visibility: (Symbol method_name) -> Symbol
21
30
  end
22
31
 
23
32
  module PublicMethods
33
+ @__safe_memo_cache__: Hash[memo_key, memo_record]?
34
+
24
35
  def memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) ?{ () -> untyped } -> bool
25
36
  def memo_count: (*untyped method_name) -> Integer
26
37
  def memo_keys: (*untyped method_name) -> Array[untyped]
@@ -28,7 +39,11 @@ module SafeMemoize
28
39
  def on_memo_expire: { (memo_key cache_key, memo_record record) -> untyped } -> void
29
40
  def on_memo_evict: { (memo_key cache_key, memo_record record) -> untyped } -> void
30
41
  def on_memo_hit: { (memo_key cache_key, memo_record record) -> untyped } -> void
42
+ def on_memo_miss: { (memo_key cache_key, memo_record record) -> untyped } -> void
31
43
  def clear_memo_hooks: (Symbol? hook_type) -> void
44
+ def warm_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) { () -> untyped } -> untyped
45
+ def dump_memo: (?Symbol | String method_name) -> Hash[memo_key, untyped]
46
+ def load_memo: (Hash[memo_key, untyped] snapshot) -> nil
32
47
  def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
33
48
  def reset_all_memos: () -> void
34
49
  end
@@ -74,11 +89,11 @@ module SafeMemoize
74
89
  end
75
90
 
76
91
  module HooksMethods
77
- @__safe_memo_hooks__: { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc] }?
92
+ @__safe_memo_hooks__: { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc], on_miss: Array[Proc] }?
78
93
 
79
94
  private
80
95
 
81
- def memo_hook_store: () -> { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc] }
96
+ def memo_hook_store: () -> { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc], on_miss: Array[Proc] }
82
97
  def register_memo_hook: (Symbol hook_type) { (memo_key cache_key, memo_record record) -> untyped } -> void
83
98
  def call_memo_hooks: (Symbol hook_type, memo_key cache_key, memo_record record) -> void
84
99
  def _clear_memo_hooks: (Symbol? hook_type) -> void
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -9,9 +9,16 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: A prepend-based memoization module for Ruby that safely caches nil and
13
- false return values, supports method arguments, and provides thread safety via double-check
14
- locking.
12
+ description: 'SafeMemoize is a production-ready, zero-dependency memoization library
13
+ for Ruby. It uses Ruby''s prepend mechanism to wrap methods with a thread-safe cache
14
+ (Mutex + double-check locking) that correctly handles nil and false return values
15
+ — fixing the silent bug in the common ||= pattern. Results are cached per unique
16
+ argument combination, so parameterized methods only compute each variant once. Additional
17
+ features include TTL expiration, LRU cache size limiting, conditional caching via
18
+ if:/unless: predicates, lifecycle hooks for hit/eviction/expiration events, per-instance
19
+ metrics (hit rate, miss rate, computation time), targeted cache invalidation, custom
20
+ cache key generators, and introspection helpers. Method visibility (public, protected,
21
+ private) is fully preserved.'
15
22
  email:
16
23
  - eclectic-coding@users.noreply.github.com
17
24
  executables: []