safe_memoize 0.9.0 → 1.1.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.
@@ -1,8 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # Class-level DSL added to any class that does +prepend SafeMemoize+.
4
5
  module ClassMethods
5
- def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil)
6
+ # Wraps an existing instance method with a thread-safe per-instance cache.
7
+ #
8
+ # Must be called *after* the method is defined. Raises +ArgumentError+ immediately
9
+ # at class-definition time if no such method exists.
10
+ #
11
+ # @param method_name [Symbol, String] name of the instance method to memoize
12
+ # @param ttl [Numeric, nil] seconds until the cached value expires; +nil+ means
13
+ # the entry never expires. Falls back to {Configuration#default_ttl} when +nil+.
14
+ # @param max_size [Integer, nil] maximum number of cached entries per instance
15
+ # for this method; the least-recently-used entry is evicted when the limit is
16
+ # reached. Falls back to {Configuration#default_max_size} when +nil+.
17
+ # @param ttl_refresh [Boolean] when +true+, every cache *hit* resets the expiry
18
+ # clock (sliding-window TTL). Requires +ttl:+ to be set.
19
+ # @param if [Proc, nil] callable predicate; the result is cached only when the
20
+ # predicate returns truthy. Receives the computed return value as its argument.
21
+ # @param unless [Proc, nil] inverse of +:if+; the result is *not* cached when the
22
+ # predicate returns truthy.
23
+ # @param shared [Boolean] when +true+, results are stored on the class rather than
24
+ # per instance — all instances share one cache.
25
+ # @param key [Proc, nil] class-level custom cache key generator. Receives the same
26
+ # arguments as the method and should return a single comparable value. Instance-level
27
+ # keys set via {PublicCustomKeyMethods#memoize_with_custom_key} take priority.
28
+ # @param store [Stores::Base, nil] custom cache store adapter. Must be a
29
+ # {Stores::Base} subclass instance. The store is shared across all instances of the
30
+ # class. When +nil+, the default per-instance in-process hash is used.
31
+ # Cannot be combined with +max_size:+ or +shared:+.
32
+ # @return [void]
33
+ # @raise [ArgumentError] if the method does not exist, or option values are invalid
34
+ #
35
+ # @example Zero-argument method
36
+ # def expensive_query = db.run("SELECT …")
37
+ # memoize :expensive_query
38
+ #
39
+ # @example With TTL and LRU cap
40
+ # def fetch(id) = http_get(id)
41
+ # memoize :fetch, ttl: 60, max_size: 500
42
+ #
43
+ # @example Conditional — only cache successful responses
44
+ # memoize :fetch, if: ->(v) { v[:status] == 200 }
45
+ #
46
+ # @example With a custom store
47
+ # STORE = SafeMemoize::Stores::Memory.new
48
+ # 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)
6
50
  method_name = method_name.to_sym
7
51
 
8
52
  unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
@@ -49,6 +93,26 @@ module SafeMemoize
49
93
  raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call)
50
94
  raise ArgumentError, ":key must be callable" if key && !key.respond_to?(:call)
51
95
 
96
+ if store
97
+ raise ArgumentError, "store: must be a SafeMemoize::Stores::Base instance (got #{store.class})" unless store.is_a?(SafeMemoize::Stores::Base)
98
+ raise ArgumentError, "max_size: is not supported with store: — use the store adapter's own eviction" if max_size
99
+ raise ArgumentError, "shared: and store: cannot be combined" if shared
100
+ end
101
+
102
+ # Resolve effective store: explicit store: wins; global default applies when
103
+ # compatible (max_size: and shared: are incompatible — fall back silently).
104
+ effective_store = store
105
+ 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})"
111
+ end
112
+ effective_store = global_default
113
+ end
114
+ end
115
+
52
116
  __safe_memo_class_key_generators__[method_name] = key if key
53
117
 
54
118
  # Normalize to a single "should cache?" predicate
@@ -58,6 +122,49 @@ module SafeMemoize
58
122
  ->(result) { !cond_unless.call(result) }
59
123
  end
60
124
 
