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.
- checksums.yaml +4 -4
- data/.yardopts +10 -0
- data/CHANGELOG.md +23 -0
- data/README.md +555 -8
- data/ROADMAP.md +7 -22
- data/Rakefile +5 -0
- data/UPGRADING.md +197 -0
- data/benchmarks/README.md +68 -0
- data/benchmarks/benchmark.rb +225 -0
- data/codecov.yml +17 -0
- data/lib/safe_memoize/adapters/opentelemetry.rb +44 -0
- data/lib/safe_memoize/adapters/statsd.rb +56 -0
- data/lib/safe_memoize/cache_metrics_methods.rb +1 -0
- data/lib/safe_memoize/cache_record_methods.rb +1 -0
- data/lib/safe_memoize/cache_store_methods.rb +1 -0
- data/lib/safe_memoize/class_methods.rb +106 -21
- data/lib/safe_memoize/configuration.rb +43 -1
- data/lib/safe_memoize/custom_key_methods.rb +1 -0
- data/lib/safe_memoize/hooks_methods.rb +31 -0
- data/lib/safe_memoize/inspection_methods.rb +2 -1
- data/lib/safe_memoize/instance_methods.rb +1 -0
- data/lib/safe_memoize/lru_methods.rb +1 -0
- data/lib/safe_memoize/public_custom_key_methods.rb +23 -0
- data/lib/safe_memoize/public_methods.rb +162 -5
- data/lib/safe_memoize/public_metrics_methods.rb +20 -0
- data/lib/safe_memoize/rails/middleware.rb +25 -0
- data/lib/safe_memoize/rails/request_scoped.rb +40 -0
- data/lib/safe_memoize/rails.rb +28 -0
- data/lib/safe_memoize/release_tooling.rb +1 -0
- data/lib/safe_memoize/version.rb +2 -1
- data/lib/safe_memoize.rb +56 -0
- data/rbi/safe_memoize.rbi +245 -0
- data/sig/safe_memoize.rbs +41 -5
- metadata +12 -1
|
@@ -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)
|
|
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
|
-
|
|
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,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 =
|
|
18
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
18
19
|
->(key) { key == cache_key }
|
|
19
20
|
end
|
|
20
21
|
end
|
|
@@ -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
|