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.
@@ -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
@@ -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
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SafeMemoize
4
4
  # The current gem version string.
5
- VERSION = "1.2.0"
5
+ VERSION = "1.4.0"
6
6
  end
data/lib/safe_memoize.rb CHANGED
@@ -2,6 +2,7 @@
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"
@@ -54,6 +55,19 @@ module SafeMemoize
54
55
  # Rescue this to catch any error raised by the library itself.
55
56
  class Error < StandardError; end
56
57
 
58
+ # @api private — sentinel distinguishing "not passed" from explicit nil/false in memoize kwargs
59
+ UNSET = Object.new.freeze
60
+
61
+ # @api private
62
+ SHARED_CACHE_REGISTRY = {}
63
+ # @api private
64
+ SHARED_CACHE_MUTEX = Mutex.new
65
+
66
+ # @api private
67
+ EXTENSION_REGISTRY = {}
68
+ # @api private
69
+ EXTENSION_MUTEX = Mutex.new
70
+
57
71
  include InstanceMethods
58
72
 
59
73
  # @api private
@@ -102,4 +116,131 @@ module SafeMemoize
102
116
  handler = configuration.on_deprecation
103
117
  handler ? handler.call(text) : warn(text)
104
118
  end
119
+
120
+ # Returns the named shared cache store, creating a new in-process
121
+ # {Stores::Memory} instance if one has not been registered under +name+.
122
+ #
123
+ # Use {register_shared_cache} to supply a custom adapter (e.g. Redis) before
124
+ # any class that references the same name is loaded.
125
+ #
126
+ # @param name [String] the logical cache name
127
+ # @return [Stores::Base]
128
+ def self.shared_cache(name)
129
+ SHARED_CACHE_MUTEX.synchronize do
130
+ SHARED_CACHE_REGISTRY[name] ||= Stores::Memory.new
131
+ end
132
+ end
133
+
134
+ # Registers a custom store under +name+, replacing any existing entry.
135
+ #
136
+ # Must be called *before* any class that references +name+ via +shared_cache:+
137
+ # is loaded, because the store is captured at +memoize+ definition time.
138
+ #
139
+ # @param name [String] the logical cache name
140
+ # @param store [Stores::Base] any {Stores::Base} subclass instance
141
+ # @return [Stores::Base] the registered store
142
+ # @raise [ArgumentError] if +store+ is not a {Stores::Base} instance
143
+ def self.register_shared_cache(name, store)
144
+ unless store.is_a?(Stores::Base)
145
+ raise ArgumentError, "store must be a SafeMemoize::Stores::Base instance (got #{store.class})"
146
+ end
147
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY[name] = store }
148
+ end
149
+
150
+ # Clears all entries in the named shared cache without removing it from the
151
+ # registry. A no-op when no cache is registered under +name+.
152
+ #
153
+ # @param name [String]
154
+ # @return [void]
155
+ def self.clear_shared_cache(name)
156
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY[name]&.clear }
157
+ end
158
+
159
+ # Removes the named shared cache from the registry entirely.
160
+ # Subsequent +shared_cache(name)+ calls will create a fresh store.
161
+ #
162
+ # @param name [String]
163
+ # @return [Stores::Base, nil] the removed store, or +nil+ if not present
164
+ def self.drop_shared_cache(name)
165
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.delete(name) }
166
+ end
167
+
168
+ # Returns a snapshot of the current registry as a plain +Hash+.
169
+ #
170
+ # @return [Hash{String => Stores::Base}]
171
+ def self.shared_caches
172
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.dup }
173
+ end
174
+
175
+ # Removes all named shared caches from the registry.
176
+ #
177
+ # Useful in test suite +after+ hooks to prevent state leaking between examples.
178
+ #
179
+ # @return [void]
180
+ def self.reset_shared_caches!
181
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.clear }
182
+ end
183
+
184
+ # Registers an extension under +name+.
185
+ #
186
+ # An extension is any Ruby object that optionally responds to the
187
+ # {Extension} interface: {Extension#handled_options}, {Extension#process_memoize_option},
188
+ # and {Extension#dispatch_cache_event}. Use {Extension} as a mixin to get a
189
+ # convenient DSL for defining these.
190
+ #
191
+ # @param name [Symbol, String] a unique identifier for this extension
192
+ # @param extension [Object] the extension object or module
193
+ # @return [Object] the registered extension
194
+ def self.register_extension(name, extension)
195
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY[name.to_sym] = extension }
196
+ end
197
+
198
+ # Removes an extension from the registry.
199
+ #
200
+ # @param name [Symbol, String]
201
+ # @return [Object, nil] the removed extension, or +nil+ if not present
202
+ def self.unregister_extension(name)
203
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.delete(name.to_sym) }
204
+ end
205
+
206
+ # Returns a snapshot of all registered extensions as a +Hash+.
207
+ #
208
+ # @return [Hash{Symbol => Object}]
209
+ def self.extensions
210
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.dup }
211
+ end
212
+
213
+ # Removes all registered extensions.
214
+ #
215
+ # Useful in test suite +after+ hooks to prevent state leaking between examples.
216
+ #
217
+ # @return [void]
218
+ def self.reset_extensions!
219
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.clear }
220
+ end
221
+
222
+ # Returns the first registered extension that declares it handles +option_name+,
223
+ # or +nil+ if none does.
224
+ #
225
+ # @param option_name [Symbol]
226
+ # @return [Object, nil]
227
+ def self.extension_for_option(option_name)
228
+ sym = option_name.to_sym
229
+ EXTENSION_MUTEX.synchronize do
230
+ EXTENSION_REGISTRY.values.find do |ext|
231
+ ext.respond_to?(:handled_options) && ext.handled_options.include?(sym)
232
+ end
233
+ end
234
+ end
235
+
236
+ # Dispatches a cache lifecycle event to all registered extensions that
237
+ # respond to +dispatch_cache_event+.
238
+ #
239
+ # @api private
240
+ def self.dispatch_extension_events(event_type, klass, method_name, cache_key, record)
241
+ exts = EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.values.dup }
242
+ exts.each do |ext|
243
+ ext.dispatch_cache_event(event_type, klass, method_name, cache_key, record) if ext.respond_to?(:dispatch_cache_event)
244
+ end
245
+ end
105
246
  end
