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.
@@ -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
- # Resolve effective store: explicit store: wins; global default applies when
103
- # compatible (max_size: and shared: are incompatible fall back silently).
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
- global_default = SafeMemoize.configuration.default_store
107
- if global_default
108
- unless global_default.is_a?(SafeMemoize::Stores::Base)
109
- raise ArgumentError,
110
- "SafeMemoize.configuration.default_store must be a Stores::Base instance (got #{global_default.class})"
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(method_name, args, kwargs)
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(method_name, args, kwargs, elapsed_time)
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(method_name, args, kwargs)
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(method_name, args, kwargs, elapsed_time)
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(method_name, args, kwargs)
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(method_name, args, kwargs, elapsed_time)
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(method_name, args, kwargs)
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(method_name, args, kwargs, elapsed_time)
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
- specific_key = (args.empty? && kwargs.empty?) ? nil : [method_name, args, kwargs]
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] == method_name }
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
- cache_key = [method_name, args, kwargs]
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
- method_name ? live.count { |key, _| key[0] == method_name.to_sym } : live.count
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
- cache_key = [method_name, args, kwargs]
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
- cache_key = [method_name, args, kwargs]
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
- [method_name, key_block.call(*args, **kwargs)]
30
+ [effective_name, key_block.call(*args, **kwargs)]
28
31
  else
29
- safe_memo_cache_key(method_name, args, kwargs)
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