125
+ if effective_store
126
+ miss = SafeMemoize::Stores::Base::MISS
127
+
128
+ mod = Module.new do
129
+ define_method(method_name) do |*args, **kwargs, &block|
130
+ return super(*args, **kwargs, &block) if block
131
+
132
+ cache_key = compute_cache_key(method_name, args, kwargs)
133
+ cached = effective_store.read(cache_key)
134
+
135
+ unless cached.equal?(miss)
136
+ effective_store.write(cache_key, cached, expires_in: ttl) if ttl_refresh
137
+ record_cache_hit(method_name, args, kwargs)
138
+ call_memo_hooks(:on_hit, cache_key, {value: cached, expires_at: nil, cached_at: nil})
139
+ return cached
140
+ end
141
+
142
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
143
+ value = Adapters::OpenTelemetry.trace(
144
+ SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name
145
+ ) { super(*args, **kwargs) }
146
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
147
+
148
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
149
+ if !condition || condition.call(value)
150
+ effective_store.write(cache_key, value, expires_in: ttl)
151
+ call_memo_hooks(:on_store, cache_key, {value: value, expires_at: nil, cached_at: now})
152
+ end
153
+
154
+ record_cache_miss(method_name, args, kwargs, elapsed_time)
155
+ call_memo_hooks(:on_miss, cache_key, {value: value, expires_at: nil, cached_at: now})
156
+
157
+ value
158
+ end
159
+
160
+ send(visibility, method_name)
161
+ end
162
+
163
+ prepend mod
164
+
165
+ return
166
+ end
167
+
61
168
  if shared
62
169
  klass = self
63
170
  shared_mutex = klass.send(:__safe_memo_shared_mutex__)
@@ -196,6 +303,45 @@ module SafeMemoize
196
303
  prepend mod
197
304
  end
198
305
 
306
+ # Memoizes every eligible public instance method defined directly on the class.
307
+ #
308
+ # Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+.
309
+ # Raises +ArgumentError+ when both +:only:+ and +:except:+ are given.
310
+ #
311
+ # @param except [Array<Symbol, String>] method names to skip
312
+ # @param only [Array<Symbol, String>] when non-empty, only these methods are memoized
313
+ # @param include_protected [Boolean] also memoize +protected+ methods
314
+ # @param include_private [Boolean] also memoize +private+ methods
315
+ # @param options [Hash] any additional options forwarded to {#memoize}
316
+ # @return [void]
317
+ # @raise [ArgumentError] if both +:only:+ and +:except:+ are given
318
+ def memoize_all(except: [], only: [], include_protected: false, include_private: false, **options)
319
+ raise ArgumentError, "cannot specify both :only and :except" if only.any? && except.any?
320
+
321
+ excluded = Array(except).map(&:to_sym)
322
+ included = Array(only).map(&:to_sym)
323
+
324
+ methods = public_instance_methods(false)
325
+ methods |= protected_instance_methods(false) if include_protected
326
+ methods |= private_instance_methods(false) if include_private
327
+
328
+ methods.each do |method_name|
329
+ next if excluded.include?(method_name)
330
+ next if included.any? && !included.include?(method_name)
331
+
332
+ memoize(method_name, **options)
333
+ end
334
+ end
335
+
336
+ # Clears one or all entries from the class-level shared cache.
337
+ #
338
+ # With no positional args after +method_name+, clears *all* shared entries for
339
+ # that method. With args/kwargs, clears only the matching entry.
340
+ #
341
+ # @param method_name [Symbol, String] the memoized method
342
+ # @param args [Array] positional arguments identifying the entry to clear
343
+ # @param kwargs [Hash] keyword arguments identifying the entry to clear
344
+ # @return [void]
199
345
  def reset_shared_memo(method_name, *args, **kwargs)
200
346
  method_name = method_name.to_sym
201
347
  specific_key = (args.empty? && kwargs.empty?) ? nil : [method_name, args, kwargs]
@@ -211,6 +357,8 @@ module SafeMemoize
211
357
  end
212
358
  end
213
359
 
360
+ # Clears the entire class-level shared cache for this class.
361
+ # @return [void]
214
362
  def reset_all_shared_memos
215
363
  __safe_memo_shared_mutex__.synchronize do
216
364
  @__safe_memo_shared_cache__ = {}
@@ -218,6 +366,12 @@ module SafeMemoize
218
366
  end
219
367
  end
220
368
 