data/sig/safe_memoize.rbs CHANGED
@@ -18,11 +18,37 @@ module SafeMemoize
18
18
  @__safe_memo_shared_mutex__: Mutex?
19
19
  @__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
20
20
 
21
+ UNSET: untyped
22
+ SHARED_CACHE_REGISTRY: Hash[String, Stores::Base]
23
+ SHARED_CACHE_MUTEX: Mutex
24
+ EXTENSION_REGISTRY: Hash[Symbol, untyped]
25
+ EXTENSION_MUTEX: Mutex
26
+
21
27
  def self.prepended: (Class base) -> void
22
28
  def self.configure: () { (Configuration) -> void } -> void
23
29
  def self.configuration: () -> Configuration
24
30
  def self.reset_configuration!: () -> Configuration
25
31
  def self.deprecate: (String subject, message: String, horizon: String) -> void
32
+ def self.shared_cache: (String name) -> Stores::Base
33
+ def self.register_shared_cache: (String name, Stores::Base store) -> Stores::Base
34
+ def self.clear_shared_cache: (String name) -> void
35
+ def self.drop_shared_cache: (String name) -> Stores::Base?
36
+ def self.shared_caches: () -> Hash[String, Stores::Base]
37
+ def self.reset_shared_caches!: () -> void
38
+ def self.register_extension: (Symbol | String name, untyped extension) -> untyped
39
+ def self.unregister_extension: (Symbol | String name) -> untyped?
40
+ def self.extensions: () -> Hash[Symbol, untyped]
41
+ def self.reset_extensions!: () -> void
42
+ def self.extension_for_option: (Symbol option_name) -> untyped?
43
+ def self.dispatch_extension_events: (Symbol event_type, Class klass, Symbol method_name, untyped cache_key, untyped record) -> void
44
+
45
+ module Extension
46
+ def handles_option: (Symbol option_name) { (untyped value, Symbol method_name, Hash[Symbol, untyped] all_options) -> Hash[Symbol, untyped]? } -> void
47
+ def on_cache_event: (*Symbol event_types) { (Class klass, Symbol method_name, untyped cache_key, untyped record) -> void } -> void
48
+ def handled_options: () -> Array[Symbol]
49
+ def process_memoize_option: (Symbol option_name, untyped value, Symbol method_name, Hash[Symbol, untyped] all_options) -> Hash[Symbol, untyped]
50
+ def dispatch_cache_event: (Symbol event_type, Class klass, Symbol method_name, untyped cache_key, untyped record) -> void
51
+ end
26
52
 
