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.
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ # Public and private helpers for fiber-local memoization.
5
+ #
6
+ # When a method is memoized with +fiber_local: true+, its cached values are
7
+ # stored in +Fiber[:__safe_memoize__]+ rather than instance variables, giving
8
+ # each fiber an isolated cache that is automatically discarded when the fiber
9
+ # terminates. No mutex is required because fibers are cooperative: only one
10
+ # fiber runs at a time within a thread.
11
+ module FiberLocalMethods
12
+ FIBER_STORE_KEY = :__safe_memoize__
13
+
14
+ # Returns +true+ if the given call is currently cached in the current fiber.
15
+ #
16
+ # @note In Ruby, +Fiber.new+ inherits the parent fiber's local storage. SafeMemoize
17
+ # detects inherited stores via an +:__owner__+ sentinel and creates a fresh
18
+ # isolated store for each fiber on first write.
19
+ #
20
+ # @param method_name [Symbol, String]
21
+ # @param args [Array]
22
+ # @param kwargs [Hash]
23
+ # @return [Boolean]
24
+ def fiber_local_memoized?(method_name, *args, **kwargs, &block)
25
+ return false if block
26
+
27
+ method_name = method_name.to_sym
28
+ cache_key = compute_cache_key(method_name, args, kwargs)
29
+ record = fiber_memo_cache_or_nil&.[](cache_key)
30
+ memo_record_live?(record)
31
+ end
32
+
33
+ # Removes one or all fiber-local cached entries for a method in the current fiber.
34
+ #
35
+ # @param method_name [Symbol, String]
36
+ # @param args [Array]
37
+ # @param kwargs [Hash]
38
+ # @return [void]
39
+ def reset_fiber_memo(method_name, *args, **kwargs)
40
+ method_name = method_name.to_sym
41
+ cache = fiber_memo_cache_or_nil
42
+ return unless cache
43
+
44
+ if args.empty? && kwargs.empty?
45
+ effective = resolve_memo_key_name(method_name)
46
+ cache.delete_if { |key, _| key[0] == effective }
47
+ fiber_memo_lru_or_nil&.delete(method_name)
48
+ else
49
+ cache_key = compute_cache_key(method_name, args, kwargs)
50
+ cache.delete(cache_key)
51
+ fiber_memo_lru_or_nil&.[](method_name)&.delete(cache_key)
52
+ end
53
+ end
54
+
55
+ # Clears all fiber-local cached entries for this instance in the current fiber.
56
+ #
57
+ # @return [void]
58
+ def reset_all_fiber_memos
59
+ store = Fiber[FIBER_STORE_KEY]
60
+ return unless store&.[](:__owner__) == Fiber.current.object_id
61
+
62
+ store.delete(object_id)
63
+ end
64
+
65
+ private
66
+
67
+ # Returns the per-fiber top-level store hash, creating a fresh one for
68
+ # this fiber if the current store was inherited from a parent fiber.
69
+ def fiber_root_store!
70
+ store = Fiber[FIBER_STORE_KEY]
71
+ unless store&.[](:__owner__) == Fiber.current.object_id
72
+ store = {__owner__: Fiber.current.object_id}
73
+ Fiber[FIBER_STORE_KEY] = store
74
+ end
75
+ store
76
+ end
77
+
78
+ def fiber_memo_store!
79
+ fiber_root_store![object_id] ||= {cache: {}, lru: {}}
80
+ end
81
+
82
+ def fiber_memo_cache!
83
+ fiber_memo_store![:cache]
84
+ end
85
+
86
+ def fiber_memo_lru!
87
+ fiber_memo_store![:lru]
88
+ end
89
+
90
+ def fiber_root_store_or_nil
91
+ store = Fiber[FIBER_STORE_KEY]
92
+ return nil unless store&.[](:__owner__) == Fiber.current.object_id
93
+
94
+ store
95
+ end
96
+
97
+ def fiber_memo_store_or_nil
98
+ fiber_root_store_or_nil&.[](object_id)
99
+ end
100
+
101
+ def fiber_memo_cache_or_nil
102
+ fiber_memo_store_or_nil&.[](:cache)
103
+ end
104
+
105
+ def fiber_memo_lru_or_nil
106
+ fiber_memo_store_or_nil&.[](:lru)
107
+ end
108
+ end
109
+ end
@@ -31,7 +31,8 @@ module SafeMemoize
31
31
  hooks.each do |hook|
32
32
  hook.call(cache_key, record)
33
33
  rescue => error