369
+ # Returns +true+ if a live shared cache entry exists for the given call signature.
370
+ #
371
+ # @param method_name [Symbol, String]
372
+ # @param args [Array] positional arguments
373
+ # @param kwargs [Hash] keyword arguments
374
+ # @return [Boolean]
221
375
  def shared_memoized?(method_name, *args, **kwargs)
222
376
  method_name = method_name.to_sym
223
377
  cache_key = [method_name, args, kwargs]
@@ -233,6 +387,11 @@ module SafeMemoize
233
387
  end
234
388
  end
235
389
 
390
+ # Returns the number of live entries in the class-level shared cache.
391
+ #
392
+ # @param method_name [Symbol, String, nil] when given, counts only entries for
393
+ # that method; when +nil+, counts all methods.
394
+ # @return [Integer]
236
395
  def shared_memo_count(method_name = nil)
237
396
  __safe_memo_shared_mutex__.synchronize do
238
397
  cache = @__safe_memo_shared_cache__ || {}
@@ -242,6 +401,13 @@ module SafeMemoize
242
401
  end
243
402
  end
244
403
 
404
+ # Returns how many seconds ago the shared entry was cached, or +nil+ if not cached
405
+ # or already expired.
406
+ #
407
+ # @param method_name [Symbol, String]
408
+ # @param args [Array]
409
+ # @param kwargs [Hash]
410
+ # @return [Float, nil]
245
411
  def shared_memo_age(method_name, *args, **kwargs)
246
412
  method_name = method_name.to_sym
247
413
  cache_key = [method_name, args, kwargs]
@@ -263,6 +429,12 @@ module SafeMemoize
263
429
  end
264
430
  end
265
431
 
432
+ # Returns +true+ if the shared entry exists but its TTL has elapsed.
433
+ #
434
+ # @param method_name [Symbol, String]
435
+ # @param args [Array]
436
+ # @param kwargs [Hash]
437
+ # @return [Boolean]
266
438
  def shared_memo_stale?(method_name, *args, **kwargs)
267
439
  method_name = method_name.to_sym
268
440
  cache_key = [method_name, args, kwargs]
@@ -281,24 +453,6 @@ module SafeMemoize
281
453
  end
282
454
  end
283
455
 
284
- def memoize_all(except: [], only: [], include_protected: false, include_private: false, **options)
285
- raise ArgumentError, "cannot specify both :only and :except" if only.any? && except.any?
286
-
287
- excluded = Array(except).map(&:to_sym)
288
- included = Array(only).map(&:to_sym)
289
-
290
- methods = public_instance_methods(false)
291
- methods |= protected_instance_methods(false) if include_protected
292
- methods |= private_instance_methods(false) if include_private
293
-
294
- methods.each do |method_name|
295
- next if excluded.include?(method_name)
296
- next if included.any? && !included.include?(method_name)
297
-
298
- memoize(method_name, **options)
299
- end
300
- end
301
-
302
456
  private
303
457
 
304
458
  def __safe_memo_shared_cache__
@@ -1,10 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # Global configuration for SafeMemoize.
5
+ #
6
+ # Obtain an instance via {SafeMemoize.configure} or {SafeMemoize.configuration}.
7
+ #
8
+ # @example
9
+ # SafeMemoize.configure do |c|
10
+ # c.default_ttl = 300
11
+ # c.default_max_size = 100
12
+ # c.on_hook_error = ->(err, type, key) { Bugsnag.notify(err) }
13
+ # end
4
14
  class Configuration
5
- attr_accessor :default_ttl, :default_max_size, :on_deprecation, :on_hook_error,
6
- :active_support_notifications, :statsd_client, :opentelemetry_tracer
15
+ # @return [Numeric, nil] Default TTL (seconds) applied to every {ClassMethods#memoize}
16
+ # call that does not specify its own +ttl:+. +nil+ means no expiry.
17
+ attr_accessor :default_ttl
7
18
 