27
53
  class Configuration
28
54
  attr_accessor default_ttl: Numeric?
@@ -33,15 +59,19 @@ module SafeMemoize
33
59
  attr_accessor statsd_client: untyped
34
60
  attr_accessor opentelemetry_tracer: untyped
35
61
  attr_accessor default_store: Stores::Base?
62
+ attr_accessor namespace: String?
36
63
 
37
64
  def initialize: () -> void
38
65
  end
39
66
 
40
67
  module ClassMethods
41
- def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool) -> void
68
+ def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, ?copy_on_read: bool, **untyped extension_options) -> void
42
69
  def safe_memoize_store: () -> Stores::Base?
43
70
  def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
44
- def memoize_all: (?except: Array[Symbol | String], ?only: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?fiber_local: bool) -> void
71
+ def safe_memoize_namespace: () -> String?
72
+ def safe_memoize_namespace=: (String?) -> String?
73
+ def safe_memoize_options: (**untyped opts) -> Hash[Symbol, untyped]?
74
+ def memoize_all: (?except: Array[Symbol | String], ?only: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?fiber_local: bool, ?namespace: String?, ?shared_cache: String?, ?copy_on_read: bool) -> void
45
75
  def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
46
76
  def reset_all_shared_memos: () -> void
47
77
  def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
@@ -55,6 +85,9 @@ module SafeMemoize
55
85
  def __safe_memo_shared_mutex__: () -> Mutex
56
86
  def __safe_memo_shared_lru_order__: () -> Hash[Symbol, Hash[memo_key, true]]
57
87
  def __safe_memo_class_key_generators__: () -> Hash[Symbol, Proc]
88
+ def __safe_memo_method_namespaces__: () -> Hash[Symbol, String]
89
+ def __safe_memo_class_cache_bust_generators__: () -> Hash[Symbol, Proc | Symbol]
90
+ def __safe_memoize_defaults__: () -> Hash[Symbol, untyped]?
58
91
  def memoized_method_visibility: (Symbol method_name) -> Symbol
59
92
  end
60
93
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: safe_memoize
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -39,6 +39,7 @@ executables: []
39
39
  extensions: []
40
40
  extra_rdoc_files: []
41
41
  files:
42
+ - ".github/FUNDING.yml"
42
43
  - ".github/workflows/ci.yml"
43
44
  - ".github/workflows/release.yml"
44
45
  - ".yardopts"
@@ -61,6 +62,7 @@ files:
61
62
  - lib/safe_memoize/class_methods.rb
62
63
  - lib/safe_memoize/configuration.rb
63
64
  - lib/safe_memoize/custom_key_methods.rb
65
+ - lib/safe_memoize/extension.rb
64
66
  - lib/safe_memoize/fiber_local_methods.rb
65
67
  - lib/safe_memoize/hooks_methods.rb
66
68
  - lib/safe_memoize/inspection_methods.rb