safe_memoize 1.1.0 → 1.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 +4 -4
- data/.github/workflows/ci.yml +2 -2
- data/CHANGELOG.md +57 -1
- data/README.md +584 -6
- data/ROADMAP.md +0 -30
- data/Rakefile +9 -5
- data/lib/safe_memoize/adapters/concurrent_ruby.rb +98 -0
- data/lib/safe_memoize/cache_metrics_methods.rb +4 -5
- data/lib/safe_memoize/class_methods.rb +330 -23
- data/lib/safe_memoize/configuration.rb +8 -0
- data/lib/safe_memoize/custom_key_methods.rb +11 -2
- data/lib/safe_memoize/extension.rb +88 -0
- data/lib/safe_memoize/fiber_local_methods.rb +109 -0
- data/lib/safe_memoize/hooks_methods.rb +8 -1
- data/lib/safe_memoize/inspection_methods.rb +34 -5
- data/lib/safe_memoize/instance_methods.rb +1 -0
- data/lib/safe_memoize/public_methods.rb +3 -2
- data/lib/safe_memoize/public_metrics_methods.rb +4 -3
- data/lib/safe_memoize/ractor_shared_methods.rb +146 -0
- data/lib/safe_memoize/release_tooling.rb +25 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +141 -0
- data/sig/safe_memoize.rbs +77 -2
- metadata +5 -1
|
@@ -29,6 +29,36 @@ module SafeMemoize
|
|
|
29
29
|
# {Stores::Base} subclass instance. The store is shared across all instances of the
|
|
30
30
|
# class. When +nil+, the default per-instance in-process hash is used.
|
|
31
31
|
# Cannot be combined with +max_size:+ or +shared:+.
|
|
32
|
+
# @param fiber_local [Boolean] when +true+, results are stored in
|
|
33
|
+
# +Fiber[:__safe_memoize__]+ rather than instance variables. Each fiber gets its
|
|
34
|
+
# own isolated cache that is automatically discarded when the fiber terminates. No
|
|
35
|
+
# mutex is acquired. Cannot be combined with +shared:+ or +store:+.
|
|
36
|
+
# @param ractor_safe [Boolean] when +true+, the class-level shared cache is owned by
|
|
37
|
+
# a supervisor +Ractor+ rather than a +Mutex+-protected ivar, making it accessible
|
|
38
|
+
# from worker Ractors. Requires +shared: true+. Cached values are deep-frozen via
|
|
39
|
+
# +Ractor.make_shareable+. Incompatible with +if:+, +unless:+, +max_size:+,
|
|
40
|
+
# +ttl_refresh:+, +key:+, and +store:+.
|
|
41
|
+
# @param namespace [String, nil] prefix prepended to every cache key for this method,
|
|
42
|
+
# scoping it to a logical partition. Takes precedence over both the class-level
|
|
43
|
+
# {#safe_memoize_namespace} and the global {SafeMemoize::Configuration#namespace}.
|
|
44
|
+
# Useful for versioning a single method independently of its peers. Must not contain
|
|
45
|
+
# the character +:+.
|
|
46
|
+
# @param cache_bust [Proc, Symbol, nil] callable invoked on the instance (via
|
|
47
|
+
# +instance_exec+) on every cache lookup to obtain a version token. The token is
|
|
48
|
+
# folded into the cache key alongside the normal arguments, so when the token
|
|
49
|
+
# changes (e.g. an ActiveRecord +updated_at+ timestamp advances after a +save+)
|
|
50
|
+
# the old key no longer matches any entry — the method is recomputed and the result
|
|
51
|
+
# stored under the new key. Accepts any callable (+Proc+, +lambda+, +Method+) that
|
|
52
|
+
# takes no arguments, or a +Symbol+ naming an instance method. Cannot be combined
|
|
53
|
+
# with +key:+.
|
|
54
|
+
# @param shared_cache [String, nil] name of a globally-registered shared cache store
|
|
55
|
+
# (see {SafeMemoize.shared_cache} and {SafeMemoize.register_shared_cache}). All
|
|
56
|
+
# instances of any class that memoizes a method with the same +shared_cache:+ name
|
|
57
|
+
# read and write the same backing store, enabling cross-class cache sharing.
|
|
58
|
+
# The store is resolved at +memoize+ definition time; call
|
|
59
|
+
# {SafeMemoize.register_shared_cache} before the class is loaded to supply a custom
|
|
60
|
+
# adapter. Incompatible with +shared:+, +store:+, +fiber_local:+, +ractor_safe:+,
|
|
61
|
+
# and +max_size:+. Composes naturally with +namespace:+, +ttl:+, +if:+, and +key:+.
|
|
32
62
|
# @return [void]
|
|
33
63
|
# @raise [ArgumentError] if the method does not exist, or option values are invalid
|
|
34
64
|
#
|
|
@@ -46,13 +76,32 @@ module SafeMemoize
|
|
|
46
76
|
# @example With a custom store
|
|
47
77
|
# STORE = SafeMemoize::Stores::Memory.new
|
|
48
78
|
# memoize :fetch, store: STORE, ttl: 300
|
|
49
|
-
def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil)
|
|
79
|
+
def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false, namespace: nil, shared_cache: nil, cache_bust: nil, **extension_options)
|
|
50
80
|
method_name = method_name.to_sym
|
|
51
81
|
|
|
52
82
|
unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
|
|
53
83
|
raise ArgumentError, "cannot memoize :#{method_name} — no instance method with that name is defined on #{self}"
|
|
54
84
|
end
|
|
55
85
|
|
|
86
|
+
unless extension_options.empty?
|
|
87
|
+
extension_options.each_key do |opt|
|
|
88
|
+
raise ArgumentError, "unknown memoize option :#{opt} — no registered extension handles it" unless SafeMemoize.extension_for_option(opt)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
injected = {}
|
|
92
|
+
extension_options.each do |opt, val|
|
|
93
|
+
result = SafeMemoize.extension_for_option(opt).process_memoize_option(opt, val, method_name, extension_options)
|
|
94
|
+
injected.merge!(result)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
ttl = injected[:ttl] if injected.key?(:ttl)
|
|
98
|
+
max_size = injected[:max_size] if injected.key?(:max_size)
|
|
99
|
+
namespace = injected[:namespace] if injected.key?(:namespace)
|
|
100
|
+
store = injected[:store] if injected.key?(:store)
|
|
101
|
+
shared_cache = injected[:shared_cache] if injected.key?(:shared_cache)
|
|
102
|
+
cache_bust = injected[:cache_bust] if injected.key?(:cache_bust)
|
|
103
|
+
end
|
|
104
|
+
|
|
56
105
|
visibility = memoized_method_visibility(method_name)
|
|
57
106
|
|
|
58
107
|
config = SafeMemoize.configuration
|
|
@@ -93,27 +142,73 @@ module SafeMemoize
|
|
|
93
142
|
raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call)
|
|
94
143
|
raise ArgumentError, ":key must be callable" if key && !key.respond_to?(:call)
|
|
95
144
|
|
|
145
|
+
if cache_bust
|
|
146
|
+
unless cache_bust.respond_to?(:call) || cache_bust.is_a?(Symbol)
|
|
147
|
+
raise ArgumentError, "cache_bust: must be a callable or Symbol (got #{cache_bust.class})"
|
|
148
|
+
end
|
|
149
|
+
raise ArgumentError, "cache_bust: and key: cannot be combined" if key
|
|
150
|
+
end
|
|
151
|
+
|
|
96
152
|
if store
|
|
97
153
|
raise ArgumentError, "store: must be a SafeMemoize::Stores::Base instance (got #{store.class})" unless store.is_a?(SafeMemoize::Stores::Base)
|
|
98
154
|
raise ArgumentError, "max_size: is not supported with store: — use the store adapter's own eviction" if max_size
|
|
99
155
|
raise ArgumentError, "shared: and store: cannot be combined" if shared
|
|
100
156
|
end
|
|
101
157
|
|
|
102
|
-
|
|
103
|
-
|
|
158
|
+
if fiber_local
|
|
159
|
+
raise ArgumentError, "fiber_local: and shared: cannot be combined" if shared
|
|
160
|
+
raise ArgumentError, "fiber_local: and store: cannot be combined" if store
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if ractor_safe
|
|
164
|
+
raise ArgumentError, "ractor_safe: requires shared: true" unless shared
|
|
165
|
+
raise ArgumentError, "ractor_safe: is incompatible with if:/unless:" if cond_if || cond_unless
|
|
166
|
+
raise ArgumentError, "ractor_safe: is incompatible with max_size:" if max_size
|
|
167
|
+
raise ArgumentError, "ractor_safe: is incompatible with ttl_refresh:" if ttl_refresh
|
|
168
|
+
raise ArgumentError, "ractor_safe: is incompatible with key:" if key
|
|
169
|
+
raise ArgumentError, "ractor_safe: is incompatible with store:" if store
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if namespace
|
|
173
|
+
raise ArgumentError, "namespace: must be a String (got #{namespace.class})" unless namespace.is_a?(String)
|
|
174
|
+
raise ArgumentError, "namespace: must not be empty" if namespace.empty?
|
|
175
|
+
raise ArgumentError, "namespace: must not contain ':'" if namespace.include?(":")
|
|
176
|
+
__safe_memo_method_namespaces__[method_name] = namespace
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
if shared_cache
|
|
180
|
+
raise ArgumentError, "shared_cache: must be a String (got #{shared_cache.class})" unless shared_cache.is_a?(String)
|
|
181
|
+
raise ArgumentError, "shared_cache: must not be empty" if shared_cache.empty?
|
|
182
|
+
raise ArgumentError, "shared_cache: and shared: cannot be combined" if shared
|
|
183
|
+
raise ArgumentError, "shared_cache: and store: cannot be combined" if store
|
|
184
|
+
raise ArgumentError, "shared_cache: and fiber_local: cannot be combined" if fiber_local
|
|
185
|
+
raise ArgumentError, "shared_cache: and ractor_safe: cannot be combined" if ractor_safe
|
|
186
|
+
raise ArgumentError, "max_size: is not supported with shared_cache: — use the store adapter's own eviction" if max_size
|
|
187
|
+
store = SafeMemoize.shared_cache(shared_cache)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Resolve effective store: per-method store: wins; then class-level
|
|
191
|
+
# safe_memoize_store; then global default_store. max_size: and shared:
|
|
192
|
+
# are incompatible with external stores — fall back silently.
|
|
104
193
|
effective_store = store
|
|
105
194
|
if effective_store.nil? && !max_size && !shared
|
|
106
|
-
|
|
107
|
-
if
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
195
|
+
class_store = safe_memoize_store
|
|
196
|
+
if class_store
|
|
197
|
+
effective_store = class_store
|
|
198
|
+
else
|
|
199
|
+
global_default = SafeMemoize.configuration.default_store
|
|
200
|
+
if global_default
|
|
201
|
+
unless global_default.is_a?(SafeMemoize::Stores::Base)
|
|
202
|
+
raise ArgumentError,
|
|
203
|
+
"SafeMemoize.configuration.default_store must be a Stores::Base instance (got #{global_default.class})"
|
|
204
|
+
end
|
|
205
|
+
effective_store = global_default
|
|
111
206
|
end
|
|
112
|
-
effective_store = global_default
|
|
113
207
|
end
|
|
114
208
|
end
|
|
115
209
|
|
|
116
210
|
__safe_memo_class_key_generators__[method_name] = key if key
|
|
211
|
+
__safe_memo_class_cache_bust_generators__[method_name] = cache_bust if cache_bust
|
|
117
212
|
|
|
118
213
|
# Normalize to a single "should cache?" predicate
|
|
119
214
|
condition = if cond_if
|
|
@@ -134,7 +229,7 @@ module SafeMemoize
|
|
|
134
229
|
|
|
135
230
|
unless cached.equal?(miss)
|
|
136
231
|
effective_store.write(cache_key, cached, expires_in: ttl) if ttl_refresh
|
|
137
|
-
record_cache_hit(
|
|
232
|
+
record_cache_hit(cache_key)
|
|
138
233
|
call_memo_hooks(:on_hit, cache_key, {value: cached, expires_at: nil, cached_at: nil})
|
|
139
234
|
return cached
|
|
140
235
|
end
|
|
@@ -151,7 +246,7 @@ module SafeMemoize
|
|
|
151
246
|
call_memo_hooks(:on_store, cache_key, {value: value, expires_at: nil, cached_at: now})
|
|
152
247
|
end
|
|
153
248
|
|
|
154
|
-
record_cache_miss(
|
|
249
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
155
250
|
call_memo_hooks(:on_miss, cache_key, {value: value, expires_at: nil, cached_at: now})
|
|
156
251
|
|
|
157
252
|
value
|
|
@@ -165,6 +260,77 @@ module SafeMemoize
|
|
|
165
260
|
return
|
|
166
261
|
end
|
|
167
262
|
|
|
263
|
+
if fiber_local
|
|
264
|
+
mod = Module.new do
|
|
265
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
266
|
+
return super(*args, **kwargs, &block) if block
|
|
267
|
+
|
|
268
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
269
|
+
fiber_cache = fiber_memo_cache!
|
|
270
|
+
record = fiber_cache[cache_key]
|
|
271
|
+
|
|
272
|
+
if memo_record_live?(record)
|
|
273
|
+
if max_size
|
|
274
|
+
lru = fiber_memo_lru![method_name] ||= {}
|
|
275
|
+
lru.delete(cache_key)
|
|
276
|
+
lru[cache_key] = true
|
|
277
|
+
end
|
|
278
|
+
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
279
|
+
record_cache_hit(cache_key)
|
|
280
|
+
call_memo_hooks(:on_hit, cache_key, record)
|
|
281
|
+
memo_record_value(record)
|
|
282
|
+
else
|
|
283
|
+
call_memo_hooks(:on_expire, cache_key, record) if record
|
|
284
|
+
|
|
285
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
286
|
+
value = Adapters::OpenTelemetry.trace(
|
|
287
|
+
SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name
|
|
288
|
+
) { super(*args, **kwargs) }
|
|
289
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
290
|
+
|
|
291
|
+
new_record = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
292
|
+
|
|
293
|
+
if !condition || condition.call(value)
|
|
294
|
+
if max_size
|
|
295
|
+
lru = fiber_memo_lru![method_name] ||= {}
|
|
296
|
+
if lru.size >= max_size
|
|
297
|
+
evict_key = lru.keys.first
|
|
298
|
+
lru.delete(evict_key)
|
|
299
|
+
evicted = fiber_cache.delete(evict_key)
|
|
300
|
+
call_memo_hooks(:on_evict, evict_key, evicted) if evicted
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
fiber_cache[cache_key] = new_record
|
|
304
|
+
if max_size
|
|
305
|
+
lru = fiber_memo_lru![method_name] ||= {}
|
|
306
|
+
lru.delete(cache_key)
|
|
307
|
+
lru[cache_key] = true
|
|
308
|
+
end
|
|
309
|
+
call_memo_hooks(:on_store, cache_key, new_record)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
313
|
+
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
314
|
+
|
|
315
|
+
value
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
send(visibility, method_name)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
prepend mod
|
|
323
|
+
|
|
324
|
+
return
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
if ractor_safe
|
|
328
|
+
extend(RactorSharedMethods) unless is_a?(RactorSharedMethods)
|
|
329
|
+
supervisor = __safe_memo_ractor_supervisor__
|
|
330
|
+
__memoize_ractor_safe__(method_name, ttl, visibility, supervisor)
|
|
331
|
+
return
|
|
332
|
+
end
|
|
333
|
+
|
|
168
334
|
if shared
|
|
169
335
|
klass = self
|
|
170
336
|
shared_mutex = klass.send(:__safe_memo_shared_mutex__)
|
|
@@ -188,7 +354,7 @@ module SafeMemoize
|
|
|
188
354
|
lru[cache_key] = true
|
|
189
355
|
end
|
|
190
356
|
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
191
|
-
record_cache_hit(
|
|
357
|
+
record_cache_hit(cache_key)
|
|
192
358
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
193
359
|
record[:value]
|
|
194
360
|
else
|
|
@@ -219,7 +385,7 @@ module SafeMemoize
|
|
|
219
385
|
call_memo_hooks(:on_store, cache_key, new_record)
|
|
220
386
|
end
|
|
221
387
|
|
|
222
|
-
record_cache_miss(
|
|
388
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
223
389
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
224
390
|
|
|
225
391
|
value
|
|
@@ -249,7 +415,7 @@ module SafeMemoize
|
|
|
249
415
|
if record
|
|
250
416
|
lru_touch(method_name, cache_key) if max_size
|
|
251
417
|
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
252
|
-
record_cache_hit(
|
|
418
|
+
record_cache_hit(cache_key)
|
|
253
419
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
254
420
|
memo_record_value(record)
|
|
255
421
|
else
|
|
@@ -265,7 +431,7 @@ module SafeMemoize
|
|
|
265
431
|
lru_touch(method_name, cache_key) if max_size
|
|
266
432
|
call_memo_hooks(:on_store, cache_key, new_record)
|
|
267
433
|
end
|
|
268
|
-
record_cache_miss(
|
|
434
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
269
435
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
270
436
|
|
|
271
437
|
value
|
|
@@ -274,7 +440,7 @@ module SafeMemoize
|
|
|
274
440
|
else
|
|
275
441
|
# Fast path: check without lock
|
|
276
442
|
if (record = memo_cache_record(cache_key))
|
|
277
|
-
record_cache_hit(
|
|
443
|
+
record_cache_hit(cache_key)
|
|
278
444
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
279
445
|
return memo_record_value(record)
|
|
280
446
|
end
|
|
@@ -287,7 +453,7 @@ module SafeMemoize
|
|
|
287
453
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
288
454
|
|
|
289
455
|
with_memo_lock do
|
|
290
|
-
record_cache_miss(
|
|
456
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
291
457
|
new_record = memo_cache_record(cache_key)
|
|
292
458
|
call_memo_hooks(:on_store, cache_key, new_record)
|
|
293
459
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
@@ -303,6 +469,57 @@ module SafeMemoize
|
|
|
303
469
|
prepend mod
|
|
304
470
|
end
|
|
305
471
|
|
|
472
|
+
# Returns the class-level default cache store, or +nil+ if not set.
|
|
473
|
+
#
|
|
474
|
+
# Set this to any {Stores::Base} instance to route every +memoize+ call on
|
|
475
|
+
# this class through that store, without needing to pass +store:+ to each
|
|
476
|
+
# individual +memoize+ call. A per-method +store:+ option still takes
|
|
477
|
+
# precedence, and the global {SafeMemoize::Configuration#default_store} is
|
|
478
|
+
# the final fallback.
|
|
479
|
+
#
|
|
480
|
+
# @return [Stores::Base, nil]
|
|
481
|
+
def safe_memoize_store
|
|
482
|
+
@__safe_memoize_store__
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Sets the class-level default cache store.
|
|
486
|
+
#
|
|
487
|
+
# @param store [Stores::Base, nil] a store instance, or +nil+ to clear
|
|
488
|
+
# @return [Stores::Base, nil]
|
|
489
|
+
# @raise [ArgumentError] if +store+ is not a {Stores::Base} instance (and not +nil+)
|
|
490
|
+
def safe_memoize_store=(store)
|
|
491
|
+
if store && !store.is_a?(SafeMemoize::Stores::Base)
|
|
492
|
+
raise ArgumentError,
|
|
493
|
+
"safe_memoize_store= must be a SafeMemoize::Stores::Base instance (got #{store.class})"
|
|
494
|
+
end
|
|
495
|
+
@__safe_memoize_store__ = store
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Returns the class-level namespace prefix, or +nil+ if not set.
|
|
499
|
+
#
|
|
500
|
+
# When set, this prefix is prepended to every cache key produced by +memoize+
|
|
501
|
+
# calls on this class that do not specify their own +namespace:+ option.
|
|
502
|
+
# The global {SafeMemoize::Configuration#namespace} is the final fallback.
|
|
503
|
+
#
|
|
504
|
+
# @return [String, nil]
|
|
505
|
+
def safe_memoize_namespace
|
|
506
|
+
@__safe_memoize_namespace__
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Sets the class-level namespace prefix.
|
|
510
|
+
#
|
|
511
|
+
# @param ns [String, nil] a non-empty string without +:+, or +nil+ to clear
|
|
512
|
+
# @return [String, nil]
|
|
513
|
+
# @raise [ArgumentError] if +ns+ is not a valid namespace string
|
|
514
|
+
def safe_memoize_namespace=(ns)
|
|
515
|
+
if ns
|
|
516
|
+
raise ArgumentError, "safe_memoize_namespace= must be a String (got #{ns.class})" unless ns.is_a?(String)
|
|
517
|
+
raise ArgumentError, "safe_memoize_namespace= must not be empty" if ns.empty?
|
|
518
|
+
raise ArgumentError, "safe_memoize_namespace= must not contain ':'" if ns.include?(":")
|
|
519
|
+
end
|
|
520
|
+
@__safe_memoize_namespace__ = ns
|
|
521
|
+
end
|
|
522
|
+
|
|
306
523
|
# Memoizes every eligible public instance method defined directly on the class.
|
|
307
524
|
#
|
|
308
525
|
# Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+.
|
|
@@ -344,14 +561,15 @@ module SafeMemoize
|
|
|
344
561
|
# @return [void]
|
|
345
562
|
def reset_shared_memo(method_name, *args, **kwargs)
|
|
346
563
|
method_name = method_name.to_sym
|
|
347
|
-
|
|
564
|
+
effective = __safe_memo_effective_key_name__(method_name)
|
|
565
|
+
specific_key = (args.empty? && kwargs.empty?) ? nil : [effective, args, kwargs]
|
|
348
566
|
|
|
349
567
|
__safe_memo_shared_mutex__.synchronize do
|
|
350
568
|
if specific_key
|
|
351
569
|
__safe_memo_shared_cache__.delete(specific_key)
|
|
352
570
|
__safe_memo_shared_lru_order__[method_name]&.delete(specific_key)
|
|
353
571
|
else
|
|
354
|
-
__safe_memo_shared_cache__.delete_if { |key, _| key[0] ==
|
|
572
|
+
__safe_memo_shared_cache__.delete_if { |key, _| key[0] == effective }
|
|
355
573
|
__safe_memo_shared_lru_order__.delete(method_name)
|
|
356
574
|
end
|
|
357
575
|
end
|
|
@@ -374,7 +592,8 @@ module SafeMemoize
|
|
|
374
592
|
# @return [Boolean]
|
|
375
593
|
def shared_memoized?(method_name, *args, **kwargs)
|
|
376
594
|
method_name = method_name.to_sym
|
|
377
|
-
|
|
595
|
+
effective = __safe_memo_effective_key_name__(method_name)
|
|
596
|
+
cache_key = [effective, args, kwargs]
|
|
378
597
|
|
|
379
598
|
__safe_memo_shared_mutex__.synchronize do
|
|
380
599
|
cache = @__safe_memo_shared_cache__
|
|
@@ -397,7 +616,12 @@ module SafeMemoize
|
|
|
397
616
|
cache = @__safe_memo_shared_cache__ || {}
|
|
398
617
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
399
618
|
live = cache.reject { |_, r| r[:expires_at] && r[:expires_at] <= now }
|
|
400
|
-
|
|
619
|
+
if method_name
|
|
620
|
+
effective = __safe_memo_effective_key_name__(method_name.to_sym)
|
|
621
|
+
live.count { |key, _| key[0] == effective }
|
|
622
|
+
else
|
|
623
|
+
live.count
|
|
624
|
+
end
|
|
401
625
|
end
|
|
402
626
|
end
|
|
403
627
|
|
|
@@ -410,7 +634,8 @@ module SafeMemoize
|
|
|
410
634
|
# @return [Float, nil]
|
|
411
635
|
def shared_memo_age(method_name, *args, **kwargs)
|
|
412
636
|
method_name = method_name.to_sym
|
|
413
|
-
|
|
637
|
+
effective = __safe_memo_effective_key_name__(method_name)
|
|
638
|
+
cache_key = [effective, args, kwargs]
|
|
414
639
|
|
|
415
640
|
__safe_memo_shared_mutex__.synchronize do
|
|
416
641
|
cache = @__safe_memo_shared_cache__
|
|
@@ -437,7 +662,8 @@ module SafeMemoize
|
|
|
437
662
|
# @return [Boolean]
|
|
438
663
|
def shared_memo_stale?(method_name, *args, **kwargs)
|
|
439
664
|
method_name = method_name.to_sym
|
|
440
|
-
|
|
665
|
+
effective = __safe_memo_effective_key_name__(method_name)
|
|
666
|
+
cache_key = [effective, args, kwargs]
|
|
441
667
|
|
|
442
668
|
__safe_memo_shared_mutex__.synchronize do
|
|
443
669
|
cache = @__safe_memo_shared_cache__
|
|
@@ -471,11 +697,92 @@ module SafeMemoize
|
|
|
471
697
|
@__safe_memo_class_key_generators__ ||= {}
|
|
472
698
|
end
|
|
473
699
|
|
|
700
|
+
def __safe_memo_method_namespaces__
|
|
701
|
+
@__safe_memo_method_namespaces__ ||= {}
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def __safe_memo_class_cache_bust_generators__
|
|
705
|
+
@__safe_memo_class_cache_bust_generators__ ||= {}
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Resolves the effective first-element key sym for a given bare method name,
|
|
709
|
+
# applying the active namespace. Used by class-level cache operations where
|
|
710
|
+
# instance methods (compute_cache_key) are unavailable.
|
|
711
|
+
def __safe_memo_effective_key_name__(method_name)
|
|
712
|
+
ns_map = @__safe_memo_method_namespaces__
|
|
713
|
+
ns = (ns_map && ns_map[method_name]) ||
|
|
714
|
+
@__safe_memoize_namespace__ ||
|
|
715
|
+
SafeMemoize.configuration.namespace
|
|
716
|
+
ns ? :"#{ns}:#{method_name}" : method_name
|
|
717
|
+
end
|
|
718
|
+
|
|
474
719
|
def memoized_method_visibility(method_name)
|
|
475
720
|
return :private if private_method_defined?(method_name)
|
|
476
721
|
return :protected if protected_method_defined?(method_name)
|
|
477
722
|
|
|
478
723
|
:public
|
|
479
724
|
end
|
|
725
|
+
|
|
726
|
+
# Builds and prepends the ractor_safe memoize wrapper in its own method so
|
|
727
|
+
# the Proc only closes over the four Ractor-shareable locals (method_name,
|
|
728
|
+
# ttl, visibility, supervisor) rather than the full memoize binding, which
|
|
729
|
+
# contains non-shareable objects like SafeMemoize.configuration.
|
|
730
|
+
#
|
|
731
|
+
# The Proc is created inside module_eval so its self is the anonymous
|
|
732
|
+
# module (a shareable object), then frozen via Ractor.make_shareable before
|
|
733
|
+
# being passed to define_method. Without that step, ANY define_method Proc
|
|
734
|
+
# is considered non-shareable by Ruby 3.x even when it captures nothing.
|
|
735
|
+
def __memoize_ractor_safe__(method_name, ttl, visibility, supervisor)
|
|
736
|
+
mod = Module.new
|
|
737
|
+
wrapper = mod.module_eval do
|
|
738
|
+
Ractor.make_shareable(
|
|
739
|
+
proc do |*args, **kwargs, &block|
|
|
740
|
+
return super(*args, **kwargs, &block) if block
|
|
741
|
+
|
|
742
|
+
cache_key = Ractor.make_shareable([method_name, deep_freeze_copy(args), deep_freeze_copy(kwargs)])
|
|
743
|
+
|
|
744
|
+
tag = Thread.current.object_id
|
|
745
|
+
supervisor.send(Ractor.make_shareable([Ractor.current, tag, :fetch, cache_key]))
|
|
746
|
+
response = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
747
|
+
|
|
748
|
+
if response[:hit]
|
|
749
|
+
record_cache_hit(cache_key)
|
|
750
|
+
call_memo_hooks(:on_hit, cache_key, response[:record])
|
|
751
|
+
return response[:record][:value]
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
755
|
+
value = super(*args, **kwargs)
|
|
756
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
757
|
+
|
|
758
|
+
begin
|
|
759
|
+
shareable_value = Ractor.make_shareable(value)
|
|
760
|
+
rescue => e
|
|
761
|
+
raise ArgumentError, "ractor_safe: memoized values must be Ractor-shareable (#{e.message})"
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
765
|
+
record = Ractor.make_shareable({
|
|
766
|
+
value: shareable_value,
|
|
767
|
+
expires_at: ttl ? now + ttl : nil,
|
|
768
|
+
cached_at: now
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
supervisor.send(Ractor.make_shareable([Ractor.current, tag, :store, cache_key, record]))
|
|
772
|
+
stored = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
773
|
+
stored_record = stored[:stored]
|
|
774
|
+
|
|
775
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
776
|
+
call_memo_hooks(:on_store, cache_key, stored_record)
|
|
777
|
+
call_memo_hooks(:on_miss, cache_key, stored_record)
|
|
778
|
+
|
|
779
|
+
stored_record[:value]
|
|
780
|
+
end
|
|
781
|
+
)
|
|
782
|
+
end
|
|
783
|
+
mod.define_method(method_name, wrapper)
|
|
784
|
+
mod.send(visibility, method_name)
|
|
785
|
+
prepend mod
|
|
786
|
+
end
|
|
480
787
|
end
|
|
481
788
|
end
|
|
@@ -48,6 +48,13 @@ module SafeMemoize
|
|
|
48
48
|
# store and will silently continue using the per-instance hash even when this is set.
|
|
49
49
|
attr_accessor :default_store
|
|
50
50
|
|
|
51
|
+
# @return [String, nil] Global namespace prefix applied to every cache key produced by
|
|
52
|
+
# {ClassMethods#memoize}. Useful for versioned deployments (change the namespace to
|
|
53
|
+
# bust all in-flight cached values) and multi-tenant setups (scope keys to a tenant
|
|
54
|
+
# identifier). A class-level {ClassMethods#safe_memoize_namespace} or a per-method
|
|
55
|
+
# +namespace:+ option takes precedence over this value. +nil+ means no prefix.
|
|
56
|
+
attr_accessor :namespace
|
|
57
|
+
|
|
51
58
|
# @api private
|
|
52
59
|
def initialize
|
|
53
60
|
@default_ttl = nil
|
|
@@ -58,6 +65,7 @@ module SafeMemoize
|
|
|
58
65
|
@statsd_client = nil
|
|
59
66
|
@opentelemetry_tracer = nil
|
|
60
67
|
@default_store = nil
|
|
68
|
+
@namespace = nil
|
|
61
69
|
end
|
|
62
70
|
end
|
|
63
71
|
end
|
|
@@ -19,14 +19,23 @@ module SafeMemoize
|
|
|
19
19
|
def compute_cache_key(method_name, args, kwargs)
|
|
20
20
|
method_name = method_name.to_sym
|
|
21
21
|
|
|
22
|
+
ns = __safe_memo_resolve_namespace__(method_name)
|
|
23
|
+
effective_name = ns ? :"#{ns}:#{method_name}" : method_name
|
|
24
|
+
|
|
22
25
|
# Instance-level key generator takes priority over class-level
|
|
23
26
|
key_block = custom_key_store[method_name] ||
|
|
24
27
|
self.class.send(:__safe_memo_class_key_generators__)[method_name]
|
|
25
28
|
|
|
26
29
|
if key_block
|
|
27
|
-
[
|
|
30
|
+
[effective_name, key_block.call(*args, **kwargs)]
|
|
28
31
|
else
|
|
29
|
-
|
|
32
|
+
bust_block = self.class.send(:__safe_memo_class_cache_bust_generators__)[method_name]
|
|
33
|
+
if bust_block
|
|
34
|
+
token = bust_block.is_a?(Symbol) ? send(bust_block) : instance_exec(&bust_block)
|
|
35
|
+
[effective_name, [deep_freeze_copy(args), deep_freeze_copy(kwargs), token]]
|
|
36
|
+
else
|
|
37
|
+
safe_memo_cache_key(effective_name, args, kwargs)
|
|
38
|
+
end
|
|
30
39
|
end
|
|
31
40
|
end
|
|
32
41
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
# Mixin for defining SafeMemoize extensions.
|
|
5
|
+
#
|
|
6
|
+
# Extend this module in any Ruby module or class that you want to register
|
|
7
|
+
# as a SafeMemoize extension. It provides a DSL for declaring custom
|
|
8
|
+
# +memoize+ options and global cache lifecycle event handlers.
|
|
9
|
+
#
|
|
10
|
+
# @example Defining an extension
|
|
11
|
+
# module MyExtension
|
|
12
|
+
# extend SafeMemoize::Extension
|
|
13
|
+
#
|
|
14
|
+
# handles_option :active_record_bust do |value, method_name, _options|
|
|
15
|
+
# { cache_bust: -> { send(:updated_at) } }
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# on_cache_event :miss do |klass, method_name, _cache_key, _record|
|
|
19
|
+
# Rails.logger.debug "cache miss: #{klass}##{method_name}"
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# SafeMemoize.register_extension(:active_record_bust, MyExtension)
|
|
24
|
+
module Extension
|
|
25
|
+
# Declares a custom +memoize+ option handled by this extension.
|
|
26
|
+
#
|
|
27
|
+
# The block is called at +memoize+ definition time whenever +option_name+
|
|
28
|
+
# appears in the +memoize+ keyword arguments. It receives the option value,
|
|
29
|
+
# the method name being memoized, and the full hash of other extension options
|
|
30
|
+
# passed to that +memoize+ call. It must return a +Hash+ of standard
|
|
31
|
+
# {ClassMethods#memoize} options to inject (e.g. +{ cache_bust: ... }+), or
|
|
32
|
+
# +nil+/empty hash for no injection.
|
|
33
|
+
#
|
|
34
|
+
# @param option_name [Symbol]
|
|
35
|
+
# @yieldparam value [Object] the option value supplied by the caller
|
|
36
|
+
# @yieldparam method_name [Symbol] the method being memoized
|
|
37
|
+
# @yieldparam all_options [Hash] other extension options in the same +memoize+ call
|
|
38
|
+
# @yieldreturn [Hash, nil] standard memoize options to inject
|
|
39
|
+
# @return [void]
|
|
40
|
+
def handles_option(option_name, &processor)
|
|
41
|
+
@__handled_options__ ||= {}
|
|
42
|
+
@__handled_options__[option_name.to_sym] = processor
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Registers a global cache lifecycle event handler.
|
|
46
|
+
#
|
|
47
|
+
# The block fires after every matching cache event across *all* memoized
|
|
48
|
+
# methods on all classes. Multiple event types can be listed in a single
|
|
49
|
+
# call. Valid types are +:on_hit+, +:on_miss+, +:on_store+, +:on_expire+,
|
|
50
|
+
# and +:on_evict+.
|
|
51
|
+
#
|
|
52
|
+
# Handlers execute on the main Ractor only; they are silently skipped from
|
|
53
|
+
# worker Ractors.
|
|
54
|
+
#
|
|
55
|
+
# @param event_types [Array<Symbol>] one or more of +:on_hit+, +:on_miss+,
|
|
56
|
+
# +:on_store+, +:on_expire+, +:on_evict+
|
|
57
|
+
# @yieldparam klass [Class] the class whose instance triggered the event
|
|
58
|
+
# @yieldparam method_name [Symbol] bare method name (namespace stripped)
|
|
59
|
+
# @yieldparam cache_key [Array] the full cache key
|
|
60
|
+
# @yieldparam record [Hash, nil] the cache record (+value+, +expires_at+, +cached_at+)
|
|
61
|
+
# @return [void]
|
|
62
|
+
def on_cache_event(*event_types, &handler)
|
|
63
|
+
@__event_handlers__ ||= {}
|
|
64
|
+
event_types.each { |type| (@__event_handlers__[type.to_sym] ||= []) << handler }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @api private
|
|
68
|
+
def handled_options
|
|
69
|
+
@__handled_options__&.keys || []
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @api private
|
|
73
|
+
def process_memoize_option(option_name, value, method_name, all_options)
|
|
74
|
+
processor = @__handled_options__&.[](option_name.to_sym)
|
|
75
|
+
result = processor&.call(value, method_name, all_options)
|
|
76
|
+
result.is_a?(Hash) ? result : {}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @api private
|
|
80
|
+
def dispatch_cache_event(event_type, klass, method_name, cache_key, record)
|
|
81
|
+
return unless @__event_handlers__
|
|
82
|
+
|
|
83
|
+
(@__event_handlers__[event_type] || []).each do |handler|
|
|
84
|
+
handler.call(klass, method_name, cache_key, record)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|