34
- handler = SafeMemoize.configuration.on_hook_error
34
+ # SafeMemoize.configuration is not accessible from non-main Ractors
35
+ handler = (Ractor.current == Ractor.main) ? SafeMemoize.configuration.on_hook_error : nil
35
36
  if handler
36
37
  handler.call(error, hook_type, cache_key)
37
38
  else
@@ -39,11 +40,17 @@ module SafeMemoize
39
40
  end
40
41
  end
41
42
 
43
+ # ActiveSupport::Notifications and StatsD integration require main-Ractor
44
+ # configuration access; skip them from worker Ractors.
45
+ return if Ractor.current != Ractor.main
46
+
42
47
  safe_memo_notify(hook_type, cache_key) if SafeMemoize.configuration.active_support_notifications
43
48
 
44
49
  if (client = SafeMemoize.configuration.statsd_client)
45
50
  Adapters::StatsD.dispatch(client, hook_type, cache_key, self.class.name)
46
51
  end
52
+
53
+ SafeMemoize.dispatch_extension_events(hook_type, self.class, safe_memo_bare_method_name(cache_key[0]), cache_key, record)
47
54
  end
48
55
 
49
56
  def safe_memo_notify(hook_type, cache_key)
@@ -13,7 +13,8 @@ module SafeMemoize
13
13
 
14
14
  def memo_matcher_for(method_name, args, kwargs)
15
15
  if args.empty? && kwargs.empty?
16
- ->(key) { key[0] == method_name }
16
+ effective = resolve_memo_key_name(method_name)
17
+ ->(key) { key[0] == effective }
17
18
  else
18
19
  cache_key = compute_cache_key(method_name, args, kwargs)
19
20
  ->(key) { key == cache_key }
@@ -28,7 +29,8 @@ module SafeMemoize
28
29
  entries = cache.to_a
29
30
  return entries unless method_name
30
31
 
31
- entries.select { |(cache_key, _)| cache_key[0] == method_name }
32
+ effective = resolve_memo_key_name(method_name)
33
+ entries.select { |(cache_key, _)| cache_key[0] == effective }
32
34
  end
33
35
 
34
36
  def safe_memo_count_for(method_name)
@@ -57,14 +59,14 @@ module SafeMemoize
57
59
  # Custom keys are [method, custom_key] (2 elements); default keys are
58
60
  # [method, args, kwargs] (3 elements). Detect and surface accordingly.
59
61
  if cache_key.length == 2
60
- method_name, custom_key = cache_key
62
+ effective_name, custom_key = cache_key
61
63
  payload = {custom_key: custom_key}
62
64
  else
63
- method_name, args, kwargs = cache_key
65
+ effective_name, args, kwargs = cache_key
64
66
  payload = {args: args, kwargs: kwargs}
65
67
  end
66
68
 
67
- payload[:method] = method_name if include_method
69
+ payload[:method] = safe_memo_bare_method_name(effective_name) if include_method
68
70
  payload[:value] = memo_record_value(value) if include_value
69
71
  payload
70
72
  end
@@ -73,6 +75,33 @@ module SafeMemoize
73
75
  [method_name.to_sym, deep_freeze_copy(args), deep_freeze_copy(kwargs)]
74
76
  end
75
77
 
78
+ # Returns the active namespace string for a bare method name, or nil.
79
+ # Priority: per-method namespace: option > class safe_memoize_namespace > global.
80
+ #
81
+ # Uses instance_variable_get throughout so this is safe to call from worker
82
+ # Ractors (reads only; lazy-init ivars must not be triggered in that context).
83
+ def __safe_memo_resolve_namespace__(method_name)
84
+ ns_map = self.class.instance_variable_get(:@__safe_memo_method_namespaces__)
85
+ (ns_map && ns_map[method_name]) ||
86
+ self.class.instance_variable_get(:@__safe_memoize_namespace__) ||
87
+ SafeMemoize.instance_variable_get(:@configuration)&.namespace
88
+ end
89
+
90
+ # Returns the effective cache key sym for a bare method name, applying any
91
+ # active namespace (per-method > class-level > global).
92
+ def resolve_memo_key_name(method_name)
93
+ ns = __safe_memo_resolve_namespace__(method_name)
94
+ ns ? :"#{ns}:#{method_name}" : method_name
95
+ end
96
+
97
+ # Strips the namespace prefix from an effective key sym, returning the bare
98
+ # method name. When no namespace is present the sym is returned unchanged.
99
+ def safe_memo_bare_method_name(key_sym)
100
+ s = key_sym.to_s
101
+ colon_idx = s.index(":")
102
+ colon_idx ? s[(colon_idx + 1)..].to_sym : key_sym
103
+ end
104
+
76
105
  def deep_freeze_copy(obj)