19
+ # @return [Integer, nil] Default LRU size cap applied to every {ClassMethods#memoize}
20
+ # call that does not specify its own +max_size:+. +nil+ means unlimited.
21
+ attr_accessor :default_max_size
22
+
23
+ # @return [Proc, nil] Custom handler for deprecation warnings.
24
+ # Receives a single +String+ message. When +nil+, warnings are written to +$stderr+.
25
+ attr_accessor :on_deprecation
26
+
27
+ # @return [Proc, nil] Custom handler for errors raised inside lifecycle hooks.
28
+ # Receives +(Exception, Symbol hook_type, cache_key)+. When +nil+, a warning is
29
+ # written to +$stderr+ and the error is swallowed.
30
+ attr_accessor :on_hook_error
31
+
32
+ # @return [Boolean] When +true+, SafeMemoize emits +ActiveSupport::Notifications+
33
+ # events for cache hits, misses, stores, evictions, and expirations.
34
+ # Requires +activesupport+ to be loaded; has zero overhead when it is not.
35
+ attr_accessor :active_support_notifications
36
+
37
+ # @return [Object, nil] Any StatsD-compatible client (responds to +#increment+).
38
+ # When set, {Adapters::StatsD} routes lifecycle events to this client.
39
+ attr_accessor :statsd_client
40
+
41
+ # @return [Object, nil] An OpenTelemetry tracer (responds to +#in_span+).
42
+ # When set, {Adapters::OpenTelemetry} wraps each cache-miss computation in a span.
43
+ attr_accessor :opentelemetry_tracer
44
+
45
+ # @return [Stores::Base, nil] Default cache store applied to every {ClassMethods#memoize}
46
+ # call that does not specify its own +store:+. +nil+ uses the built-in per-instance
47
+ # hash cache. Methods using +max_size:+ or +shared:+ are incompatible with an external
48
+ # store and will silently continue using the per-instance hash even when this is set.
49
+ attr_accessor :default_store
50
+
51
+ # @api private
8
52
  def initialize
9
53
  @default_ttl = nil
10
54
  @default_max_size = nil
@@ -13,6 +57,7 @@ module SafeMemoize
13
57
  @active_support_notifications = false
14
58
  @statsd_client = nil
15
59
  @opentelemetry_tracer = nil
60
+ @default_store = nil
16
61
  end
17
62
  end
18
63
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module CustomKeyMethods
5
6
  private
6
7
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module HooksMethods
5
6
  NOTIFICATION_EVENT_NAMES = {
6
7
  on_hit: "cache_hit.safe_memoize",
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module InspectionMethods
5
6
  private
6
7
 
@@ -14,7 +15,7 @@ module SafeMemoize
14
15
  if args.empty? && kwargs.empty?
15
16
  ->(key) { key[0] == method_name }
16
17
  else
17
- cache_key = safe_memo_cache_key(method_name, args, kwargs)
18
+ cache_key = compute_cache_key(method_name, args, kwargs)
18
19
  ->(key) { key == cache_key }
19
20
  end
20
21
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module InstanceMethods
5
6
  include PublicMethods
6
7
  include CacheStoreMethods
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module LruMethods
5
6
  private
6
7
 
@@ -1,13 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # Instance-level custom cache key registration.
4
5
  module PublicCustomKeyMethods
6
+ # Registers a per-instance custom key generator for a memoized method.
7
+ #
8
+ # The block receives the same arguments as the method and should return a
9
+ # single value used as the cache key. Two calls that produce the same key
10
+ # value share one cached result, regardless of their raw arguments.
11
+ #
12
+ # Instance-level keys take priority over the class-level +key:+ option set
13
+ # in {ClassMethods#memoize}.
14
+ #
15
+ # @param method_name [Symbol, String]
16
+ # @yield [*args, **kwargs] called with the method's arguments on each invocation
17
+ # @yieldreturn [Object] the key value (must be comparable with +==+)
18
+ # @return [void]
19
+ # @raise [ArgumentError] if no block is given
20
+ #
21
+ # @example Collapse all option hashes that share the same user ID
22
+ # obj.memoize_with_custom_key(:fetch) { |user_id, _options| user_id }
5
23
  def memoize_with_custom_key(method_name, &key_generator)
6
24
  raise ArgumentError, "block required for key generation" unless key_generator
7
25
 
8
26
  register_custom_key(method_name, &key_generator)
9
27
  end
10
28
 
29
+ # Removes the custom key generator for one method, or all generators.
30
+ #
31
+ # @param method_name [Symbol, String, nil] when given, removes only that method's
32
+ # generator; when +nil+, removes all generators on this instance
33
+ # @return [void]
11
34
  def clear_custom_keys(method_name = nil)
12
35
  if method_name
13
36
  with_memo_lock do