safe_memoize 1.1.0 → 1.3.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/.github/workflows/ci.yml +2 -2
- data/CHANGELOG.md +57 -1
- data/README.md +584 -6
- data/ROADMAP.md +0 -30
- data/Rakefile +9 -5
- data/lib/safe_memoize/adapters/concurrent_ruby.rb +98 -0
- data/lib/safe_memoize/cache_metrics_methods.rb +4 -5
- data/lib/safe_memoize/class_methods.rb +330 -23
- data/lib/safe_memoize/configuration.rb +8 -0
- data/lib/safe_memoize/custom_key_methods.rb +11 -2
- data/lib/safe_memoize/extension.rb +88 -0
- data/lib/safe_memoize/fiber_local_methods.rb +109 -0
- data/lib/safe_memoize/hooks_methods.rb +8 -1
- data/lib/safe_memoize/inspection_methods.rb +34 -5
- data/lib/safe_memoize/instance_methods.rb +1 -0
- data/lib/safe_memoize/public_methods.rb +3 -2
- data/lib/safe_memoize/public_metrics_methods.rb +4 -3
- data/lib/safe_memoize/ractor_shared_methods.rb +146 -0
- data/lib/safe_memoize/release_tooling.rb +25 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +141 -0
- data/sig/safe_memoize.rbs +77 -2
- metadata +5 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
# Public and private helpers for fiber-local memoization.
|
|
5
|
+
#
|
|
6
|
+
# When a method is memoized with +fiber_local: true+, its cached values are
|
|
7
|
+
# stored in +Fiber[:__safe_memoize__]+ rather than instance variables, giving
|
|
8
|
+
# each fiber an isolated cache that is automatically discarded when the fiber
|
|
9
|
+
# terminates. No mutex is required because fibers are cooperative: only one
|
|
10
|
+
# fiber runs at a time within a thread.
|
|
11
|
+
module FiberLocalMethods
|
|
12
|
+
FIBER_STORE_KEY = :__safe_memoize__
|
|
13
|
+
|
|
14
|
+
# Returns +true+ if the given call is currently cached in the current fiber.
|
|
15
|
+
#
|
|
16
|
+
# @note In Ruby, +Fiber.new+ inherits the parent fiber's local storage. SafeMemoize
|
|
17
|
+
# detects inherited stores via an +:__owner__+ sentinel and creates a fresh
|
|
18
|
+
# isolated store for each fiber on first write.
|
|
19
|
+
#
|
|
20
|
+
# @param method_name [Symbol, String]
|
|
21
|
+
# @param args [Array]
|
|
22
|
+
# @param kwargs [Hash]
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def fiber_local_memoized?(method_name, *args, **kwargs, &block)
|
|
25
|
+
return false if block
|
|
26
|
+
|
|
27
|
+
method_name = method_name.to_sym
|
|
28
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
29
|
+
record = fiber_memo_cache_or_nil&.[](cache_key)
|
|
30
|
+
memo_record_live?(record)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Removes one or all fiber-local cached entries for a method in the current fiber.
|
|
34
|
+
#
|
|
35
|
+
# @param method_name [Symbol, String]
|
|
36
|
+
# @param args [Array]
|
|
37
|
+
# @param kwargs [Hash]
|
|
38
|
+
# @return [void]
|
|
39
|
+
def reset_fiber_memo(method_name, *args, **kwargs)
|
|
40
|
+
method_name = method_name.to_sym
|
|
41
|
+
cache = fiber_memo_cache_or_nil
|
|
42
|
+
return unless cache
|
|
43
|
+
|
|
44
|
+
if args.empty? && kwargs.empty?
|
|
45
|
+
effective = resolve_memo_key_name(method_name)
|
|
46
|
+
cache.delete_if { |key, _| key[0] == effective }
|
|
47
|
+
fiber_memo_lru_or_nil&.delete(method_name)
|
|
48
|
+
else
|
|
49
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
50
|
+
cache.delete(cache_key)
|
|
51
|
+
fiber_memo_lru_or_nil&.[](method_name)&.delete(cache_key)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Clears all fiber-local cached entries for this instance in the current fiber.
|
|
56
|
+
#
|
|
57
|
+
# @return [void]
|
|
58
|
+
def reset_all_fiber_memos
|
|
59
|
+
store = Fiber[FIBER_STORE_KEY]
|
|
60
|
+
return unless store&.[](:__owner__) == Fiber.current.object_id
|
|
61
|
+
|
|
62
|
+
store.delete(object_id)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Returns the per-fiber top-level store hash, creating a fresh one for
|
|
68
|
+
# this fiber if the current store was inherited from a parent fiber.
|
|
69
|
+
def fiber_root_store!
|
|
70
|
+
store = Fiber[FIBER_STORE_KEY]
|
|
71
|
+
unless store&.[](:__owner__) == Fiber.current.object_id
|
|
72
|
+
store = {__owner__: Fiber.current.object_id}
|
|
73
|
+
Fiber[FIBER_STORE_KEY] = store
|
|
74
|
+
end
|
|
75
|
+
store
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fiber_memo_store!
|
|
79
|
+
fiber_root_store![object_id] ||= {cache: {}, lru: {}}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fiber_memo_cache!
|
|
83
|
+
fiber_memo_store![:cache]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def fiber_memo_lru!
|
|
87
|
+
fiber_memo_store![:lru]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def fiber_root_store_or_nil
|
|
91
|
+
store = Fiber[FIBER_STORE_KEY]
|
|
92
|
+
return nil unless store&.[](:__owner__) == Fiber.current.object_id
|
|
93
|
+
|
|
94
|
+
store
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fiber_memo_store_or_nil
|
|
98
|
+
fiber_root_store_or_nil&.[](object_id)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fiber_memo_cache_or_nil
|
|
102
|
+
fiber_memo_store_or_nil&.[](:cache)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fiber_memo_lru_or_nil
|
|
106
|
+
fiber_memo_store_or_nil&.[](:lru)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -31,7 +31,8 @@ module SafeMemoize
|
|
|
31
31
|
hooks.each do |hook|
|
|
32
32
|
hook.call(cache_key, record)
|
|
33
33
|
rescue => error
|
|
34
|
-
|
|
34
|
+
# SafeMemoize.configuration is not accessible from non-main Ractors
|
|
35
|
+
handler = (Ractor.current == Ractor.main) ? SafeMemoize.configuration.on_hook_error : nil
|
|
35
36
|
if handler
|
|
36
37
|
handler.call(error, hook_type, cache_key)
|
|
37
38
|
else
|
|
@@ -39,11 +40,17 @@ module SafeMemoize
|
|
|
39
40
|
end
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
# ActiveSupport::Notifications and StatsD integration require main-Ractor
|
|
44
|
+
# configuration access; skip them from worker Ractors.
|
|
45
|
+
return if Ractor.current != Ractor.main
|
|
46
|
+
|
|
42
47
|
safe_memo_notify(hook_type, cache_key) if SafeMemoize.configuration.active_support_notifications
|
|
43
48
|
|
|
44
49
|
if (client = SafeMemoize.configuration.statsd_client)
|
|
45
50
|
Adapters::StatsD.dispatch(client, hook_type, cache_key, self.class.name)
|
|
46
51
|
end
|
|
52
|
+
|
|
53
|
+
SafeMemoize.dispatch_extension_events(hook_type, self.class, safe_memo_bare_method_name(cache_key[0]), cache_key, record)
|
|
47
54
|
end
|
|
48
55
|
|
|
49
56
|
def safe_memo_notify(hook_type, cache_key)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
effective_name, custom_key = cache_key
|
|
61
63
|
payload = {custom_key: custom_key}
|
|
62
64
|
else
|
|
63
|
-
|
|
65
|
+
effective_name, args, kwargs = cache_key
|
|
64
66
|
payload = {args: args, kwargs: kwargs}
|
|
65
67
|
end
|
|
66
68
|
|
|
67
|
-
payload[: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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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] =
|
|
83
|
+
entry[:method] = safe_memo_bare_method_name(effective_name) if include_method
|
|
83
84
|
entry
|
|
84
85
|
end
|
|
85
86
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
# Class-level methods for Ractor-safe shared caching.
|
|
5
|
+
#
|
|
6
|
+
# Mixed into a class (via ClassMethods) when any method is memoized with
|
|
7
|
+
# +shared: true, ractor_safe: true+. The class owns a supervisor +Ractor+ that
|
|
8
|
+
# holds the mutable cache hash. All cache reads and writes are serialized through
|
|
9
|
+
# the supervisor's message loop, removing the need for a +Mutex+ (which is not
|
|
10
|
+
# Ractor-shareable).
|
|
11
|
+
#
|
|
12
|
+
# Constraints for +ractor_safe: true+ memoization:
|
|
13
|
+
# - Cached return values are made Ractor-shareable via +Ractor.make_shareable+
|
|
14
|
+
# (deep-frozen in place). Ensure return values can be frozen.
|
|
15
|
+
# - +if:+, +unless:+, +max_size:+, +ttl_refresh:+, +key:+, and +store:+ are
|
|
16
|
+
# incompatible and raise +ArgumentError+ at +memoize+ time.
|
|
17
|
+
# - When calling a ractor-safe memoized method from the main Ractor with multiple
|
|
18
|
+
# threads, responses are matched by thread identity so concurrent callers do not
|
|
19
|
+
# consume each other's replies.
|
|
20
|
+
module RactorSharedMethods
|
|
21
|
+
# Clears one or all entries from the Ractor-safe shared cache.
|
|
22
|
+
#
|
|
23
|
+
# @param method_name [Symbol, String]
|
|
24
|
+
# @param args [Array] positional args identifying a specific entry; omit to clear all
|
|
25
|
+
# @param kwargs [Hash]
|
|
26
|
+
# @return [void]
|
|
27
|
+
def reset_ractor_memo(method_name, *args, **kwargs)
|
|
28
|
+
method_name = method_name.to_sym
|
|
29
|
+
sup = @__safe_memo_ractor_supervisor__
|
|
30
|
+
return unless sup
|
|
31
|
+
|
|
32
|
+
if args.empty? && kwargs.empty?
|
|
33
|
+
__ractor_cache_send__(sup, :delete_all, method_name)
|
|
34
|
+
else
|
|
35
|
+
key = Ractor.make_shareable([method_name, args.freeze, kwargs.freeze])
|
|
36
|
+
__ractor_cache_send__(sup, :delete_one, key)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Clears the entire Ractor-safe shared cache for this class.
|
|
41
|
+
# @return [void]
|
|
42
|
+
def reset_all_ractor_memos
|
|
43
|
+
sup = @__safe_memo_ractor_supervisor__
|
|
44
|
+
return unless sup
|
|
45
|
+
|
|
46
|
+
__ractor_cache_send__(sup, :clear)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns +true+ if a live entry exists in the Ractor-safe shared cache.
|
|
50
|
+
#
|
|
51
|
+
# @param method_name [Symbol, String]
|
|
52
|
+
# @param args [Array]
|
|
53
|
+
# @param kwargs [Hash]
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def ractor_memoized?(method_name, *args, **kwargs)
|
|
56
|
+
method_name = method_name.to_sym
|
|
57
|
+
sup = @__safe_memo_ractor_supervisor__
|
|
58
|
+
return false unless sup
|
|
59
|
+
|
|
60
|
+
key = Ractor.make_shareable([method_name, args.freeze, kwargs.freeze])
|
|
61
|
+
__ractor_cache_send__(sup, :memoized, key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the number of live entries in the Ractor-safe shared cache.
|
|
65
|
+
#
|
|
66
|
+
# @param method_name [Symbol, String, nil] when given, counts only entries for
|
|
67
|
+
# that method; when +nil+, counts all.
|
|
68
|
+
# @return [Integer]
|
|
69
|
+
def ractor_memo_count(method_name = nil)
|
|
70
|
+
sup = @__safe_memo_ractor_supervisor__
|
|
71
|
+
return 0 unless sup
|
|
72
|
+
|
|
73
|
+
__ractor_cache_send__(sup, :count, method_name&.to_sym)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Sends a message to the supervisor and blocks until the tagged response arrives.
|
|
79
|
+
# Uses Thread.current.object_id as a per-call tag so concurrent threads in the
|
|
80
|
+
# main Ractor do not steal each other's replies.
|
|
81
|
+
def __ractor_cache_send__(supervisor, op, *args)
|
|
82
|
+
tag = Thread.current.object_id
|
|
83
|
+
msg = Ractor.make_shareable([Ractor.current, tag, op, *args])
|
|
84
|
+
supervisor.send(msg)
|
|
85
|
+
Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Creates the supervisor Ractor that owns this class's Ractor-safe shared cache.
|
|
89
|
+
# Must be called from the main Ractor at class-definition time.
|
|
90
|
+
def __safe_memo_ractor_supervisor__
|
|
91
|
+
# :nocov:
|
|
92
|
+
@__safe_memo_ractor_supervisor__ ||= Ractor.new do
|
|
93
|
+
cache = {}
|
|
94
|
+
|
|
95
|
+
loop do
|
|
96
|
+
caller_ractor, tag, op, *args = Ractor.receive
|
|
97
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
|
+
|
|
99
|
+
result = case op
|
|
100
|
+
when :fetch
|
|
101
|
+
key = args[0]
|
|
102
|
+
record = cache[key]
|
|
103
|
+
live = record && (record[:expires_at].nil? || record[:expires_at] > now)
|
|
104
|
+
live ? {hit: true, record: record} : {hit: false, record: nil}
|
|
105
|
+
|
|
106
|
+
when :store
|
|
107
|
+
key, new_record = args
|
|
108
|
+
existing = cache[key]
|
|
109
|
+
live = existing && (existing[:expires_at].nil? || existing[:expires_at] > now)
|
|
110
|
+
cache[key] = new_record unless live
|
|
111
|
+
{stored: live ? existing : new_record}
|
|
112
|
+
|
|
113
|
+
when :delete_all
|
|
114
|
+
method_name = args[0]
|
|
115
|
+
cache.delete_if { |k, _| k[0] == method_name }
|
|
116
|
+
:ok
|
|
117
|
+
|
|
118
|
+
when :delete_one
|
|
119
|
+
cache.delete(args[0])
|
|
120
|
+
:ok
|
|
121
|
+
|
|
122
|
+
when :clear
|
|
123
|
+
cache.clear
|
|
124
|
+
:ok
|
|
125
|
+
|
|
126
|
+
when :memoized
|
|
127
|
+
key = args[0]
|
|
128
|
+
record = cache[key]
|
|
129
|
+
!!(record && (record[:expires_at].nil? || record[:expires_at] > now))
|
|
130
|
+
|
|
131
|
+
when :count
|
|
132
|
+
method_name = args[0]
|
|
133
|
+
cache.count do |k, r|
|
|
134
|
+
next false if r[:expires_at] && r[:expires_at] <= now
|
|
135
|
+
method_name.nil? || k[0] == method_name
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
response = Ractor.make_shareable([tag, result])
|
|
140
|
+
caller_ractor.send(response)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
# :nocov:
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -46,6 +46,31 @@ module SafeMemoize
|
|
|
46
46
|
contents.sub(UNRELEASED_HEADING, "#{UNRELEASED_HEADING}\n\n#{release_heading}")
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Removes milestone sections from ROADMAP.md where every feature row has
|
|
50
|
+
# "Shipped" status. Non-milestone sections (Versioning policy, Contributing,
|
|
51
|
+
# etc.) and sections with any non-Shipped row are left untouched.
|
|
52
|
+
#
|
|
53
|
+
# Sections are delimited by the +\n\n---\n\n+ horizontal-rule separator that
|
|
54
|
+
# the ROADMAP uses between headings. A milestone section is any section whose
|
|
55
|
+
# first non-blank line starts with +## v+.
|
|
56
|
+
def prune_roadmap(contents)
|
|
57
|
+
separator = "\n\n---\n\n"
|
|
58
|
+
sections = contents.split(separator)
|
|
59
|
+
|
|
60
|
+
pruned = sections.reject do |section|
|
|
61
|
+
next false unless section.lstrip.start_with?("## v")
|
|
62
|
+
|
|
63
|
+
# Table rows: lines starting with "|"; drop alignment rows (only |, -, :, whitespace)
|
|
64
|
+
rows = section.lines.select { |l| l.strip.start_with?("|") }
|
|
65
|
+
rows = rows.reject { |l| l.match?(/\A[\s|:-]+\z/) }
|
|
66
|
+
data_rows = rows.drop(1) # first row is the header
|
|
67
|
+
|
|
68
|
+
data_rows.any? && data_rows.all? { |row| row.strip.end_with?("Shipped |") }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
pruned.join(separator)
|
|
72
|
+
end
|
|
73
|
+
|
|
49
74
|
def extract_release_notes(contents, version)
|
|
50
75
|
normalized_version = normalize_version(version)
|
|
51
76
|
lines = contents.lines
|
data/lib/safe_memoize/version.rb
CHANGED
data/lib/safe_memoize.rb
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
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"
|
|
8
9
|
require_relative "safe_memoize/adapters/opentelemetry"
|
|
10
|
+
require_relative "safe_memoize/adapters/concurrent_ruby"
|
|
9
11
|
require_relative "safe_memoize/class_methods"
|
|
10
12
|
require_relative "safe_memoize/public_methods"
|
|
11
13
|
require_relative "safe_memoize/cache_store_methods"
|
|
@@ -17,6 +19,8 @@ require_relative "safe_memoize/public_metrics_methods"
|
|
|
17
19
|
require_relative "safe_memoize/custom_key_methods"
|
|
18
20
|
require_relative "safe_memoize/public_custom_key_methods"
|
|
19
21
|
require_relative "safe_memoize/lru_methods"
|
|
22
|
+
require_relative "safe_memoize/fiber_local_methods"
|
|
23
|
+
require_relative "safe_memoize/ractor_shared_methods"
|
|
20
24
|
require_relative "safe_memoize/instance_methods"
|
|
21
25
|
|
|
22
26
|
# Thread-safe memoization for Ruby that correctly handles +nil+ and +false+ values.
|
|
@@ -51,6 +55,16 @@ module SafeMemoize
|
|
|
51
55
|
# Rescue this to catch any error raised by the library itself.
|
|
52
56
|
class Error < StandardError; end
|
|
53
57
|
|
|
58
|
+
# @api private
|
|
59
|
+
SHARED_CACHE_REGISTRY = {}
|
|
60
|
+
# @api private
|
|
61
|
+
SHARED_CACHE_MUTEX = Mutex.new
|
|
62
|
+
|
|
63
|
+
# @api private
|
|
64
|
+
EXTENSION_REGISTRY = {}
|
|
65
|
+
# @api private
|
|
66
|
+
EXTENSION_MUTEX = Mutex.new
|
|
67
|
+
|
|
54
68
|
include InstanceMethods
|
|
55
69
|
|
|
56
70
|
# @api private
|
|
@@ -99,4 +113,131 @@ module SafeMemoize
|
|
|
99
113
|
handler = configuration.on_deprecation
|
|
100
114
|
handler ? handler.call(text) : warn(text)
|
|
101
115
|
end
|
|
116
|
+
|
|
117
|
+
# Returns the named shared cache store, creating a new in-process
|
|
118
|
+
# {Stores::Memory} instance if one has not been registered under +name+.
|
|
119
|
+
#
|
|
120
|
+
# Use {register_shared_cache} to supply a custom adapter (e.g. Redis) before
|
|
121
|
+
# any class that references the same name is loaded.
|
|
122
|
+
#
|
|
123
|
+
# @param name [String] the logical cache name
|
|
124
|
+
# @return [Stores::Base]
|
|
125
|
+
def self.shared_cache(name)
|
|
126
|
+
SHARED_CACHE_MUTEX.synchronize do
|
|
127
|
+
SHARED_CACHE_REGISTRY[name] ||= Stores::Memory.new
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Registers a custom store under +name+, replacing any existing entry.
|
|
132
|
+
#
|
|
133
|
+
# Must be called *before* any class that references +name+ via +shared_cache:+
|
|
134
|
+
# is loaded, because the store is captured at +memoize+ definition time.
|
|
135
|
+
#
|
|
136
|
+
# @param name [String] the logical cache name
|
|
137
|
+
# @param store [Stores::Base] any {Stores::Base} subclass instance
|
|
138
|
+
# @return [Stores::Base] the registered store
|
|
139
|
+
# @raise [ArgumentError] if +store+ is not a {Stores::Base} instance
|
|
140
|
+
def self.register_shared_cache(name, store)
|
|
141
|
+
unless store.is_a?(Stores::Base)
|
|
142
|
+
raise ArgumentError, "store must be a SafeMemoize::Stores::Base instance (got #{store.class})"
|
|
143
|
+
end
|
|
144
|
+
SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY[name] = store }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Clears all entries in the named shared cache without removing it from the
|
|
148
|
+
# registry. A no-op when no cache is registered under +name+.
|
|
149
|
+
#
|
|
150
|
+
# @param name [String]
|
|
151
|
+
# @return [void]
|
|
152
|
+
def self.clear_shared_cache(name)
|
|
153
|
+
SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY[name]&.clear }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Removes the named shared cache from the registry entirely.
|
|
157
|
+
# Subsequent +shared_cache(name)+ calls will create a fresh store.
|
|
158
|
+
#
|
|
159
|
+
# @param name [String]
|
|
160
|
+
# @return [Stores::Base, nil] the removed store, or +nil+ if not present
|
|
161
|
+
def self.drop_shared_cache(name)
|
|
162
|
+
SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.delete(name) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Returns a snapshot of the current registry as a plain +Hash+.
|
|
166
|
+
#
|
|
167
|
+
# @return [Hash{String => Stores::Base}]
|
|
168
|
+
def self.shared_caches
|
|
169
|
+
SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.dup }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Removes all named shared caches from the registry.
|
|
173
|
+
#
|
|
174
|
+
# Useful in test suite +after+ hooks to prevent state leaking between examples.
|
|
175
|
+
#
|
|
176
|
+
# @return [void]
|
|
177
|
+
def self.reset_shared_caches!
|
|
178
|
+
SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.clear }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Registers an extension under +name+.
|
|
182
|
+
#
|
|
183
|
+
# An extension is any Ruby object that optionally responds to the
|
|
184
|
+
# {Extension} interface: {Extension#handled_options}, {Extension#process_memoize_option},
|
|
185
|
+
# and {Extension#dispatch_cache_event}. Use {Extension} as a mixin to get a
|
|
186
|
+
# convenient DSL for defining these.
|
|
187
|
+
#
|
|
188
|
+
# @param name [Symbol, String] a unique identifier for this extension
|
|
189
|
+
# @param extension [Object] the extension object or module
|
|
190
|
+
# @return [Object] the registered extension
|
|
191
|
+
def self.register_extension(name, extension)
|
|
192
|
+
EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY[name.to_sym] = extension }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Removes an extension from the registry.
|
|
196
|
+
#
|
|
197
|
+
# @param name [Symbol, String]
|
|
198
|
+
# @return [Object, nil] the removed extension, or +nil+ if not present
|
|
199
|
+
def self.unregister_extension(name)
|
|
200
|
+
EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.delete(name.to_sym) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Returns a snapshot of all registered extensions as a +Hash+.
|
|
204
|
+
#
|
|
205
|
+
# @return [Hash{Symbol => Object}]
|
|
206
|
+
def self.extensions
|
|
207
|
+
EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.dup }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Removes all registered extensions.
|
|
211
|
+
#
|
|
212
|
+
# Useful in test suite +after+ hooks to prevent state leaking between examples.
|
|
213
|
+
#
|
|
214
|
+
# @return [void]
|
|
215
|
+
def self.reset_extensions!
|
|
216
|
+
EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.clear }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Returns the first registered extension that declares it handles +option_name+,
|
|
220
|
+
# or +nil+ if none does.
|
|
221
|
+
#
|
|
222
|
+
# @param option_name [Symbol]
|
|
223
|
+
# @return [Object, nil]
|
|
224
|
+
def self.extension_for_option(option_name)
|
|
225
|
+
sym = option_name.to_sym
|
|
226
|
+
EXTENSION_MUTEX.synchronize do
|
|
227
|
+
EXTENSION_REGISTRY.values.find do |ext|
|
|
228
|
+
ext.respond_to?(:handled_options) && ext.handled_options.include?(sym)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Dispatches a cache lifecycle event to all registered extensions that
|
|
234
|
+
# respond to +dispatch_cache_event+.
|
|
235
|
+
#
|
|
236
|
+
# @api private
|
|
237
|
+
def self.dispatch_extension_events(event_type, klass, method_name, cache_key, record)
|
|
238
|
+
exts = EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.values.dup }
|
|
239
|
+
exts.each do |ext|
|
|
240
|
+
ext.dispatch_cache_event(event_type, klass, method_name, cache_key, record) if ext.respond_to?(:dispatch_cache_event)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
102
243
|
end
|