77
106
  case obj
78
107
  when Array
@@ -13,5 +13,6 @@ module SafeMemoize
13
13
  include CustomKeyMethods
14
14
  include PublicCustomKeyMethods
15
15
  include LruMethods
16
+ include FiberLocalMethods
16
17
  end
17
18
  end
@@ -214,7 +214,8 @@ module SafeMemoize
214
214
 
215
215
  with_memo_lock do
216
216
  cache = memo_cache_or_nil || {}
217
- entries = method_name ? cache.select { |key, _| key[0] == method_name } : cache.dup
217
+ effective = method_name && resolve_memo_key_name(method_name)
218
+ entries = effective ? cache.select { |key, _| key[0] == effective } : cache.dup
218
219
  entries.select! { |_, record| memo_record_live?(record) }
219
220
  entries.transform_values { |record| memo_record_value(record) }
220
221
  end
@@ -401,7 +402,7 @@ module SafeMemoize
401
402
 
402
403
  age = (now - record[:cached_at]).round(6) if record[:cached_at]
403
404
 
404
- metrics_key = safe_memo_cache_key(method_name, args, kwargs)
405
+ metrics_key = compute_cache_key(method_name, args, kwargs)
405
406
  entry_metrics = memo_metrics_store[metrics_key] || {hits: 0, misses: 0}
406
407
 
407
408
  custom_key = (cache_key.length == 2) ? cache_key[1] : nil
@@ -26,7 +26,8 @@ module SafeMemoize
26
26
  method_name = method_name.to_sym
27
27
 
28
28
  with_memo_lock do
29
- metrics = memo_metrics_store.select { |key, _| key[0] == method_name }
29
+ effective = resolve_memo_key_name(method_name)
30
+ metrics = memo_metrics_store.select { |key, _| key[0] == effective }
30
31
  return empty_stats.merge(method: method_name) if metrics.empty?
31
32
 
32
33
  aggregate_metrics(metrics, include_method: false).merge(method: method_name)
@@ -73,13 +74,13 @@ module SafeMemoize
73
74
  avg_time = total_misses.zero? ? 0.0 : (total_time / total_misses).round(6)
74
75
 
75
76
  entries = metrics.map do |cache_key, stats|
76
- method_name, args, _kwargs = cache_key
77
+ effective_name, args, _kwargs = cache_key
77
78
  entry_calls = stats[:hits] + stats[:misses]
78
79
  entry_hit_rate = entry_calls.zero? ? 0.0 : (stats[:hits].to_f / entry_calls * 100).round(2)
79
80
 
80
81
  entry = {args: args, hits: stats[:hits], misses: stats[:misses],
81
82
  hit_rate: entry_hit_rate, computation_time: stats[:total_time].round(6)}
82
- entry[:method] = method_name if include_method
83
+ entry[:method] = safe_memo_bare_method_name(effective_name) if include_method
83
84
  entry
84
85
  end
