safe_memoize 1.2.0 → 1.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 +4 -4
- data/.github/FUNDING.yml +15 -0
- data/CHANGELOG.md +39 -0
- data/README.md +383 -1
- data/ROADMAP.md +31 -4
- data/lib/safe_memoize/cache_metrics_methods.rb +4 -5
- data/lib/safe_memoize/class_methods.rb +243 -36
- 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 +2 -1
- data/lib/safe_memoize/hooks_methods.rb +2 -0
- data/lib/safe_memoize/inspection_methods.rb +34 -5
- data/lib/safe_memoize/public_methods.rb +3 -2
- data/lib/safe_memoize/public_metrics_methods.rb +4 -3
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +141 -0
- data/sig/safe_memoize.rbs +35 -2
- metadata +3 -1
|
@@ -37,7 +37,33 @@ module SafeMemoize
|
|
|
37
37
|
# a supervisor +Ractor+ rather than a +Mutex+-protected ivar, making it accessible
|
|
38
38
|
# from worker Ractors. Requires +shared: true+. Cached values are deep-frozen via
|
|
39
39
|
# +Ractor.make_shareable+. Incompatible with +if:+, +unless:+, +max_size:+,
|
|
40
|
-
# +ttl_refresh:+, +key:+, and +
|
|
40
|
+
# +ttl_refresh:+, +key:+, +store:+, and +copy_on_read:+.
|
|
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:+.
|
|
62
|
+
# @param copy_on_read [Boolean] when +true+, every cache read returns a +dup+ (or
|
|
63
|
+
# +deep_dup+ when available) of the stored value rather than the cached object
|
|
64
|
+
# itself. Prevents callers from mutating shared cached state. Frozen and +nil+
|
|
65
|
+
# values are returned as-is. Incompatible with +ractor_safe:+ (ractor values are
|
|
66
|
+
# always frozen; use that guarantee instead).
|
|
41
67
|
# @return [void]
|
|
42
68
|
# @raise [ArgumentError] if the method does not exist, or option values are invalid
|
|
43
69
|
#
|
|
@@ -52,28 +78,78 @@ module SafeMemoize
|
|
|
52
78
|
# @example Conditional — only cache successful responses
|
|
53
79
|
# memoize :fetch, if: ->(v) { v[:status] == 200 }
|
|
54
80
|
#
|
|
81
|
+
# @example Copy-on-read — protect mutable cached config
|
|
82
|
+
# memoize :config, copy_on_read: true
|
|
83
|
+
#
|
|
55
84
|
# @example With a custom store
|
|
56
85
|
# STORE = SafeMemoize::Stores::Memory.new
|
|
57
86
|
# memoize :fetch, store: STORE, ttl: 300
|
|
58
|
-
def memoize(method_name, ttl:
|
|
87
|
+
def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UNSET, unless: UNSET, shared: UNSET, key: UNSET, store: UNSET, fiber_local: UNSET, ractor_safe: UNSET, namespace: UNSET, shared_cache: UNSET, cache_bust: UNSET, copy_on_read: UNSET, **extension_options)
|
|
59
88
|
method_name = method_name.to_sym
|
|
60
89
|
|
|
61
90
|
unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
|
|
62
91
|
raise ArgumentError, "cannot memoize :#{method_name} — no instance method with that name is defined on #{self}"
|
|
63
92
|
end
|
|
64
93
|
|
|
94
|
+
unless extension_options.empty?
|
|
95
|
+
extension_options.each_key do |opt|
|
|
96
|
+
raise ArgumentError, "unknown memoize option :#{opt} — no registered extension handles it" unless SafeMemoize.extension_for_option(opt)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
injected = {}
|
|
100
|
+
extension_options.each do |opt, val|
|
|
101
|
+
result = SafeMemoize.extension_for_option(opt).process_memoize_option(opt, val, method_name, extension_options)
|
|
102
|
+
injected.merge!(result)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
ttl = injected[:ttl] if injected.key?(:ttl)
|
|
106
|
+
max_size = injected[:max_size] if injected.key?(:max_size)
|
|
107
|
+
namespace = injected[:namespace] if injected.key?(:namespace)
|
|
108
|
+
store = injected[:store] if injected.key?(:store)
|
|
109
|
+
shared_cache = injected[:shared_cache] if injected.key?(:shared_cache)
|
|
110
|
+
cache_bust = injected[:cache_bust] if injected.key?(:cache_bust)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# :if and :unless are reserved Ruby keywords; use binding to extract them
|
|
114
|
+
cond_if = binding.local_variable_get(:if)
|
|
115
|
+
cond_unless = binding.local_variable_get(:unless)
|
|
116
|
+
|
|
117
|
+
# Apply class-level defaults (safe_memoize_options) for any still-unset options
|
|
118
|
+
if (cls_defaults = __safe_memoize_defaults__)
|
|
119
|
+
ttl = cls_defaults[:ttl] if ttl.equal?(UNSET) && cls_defaults.key?(:ttl)
|
|
120
|
+
max_size = cls_defaults[:max_size] if max_size.equal?(UNSET) && cls_defaults.key?(:max_size)
|
|
121
|
+
ttl_refresh = cls_defaults[:ttl_refresh] if ttl_refresh.equal?(UNSET) && cls_defaults.key?(:ttl_refresh)
|
|
122
|
+
cond_if = cls_defaults[:if] if cond_if.equal?(UNSET) && cls_defaults.key?(:if)
|
|
123
|
+
cond_unless = cls_defaults[:unless] if cond_unless.equal?(UNSET) && cls_defaults.key?(:unless)
|
|
124
|
+
key = cls_defaults[:key] if key.equal?(UNSET) && cls_defaults.key?(:key)
|
|
125
|
+
cache_bust = cls_defaults[:cache_bust] if cache_bust.equal?(UNSET) && cls_defaults.key?(:cache_bust)
|
|
126
|
+
copy_on_read = cls_defaults[:copy_on_read] if copy_on_read.equal?(UNSET) && cls_defaults.key?(:copy_on_read)
|
|
127
|
+
namespace = cls_defaults[:namespace] if namespace.equal?(UNSET) && cls_defaults.key?(:namespace)
|
|
128
|
+
store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Normalize remaining UNSET to original per-call defaults
|
|
132
|
+
ttl = nil if ttl.equal?(UNSET)
|
|
133
|
+
max_size = nil if max_size.equal?(UNSET)
|
|
134
|
+
ttl_refresh = false if ttl_refresh.equal?(UNSET)
|
|
135
|
+
shared = false if shared.equal?(UNSET)
|
|
136
|
+
key = nil if key.equal?(UNSET)
|
|
137
|
+
store = nil if store.equal?(UNSET)
|
|
138
|
+
fiber_local = false if fiber_local.equal?(UNSET)
|
|
139
|
+
ractor_safe = false if ractor_safe.equal?(UNSET)
|
|
140
|
+
namespace = nil if namespace.equal?(UNSET)
|
|
141
|
+
shared_cache = nil if shared_cache.equal?(UNSET)
|
|
142
|
+
cache_bust = nil if cache_bust.equal?(UNSET)
|
|
143
|
+
copy_on_read = false if copy_on_read.equal?(UNSET)
|
|
144
|
+
cond_if = nil if cond_if.equal?(UNSET)
|
|
145
|
+
cond_unless = nil if cond_unless.equal?(UNSET)
|
|
146
|
+
|
|
65
147
|
visibility = memoized_method_visibility(method_name)
|
|
66
148
|
|
|
67
149
|
config = SafeMemoize.configuration
|
|
68
150
|
ttl = config.default_ttl if ttl.nil?
|
|
69
151
|
max_size = config.default_max_size if max_size.nil?
|
|
70
152
|
|
|
71
|
-
# :if and :unless are reserved Ruby keywords, so they can't be referenced
|
|
72
|
-
# as local variables directly. binding.local_variable_get is the only way
|
|
73
|
-
# to read keyword arguments with those names inside the method body.
|
|
74
|
-
cond_if = binding.local_variable_get(:if)
|
|
75
|
-
cond_unless = binding.local_variable_get(:unless)
|
|
76
|
-
|
|
77
153
|
ttl = if ttl.nil?
|
|
78
154
|
nil
|
|
79
155
|
else
|
|
@@ -102,6 +178,13 @@ module SafeMemoize
|
|
|
102
178
|
raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call)
|
|
103
179
|
raise ArgumentError, ":key must be callable" if key && !key.respond_to?(:call)
|
|
104
180
|
|
|
181
|
+
if cache_bust
|
|
182
|
+
unless cache_bust.respond_to?(:call) || cache_bust.is_a?(Symbol)
|
|
183
|
+
raise ArgumentError, "cache_bust: must be a callable or Symbol (got #{cache_bust.class})"
|
|
184
|
+
end
|
|
185
|
+
raise ArgumentError, "cache_bust: and key: cannot be combined" if key
|
|
186
|
+
end
|
|
187
|
+
|
|
105
188
|
if store
|
|
106
189
|
raise ArgumentError, "store: must be a SafeMemoize::Stores::Base instance (got #{store.class})" unless store.is_a?(SafeMemoize::Stores::Base)
|
|
107
190
|
raise ArgumentError, "max_size: is not supported with store: — use the store adapter's own eviction" if max_size
|
|
@@ -120,6 +203,25 @@ module SafeMemoize
|
|
|
120
203
|
raise ArgumentError, "ractor_safe: is incompatible with ttl_refresh:" if ttl_refresh
|
|
121
204
|
raise ArgumentError, "ractor_safe: is incompatible with key:" if key
|
|
122
205
|
raise ArgumentError, "ractor_safe: is incompatible with store:" if store
|
|
206
|
+
raise ArgumentError, "ractor_safe: is incompatible with copy_on_read:" if copy_on_read
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
if namespace
|
|
210
|
+
raise ArgumentError, "namespace: must be a String (got #{namespace.class})" unless namespace.is_a?(String)
|
|
211
|
+
raise ArgumentError, "namespace: must not be empty" if namespace.empty?
|
|
212
|
+
raise ArgumentError, "namespace: must not contain ':'" if namespace.include?(":")
|
|
213
|
+
__safe_memo_method_namespaces__[method_name] = namespace
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if shared_cache
|
|
217
|
+
raise ArgumentError, "shared_cache: must be a String (got #{shared_cache.class})" unless shared_cache.is_a?(String)
|
|
218
|
+
raise ArgumentError, "shared_cache: must not be empty" if shared_cache.empty?
|
|
219
|
+
raise ArgumentError, "shared_cache: and shared: cannot be combined" if shared
|
|
220
|
+
raise ArgumentError, "shared_cache: and store: cannot be combined" if store
|
|
221
|
+
raise ArgumentError, "shared_cache: and fiber_local: cannot be combined" if fiber_local
|
|
222
|
+
raise ArgumentError, "shared_cache: and ractor_safe: cannot be combined" if ractor_safe
|
|
223
|
+
raise ArgumentError, "max_size: is not supported with shared_cache: — use the store adapter's own eviction" if max_size
|
|
224
|
+
store = SafeMemoize.shared_cache(shared_cache)
|
|
123
225
|
end
|
|
124
226
|
|
|
125
227
|
# Resolve effective store: per-method store: wins; then class-level
|
|
@@ -143,6 +245,7 @@ module SafeMemoize
|
|
|
143
245
|
end
|
|
144
246
|
|
|
145
247
|
__safe_memo_class_key_generators__[method_name] = key if key
|
|
248
|
+
__safe_memo_class_cache_bust_generators__[method_name] = cache_bust if cache_bust
|
|
146
249
|
|
|
147
250
|
# Normalize to a single "should cache?" predicate
|
|
148
251
|
condition = if cond_if
|
|
@@ -151,6 +254,18 @@ module SafeMemoize
|
|
|
151
254
|
->(result) { !cond_unless.call(result) }
|
|
152
255
|
end
|
|
153
256
|
|
|
257
|
+
# Build a value-duplication function for copy_on_read: true.
|
|
258
|
+
# Frozen and nil values are returned as-is; deep_dup is preferred when available
|
|
259
|
+
# (e.g. ActiveRecord objects) so nested mutable structures are also protected.
|
|
260
|
+
dup_fn = if copy_on_read
|
|
261
|
+
lambda do |v|
|
|
262
|
+
return v if v.nil? || v.frozen?
|
|
263
|
+
v.respond_to?(:deep_dup) ? v.deep_dup : v.dup
|
|
264
|
+
end
|
|
265
|
+
else
|
|
266
|
+
->(v) { v }
|
|
267
|
+
end
|
|
268
|
+
|
|
154
269
|
if effective_store
|
|
155
270
|
miss = SafeMemoize::Stores::Base::MISS
|
|
156
271
|
|
|
@@ -163,9 +278,9 @@ module SafeMemoize
|
|
|
163
278
|
|
|
164
279
|
unless cached.equal?(miss)
|
|
165
280
|
effective_store.write(cache_key, cached, expires_in: ttl) if ttl_refresh
|
|
166
|
-
record_cache_hit(
|
|
281
|
+
record_cache_hit(cache_key)
|
|
167
282
|
call_memo_hooks(:on_hit, cache_key, {value: cached, expires_at: nil, cached_at: nil})
|
|
168
|
-
return cached
|
|
283
|
+
return dup_fn.call(cached)
|
|
169
284
|
end
|
|
170
285
|
|
|
171
286
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -180,10 +295,10 @@ module SafeMemoize
|
|
|
180
295
|
call_memo_hooks(:on_store, cache_key, {value: value, expires_at: nil, cached_at: now})
|
|
181
296
|
end
|
|
182
297
|
|
|
183
|
-
record_cache_miss(
|
|
298
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
184
299
|
call_memo_hooks(:on_miss, cache_key, {value: value, expires_at: nil, cached_at: now})
|
|
185
300
|
|
|
186
|
-
value
|
|
301
|
+
dup_fn.call(value)
|
|
187
302
|
end
|
|
188
303
|
|
|
189
304
|
send(visibility, method_name)
|
|
@@ -210,9 +325,9 @@ module SafeMemoize
|
|
|
210
325
|
lru[cache_key] = true
|
|
211
326
|
end
|
|
212
327
|
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
213
|
-
record_cache_hit(
|
|
328
|
+
record_cache_hit(cache_key)
|
|
214
329
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
215
|
-
memo_record_value(record)
|
|
330
|
+
dup_fn.call(memo_record_value(record))
|
|
216
331
|
else
|
|
217
332
|
call_memo_hooks(:on_expire, cache_key, record) if record
|
|
218
333
|
|
|
@@ -243,10 +358,10 @@ module SafeMemoize
|
|
|
243
358
|
call_memo_hooks(:on_store, cache_key, new_record)
|
|
244
359
|
end
|
|
245
360
|
|
|
246
|
-
record_cache_miss(
|
|
361
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
247
362
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
248
363
|
|
|
249
|
-
value
|
|
364
|
+
dup_fn.call(value)
|
|
250
365
|
end
|
|
251
366
|
end
|
|
252
367
|
|
|
@@ -288,9 +403,9 @@ module SafeMemoize
|
|
|
288
403
|
lru[cache_key] = true
|
|
289
404
|
end
|
|
290
405
|
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
291
|
-
record_cache_hit(
|
|
406
|
+
record_cache_hit(cache_key)
|
|
292
407
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
293
|
-
record[:value]
|
|
408
|
+
dup_fn.call(record[:value])
|
|
294
409
|
else
|
|
295
410
|
call_memo_hooks(:on_expire, cache_key, record) if record && !record_live
|
|
296
411
|
|
|
@@ -319,10 +434,10 @@ module SafeMemoize
|
|
|
319
434
|
call_memo_hooks(:on_store, cache_key, new_record)
|
|
320
435
|
end
|
|
321
436
|
|
|
322
|
-
record_cache_miss(
|
|
437
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
323
438
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
324
439
|
|
|
325
|
-
value
|
|
440
|
+
dup_fn.call(value)
|
|
326
441
|
end
|
|
327
442
|
end
|
|
328
443
|
end
|
|
@@ -349,9 +464,9 @@ module SafeMemoize
|
|
|
349
464
|
if record
|
|
350
465
|
lru_touch(method_name, cache_key) if max_size
|
|
351
466
|
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
352
|
-
record_cache_hit(
|
|
467
|
+
record_cache_hit(cache_key)
|
|
353
468
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
354
|
-
memo_record_value(record)
|
|
469
|
+
dup_fn.call(memo_record_value(record))
|
|
355
470
|
else
|
|
356
471
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
357
472
|
value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
|
|
@@ -365,18 +480,18 @@ module SafeMemoize
|
|
|
365
480
|
lru_touch(method_name, cache_key) if max_size
|
|
366
481
|
call_memo_hooks(:on_store, cache_key, new_record)
|
|
367
482
|
end
|
|
368
|
-
record_cache_miss(
|
|
483
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
369
484
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
370
485
|
|
|
371
|
-
value
|
|
486
|
+
dup_fn.call(value)
|
|
372
487
|
end
|
|
373
488
|
end
|
|
374
489
|
else
|
|
375
490
|
# Fast path: check without lock
|
|
376
491
|
if (record = memo_cache_record(cache_key))
|
|
377
|
-
record_cache_hit(
|
|
492
|
+
record_cache_hit(cache_key)
|
|
378
493
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
379
|
-
return memo_record_value(record)
|
|
494
|
+
return dup_fn.call(memo_record_value(record))
|
|
380
495
|
end
|
|
381
496
|
|
|
382
497
|
# Cache miss - compute and store
|
|
@@ -387,13 +502,13 @@ module SafeMemoize
|
|
|
387
502
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
388
503
|
|
|
389
504
|
with_memo_lock do
|
|
390
|
-
record_cache_miss(
|
|
505
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
391
506
|
new_record = memo_cache_record(cache_key)
|
|
392
507
|
call_memo_hooks(:on_store, cache_key, new_record)
|
|
393
508
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
394
509
|
end
|
|
395
510
|
|
|
396
|
-
result
|
|
511
|
+
dup_fn.call(result)
|
|
397
512
|
end
|
|
398
513
|
end
|
|
399
514
|
|
|
@@ -429,6 +544,66 @@ module SafeMemoize
|
|
|
429
544
|
@__safe_memoize_store__ = store
|
|
430
545
|
end
|
|
431
546
|
|
|
547
|
+
# Returns the class-level namespace prefix, or +nil+ if not set.
|
|
548
|
+
#
|
|
549
|
+
# When set, this prefix is prepended to every cache key produced by +memoize+
|
|
550
|
+
# calls on this class that do not specify their own +namespace:+ option.
|
|
551
|
+
# The global {SafeMemoize::Configuration#namespace} is the final fallback.
|
|
552
|
+
#
|
|
553
|
+
# @return [String, nil]
|
|
554
|
+
def safe_memoize_namespace
|
|
555
|
+
@__safe_memoize_namespace__
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Sets the class-level namespace prefix.
|
|
559
|
+
#
|
|
560
|
+
# @param ns [String, nil] a non-empty string without +:+, or +nil+ to clear
|
|
561
|
+
# @return [String, nil]
|
|
562
|
+
# @raise [ArgumentError] if +ns+ is not a valid namespace string
|
|
563
|
+
def safe_memoize_namespace=(ns)
|
|
564
|
+
if ns
|
|
565
|
+
raise ArgumentError, "safe_memoize_namespace= must be a String (got #{ns.class})" unless ns.is_a?(String)
|
|
566
|
+
raise ArgumentError, "safe_memoize_namespace= must not be empty" if ns.empty?
|
|
567
|
+
raise ArgumentError, "safe_memoize_namespace= must not contain ':'" if ns.include?(":")
|
|
568
|
+
end
|
|
569
|
+
@__safe_memoize_namespace__ = ns
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Sets class-wide default options applied to every subsequent {#memoize} call
|
|
573
|
+
# on this class. Per-call options take precedence; class defaults take
|
|
574
|
+
# precedence over global {SafeMemoize::Configuration} defaults.
|
|
575
|
+
#
|
|
576
|
+
# Call with no arguments (or an empty hash) to clear all class-level defaults.
|
|
577
|
+
#
|
|
578
|
+
# @example Apply a TTL and LRU cap to every memoized method on the class
|
|
579
|
+
# class ApiClient
|
|
580
|
+
# prepend SafeMemoize
|
|
581
|
+
# safe_memoize_options ttl: 60, max_size: 200
|
|
582
|
+
#
|
|
583
|
+
# def fetch(id) = http.get(id)
|
|
584
|
+
# memoize :fetch # uses ttl: 60, max_size: 200
|
|
585
|
+
#
|
|
586
|
+
# def list = http.get("/all")
|
|
587
|
+
# memoize :list, ttl: 300 # uses max_size: 200, ttl: 300
|
|
588
|
+
# end
|
|
589
|
+
#
|
|
590
|
+
# @example Protect all cached values from mutation
|
|
591
|
+
# safe_memoize_options copy_on_read: true
|
|
592
|
+
#
|
|
593
|
+
# @param opts [Hash] any subset of {#memoize} options except mode-switch options
|
|
594
|
+
# (+shared:+, +fiber_local:+, +ractor_safe:+, +shared_cache:+)
|
|
595
|
+
# @return [Hash] the stored defaults
|
|
596
|
+
# @raise [ArgumentError] for disallowed options
|
|
597
|
+
def safe_memoize_options(**opts)
|
|
598
|
+
disallowed = %i[shared fiber_local ractor_safe shared_cache]
|
|
599
|
+
bad = opts.keys & disallowed
|
|
600
|
+
unless bad.empty?
|
|
601
|
+
raise ArgumentError,
|
|
602
|
+
"safe_memoize_options does not accept #{bad.map { |k| ":#{k}" }.join(", ")} — pass mode-switch options per memoize call"
|
|
603
|
+
end
|
|
604
|
+
@__safe_memoize_defaults__ = opts.empty? ? nil : opts
|
|
605
|
+
end
|
|
606
|
+
|
|
432
607
|
# Memoizes every eligible public instance method defined directly on the class.
|
|
433
608
|
#
|
|
434
609
|
# Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+.
|
|
@@ -470,14 +645,15 @@ module SafeMemoize
|
|
|
470
645
|
# @return [void]
|
|
471
646
|
def reset_shared_memo(method_name, *args, **kwargs)
|
|
472
647
|
method_name = method_name.to_sym
|
|
473
|
-
|
|
648
|
+
effective = __safe_memo_effective_key_name__(method_name)
|
|
649
|
+
specific_key = (args.empty? && kwargs.empty?) ? nil : [effective, args, kwargs]
|
|
474
650
|
|
|
475
651
|
__safe_memo_shared_mutex__.synchronize do
|
|
476
652
|
if specific_key
|
|
477
653
|
__safe_memo_shared_cache__.delete(specific_key)
|
|
478
654
|
__safe_memo_shared_lru_order__[method_name]&.delete(specific_key)
|
|
479
655
|
else
|
|
480
|
-
__safe_memo_shared_cache__.delete_if { |key, _| key[0] ==
|
|
656
|
+
__safe_memo_shared_cache__.delete_if { |key, _| key[0] == effective }
|
|
481
657
|
__safe_memo_shared_lru_order__.delete(method_name)
|
|
482
658
|
end
|
|
483
659
|
end
|
|
@@ -500,7 +676,8 @@ module SafeMemoize
|
|
|
500
676
|
# @return [Boolean]
|
|
501
677
|
def shared_memoized?(method_name, *args, **kwargs)
|
|
502
678
|
method_name = method_name.to_sym
|
|
503
|
-
|
|
679
|
+
effective = __safe_memo_effective_key_name__(method_name)
|
|
680
|
+
cache_key = [effective, args, kwargs]
|
|
504
681
|
|
|
505
682
|
__safe_memo_shared_mutex__.synchronize do
|
|
506
683
|
cache = @__safe_memo_shared_cache__
|
|
@@ -523,7 +700,12 @@ module SafeMemoize
|
|
|
523
700
|
cache = @__safe_memo_shared_cache__ || {}
|
|
524
701
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
525
702
|
live = cache.reject { |_, r| r[:expires_at] && r[:expires_at] <= now }
|
|
526
|
-
|
|
703
|
+
if method_name
|
|
704
|
+
effective = __safe_memo_effective_key_name__(method_name.to_sym)
|
|
705
|
+
live.count { |key, _| key[0] == effective }
|
|
706
|
+
else
|
|
707
|
+
live.count
|
|
708
|
+
end
|
|
527
709
|
end
|
|
528
710
|
end
|
|
529
711
|
|
|
@@ -536,7 +718,8 @@ module SafeMemoize
|
|
|
536
718
|
# @return [Float, nil]
|
|
537
719
|
def shared_memo_age(method_name, *args, **kwargs)
|
|
538
720
|
method_name = method_name.to_sym
|
|
539
|
-
|
|
721
|
+
effective = __safe_memo_effective_key_name__(method_name)
|
|
722
|
+
cache_key = [effective, args, kwargs]
|
|
540
723
|
|
|
541
724
|
__safe_memo_shared_mutex__.synchronize do
|
|
542
725
|
cache = @__safe_memo_shared_cache__
|
|
@@ -563,7 +746,8 @@ module SafeMemoize
|
|
|
563
746
|
# @return [Boolean]
|
|
564
747
|
def shared_memo_stale?(method_name, *args, **kwargs)
|
|
565
748
|
method_name = method_name.to_sym
|
|
566
|
-
|
|
749
|
+
effective = __safe_memo_effective_key_name__(method_name)
|
|
750
|
+
cache_key = [effective, args, kwargs]
|
|
567
751
|
|
|
568
752
|
__safe_memo_shared_mutex__.synchronize do
|
|
569
753
|
cache = @__safe_memo_shared_cache__
|
|
@@ -597,6 +781,29 @@ module SafeMemoize
|
|
|
597
781
|
@__safe_memo_class_key_generators__ ||= {}
|
|
598
782
|
end
|
|
599
783
|
|
|
784
|
+
def __safe_memo_method_namespaces__
|
|
785
|
+
@__safe_memo_method_namespaces__ ||= {}
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def __safe_memo_class_cache_bust_generators__
|
|
789
|
+
@__safe_memo_class_cache_bust_generators__ ||= {}
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def __safe_memoize_defaults__
|
|
793
|
+
@__safe_memoize_defaults__
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# Resolves the effective first-element key sym for a given bare method name,
|
|
797
|
+
# applying the active namespace. Used by class-level cache operations where
|
|
798
|
+
# instance methods (compute_cache_key) are unavailable.
|
|
799
|
+
def __safe_memo_effective_key_name__(method_name)
|
|
800
|
+
ns_map = @__safe_memo_method_namespaces__
|
|
801
|
+
ns = (ns_map && ns_map[method_name]) ||
|
|
802
|
+
@__safe_memoize_namespace__ ||
|
|
803
|
+
SafeMemoize.configuration.namespace
|
|
804
|
+
ns ? :"#{ns}:#{method_name}" : method_name
|
|
805
|
+
end
|
|
806
|
+
|
|
600
807
|
def memoized_method_visibility(method_name)
|
|
601
808
|
return :private if private_method_defined?(method_name)
|
|
602
809
|
return :protected if protected_method_defined?(method_name)
|
|
@@ -627,7 +834,7 @@ module SafeMemoize
|
|
|
627
834
|
response = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
628
835
|
|
|
629
836
|
if response[:hit]
|
|
630
|
-
record_cache_hit(
|
|
837
|
+
record_cache_hit(cache_key)
|
|
631
838
|
call_memo_hooks(:on_hit, cache_key, response[:record])
|
|
632
839
|
return response[:record][:value]
|
|
633
840
|
end
|
|
@@ -653,7 +860,7 @@ module SafeMemoize
|
|
|
653
860
|
stored = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
654
861
|
stored_record = stored[:stored]
|
|
655
862
|
|
|
656
|
-
record_cache_miss(
|
|
863
|
+
record_cache_miss(cache_key, elapsed_time)
|
|
657
864
|
call_memo_hooks(:on_store, cache_key, stored_record)
|
|
658
865
|
call_memo_hooks(:on_miss, cache_key, stored_record)
|
|
659
866
|
|
|
@@ -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
|
|
@@ -42,7 +42,8 @@ module SafeMemoize
|
|
|
42
42
|
return unless cache
|
|
43
43
|
|
|
44
44
|
if args.empty? && kwargs.empty?
|
|
45
|
-
|
|
45
|
+
effective = resolve_memo_key_name(method_name)
|
|
46
|
+
cache.delete_if { |key, _| key[0] == effective }
|
|
46
47
|
fiber_memo_lru_or_nil&.delete(method_name)
|
|
47
48
|
else
|
|
48
49
|
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
@@ -49,6 +49,8 @@ module SafeMemoize
|
|
|
49
49
|
if (client = SafeMemoize.configuration.statsd_client)
|
|
50
50
|
Adapters::StatsD.dispatch(client, hook_type, cache_key, self.class.name)
|
|
51
51
|
end
|
|
52
|
+
|
|
53
|
+
SafeMemoize.dispatch_extension_events(hook_type, self.class, safe_memo_bare_method_name(cache_key[0]), cache_key, record)
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
def safe_memo_notify(hook_type, cache_key)
|