safe_memoize 0.5.0 → 0.6.1

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: 036e876f53d96a1c6ca54ae257bb631c30b0b973a0f0bed77b6fafd92f187c93
4
+ data.tar.gz: 2d09472476154ff424940a59666b5a891d95156d44a829025ad9929f92fed24d
5
5
  SHA512:
6
- metadata.gz: aab068ebb5b277b6b74f79ea2226c81efe3eb620a3b54630339238c3fd536797261af12e84ed446ccd325cf5902180f3c9bd2b00c2ba591a589c829d6223ea30
7
- data.tar.gz: c75b60edcc7a189d0e062eb470b64874de56a916ac8369ceb1a7992bc23e7ec1a2303550f620da0be35fc75ad2d28419faba0ddc0c089a5875203004af73272e
6
+ metadata.gz: d0004ea27b6d21d3e32df92f782ddb0d4a768212d4344e3d4e4793d6eb44bb481b309a05a875bb166e1633f3be8c08e4580b006f512dd74723383e11516ff5c4
7
+ data.tar.gz: 6a81f21e5e4347a723305893cfcf3dbe8b4b71bdb0ef3767774276e7046893e5ec12471898b6ecfc87df135b0462691324fa16808369a5ad98fc6e54d6ce1a14
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.1] - 2026-05-17
4
+
5
+ - Fix `memo_keys` and `memo_values` showing `args: custom_key, kwargs: nil` for methods using `memoize_with_custom_key` — now surfaces as `custom_key:`
6
+ - Refactor `cache_stats` / `cache_stats_for` to share aggregation logic via private helpers
7
+
8
+ ## [0.6.0] - 2026-05-17
9
+
10
+ - Fix TTL clock starting at `memoize` definition time instead of first method call
11
+ - Fix metrics key silently dropping kwargs, causing methods that differ only in kwargs to share a metrics bucket
12
+ - Fix stale LRU references remaining after expired entries are pruned
13
+ - Add `ttl:` option to `warm_memo` so warmed entries can be given an expiry
14
+ - Add `max_size:` support for `shared: true` memoization (class-level LRU eviction)
15
+ - Add `ttl_refresh: true` option on `memoize` for sliding window TTL — resets expiry on every cache hit
16
+ - Add `include_protected:` and `include_private:` options to `memoize_all`
17
+ - Add `memo_ttl_remaining` for TTL introspection — returns seconds until expiry, `nil` for no TTL, `0` for uncached/expired
18
+
3
19
  ## [0.5.0] - 2026-05-17
4
20
 
5
21
  - Drop support for Ruby 3.2 (EOL); minimum required version is now Ruby 3.3
@@ -15,7 +31,7 @@
15
31
  - All instances share one cache; the method is computed only once regardless of how many objects exist
16
32
  - Class-level invalidation: `reset_shared_memo`, `reset_all_shared_memos`
17
33
  - Class-level inspection: `shared_memoized?`, `shared_memo_count`
18
- - Supports `ttl:`, `if:`, and `unless:` options
34
+ - Supports `ttl:`, `if:`, and `unless:` options
19
35
  - Instance hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance
20
36
  - Add `memoize_all` to memoize every public method defined on the class in one call
21
37
  - Accepts all options supported by `memoize` (`ttl:`, `max_size:`, `if:`, `unless:`)
data/README.md CHANGED
@@ -4,7 +4,7 @@ Thread-safe memoization for Ruby that correctly handles `nil` and `false` values
4
4
 
5
5
  SafeMemoize is a production-ready, zero-dependency memoization library for Ruby. It wraps methods with a `prepend`-based cache that handles everything the standard `||=` idiom gets wrong: `nil` and `false` return values are cached correctly, per-argument result maps eliminate redundant computation for parameterized methods, and a per-instance `Mutex` with double-check locking makes the whole thing safe under concurrent load.
6
6
 