85
86
 
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ # Class-level methods for Ractor-safe shared caching.
5
+ #
6
+ # Mixed into a class (via ClassMethods) when any method is memoized with
7
+ # +shared: true, ractor_safe: true+. The class owns a supervisor +Ractor+ that
8
+ # holds the mutable cache hash. All cache reads and writes are serialized through
9
+ # the supervisor's message loop, removing the need for a +Mutex+ (which is not
10
+ # Ractor-shareable).
11
+ #
12
+ # Constraints for +ractor_safe: true+ memoization:
13
+ # - Cached return values are made Ractor-shareable via +Ractor.make_shareable+
14
+ # (deep-frozen in place). Ensure return values can be frozen.
15
+ # - +if:+, +unless:+, +max_size:+, +ttl_refresh:+, +key:+, and +store:+ are
16
+ # incompatible and raise +ArgumentError+ at +memoize+ time.
17
+ # - When calling a ractor-safe memoized method from the main Ractor with multiple
18
+ # threads, responses are matched by thread identity so concurrent callers do not
19
+ # consume each other's replies.
20
+ module RactorSharedMethods
21
+ # Clears one or all entries from the Ractor-safe shared cache.
22
+ #
23
+ # @param method_name [Symbol, String]
24
+ # @param args [Array] positional args identifying a specific entry; omit to clear all
25
+ # @param kwargs [Hash]
26
+ # @return [void]
27
+ def reset_ractor_memo(method_name, *args, **kwargs)
28
+ method_name = method_name.to_sym
29
+ sup = @__safe_memo_ractor_supervisor__
30
+ return unless sup
31
+
32
+ if args.empty? && kwargs.empty?
33
+ __ractor_cache_send__(sup, :delete_all, method_name)
34
+ else
35
+ key = Ractor.make_shareable([method_name, args.freeze, kwargs.freeze])
36
+ __ractor_cache_send__(sup, :delete_one, key)
37
+ end
38
+ end
39
+
40
+ # Clears the entire Ractor-safe shared cache for this class.
41
+ # @return [void]
42
+ def reset_all_ractor_memos
43
+ sup = @__safe_memo_ractor_supervisor__
44
+ return unless sup
45
+
46
+ __ractor_cache_send__(sup, :clear)
47
+ end
48
+
49
+ # Returns +true+ if a live entry exists in the Ractor-safe shared cache.
50
+ #
51
+ # @param method_name [Symbol, String]
52
+ # @param args [Array]
53
+ # @param kwargs [Hash]
54
+ # @return [Boolean]
55
+ def ractor_memoized?(method_name, *args, **kwargs)
56
+ method_name = method_name.to_sym
57
+ sup = @__safe_memo_ractor_supervisor__
58
+ return false unless sup
59
+
60
+ key = Ractor.make_shareable([method_name, args.freeze, kwargs.freeze])
61
+ __ractor_cache_send__(sup, :memoized, key)
62
+ end
63
+
64
+ # Returns the number of live entries in the Ractor-safe shared cache.
65
+ #
66
+ # @param method_name [Symbol, String, nil] when given, counts only entries for
67
+ # that method; when +nil+, counts all.
68
+ # @return [Integer]
69
+ def ractor_memo_count(method_name = nil)
70
+ sup = @__safe_memo_ractor_supervisor__
71
+ return 0 unless sup
72
+
73
+ __ractor_cache_send__(sup, :count, method_name&.to_sym)
74
+ end
75
+
76
+ private
77
+
78
+ # Sends a message to the supervisor and blocks until the tagged response arrives.
79
+ # Uses Thread.current.object_id as a per-call tag so concurrent threads in the
80
+ # main Ractor do not steal each other's replies.
81
+ def __ractor_cache_send__(supervisor, op, *args)
82
+ tag = Thread.current.object_id
83
+ msg = Ractor.make_shareable([Ractor.current, tag, op, *args])
84
+ supervisor.send(msg)
85
+ Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
86
+ end
87
+
88
+ # Creates the supervisor Ractor that owns this class's Ractor-safe shared cache.
89
+ # Must be called from the main Ractor at class-definition time.
90
+ def __safe_memo_ractor_supervisor__
91
+ # :nocov:
92
+ @__safe_memo_ractor_supervisor__ ||= Ractor.new do
93
+ cache = {}
94
+
95
+ loop do
96
+ caller_ractor, tag, op, *args = Ractor.receive
97
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+
99
+ result = case op
100
+ when :fetch
101
+ key = args[0]
102
+ record = cache[key]
103
+ live = record && (record[:expires_at].nil? || record[:expires_at] > now)
104
+ live ? {hit: true, record: record} : {hit: false, record: nil}
105
+
106
+ when :store
107
+ key, new_record = args
108
+ existing = cache[key]
109
+ live = existing && (existing[:expires_at].nil? || existing[:expires_at] > now)
110
+ cache[key] = new_record unless live
111
+ {stored: live ? existing : new_record}
112
+
113
+ when :delete_all
114
+ method_name = args[0]
115
+ cache.delete_if { |k, _| k[0] == method_name }
116
+ :ok
117
+
118
+ when :delete_one
119
+ cache.delete(args[0])
120
+ :ok
121
+
122
+ when :clear
123
+ cache.clear
124
+ :ok
125
+
126
+ when :memoized
127
+ key = args[0]
128
+ record = cache[key]
129
+ !!(record && (record[:expires_at].nil? || record[:expires_at] > now))
130
+
131
+ when :count
132
+ method_name = args[0]
133
+ cache.count do |k, r|
134
+ next false if r[:expires_at] && r[:expires_at] <= now
135
+ method_name.nil? || k[0] == method_name
136
+ end
137
+ end
138
+
139
+ response = Ractor.make_shareable([tag, result])
140
+ caller_ractor.send(response)
141
+ end
142
+ end
143
+ # :nocov:
144
+ end
145
+ end
146
+ end
@@ -46,6 +46,31 @@ module SafeMemoize
46
46
  contents.sub(UNRELEASED_HEADING, "#{UNRELEASED_HEADING}\n\n#{release_heading}")
