safe_memoize 0.8.0 → 1.0.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,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module CacheStoreMethods
5
6
  private
6
7
 
@@ -1,7 +1,43 @@
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
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
+ # @return [void]
29
+ # @raise [ArgumentError] if the method does not exist, or option values are invalid
30
+ #
31
+ # @example Zero-argument method
32
+ # def expensive_query = db.run("SELECT …")
33
+ # memoize :expensive_query
34
+ #
35
+ # @example With TTL and LRU cap
36
+ # def fetch(id) = http_get(id)
37
+ # memoize :fetch, ttl: 60, max_size: 500
38
+ #
39
+ # @example Conditional — only cache successful responses
40
+ # memoize :fetch, if: ->(v) { v[:status] == 200 }
5
41
  def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil)
6
42
  method_name = method_name.to_sym
7
43
 
@@ -88,7 +124,7 @@ module SafeMemoize
88
124
  call_memo_hooks(:on_expire, cache_key, record) if record && !record_live
89
125
 
90
126
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
- value = super(*args, **kwargs)
127
+ value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, klass.name) { super(*args, **kwargs) }
92
128
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
93
129
 
94
130
  new_record = memo_record(value, expires_at: memo_expires_at(ttl))
@@ -147,7 +183,7 @@ module SafeMemoize
147
183
  memo_record_value(record)
148
184
  else
149
185
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
150
- value = super(*args, **kwargs)
186
+ value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
151
187
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
152
188
 
153
189
  new_record = memo_record(value, expires_at: memo_expires_at(ttl))
@@ -174,7 +210,9 @@ module SafeMemoize
174
210
 
175
211
  # Cache miss - compute and store
176
212
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
177
- result = memo_fetch_or_store(cache_key, ttl: ttl) { super(*args, **kwargs) }
213
+ result = memo_fetch_or_store(cache_key, ttl: ttl) do
214
+ Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
215
+ end
178
216
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
179
217
 
180
218
  with_memo_lock do
@@ -194,6 +232,45 @@ module SafeMemoize
194
232
  prepend mod
195
233
  end
196
234
 
235
+ # Memoizes every eligible public instance method defined directly on the class.
236
+ #
237
+ # Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+.
238
+ # Raises +ArgumentError+ when both +:only:+ and +:except:+ are given.
239
+ #
240
+ # @param except [Array<Symbol, String>] method names to skip
241
+ # @param only [Array<Symbol, String>] when non-empty, only these methods are memoized
242
+ # @param include_protected [Boolean] also memoize +protected+ methods
243
+ # @param include_private [Boolean] also memoize +private+ methods
244
+ # @param options [Hash] any additional options forwarded to {#memoize}
245
+ # @return [void]
246
+ # @raise [ArgumentError] if both +:only:+ and +:except:+ are given
247
+ def memoize_all(except: [], only: [], include_protected: false, include_private: false, **options)
248
+ raise ArgumentError, "cannot specify both :only and :except" if only.any? && except.any?
249
+
250
+ excluded = Array(except).map(&:to_sym)
251
+ included = Array(only).map(&:to_sym)
252
+
253
+ methods = public_instance_methods(false)
254
+ methods |= protected_instance_methods(false) if include_protected
255
+ methods |= private_instance_methods(false) if include_private
256
+
257
+ methods.each do |method_name|
258
+ next if excluded.include?(method_name)
259
+ next if included.any? && !included.include?(method_name)
260
+
261
+ memoize(method_name, **options)
262
+ end
263
+ end
264
+
265
+ # Clears one or all entries from the class-level shared cache.
266
+ #
267
+ # With no positional args after +method_name+, clears *all* shared entries for
268
+ # that method. With args/kwargs, clears only the matching entry.
269
+ #
270
+ # @param method_name [Symbol, String] the memoized method
271
+ # @param args [Array] positional arguments identifying the entry to clear
272
+ # @param kwargs [Hash] keyword arguments identifying the entry to clear
273
+ # @return [void]
197
274
  def reset_shared_memo(method_name, *args, **kwargs)
198
275
  method_name = method_name.to_sym
199
276
  specific_key = (args.empty? && kwargs.empty?) ? nil : [method_name, args, kwargs]
@@ -209,6 +286,8 @@ module SafeMemoize
209
286
  end
210
287
  end
211
288
 
289
+ # Clears the entire class-level shared cache for this class.
290
+ # @return [void]
212
291
  def reset_all_shared_memos
213
292
  __safe_memo_shared_mutex__.synchronize do
214
293
  @__safe_memo_shared_cache__ = {}
@@ -216,6 +295,12 @@ module SafeMemoize
216
295
  end
217
296
  end
218
297
 
298
+ # Returns +true+ if a live shared cache entry exists for the given call signature.
299
+ #
300
+ # @param method_name [Symbol, String]
301
+ # @param args [Array] positional arguments
302
+ # @param kwargs [Hash] keyword arguments
303
+ # @return [Boolean]
219
304
  def shared_memoized?(method_name, *args, **kwargs)