7
- Beyond the basics, SafeMemoize ships with TTL expiration, LRU cache size capping, conditional caching via `if:`/`unless:` predicates, lifecycle hooks for cache hits, evictions, and expirations, per-instance metrics (hit rate, miss rate, average computation time), targeted and bulk cache invalidation, custom cache key generators, and rich introspection helpers (`memoized?`, `memo_count`, `memo_keys`, `memo_values`). It preserves method visibility (public, protected, and private) and requires no runtime dependencies.
7
+ Beyond the basics, SafeMemoize ships with TTL expiration (including sliding window refresh via `ttl_refresh:`), LRU cache size capping, conditional caching via `if:`/`unless:` predicates, lifecycle hooks for cache hits, evictions, and expirations, per-instance metrics (hit rate, miss rate, average computation time), targeted and bulk cache invalidation, custom cache key generators, and rich introspection helpers (`memoized?`, `memo_count`, `memo_keys`, `memo_values`, `memo_ttl_remaining`). It preserves method visibility (public, protected, and private) and requires no runtime dependencies.
8
8
 
9
9
  ## The Problem
10
10
 
@@ -31,14 +31,16 @@ SafeMemoize uses `Hash#key?` to distinguish "not yet cached" from "cached nil/fa
31
31
  - [Includes a `memo_keys` helper for inspecting cached signatures](#cache-inspection)
32
32
  - [Includes a `memo_values` helper for inspecting cached signatures and values](#cache-inspection)
33
33
  - [Optional TTL expiration support for cached entries](#ttl-expiration)
34
+ - [Sliding window TTL via `ttl_refresh: true`](#sliding-window-ttl)
34
35
  - [Optional LRU cache size limit per method via `max_size:`](#lru-cache-size-limit)
35
36
  - [Conditional caching via `if:` and `unless:` predicates](#conditional-caching)
36
37
  - [Lifecycle hooks for hit, miss, eviction, and expiration events](#lifecycle-hooks)
37
38
  - [Per-instance cache metrics (hit rate, miss rate, computation time)](#cache-metrics)
38
39
  - [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)
40
+ - [Class-level shared cache via `shared: true` with optional LRU](#shared-cache)
41
+ - [Bulk memoization via `memoize_all` (public, protected, and private)](#bulk-memoization)
41
42
  - [Custom cache key generation per method](#custom-cache-keys)
43
+ - [TTL introspection via `memo_ttl_remaining`](#cache-inspection)
42
44
 
43
45
  ## Installation
44
46
 
@@ -203,6 +205,23 @@ end
203
205
 
204
206
  With a TTL, cached values expire automatically after the given number of seconds. The next call recomputes and refreshes the cache.
205
207
 
208
+ ### Sliding window TTL
209
+
210
+ Add `ttl_refresh: true` to reset the expiry clock on every cache hit, so the entry only expires after a full TTL of inactivity:
211
+
212
+ ```ruby
213
+ class SessionService
214
+ prepend SafeMemoize
215
+
216
+ def user_data(user_id)
217
+ fetch_from_db(user_id)
218
+ end
219
+ memoize :user_data, ttl: 300, ttl_refresh: true
220
+ end
221
+ ```
222
+
223
+ Without `ttl_refresh:`, the entry expires 300 seconds after it was first cached. With it, the clock resets on every read — the entry is evicted only if the method goes 300 seconds without being called. `ttl_refresh: true` requires `ttl:` to be set and works with both per-instance and `shared: true` memoization.
224
+
206
225
  ### LRU cache size limit
207
226
 
208
227
  Pass `max_size:` to cap how many entries a method can hold. When the limit is reached the least-recently-used entry is evicted to make room:
@@ -282,6 +301,12 @@ obj.warm_memo(:find, 42) { cached_user }
282
301
  obj.warm_memo(:search, "ruby", page: 2) { cached_results }
283
302
  ```
284
303
 
304
+ Pass `ttl:` to give the warmed entry an expiry:
305
+
306
+ ```ruby
307
+ obj.warm_memo(:current_quote, ttl: 60) { cached_quote }
308
+ ```
309
+
285
310
  Useful for seeding the cache from a persistent store on startup, or overriding a cached value in tests.
286
311
 
287
312
  #### Exporting and restoring the cache
@@ -349,9 +374,15 @@ ConfigService.shared_memo_count # Total shared cached entr
349
374
  ConfigService.shared_memo_count(:find) # Entries for one method
350
375
  ```
351
376
 
352
- `shared: true` supports `ttl:`, `if:`, and `unless:` options. `max_size:` is not supported (shared LRU may be added in a future release).
377
+ `shared: true` supports `ttl:`, `ttl_refresh:`, `if:`, `unless:`, and `max_size:` options.
378
+
379
+ Pass `max_size:` to cap how many entries are kept across all instances. Eviction is LRU, tracked at the class level:
380
+
381
+ ```ruby
382
+ memoize :find, shared: true, max_size: 500
383
+ ```
353
384
 
354
- Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance as usual.
385
+ Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`, `on_memo_evict`) fire on the calling instance as usual.
355
386
 
356
387
  ### Bulk memoization
357
388
 
@@ -391,7 +422,15 @@ Use `except:` to skip specific methods:
391
422
  memoize_all except: [:version, :name]
392
423
  ```
393
424
 
394
- Only public methods defined directly on the class are memoized inherited, private, and protected methods are not affected.
425
+ By default only public methods defined directly on the class are memoized. Use `include_protected:` or `include_private:` to opt those visibilities in:
426
+
427
+ ```ruby
428
+ memoize_all include_protected: true
429
+ memoize_all include_private: true
430
+ memoize_all include_protected: true, include_private: true
431
+ ```
432
+
433
+ Inherited methods are never affected regardless of visibility.
395
434
 
396
435
  ### Custom cache keys
397
436
 
@@ -447,6 +486,10 @@ obj.memo_keys # All cached signatures with method, a
447
486
  obj.memo_keys(:search) # Cached signatures for one method
448
487
  obj.memo_values # Cached signatures and values for all methods
449
488
  obj.memo_values(:search) # Cached signatures and values for one method
489
+
490
+ obj.memo_ttl_remaining(:current_quote) # => 47.231 (seconds until expiry)
491
+ obj.memo_ttl_remaining(:current_user) # => nil (no TTL set)
492
+ obj.memo_ttl_remaining(:find, 42) # => 0 (not cached or already expired)
450
493
  ```
451
494
 
452
495
  ### Cache metrics
@@ -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,16 +2,20 @@
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
 
9
+ # :if and :unless are reserved Ruby keywords, so they can't be referenced
10
+ # as local variables directly. binding.local_variable_get is the only way
11
+ # to read keyword arguments with those names inside the method body.
9
12
  cond_if = binding.local_variable_get(:if)
10
13
  cond_unless = binding.local_variable_get(:unless)
11
14
 
12
15
  ttl = if ttl.nil?
13
16
  nil
14
17
  else
18
+
15
19
  ttl = Float(ttl)
16
20
  raise ArgumentError, "ttl must be non-negative" if ttl < 0
17
21
 
@@ -27,7 +31,7 @@ module SafeMemoize
27
31
  max_size
28
32
  end
29
33
 
30
- raise ArgumentError, "max_size: is not supported with shared: true" if shared && max_size
34
+ raise ArgumentError, "ttl_refresh: requires a ttl: to be set" if ttl_refresh && ttl.nil?
31
35
 
32
36
  if cond_if && cond_unless
33
37
  raise ArgumentError, "cannot specify both :if and :unless"
@@ -42,8 +46,6 @@ module SafeMemoize
42
46
  ->(result) { !cond_unless.call(result) }
43
47
  end
44
48
 
45
- expires_at = ttl && Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
46
-
47
49
  if shared
48
50
  klass = self
49
51
  shared_mutex = klass.send(:__safe_memo_shared_mutex__)
@@ -61,7 +63,13 @@ module SafeMemoize
61
63
  record_live = record && (record[:expires_at].nil? || record[:expires_at] > now)
62
64
 
63
65
  if record_live
64
- record_cache_hit(method_name, args)
66
+ if max_size
67
+ lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
68
+ lru.delete(cache_key)
69
+ lru[cache_key] = true
70
+ end
71
+ record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
72
+ record_cache_hit(method_name, args, kwargs)
65
73
  call_memo_hooks(:on_hit, cache_key, record)
66
74
  record[:value]
67
75
  else
@@ -71,10 +79,27 @@ module SafeMemoize
71
79
  value = super(*args, **kwargs)
72
80
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
73
81
 
74
- new_record = {value: value, expires_at: expires_at}
75
- shared_cache[cache_key] = new_record unless condition && !condition.call(value)
82
+ new_record = {value: value, expires_at: memo_expires_at(ttl)}
76
83
 
77
- record_cache_miss(method_name, args, elapsed_time)
84
+ if !condition || condition.call(value)
85
+ if max_size
86
+ lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
87
+ lru.delete_if { |key, _| !shared_cache.key?(key) }
88
+ if lru.size >= max_size
89
+ lru_key = lru.keys.first
90
+ lru.delete(lru_key)
91
+ evicted = shared_cache.delete(lru_key)
92
+ call_memo_hooks(:on_evict, lru_key, evicted) if evicted
93
+ end
94
+ end
95
+ shared_cache[cache_key] = new_record
96
+ if max_size
97
+ lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
98
+ lru[cache_key] = true
99
+ end
100
+ end
101
+
102
+ record_cache_miss(method_name, args, kwargs, elapsed_time)
78
103
  call_memo_hooks(:on_miss, cache_key, new_record)
79
104
 
80
105
  value
@@ -97,13 +122,14 @@ module SafeMemoize
97
122
 
98
123
  cache_key = compute_cache_key(method_name, args, kwargs)
99
124
 
100
- if max_size || condition
101
- # Locked path: used when LRU tracking or conditional storage is needed.
125
+ if max_size || condition || ttl_refresh
126
+ # Locked path: used when LRU tracking, conditional storage, or TTL refresh is needed.
102
127
  memo_mutex!.synchronize do
103
128
  record = memo_cache_record(cache_key)
104
129
  if record
105
130
  lru_touch(method_name, cache_key) if max_size
106
- record_cache_hit(method_name, args)
131
+ record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
132
+ record_cache_hit(method_name, args, kwargs)
107
133
  call_memo_hooks(:on_hit, cache_key, record)
108
134
  memo_record_value(record)
109
135
  else
@@ -111,14 +137,14 @@ module SafeMemoize
111
137
  value = super(*args, **kwargs)
112
138
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
113
139
 
114
- new_record = memo_record(value, expires_at: expires_at)
140
+ new_record = memo_record(value, expires_at: memo_expires_at(ttl))
115
141
  if !condition || condition.call(value)
116
142
  lru_evict_if_over_limit(method_name, max_size) if max_size
117
143
  @__safe_memo_cache__ ||= {}
118
144
  @__safe_memo_cache__[cache_key] = new_record
119
145
  lru_touch(method_name, cache_key) if max_size
120
146
  end
121
- record_cache_miss(method_name, args, elapsed_time)
147
+ record_cache_miss(method_name, args, kwargs, elapsed_time)
122
148
  call_memo_hooks(:on_miss, cache_key, new_record)
123
149
 
124
150
  value
@@ -127,18 +153,18 @@ module SafeMemoize
127
153
  else
128
154
  # Fast path: check without lock
129
155
  if (record = memo_cache_record(cache_key))
130
- record_cache_hit(method_name, args)
156
+ record_cache_hit(method_name, args, kwargs)
131
157
  call_memo_hooks(:on_hit, cache_key, record)
132
158
  return memo_record_value(record)
133
159
  end
134
160
 
135
161
  # Cache miss - compute and store
136
162
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
- result = memo_fetch_or_store(cache_key, expires_at: expires_at) { super(*args, **kwargs) }
163
+ result = memo_fetch_or_store(cache_key, ttl: ttl) { super(*args, **kwargs) }
138
164
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
139
165
 
140
166
  with_memo_lock do
141
- record_cache_miss(method_name, args, elapsed_time)
167
+ record_cache_miss(method_name, args, kwargs, elapsed_time)
142
168
  new_record = memo_cache_record(cache_key)
143
169
  call_memo_hooks(:on_miss, cache_key, new_record)
144
170
  end
@@ -155,21 +181,23 @@ module SafeMemoize
155
181
 
156
182
  def reset_shared_memo(method_name, *args, **kwargs)
157
183
  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
184
+ specific_key = (args.empty? && kwargs.empty?) ? nil : [method_name, args, kwargs]
164
185
 
165
186
  __safe_memo_shared_mutex__.synchronize do
166
- __safe_memo_shared_cache__.delete_if { |key, _| matcher.call(key) }
187
+ if specific_key
188
+ __safe_memo_shared_cache__.delete(specific_key)
189
+ __safe_memo_shared_lru_order__[method_name]&.delete(specific_key)
190
+ else
191
+ __safe_memo_shared_cache__.delete_if { |key, _| key[0] == method_name }
192
+ __safe_memo_shared_lru_order__.delete(method_name)
193
+ end
167
194
  end
168
195
  end
169
196
 
170
197
  def reset_all_shared_memos
171
198
  __safe_memo_shared_mutex__.synchronize do
172
199
  @__safe_memo_shared_cache__ = {}
200
+ @__safe_memo_shared_lru_order__ = {}
173
201
  end
174
202
  end
175
203
 
@@ -197,9 +225,14 @@ module SafeMemoize
197
225
  end
198
226
  end
199
227
 
200
- def memoize_all(except: [], **options)
228
+ def memoize_all(except: [], include_protected: false, include_private: false, **options)
201
229
  excluded = Array(except).map(&:to_sym)
202
- public_instance_methods(false).each do |method_name|
230
+
231
+ methods = public_instance_methods(false)
232
+ methods |= protected_instance_methods(false) if include_protected
233
+ methods |= private_instance_methods(false) if include_private
234
+
235
+ methods.each do |method_name|
203
236
  next if excluded.include?(method_name)
204
237
 
205
238
  memoize(method_name, **options)
@@ -216,6 +249,10 @@ module SafeMemoize
216
249
  @__safe_memo_shared_mutex__ ||= Mutex.new
217
250
  end
218
251
 
252
+ def __safe_memo_shared_lru_order__
253
+ @__safe_memo_shared_lru_order__ ||= {}
254
+ end
255
+
219
256
  def memoized_method_visibility(method_name)
220
257
  return :private if private_method_defined?(method_name)
221
258
  return :protected if protected_method_defined?(method_name)
@@ -53,9 +53,16 @@ module SafeMemoize
53
53
  end
54
54
 
55
55
  def memo_projection(cache_key, value, include_method:, include_value:)
56
- method_name, args, kwargs = cache_key
56
+ # Custom keys are [method, custom_key] (2 elements); default keys are
57
+ # [method, args, kwargs] (3 elements). Detect and surface accordingly.
58
+ if cache_key.length == 2
59
+ method_name, custom_key = cache_key
60
+ payload = {custom_key: custom_key}
61
+ else
62
+ method_name, args, kwargs = cache_key
63
+ payload = {args: args, kwargs: kwargs}
64
+ end
57
65
 
58
- payload = {args: args, kwargs: kwargs}
59
66
  payload[:method] = method_name if include_method
60
67
  payload[:value] = memo_record_value(value) if include_value
61
68
  payload
@@ -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
@@ -5,53 +5,9 @@ module SafeMemoize
5
5
  def cache_stats
6
6
  with_memo_lock do
7
7
  metrics = memo_metrics_store
8
+ return empty_stats if metrics.empty?
8
9
 
9
- if metrics.empty?
10
- return {
11
- total_hits: 0,
12
- total_misses: 0,
13
- hit_rate: 0.0,
14
- miss_rate: 0.0,
15
- average_computation_time: 0.0,
16
- entries: []
17
- }
18
- end
19
-
20
- total_hits = metrics.values.sum { |m| m[:hits] }
21
- total_misses = metrics.values.sum { |m| m[:misses] }
22
- total_time = metrics.values.sum { |m| m[:total_time] }
23
- total_calls = total_hits + total_misses
24
-
25
- hit_rate = total_calls.zero? ? 0.0 : (total_hits.to_f / total_calls * 100).round(2)
26
- miss_rate = total_calls.zero? ? 0.0 : (total_misses.to_f / total_calls * 100).round(2)
27
- avg_time = total_misses.zero? ? 0.0 : (total_time / total_misses).round(6)
28
-
29
- entries = metrics.map do |cache_key, stats|
30
- method_name, args, _kwargs = cache_key
31
- entry_hit_rate = if (stats[:hits] + stats[:misses]).zero?
32
- 0.0
33
- else
34
- (stats[:hits].to_f / (stats[:hits] + stats[:misses]) * 100).round(2)
35
- end
36
-
37
- {
38
- method: method_name,
39
- args: args,
40
- hits: stats[:hits],
41
- misses: stats[:misses],
42
- hit_rate: entry_hit_rate,
43
- computation_time: stats[:total_time].round(6)
44
- }
45
- end
46
-
47
- {
48
- total_hits: total_hits,
49
- total_misses: total_misses,
50
- hit_rate: hit_rate,
51
- miss_rate: miss_rate,
52
- average_computation_time: avg_time,
53
- entries: entries
54
- }
10
+ aggregate_metrics(metrics, include_method: true)
55
11
  end
56
12
  end
57
13
 
@@ -59,67 +15,19 @@ module SafeMemoize
59
15
  method_name = method_name.to_sym
60
16
 
61
17
  with_memo_lock do
62
- metrics = memo_metrics_store
63
- method_metrics = metrics.select { |key, _| key[0] == method_name }
64
-
65
- if method_metrics.empty?
66
- return {
67
- method: method_name,
68
- total_hits: 0,
69
- total_misses: 0,
70
- hit_rate: 0.0,
71
- miss_rate: 0.0,
72
- average_computation_time: 0.0,
73
- entries: []
74
- }
75
- end
76
-
77
- total_hits = method_metrics.values.sum { |m| m[:hits] }
78
- total_misses = method_metrics.values.sum { |m| m[:misses] }
79
- total_time = method_metrics.values.sum { |m| m[:total_time] }
80
- total_calls = total_hits + total_misses
81
-
82
- hit_rate = total_calls.zero? ? 0.0 : (total_hits.to_f / total_calls * 100).round(2)
83
- miss_rate = total_calls.zero? ? 0.0 : (total_misses.to_f / total_calls * 100).round(2)
84
- avg_time = total_misses.zero? ? 0.0 : (total_time / total_misses).round(6)
85
-
86
- entries = method_metrics.map do |cache_key, stats|
87
- _method, args, _kwargs = cache_key
88
- entry_hit_rate = if (stats[:hits] + stats[:misses]).zero?
89
- 0.0
90
- else
91
- (stats[:hits].to_f / (stats[:hits] + stats[:misses]) * 100).round(2)
92
- end
93
-
94
- {
95
- args: args,
96
- hits: stats[:hits],
97
- misses: stats[:misses],
98
- hit_rate: entry_hit_rate,
99
- computation_time: stats[:total_time].round(6)
100
- }
101
- end
18
+ metrics = memo_metrics_store.select { |key, _| key[0] == method_name }
19
+ return empty_stats.merge(method: method_name) if metrics.empty?
102
20
 
103
- {
104
- method: method_name,
105
- total_hits: total_hits,
106
- total_misses: total_misses,
107
- hit_rate: hit_rate,
108
- miss_rate: miss_rate,
109
- average_computation_time: avg_time,
110
- entries: entries
111
- }
21
+ aggregate_metrics(metrics, include_method: false).merge(method: method_name)
112
22
  end
113
23
  end
114
24
 
115
25
  def cache_hit_rate
116
- stats = cache_stats
117
- stats[:hit_rate]
26
+ cache_stats[:hit_rate]
118
27
  end
119
28
 
120
29
  def cache_miss_rate
121
- stats = cache_stats
122
- stats[:miss_rate]
30
+ cache_stats[:miss_rate]
123
31
  end
124
32
 
125
33
  def cache_metrics_reset
@@ -127,5 +35,43 @@ module SafeMemoize
127
35
  _reset_cache_metrics
128
36
  end
129
37
  end
38
+
39
+ private
40
+
41
+ def aggregate_metrics(metrics, include_method:)
42
+ total_hits = metrics.values.sum { |m| m[:hits] }
43
+ total_misses = metrics.values.sum { |m| m[:misses] }
44
+ total_time = metrics.values.sum { |m| m[:total_time] }
45
+ total_calls = total_hits + total_misses
46
+
47
+ hit_rate = total_calls.zero? ? 0.0 : (total_hits.to_f / total_calls * 100).round(2)
48
+ miss_rate = total_calls.zero? ? 0.0 : (total_misses.to_f / total_calls * 100).round(2)
49
+ avg_time = total_misses.zero? ? 0.0 : (total_time / total_misses).round(6)
50
+
51
+ entries = metrics.map do |cache_key, stats|
52
+ method_name, args, _kwargs = cache_key
53
+ entry_calls = stats[:hits] + stats[:misses]
54
+ entry_hit_rate = entry_calls.zero? ? 0.0 : (stats[:hits].to_f / entry_calls * 100).round(2)
55
+
56
+ entry = {args: args, hits: stats[:hits], misses: stats[:misses],
57
+ hit_rate: entry_hit_rate, computation_time: stats[:total_time].round(6)}
58
+ entry[:method] = method_name if include_method
59
+ entry
60
+ end
61
+
62
+ {
63
+ total_hits: total_hits,
64
+ total_misses: total_misses,
65
+ hit_rate: hit_rate,
66
+ miss_rate: miss_rate,
67
+ average_computation_time: avg_time,
68
+ entries: entries
69
+ }
70
+ end
71
+
72
+ def empty_stats
73
+ {total_hits: 0, total_misses: 0, hit_rate: 0.0, miss_rate: 0.0,
74
+ average_computation_time: 0.0, entries: []}
75
+ end
130
76
  end
131
77
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.1"
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
 
@@ -116,6 +119,11 @@ module SafeMemoize
116
119
  def cache_hit_rate: () -> Float
117
120
  def cache_miss_rate: () -> Float
118
121
  def cache_metrics_reset: () -> void
122
+
123
+ private
124
+
125
+ def aggregate_metrics: (Hash[memo_key, untyped] metrics, include_method: bool) -> Hash[Symbol, untyped]
126
+ def empty_stats: () -> Hash[Symbol, untyped]
119
127
  end
120
128
 
121
129
  module CustomKeyMethods
@@ -142,6 +150,7 @@ module SafeMemoize
142
150
  def lru_order_store: () -> Hash[Symbol, Hash[memo_key, true]]
143
151
  def lru_touch: (Symbol method_name, memo_key cache_key) -> void
144
152
  def lru_evict_if_over_limit: (Symbol method_name, Integer max_size) -> void
153
+ def lru_remove: (Symbol method_name, memo_key cache_key) -> void
145
154
  def lru_clear_all: () -> void
146
155
  end
147
156
 
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith