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,19 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # Public instance methods mixed into every class that prepends {SafeMemoize}.
4
5
  module PublicMethods
6
+ # Returns +true+ if the given call is currently cached (and not expired).
7
+ #
8
+ # Always returns +false+ when a block is provided, because block-taking methods
9
+ # cannot be safely keyed by arguments alone.
10
+ #
11
+ # @param method_name [Symbol, String]
12
+ # @param args [Array] positional arguments used to look up the entry
13
+ # @param kwargs [Hash] keyword arguments used to look up the entry
14
+ # @return [Boolean]
5
15
  def memoized?(method_name, *args, **kwargs, &block)
6
16
  return false if block
7
17
 
8
- cache_key = safe_memo_cache_key(method_name, args, kwargs)
18
+ cache_key = compute_cache_key(method_name, args, kwargs)
9
19
 
10
20
  with_memo_lock do
11
21
  memo_cache_hit?(cache_key)
12
22
  end
13
23
  end
14
24
 
25
+ # Returns the number of seconds until the cached entry expires.
26
+ #
27
+ # @param method_name [Symbol, String]
28
+ # @param args [Array]
29
+ # @param kwargs [Hash]
30
+ # @return [Float] seconds remaining (may be 0 if already expired)
31
+ # @return [nil] if the entry has no TTL or is not cached
15
32
  def memo_ttl_remaining(method_name, *args, **kwargs)
16
- cache_key = safe_memo_cache_key(method_name, args, kwargs)
33
+ cache_key = compute_cache_key(method_name, args, kwargs)
17
34
 
18
35
  with_memo_lock do
19
36
  record = memo_cache_record(cache_key)
@@ -27,6 +44,10 @@ module SafeMemoize
27
44
  end
28
45
  end
29
46
 
47
+ # Returns the number of live cached entries for a method (or all methods).
48
+ #
49
+ # @param method_name [Symbol, String, nil] when omitted, counts all methods
50
+ # @return [Integer]
30
51
  def memo_count(*method_name)
31
52
  scoped_method = safe_memo_scoped_method(method_name)
32
53
 
@@ -35,6 +56,13 @@ module SafeMemoize
35
56
  end
36
57
  end
37
58
 
59
+ # Returns metadata hashes describing each cached entry.
60
+ #
61
+ # Each hash contains +:args+, +:kwargs+ (or +:custom_key+ for custom-keyed entries),
62
+ # and +:method+ when no +method_name+ filter is applied.
63
+ #
64
+ # @param method_name [Symbol, String, nil] when omitted, returns entries for all methods
65
+ # @return [Array<Hash>]
38
66
  def memo_keys(*method_name)
39
67
  scoped_method = safe_memo_scoped_method(method_name)
40
68
 
@@ -43,6 +71,12 @@ module SafeMemoize
43
71
  end
44
72
  end
45
73
 
74
+ # Returns metadata hashes including the cached value for each entry.
75
+ #
76
+ # Each hash contains all fields from {#memo_keys} plus +:value+.
77
+ #
78
+ # @param method_name [Symbol, String, nil] when omitted, returns entries for all methods
79
+ # @return [Array<Hash>]
46
80
  def memo_values(*method_name)
47
81
  scoped_method = safe_memo_scoped_method(method_name)
48
82
 
@@ -51,42 +85,90 @@ module SafeMemoize
51
85
  end
52
86
  end
53
87
 
88
+ # Registers a hook that fires on every cache hit.
89
+ #
90
+ # @yield [cache_key, record] called synchronously inside the cache lock
91
+ # @yieldparam cache_key [Array] the internal cache key
92
+ # @yieldparam record [Hash] the cache record (+:value+, +:expires_at+, +:cached_at+)
93
+ # @return [void]
94
+ # @raise [ArgumentError] if no block is given
54
95
  def on_memo_expire(&block)
55
96
  raise ArgumentError, "block required" unless block
56
97
 
57
98
  register_memo_hook(:on_expire, &block)
58
99
  end
59
100
 
101
+ # Registers a hook that fires when an LRU eviction occurs.
102
+ #
103
+ # @yield [cache_key, record]
104
+ # @return [void]
105
+ # @raise [ArgumentError] if no block is given
60
106
  def on_memo_evict(&block)
61
107
  raise ArgumentError, "block required" unless block
62
108
 
63
109
  register_memo_hook(:on_evict, &block)
64
110
  end
65
111
 
112
+ # Registers a hook that fires on every cache hit.
113
+ #
114
+ # @yield [cache_key, record]
115
+ # @return [void]
116
+ # @raise [ArgumentError] if no block is given
66
117
  def on_memo_hit(&block)
67
118
  raise ArgumentError, "block required" unless block
68
119
 
69
120
  register_memo_hook(:on_hit, &block)
70
121
  end
71
122
 
123
+ # Registers a hook that fires on every cache miss (before the value is stored).
124
+ #
125
+ # @yield [cache_key, record]
126
+ # @return [void]
127
+ # @raise [ArgumentError] if no block is given
72
128
  def on_memo_miss(&block)
73
129
  raise ArgumentError, "block required" unless block
74
130
 
75
131
  register_memo_hook(:on_miss, &block)
76
132
  end
77
133
 
134
+ # Registers a hook that fires whenever a value is written to the cache
135
+ # (miss, {#warm_memo}, or {#load_memo}).
136
+ #
137
+ # @yield [cache_key, record]
138
+ # @return [void]
139
+ # @raise [ArgumentError] if no block is given
78
140
  def on_memo_store(&block)
79
141
  raise ArgumentError, "block required" unless block
80
142
 
81
143
  register_memo_hook(:on_store, &block)
82
144
  end
83
145
 
146
+ # Removes all registered hooks, or only hooks of a specific type.
147
+ #
148
+ # @param hook_type [Symbol, nil] one of +:on_hit+, +:on_miss+, +:on_store+,
149
+ # +:on_expire+, +:on_evict+; when +nil+ all hooks are cleared
150
+ # @return [void]
84
151
  def clear_memo_hooks(hook_type = nil)
85
152
  with_memo_lock do
86
153
  _clear_memo_hooks(hook_type)
87
154
  end
88
155
  end
89
156
 
157
+ # Pre-populates a cache entry with the value returned by the block without
158
+ # calling the memoized method itself.
159
+ #
160
+ # Useful for warming caches from a serialized snapshot or an external source.
161
+ #
162
+ # @param method_name [Symbol, String]
163
+ # @param args [Array] positional arguments that identify the cache slot
164
+ # @param ttl [Numeric, nil] optional expiry for the warmed entry
165
+ # @param kwargs [Hash] keyword arguments that identify the cache slot
166
+ # @yield [] must return the value to store
167
+ # @return [Object] the value returned by the block
168
+ # @raise [ArgumentError] if no block is given
169
+ #
170
+ # @example
171
+ # obj.warm_memo(:find, 42) { User.new(id: 42, name: "cached") }
90
172
  def warm_memo(method_name, *args, ttl: nil, **kwargs, &block)
91
173
  raise ArgumentError, "block required" unless block
92
174
 
@@ -104,6 +186,17 @@ module SafeMemoize
104
186
  value
105
187
  end
106
188
 
189
+ # Calls the memoized method for each argument set and caches all results.
190
+ #
191
+ # Equivalent to calling the method for each arg set individually, but expressed
192
+ # as a single call for clarity.
193
+ #
194
+ # @param method_name [Symbol, String]
195
+ # @param arg_sets [Array<Array>] each element is an argument list for one call
196
+ # @return [Array] cached values in input order
197
+ #
198
+ # @example
199
+ # obj.memo_preload(:find, [1], [2], [3])
107
200
  def memo_preload(method_name, *arg_sets)
108
201
  method_name = method_name.to_sym
109
202
  arg_sets.map do |args|
@@ -111,6 +204,11 @@ module SafeMemoize
111
204
  end
112
205
  end
113
206
 
207
+ # Exports live cache entries as a plain hash suitable for serialization.
208
+ #
209
+ # @param method_name [Symbol, String, nil] when given, exports only entries for
210
+ # that method; when +nil+, exports all methods
211
+ # @return [Hash] mapping cache keys to their cached values (expired entries excluded)
114
212
  def dump_memo(method_name = nil)
115
213
  method_name = method_name&.to_sym
116
214
 
@@ -122,6 +220,14 @@ module SafeMemoize
122
220
  end
123
221
  end
124
222
 
223
+ # Restores cache entries from a snapshot produced by {#dump_memo}.
224
+ #
225
+ # Existing entries are not cleared; snapshot keys are merged in.
226
+ # Each restored entry fires the +:on_store+ hook.
227
+ #
228
+ # @param snapshot [Hash] a hash previously returned by {#dump_memo}
229
+ # @return [nil]
230
+ # @raise [ArgumentError] if +snapshot+ is not a +Hash+
125
231
  def load_memo(snapshot)
126
232
  raise ArgumentError, "snapshot must be a Hash" unless snapshot.is_a?(Hash)
127
233
 
@@ -137,9 +243,17 @@ module SafeMemoize
137
243
  nil
138
244
  end
139
245
 
246
+ # Resets the expiry clock on a live cached entry without recomputing its value.
247
+ #
248
+ # @param method_name [Symbol, String]
249
+ # @param args [Array]
250
+ # @param ttl [Numeric, nil] new TTL to apply; when +nil+, uses the original TTL
251
+ # derived from the entry's +cached_at+ and +expires_at+ timestamps
252
+ # @param kwargs [Hash]
253
+ # @return [Boolean] +true+ if the entry existed and was touched; +false+ otherwise
140
254
  def memo_touch(method_name, *args, ttl: nil, **kwargs)
141
255
  method_name = method_name.to_sym
142
- cache_key = safe_memo_cache_key(method_name, args, kwargs)
256
+ cache_key = compute_cache_key(method_name, args, kwargs)
143
257
 
144
258
  with_memo_lock do
145
259
  cache = memo_cache_or_nil
@@ -162,14 +276,27 @@ module SafeMemoize
162
276
  end
163
277
  end
164
278
 
279
+ # Clears the cached entry and immediately re-calls the method to populate a
280
+ # fresh value.
281
+ #
282
+ # @param method_name [Symbol, String]
283
+ # @param args [Array]
284
+ # @param kwargs [Hash]
285
+ # @return [Object] the freshly computed and cached value
165
286
  def memo_refresh(method_name, *args, **kwargs)
166
287
  method_name = method_name.to_sym
167
288
  reset_memo(method_name, *args, **kwargs)
168
289
  send(method_name, *args, **kwargs)
169
290
  end
170
291
 
292
+ # Returns how many seconds ago the entry was cached, or +nil+ if not cached.
293
+ #
294
+ # @param method_name [Symbol, String]
295
+ # @param args [Array]
296
+ # @param kwargs [Hash]
297
+ # @return [Float, nil]
171
298
  def memo_age(method_name, *args, **kwargs)
172
- cache_key = safe_memo_cache_key(method_name, args, kwargs)
299
+ cache_key = compute_cache_key(method_name, args, kwargs)
173
300
 
174
301
  with_memo_lock do
175
302
  record = memo_cache_record(cache_key)
@@ -182,8 +309,14 @@ module SafeMemoize
182
309
  end
183
310
  end
184
311
 
312
+ # Returns +true+ if the entry exists but its TTL has elapsed.
313
+ #
314
+ # @param method_name [Symbol, String]
315
+ # @param args [Array]
316
+ # @param kwargs [Hash]
317
+ # @return [Boolean]
185
318
  def memo_stale?(method_name, *args, **kwargs)
186
- cache_key = safe_memo_cache_key(method_name, args, kwargs)
319
+ cache_key = compute_cache_key(method_name, args, kwargs)
187
320
 
188
321
  with_memo_lock do
189
322
  cache = memo_cache_or_nil
@@ -196,6 +329,16 @@ module SafeMemoize
196
329
  end
197
330
  end
198
331
 
332
+ # Removes one or all cached entries for a method.
333
+ #
334
+ # When called with only +method_name+, all entries for that method are cleared.
335
+ # When called with +method_name+ *and* arguments, only the exact matching entry
336
+ # is cleared. Each evicted entry fires the +:on_evict+ hook.
337
+ #
338
+ # @param method_name [Symbol, String]
339
+ # @param args [Array] positional arguments identifying a specific entry
340
+ # @param kwargs [Hash] keyword arguments identifying a specific entry
341
+ # @return [void]
199
342
  def reset_memo(method_name, *args, **kwargs)
200
343
  method_name = method_name.to_sym
201
344
 
@@ -215,6 +358,10 @@ module SafeMemoize
215
358
  end
216
359
  end
217
360
 
361
+ # Clears all cached entries for every method on this instance.
362
+ # Each evicted entry fires the +:on_evict+ hook.
363
+ #
364
+ # @return [void]
218
365
  def reset_all_memos
219
366
  with_memo_lock do
220
367
  if defined?(@__safe_memo_cache__) && @__safe_memo_cache__
@@ -227,6 +374,16 @@ module SafeMemoize
227
374
  end
228
375
  end
229
376
 
377
+ # Returns a detailed snapshot of a single cached entry, or +nil+ if not cached.
378
+ #
379
+ # All reads are performed inside a single mutex hold.
380
+ #
381
+ # @param method_name [Symbol, String]
382
+ # @param args [Array]
383
+ # @param kwargs [Hash]
384
+ # @return [Hash, nil] hash with keys +:cached+, +:value+, +:hits+, +:misses+,
385
+ # +:ttl_remaining+, +:age+, +:custom_key+, +:lru_position+; or +nil+ when
386
+ # the entry is not present
230
387
  def memo_inspect(method_name, *args, **kwargs)