47
47
  end
48
48
 
49
+ # Removes milestone sections from ROADMAP.md where every feature row has
50
+ # "Shipped" status. Non-milestone sections (Versioning policy, Contributing,
51
+ # etc.) and sections with any non-Shipped row are left untouched.
52
+ #
53
+ # Sections are delimited by the +\n\n---\n\n+ horizontal-rule separator that
54
+ # the ROADMAP uses between headings. A milestone section is any section whose
55
+ # first non-blank line starts with +## v+.
56
+ def prune_roadmap(contents)
57
+ separator = "\n\n---\n\n"
58
+ sections = contents.split(separator)
59
+
60
+ pruned = sections.reject do |section|
61
+ next false unless section.lstrip.start_with?("## v")
62
+
63
+ # Table rows: lines starting with "|"; drop alignment rows (only |, -, :, whitespace)
64
+ rows = section.lines.select { |l| l.strip.start_with?("|") }
65
+ rows = rows.reject { |l| l.match?(/\A[\s|:-]+\z/) }
66
+ data_rows = rows.drop(1) # first row is the header
67
+
68
+ data_rows.any? && data_rows.all? { |row| row.strip.end_with?("Shipped |") }
69
+ end
70
+
71
+ pruned.join(separator)
72
+ end
73
+
49
74
  def extract_release_notes(contents, version)
50
75
  normalized_version = normalize_version(version)
51
76
  lines = contents.lines
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SafeMemoize
4
4
  # The current gem version string.
5
- VERSION = "1.1.0"
5
+ VERSION = "1.3.0"
6
6
  end
data/lib/safe_memoize.rb CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  require_relative "safe_memoize/version"
4
4
  require_relative "safe_memoize/configuration"
5
+ require_relative "safe_memoize/extension"
5
6
  require_relative "safe_memoize/stores/base"
6
7
  require_relative "safe_memoize/stores/memory"
7
8
  require_relative "safe_memoize/adapters/statsd"
8
9
  require_relative "safe_memoize/adapters/opentelemetry"
10
+ require_relative "safe_memoize/adapters/concurrent_ruby"
9
11
  require_relative "safe_memoize/class_methods"
10
12
  require_relative "safe_memoize/public_methods"
11
13
  require_relative "safe_memoize/cache_store_methods"
@@ -17,6 +19,8 @@ require_relative "safe_memoize/public_metrics_methods"
17
19
  require_relative "safe_memoize/custom_key_methods"
18
20
  require_relative "safe_memoize/public_custom_key_methods"
19
21
  require_relative "safe_memoize/lru_methods"
22
+ require_relative "safe_memoize/fiber_local_methods"
23
+ require_relative "safe_memoize/ractor_shared_methods"
20
24
  require_relative "safe_memoize/instance_methods"
21
25
 
22
26
  # Thread-safe memoization for Ruby that correctly handles +nil+ and +false+ values.
@@ -51,6 +55,16 @@ module SafeMemoize
51
55
  # Rescue this to catch any error raised by the library itself.
52
56
  class Error < StandardError; end
53
57
 
58
+ # @api private
59
+ SHARED_CACHE_REGISTRY = {}
60
+ # @api private
61
+ SHARED_CACHE_MUTEX = Mutex.new
62
+
63
+ # @api private
64
+ EXTENSION_REGISTRY = {}
65
+ # @api private
66
+ EXTENSION_MUTEX = Mutex.new
67
+
54
68
  include InstanceMethods
55
69
 
56
70
  # @api private
@@ -99,4 +113,131 @@ module SafeMemoize
99
113
  handler = configuration.on_deprecation
100
114
  handler ? handler.call(text) : warn(text)
101
115
  end
