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.
@@ -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 +store:+.
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: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false)
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(method_name, args, kwargs)
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(method_name, args, kwargs, elapsed_time)
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(method_name, args, kwargs)
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(method_name, args, kwargs, elapsed_time)
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(method_name, args, kwargs)
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(method_name, args, kwargs, elapsed_time)
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(method_name, args, kwargs)
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(method_name, args, kwargs, elapsed_time)
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(method_name, args, kwargs)
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(method_name, args, kwargs, elapsed_time)
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
- specific_key = (args.empty? && kwargs.empty?) ? nil : [method_name, args, kwargs]
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] == method_name }
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
- cache_key = [method_name, args, kwargs]
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
- method_name ? live.count { |key, _| key[0] == method_name.to_sym } : live.count
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
- cache_key = [method_name, args, kwargs]
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
- cache_key = [method_name, args, kwargs]
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(method_name, args, kwargs)
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(method_name, args, kwargs, elapsed_time)
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
- [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
@@ -42,7 +42,8 @@ module SafeMemoize
42
42
  return unless cache
43
43
 
44
44
  if args.empty? && kwargs.empty?
45
- cache.delete_if { |key, _| key[0] == method_name }
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)