231
388
  method_name = method_name.to_sym
232
389
  cache_key = compute_cache_key(method_name, args, kwargs)
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # Per-instance cache metrics: hit/miss counts and average computation time.
4
5
  module PublicMetricsMethods
6
+ # Returns aggregate metrics across all memoized methods on this instance.
7
+ #
8
+ # @return [Hash] with keys +:total_hits+, +:total_misses+, +:hit_rate+,
9
+ # +:miss_rate+, +:average_computation_time+, and +:entries+ (one entry
10
+ # per cached argument combination)
5
11
  def cache_stats
6
12
  with_memo_lock do
7
13
  metrics = memo_metrics_store
@@ -11,6 +17,11 @@ module SafeMemoize
11
17
  end
12
18
  end
13
19
 
20
+ # Returns metrics for a single memoized method.
21
+ #
22
+ # @param method_name [Symbol, String]
23
+ # @return [Hash] same shape as {#cache_stats} but scoped to one method,
24
+ # with an extra +:method+ key
14
25
  def cache_stats_for(method_name)
15
26
  method_name = method_name.to_sym
16
27
 
@@ -22,14 +33,23 @@ module SafeMemoize
22
33
  end
23
34
  end
24
35
 
36
+ # Returns the overall cache hit rate as a percentage (0.0–100.0).
37
+ # @return [Float]
25
38
  def cache_hit_rate
26
39
  cache_stats[:hit_rate]
27
40
  end
28
41
 
42
+ # Returns the overall cache miss rate as a percentage (0.0–100.0).
43
+ # @return [Float]
29
44
  def cache_miss_rate
30
45
  cache_stats[:miss_rate]
31
46
  end
32
47
 
48
+ # Resets hit/miss counters, either for one method or for all methods.
49
+ #
50
+ # @param method_name [Symbol, String, nil] when given, resets only that method's
51
+ # metrics; when +nil+, resets all
52
+ # @return [void]
33
53
  def cache_metrics_reset(method_name = nil)
34
54
  with_memo_lock do
35
55
  if method_name
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module Rails
5
+ # Rack middleware that resets all thread-tracked memoized instances at the
6
+ # end of each request. Useful for service objects that are instantiated
7
+ # per-request and register themselves via `SafeMemoize::Rails.track(self)`.
8
+ #
9
+ # Add to your Rack stack in config/application.rb:
10
+ # config.middleware.use SafeMemoize::Rails::Middleware
11
+ class Middleware
12
+ def initialize(app)
13
+ @app = app
14
+ end
15
+
16
+ # @param env [Hash] Rack environment
17
+ # @return [Array] Rack response triplet
18
+ def call(env)
19
+ @app.call(env)
20
+ ensure
21
+ SafeMemoize::Rails.reset_tracked!
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module Rails
5
+ # Include in a Rails controller to automatically reset instance memos after
6
+ # each request. In non-controller classes (service objects, models), include
7
+ # it to gain `reset_request_memos` and call it manually at the end of a
8
+ # request or job.
9
+ #
10
+ # The class must also `prepend SafeMemoize` for `reset_all_memos` to exist.
11
+ #
12
+ # Example — controller:
13
+ # class ApplicationController < ActionController::Base
14
+ # prepend SafeMemoize
15
+ # include SafeMemoize::Rails::RequestScoped
16
+ # end
17
+ #
18
+ # Example — service object with middleware tracking:
19
+ # class ReportService
20
+ # prepend SafeMemoize
21
+ # include SafeMemoize::Rails::RequestScoped
22
+ #
23
+ # def initialize
24
+ # SafeMemoize::Rails.track(self)
25
+ # end
26
+ # end
27
+ module RequestScoped
28
+ # @api private
29
+ def self.included(base)
30
+ base.after_action :reset_all_memos if base.respond_to?(:after_action)
31
+ end
32
+
33
+ # Resets all memoized values on this instance. Delegates to {PublicMethods#reset_all_memos}.
34
+ # @return [void]
35
+ def reset_request_memos
36
+ reset_all_memos
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "safe_memoize"
4
+ require_relative "rails/request_scoped"
5
+ require_relative "rails/middleware"
6
+
7
+ module SafeMemoize
8
+ # Optional Rails integration. Not auto-required — add to your initializer:
9
+ # require "safe_memoize/rails"
10
+ module Rails
11
+ # Register an instance to have its memos reset at the end of the current
12
+ # request (via Middleware). Thread-local; each thread maintains its own list.
13
+ def self.track(instance)
14
+ (Thread.current[:safe_memoize_tracked] ||= []) << instance
15
+ end
16
+
17
+ # Reset all tracked instances and clear the list. Called automatically by
18
+ # Middleware after each request. Safe to call with an empty list.
19
+ def self.reset_tracked!
20
+ instances = Thread.current[:safe_memoize_tracked] || []
21
+ instances.each do |instance|
22
+ instance.reset_all_memos if instance.respond_to?(:reset_all_memos)
23
+ end
24
+ ensure
25
+ Thread.current[:safe_memoize_tracked] = []
26
+ end
27
+ end
28
+ end
@@ -3,6 +3,7 @@
3
3
  require "date"