116
+
117
+ # Returns the named shared cache store, creating a new in-process
118
+ # {Stores::Memory} instance if one has not been registered under +name+.
119
+ #
120
+ # Use {register_shared_cache} to supply a custom adapter (e.g. Redis) before
121
+ # any class that references the same name is loaded.
122
+ #
123
+ # @param name [String] the logical cache name
124
+ # @return [Stores::Base]
125
+ def self.shared_cache(name)
126
+ SHARED_CACHE_MUTEX.synchronize do
127
+ SHARED_CACHE_REGISTRY[name] ||= Stores::Memory.new
128
+ end
129
+ end
130
+
131
+ # Registers a custom store under +name+, replacing any existing entry.
132
+ #
133
+ # Must be called *before* any class that references +name+ via +shared_cache:+
134
+ # is loaded, because the store is captured at +memoize+ definition time.
135
+ #
136
+ # @param name [String] the logical cache name
137
+ # @param store [Stores::Base] any {Stores::Base} subclass instance
138
+ # @return [Stores::Base] the registered store
139
+ # @raise [ArgumentError] if +store+ is not a {Stores::Base} instance
140
+ def self.register_shared_cache(name, store)
141
+ unless store.is_a?(Stores::Base)
142
+ raise ArgumentError, "store must be a SafeMemoize::Stores::Base instance (got #{store.class})"
143
+ end
144
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY[name] = store }
145
+ end
146
+
147
+ # Clears all entries in the named shared cache without removing it from the
148
+ # registry. A no-op when no cache is registered under +name+.
149
+ #
150
+ # @param name [String]
151
+ # @return [void]
152
+ def self.clear_shared_cache(name)
153
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY[name]&.clear }
154
+ end
155
+
156
+ # Removes the named shared cache from the registry entirely.
157
+ # Subsequent +shared_cache(name)+ calls will create a fresh store.
158
+ #
159
+ # @param name [String]
160
+ # @return [Stores::Base, nil] the removed store, or +nil+ if not present
161
+ def self.drop_shared_cache(name)
162
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.delete(name) }
163
+ end
164
+
165
+ # Returns a snapshot of the current registry as a plain +Hash+.
166
+ #
167
+ # @return [Hash{String => Stores::Base}]
168
+ def self.shared_caches
169
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.dup }
170
+ end
171
+
172
+ # Removes all named shared caches from the registry.
173
+ #
174
+ # Useful in test suite +after+ hooks to prevent state leaking between examples.
175
+ #
176
+ # @return [void]
177
+ def self.reset_shared_caches!
178
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.clear }
179
+ end
180
+
181
+ # Registers an extension under +name+.
182
+ #
183
+ # An extension is any Ruby object that optionally responds to the
184
+ # {Extension} interface: {Extension#handled_options}, {Extension#process_memoize_option},
185
+ # and {Extension#dispatch_cache_event}. Use {Extension} as a mixin to get a
186
+ # convenient DSL for defining these.
187
+ #
188
+ # @param name [Symbol, String] a unique identifier for this extension
189
+ # @param extension [Object] the extension object or module
190
+ # @return [Object] the registered extension
191
+ def self.register_extension(name, extension)
192
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY[name.to_sym] = extension }
193
+ end
194
+
195
+ # Removes an extension from the registry.
196
+ #
197
+ # @param name [Symbol, String]
198
+ # @return [Object, nil] the removed extension, or +nil+ if not present
199
+ def self.unregister_extension(name)
200
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.delete(name.to_sym) }
201
+ end
202
+
203
+ # Returns a snapshot of all registered extensions as a +Hash+.
204
+ #
205
+ # @return [Hash{Symbol => Object}]
206
+ def self.extensions
207
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.dup }
208
+ end
209
+
210
+ # Removes all registered extensions.
211
+ #
212
+ # Useful in test suite +after+ hooks to prevent state leaking between examples.
213
+ #
214
+ # @return [void]
215
+ def self.reset_extensions!
216
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.clear }
217
+ end
218
+
219
+ # Returns the first registered extension that declares it handles +option_name+,
220
+ # or +nil+ if none does.
221
+ #
222
+ # @param option_name [Symbol]
223
+ # @return [Object, nil]
224
+ def self.extension_for_option(option_name)
225
+ sym = option_name.to_sym
226
+ EXTENSION_MUTEX.synchronize do
227
+ EXTENSION_REGISTRY.values.find do |ext|
228
+ ext.respond_to?(:handled_options) && ext.handled_options.include?(sym)
229
+ end
230
+ end
231
+ end
232
+
233
+ # Dispatches a cache lifecycle event to all registered extensions that
234
+ # respond to +dispatch_cache_event+.
235
+ #
236
+ # @api private
237
+ def self.dispatch_extension_events(event_type, klass, method_name, cache_key, record)
238
+ exts = EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.values.dup }
239
+ exts.each do |ext|
240
+ ext.dispatch_cache_event(event_type, klass, method_name, cache_key, record) if ext.respond_to?(:dispatch_cache_event)
241
+ end
242
+ end
102
243
  end