220
305
  method_name = method_name.to_sym
221
306
  cache_key = [method_name, args, kwargs]
@@ -231,6 +316,11 @@ module SafeMemoize
231
316
  end
232
317
  end
233
318
 
319
+ # Returns the number of live entries in the class-level shared cache.
320
+ #
321
+ # @param method_name [Symbol, String, nil] when given, counts only entries for
322
+ # that method; when +nil+, counts all methods.
323
+ # @return [Integer]
234
324
  def shared_memo_count(method_name = nil)
235
325
  __safe_memo_shared_mutex__.synchronize do
236
326
  cache = @__safe_memo_shared_cache__ || {}
@@ -240,6 +330,13 @@ module SafeMemoize
240
330
  end
241
331
  end
242
332
 
333
+ # Returns how many seconds ago the shared entry was cached, or +nil+ if not cached
334
+ # or already expired.
335
+ #
336
+ # @param method_name [Symbol, String]
337
+ # @param args [Array]
338
+ # @param kwargs [Hash]
339
+ # @return [Float, nil]
243
340
  def shared_memo_age(method_name, *args, **kwargs)
244
341
  method_name = method_name.to_sym
245
342
  cache_key = [method_name, args, kwargs]
@@ -261,6 +358,12 @@ module SafeMemoize
261
358
  end
262
359
  end
263
360
 
361
+ # Returns +true+ if the shared entry exists but its TTL has elapsed.
362
+ #
363
+ # @param method_name [Symbol, String]
364
+ # @param args [Array]
365
+ # @param kwargs [Hash]
366
+ # @return [Boolean]
264
367
  def shared_memo_stale?(method_name, *args, **kwargs)
265
368
  method_name = method_name.to_sym
266
369
  cache_key = [method_name, args, kwargs]
@@ -279,24 +382,6 @@ module SafeMemoize
279
382
  end
280
383
  end
281
384
 
282
- def memoize_all(except: [], only: [], include_protected: false, include_private: false, **options)
283
- raise ArgumentError, "cannot specify both :only and :except" if only.any? && except.any?
284
-
285
- excluded = Array(except).map(&:to_sym)
286
- included = Array(only).map(&:to_sym)
287
-
288
- methods = public_instance_methods(false)
289
- methods |= protected_instance_methods(false) if include_protected
290
- methods |= private_instance_methods(false) if include_private
291
-
292
- methods.each do |method_name|
293
- next if excluded.include?(method_name)
294
- next if included.any? && !included.include?(method_name)
295
-
296
- memoize(method_name, **options)
297
- end
298
- end
299
-
300
385
  private
301
386
 
302
387
  def __safe_memo_shared_cache__
@@ -1,14 +1,56 @@
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
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
6
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
+ # @api private
7
46
  def initialize
8
47
  @default_ttl = nil
9
48
  @default_max_size = nil
10
49
  @on_deprecation = nil
11
50
  @on_hook_error = nil
51
+ @active_support_notifications = false
52
+ @statsd_client = nil
53
+ @opentelemetry_tracer = nil
12
54
  end
13
55
  end
14
56
  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,7 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module HooksMethods
6
+ NOTIFICATION_EVENT_NAMES = {
7
+ on_hit: "cache_hit.safe_memoize",
8
+ on_miss: "cache_miss.safe_memoize",
9
+ on_evict: "cache_evict.safe_memoize",
10
+ on_expire: "cache_expire.safe_memoize",
11
+ on_store: "cache_store.safe_memoize"
12
+ }.freeze
13
+
5
14
  private
6
15
 
7
16
  def memo_hook_store
@@ -29,6 +38,28 @@ module SafeMemoize
29
38
  warn "[SafeMemoize] Hook error in #{hook_type}: #{error.message}"
30
39
  end
31
40
  end
41
+
42
+ safe_memo_notify(hook_type, cache_key) if SafeMemoize.configuration.active_support_notifications
43
+
44
+ if (client = SafeMemoize.configuration.statsd_client)
45
+ Adapters::StatsD.dispatch(client, hook_type, cache_key, self.class.name)
46
+ end
47
+ end
48
+
49
+ def safe_memo_notify(hook_type, cache_key)
50
+ return unless defined?(ActiveSupport::Notifications)
51
+
52
+ asn = ActiveSupport::Notifications
53
+ return unless asn.respond_to?(:instrument)
54
+
55
+ event = NOTIFICATION_EVENT_NAMES[hook_type]
56
+ return unless event
57
+
58
+ asn.instrument(event, {
59
+ method: cache_key[0],
60
+ key: cache_key,
61
+ class: self.class.name
62
+ })
32
63
  end
33
64
 
34
65
  def _clear_memo_hooks(hook_type = nil)
@@ -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