4
4
 
5
5
  module SafeMemoize
6
+ # @api private
6
7
  module ReleaseTooling
7
8
  module_function
8
9
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
- VERSION = "0.8.0"
4
+ # The current gem version string.
5
+ VERSION = "1.0.0"
5
6
  end
data/lib/safe_memoize.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "safe_memoize/version"
4
4
  require_relative "safe_memoize/configuration"
5
+ require_relative "safe_memoize/adapters/statsd"
6
+ require_relative "safe_memoize/adapters/opentelemetry"
5
7
  require_relative "safe_memoize/class_methods"
6
8
  require_relative "safe_memoize/public_methods"
7
9
  require_relative "safe_memoize/cache_store_methods"
@@ -15,27 +17,81 @@ require_relative "safe_memoize/public_custom_key_methods"
15
17
  require_relative "safe_memoize/lru_methods"
16
18
  require_relative "safe_memoize/instance_methods"
17
19
 
20
+ # Thread-safe memoization for Ruby that correctly handles +nil+ and +false+ values.
21
+ #
22
+ # Prepend this module into any class, then call {ClassMethods#memoize} to wrap
23
+ # instance methods with a per-instance cache backed by a +Mutex+.
24
+ #
25
+ # @example Basic usage
26
+ # class UserService
27
+ # prepend SafeMemoize
28
+ #
29
+ # def current_user
30
+ # User.find_by(session_id: session_id)
31
+ # end
32
+ # memoize :current_user
33
+ # end
34
+ #
35
+ # @example With TTL and LRU cap
36
+ # class ApiClient
37
+ # prepend SafeMemoize
38
+ #
39
+ # def fetch(id)
40
+ # http_get("/items/#{id}")
41
+ # end
42
+ # memoize :fetch, ttl: 60, max_size: 500
43
+ # end
44
+ #
45
+ # @see ClassMethods#memoize
46
+ # @see https://github.com/eclectic-coding/safe_memoize README
18
47
  module SafeMemoize
48
+ # Base class for all SafeMemoize-specific exceptions.
49
+ # Rescue this to catch any error raised by the library itself.
19
50
  class Error < StandardError; end
20
51
 
21
52
  include InstanceMethods
22
53
 
54
+ # @api private
23
55
  def self.prepended(base)
24
56
  base.extend(ClassMethods)
25
57
  end
26
58
 
59
+ # Yields the global {Configuration} object for mutation.
60
+ #
61
+ # @example
62
+ # SafeMemoize.configure do |c|
63
+ # c.default_ttl = 300
64
+ # end
65
+ #
66
+ # @yield [config] The current {Configuration} instance.
67
+ # @yieldparam config [Configuration]
68
+ # @return [void]
27
69
  def self.configure
28
70
  yield configuration
29
71
  end
30
72
 
73
+ # Returns the global {Configuration} instance, creating it on first access.
74
+ #
75
+ # @return [Configuration]
31
76
  def self.configuration
32
77
  @configuration ||= Configuration.new
33
78
  end
34
79
 
80
+ # Resets the global configuration to all defaults.
81
+ #
82
+ # Useful in test suites to prevent configuration leaking between examples.
83
+ #
84
+ # @return [Configuration] the new blank configuration
35
85
  def self.reset_configuration!
36
86
  @configuration = Configuration.new
37
87
  end
38
88
 
89
+ # Emits a structured deprecation warning through the configured handler.
90
+ #
91
+ # @param subject [String] short identifier of the deprecated symbol
92
+ # @param message [String] migration instructions
93
+ # @param horizon [String] version when the symbol will be removed (e.g. +"v2.0.0"+)
94
+ # @return [void]
39
95
  def self.deprecate(subject, message:, horizon:)
40
96
  text = "[SafeMemoize] #{subject} is deprecated and will be removed in #{horizon}. #{message}"
41
97
  handler = configuration.on_deprecation