safe_memoize 1.0.0 → 1.2.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 +42 -1
- data/README.md +288 -6
- data/ROADMAP.md +0 -42
- data/Rakefile +13 -1
- data/lib/safe_memoize/adapters/concurrent_ruby.rb +98 -0
- data/lib/safe_memoize/class_methods.rb +260 -1
- data/lib/safe_memoize/configuration.rb +7 -0
- data/lib/safe_memoize/fiber_local_methods.rb +108 -0
- data/lib/safe_memoize/hooks_methods.rb +6 -1
- data/lib/safe_memoize/instance_methods.rb +1 -0
- data/lib/safe_memoize/ractor_shared_methods.rb +146 -0
- data/lib/safe_memoize/release_tooling.rb +25 -0
- data/lib/safe_memoize/stores/base.rb +85 -0
- data/lib/safe_memoize/stores/memory.rb +70 -0
- data/lib/safe_memoize/stores/rails_cache.rb +128 -0
- data/lib/safe_memoize/stores/redis.rb +111 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +5 -0
- data/sig/safe_memoize.rbs +74 -2
- metadata +8 -1
|
@@ -25,6 +25,19 @@ module SafeMemoize
|
|
|
25
25
|
# @param key [Proc, nil] class-level custom cache key generator. Receives the same
|
|
26
26
|
# arguments as the method and should return a single comparable value. Instance-level
|
|
27
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
|
+
# @param fiber_local [Boolean] when +true+, results are stored in
|
|
33
|
+
# +Fiber[:__safe_memoize__]+ rather than instance variables. Each fiber gets its
|
|
34
|
+
# own isolated cache that is automatically discarded when the fiber terminates. No
|
|
35
|
+
# mutex is acquired. Cannot be combined with +shared:+ or +store:+.
|
|
36
|
+
# @param ractor_safe [Boolean] when +true+, the class-level shared cache is owned by
|
|
37
|
+
# a supervisor +Ractor+ rather than a +Mutex+-protected ivar, making it accessible
|
|
38
|
+
# from worker Ractors. Requires +shared: true+. Cached values are deep-frozen via
|
|
39
|
+
# +Ractor.make_shareable+. Incompatible with +if:+, +unless:+, +max_size:+,
|
|
40
|
+
# +ttl_refresh:+, +key:+, and +store:+.
|
|
28
41
|
# @return [void]
|
|
29
42
|
# @raise [ArgumentError] if the method does not exist, or option values are invalid
|
|
30
43
|
#
|
|
@@ -38,7 +51,11 @@ module SafeMemoize
|
|
|
38
51
|
#
|
|
39
52
|
# @example Conditional — only cache successful responses
|
|
40
53
|
# memoize :fetch, if: ->(v) { v[:status] == 200 }
|
|
41
|
-
|
|
54
|
+
#
|
|
55
|
+
# @example With a custom store
|
|
56
|
+
# STORE = SafeMemoize::Stores::Memory.new
|
|
57
|
+
# memoize :fetch, store: STORE, ttl: 300
|
|
58
|
+
def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false)
|
|
42
59
|
method_name = method_name.to_sym
|
|
43
60
|
|
|
44
61
|
unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
|
|
@@ -85,6 +102,46 @@ module SafeMemoize
|
|
|
85
102
|
raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call)
|
|
86
103
|
raise ArgumentError, ":key must be callable" if key && !key.respond_to?(:call)
|
|
87
104
|
|
|
105
|
+
if store
|
|
106
|
+
raise ArgumentError, "store: must be a SafeMemoize::Stores::Base instance (got #{store.class})" unless store.is_a?(SafeMemoize::Stores::Base)
|
|
107
|
+
raise ArgumentError, "max_size: is not supported with store: — use the store adapter's own eviction" if max_size
|
|
108
|
+
raise ArgumentError, "shared: and store: cannot be combined" if shared
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if fiber_local
|
|
112
|
+
raise ArgumentError, "fiber_local: and shared: cannot be combined" if shared
|
|
113
|
+
raise ArgumentError, "fiber_local: and store: cannot be combined" if store
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if ractor_safe
|
|
117
|
+
raise ArgumentError, "ractor_safe: requires shared: true" unless shared
|
|
118
|
+
raise ArgumentError, "ractor_safe: is incompatible with if:/unless:" if cond_if || cond_unless
|
|
119
|
+
raise ArgumentError, "ractor_safe: is incompatible with max_size:" if max_size
|
|
120
|
+
raise ArgumentError, "ractor_safe: is incompatible with ttl_refresh:" if ttl_refresh
|
|
121
|
+
raise ArgumentError, "ractor_safe: is incompatible with key:" if key
|
|
122
|
+
raise ArgumentError, "ractor_safe: is incompatible with store:" if store
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Resolve effective store: per-method store: wins; then class-level
|
|
126
|
+
# safe_memoize_store; then global default_store. max_size: and shared:
|
|
127
|
+
# are incompatible with external stores — fall back silently.
|
|
128
|
+
effective_store = store
|
|
129
|
+
if effective_store.nil? && !max_size && !shared
|
|
130
|
+
class_store = safe_memoize_store
|
|
131
|
+
if class_store
|
|
132
|
+
effective_store = class_store
|
|
133
|
+
else
|
|
134
|
+
global_default = SafeMemoize.configuration.default_store
|
|
135
|
+
if global_default
|
|
136
|
+
unless global_default.is_a?(SafeMemoize::Stores::Base)
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"SafeMemoize.configuration.default_store must be a Stores::Base instance (got #{global_default.class})"
|
|
139
|
+
end
|
|
140
|
+
effective_store = global_default
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
88
145
|
__safe_memo_class_key_generators__[method_name] = key if key
|
|
89
146
|
|
|
90
147
|
# Normalize to a single "should cache?" predicate
|
|
@@ -94,6 +151,120 @@ module SafeMemoize
|
|
|
94
151
|
->(result) { !cond_unless.call(result) }
|
|
95
152
|
end
|
|
96
153
|
|
|
154
|
+
if effective_store
|
|
155
|
+
miss = SafeMemoize::Stores::Base::MISS
|
|
156
|
+
|
|
157
|
+
mod = Module.new do
|
|
158
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
159
|
+
return super(*args, **kwargs, &block) if block
|
|
160
|
+
|
|
161
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
162
|
+
cached = effective_store.read(cache_key)
|
|
163
|
+
|
|
164
|
+
unless cached.equal?(miss)
|
|
165
|
+
effective_store.write(cache_key, cached, expires_in: ttl) if ttl_refresh
|
|
166
|
+
record_cache_hit(method_name, args, kwargs)
|
|
167
|
+
call_memo_hooks(:on_hit, cache_key, {value: cached, expires_at: nil, cached_at: nil})
|
|
168
|
+
return cached
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
172
|
+
value = Adapters::OpenTelemetry.trace(
|
|
173
|
+
SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name
|
|
174
|
+
) { super(*args, **kwargs) }
|
|
175
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
176
|
+
|
|
177
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
178
|
+
if !condition || condition.call(value)
|
|
179
|
+
effective_store.write(cache_key, value, expires_in: ttl)
|
|
180
|
+
call_memo_hooks(:on_store, cache_key, {value: value, expires_at: nil, cached_at: now})
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
record_cache_miss(method_name, args, kwargs, elapsed_time)
|
|
184
|
+
call_memo_hooks(:on_miss, cache_key, {value: value, expires_at: nil, cached_at: now})
|
|
185
|
+
|
|
186
|
+
value
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
send(visibility, method_name)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
prepend mod
|
|
193
|
+
|
|
194
|
+
return
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if fiber_local
|
|
198
|
+
mod = Module.new do
|
|
199
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
200
|
+
return super(*args, **kwargs, &block) if block
|
|
201
|
+
|
|
202
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
203
|
+
fiber_cache = fiber_memo_cache!
|
|
204
|
+
record = fiber_cache[cache_key]
|
|
205
|
+
|
|
206
|
+
if memo_record_live?(record)
|
|
207
|
+
if max_size
|
|
208
|
+
lru = fiber_memo_lru![method_name] ||= {}
|
|
209
|
+
lru.delete(cache_key)
|
|
210
|
+
lru[cache_key] = true
|
|
211
|
+
end
|
|
212
|
+
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
213
|
+
record_cache_hit(method_name, args, kwargs)
|
|
214
|
+
call_memo_hooks(:on_hit, cache_key, record)
|
|
215
|
+
memo_record_value(record)
|
|
216
|
+
else
|
|
217
|
+
call_memo_hooks(:on_expire, cache_key, record) if record
|
|
218
|
+
|
|
219
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
220
|
+
value = Adapters::OpenTelemetry.trace(
|
|
221
|
+
SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name
|
|
222
|
+
) { super(*args, **kwargs) }
|
|
223
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
224
|
+
|
|
225
|
+
new_record = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
226
|
+
|
|
227
|
+
if !condition || condition.call(value)
|
|
228
|
+
if max_size
|
|
229
|
+
lru = fiber_memo_lru![method_name] ||= {}
|
|
230
|
+
if lru.size >= max_size
|
|
231
|
+
evict_key = lru.keys.first
|
|
232
|
+
lru.delete(evict_key)
|
|
233
|
+
evicted = fiber_cache.delete(evict_key)
|
|
234
|
+
call_memo_hooks(:on_evict, evict_key, evicted) if evicted
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
fiber_cache[cache_key] = new_record
|
|
238
|
+
if max_size
|
|
239
|
+
lru = fiber_memo_lru![method_name] ||= {}
|
|
240
|
+
lru.delete(cache_key)
|
|
241
|
+
lru[cache_key] = true
|
|
242
|
+
end
|
|
243
|
+
call_memo_hooks(:on_store, cache_key, new_record)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
record_cache_miss(method_name, args, kwargs, elapsed_time)
|
|
247
|
+
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
248
|
+
|
|
249
|
+
value
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
send(visibility, method_name)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
prepend mod
|
|
257
|
+
|
|
258
|
+
return
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
if ractor_safe
|
|
262
|
+
extend(RactorSharedMethods) unless is_a?(RactorSharedMethods)
|
|
263
|
+
supervisor = __safe_memo_ractor_supervisor__
|
|
264
|
+
__memoize_ractor_safe__(method_name, ttl, visibility, supervisor)
|
|
265
|
+
return
|
|
266
|
+
end
|
|
267
|
+
|
|
97
268
|
if shared
|
|
98
269
|
klass = self
|
|
99
270
|
shared_mutex = klass.send(:__safe_memo_shared_mutex__)
|
|
@@ -232,6 +403,32 @@ module SafeMemoize
|
|
|
232
403
|
prepend mod
|
|
233
404
|
end
|
|
234
405
|
|
|
406
|
+
# Returns the class-level default cache store, or +nil+ if not set.
|
|
407
|
+
#
|
|
408
|
+
# Set this to any {Stores::Base} instance to route every +memoize+ call on
|
|
409
|
+
# this class through that store, without needing to pass +store:+ to each
|
|
410
|
+
# individual +memoize+ call. A per-method +store:+ option still takes
|
|
411
|
+
# precedence, and the global {SafeMemoize::Configuration#default_store} is
|
|
412
|
+
# the final fallback.
|
|
413
|
+
#
|
|
414
|
+
# @return [Stores::Base, nil]
|
|
415
|
+
def safe_memoize_store
|
|
416
|
+
@__safe_memoize_store__
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Sets the class-level default cache store.
|
|
420
|
+
#
|
|
421
|
+
# @param store [Stores::Base, nil] a store instance, or +nil+ to clear
|
|
422
|
+
# @return [Stores::Base, nil]
|
|
423
|
+
# @raise [ArgumentError] if +store+ is not a {Stores::Base} instance (and not +nil+)
|
|
424
|
+
def safe_memoize_store=(store)
|
|
425
|
+
if store && !store.is_a?(SafeMemoize::Stores::Base)
|
|
426
|
+
raise ArgumentError,
|
|
427
|
+
"safe_memoize_store= must be a SafeMemoize::Stores::Base instance (got #{store.class})"
|
|
428
|
+
end
|
|
429
|
+
@__safe_memoize_store__ = store
|
|
430
|
+
end
|
|
431
|
+
|
|
235
432
|
# Memoizes every eligible public instance method defined directly on the class.
|
|
236
433
|
#
|
|
237
434
|
# Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+.
|
|
@@ -406,5 +603,67 @@ module SafeMemoize
|
|
|
406
603
|
|
|
407
604
|
:public
|
|
408
605
|
end
|
|
606
|
+
|
|
607
|
+
# Builds and prepends the ractor_safe memoize wrapper in its own method so
|
|
608
|
+
# the Proc only closes over the four Ractor-shareable locals (method_name,
|
|
609
|
+
# ttl, visibility, supervisor) rather than the full memoize binding, which
|
|
610
|
+
# contains non-shareable objects like SafeMemoize.configuration.
|
|
611
|
+
#
|
|
612
|
+
# The Proc is created inside module_eval so its self is the anonymous
|
|
613
|
+
# module (a shareable object), then frozen via Ractor.make_shareable before
|
|
614
|
+
# being passed to define_method. Without that step, ANY define_method Proc
|
|
615
|
+
# is considered non-shareable by Ruby 3.x even when it captures nothing.
|
|
616
|
+
def __memoize_ractor_safe__(method_name, ttl, visibility, supervisor)
|
|
617
|
+
mod = Module.new
|
|
618
|
+
wrapper = mod.module_eval do
|
|
619
|
+
Ractor.make_shareable(
|
|
620
|
+
proc do |*args, **kwargs, &block|
|
|
621
|
+
return super(*args, **kwargs, &block) if block
|
|
622
|
+
|
|
623
|
+
cache_key = Ractor.make_shareable([method_name, deep_freeze_copy(args), deep_freeze_copy(kwargs)])
|
|
624
|
+
|
|
625
|
+
tag = Thread.current.object_id
|
|
626
|
+
supervisor.send(Ractor.make_shareable([Ractor.current, tag, :fetch, cache_key]))
|
|
627
|
+
response = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
628
|
+
|
|
629
|
+
if response[:hit]
|
|
630
|
+
record_cache_hit(method_name, args, kwargs)
|
|
631
|
+
call_memo_hooks(:on_hit, cache_key, response[:record])
|
|
632
|
+
return response[:record][:value]
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
636
|
+
value = super(*args, **kwargs)
|
|
637
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
638
|
+
|
|
639
|
+
begin
|
|
640
|
+
shareable_value = Ractor.make_shareable(value)
|
|
641
|
+
rescue => e
|
|
642
|
+
raise ArgumentError, "ractor_safe: memoized values must be Ractor-shareable (#{e.message})"
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
646
|
+
record = Ractor.make_shareable({
|
|
647
|
+
value: shareable_value,
|
|
648
|
+
expires_at: ttl ? now + ttl : nil,
|
|
649
|
+
cached_at: now
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
supervisor.send(Ractor.make_shareable([Ractor.current, tag, :store, cache_key, record]))
|
|
653
|
+
stored = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
654
|
+
stored_record = stored[:stored]
|
|
655
|
+
|
|
656
|
+
record_cache_miss(method_name, args, kwargs, elapsed_time)
|
|
657
|
+
call_memo_hooks(:on_store, cache_key, stored_record)
|
|
658
|
+
call_memo_hooks(:on_miss, cache_key, stored_record)
|
|
659
|
+
|
|
660
|
+
stored_record[:value]
|
|
661
|
+
end
|
|
662
|
+
)
|
|
663
|
+
end
|
|
664
|
+
mod.define_method(method_name, wrapper)
|
|
665
|
+
mod.send(visibility, method_name)
|
|
666
|
+
prepend mod
|
|
667
|
+
end
|
|
409
668
|
end
|
|
410
669
|
end
|
|
@@ -42,6 +42,12 @@ module SafeMemoize
|
|
|
42
42
|
# When set, {Adapters::OpenTelemetry} wraps each cache-miss computation in a span.
|
|
43
43
|
attr_accessor :opentelemetry_tracer
|
|
44
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
|
+
|
|
45
51
|
# @api private
|
|
46
52
|
def initialize
|
|
47
53
|
@default_ttl = nil
|
|
@@ -51,6 +57,7 @@ module SafeMemoize
|
|
|
51
57
|
@active_support_notifications = false
|
|
52
58
|
@statsd_client = nil
|
|
53
59
|
@opentelemetry_tracer = nil
|
|
60
|
+
@default_store = nil
|
|
54
61
|
end
|
|
55
62
|
end
|
|
56
63
|
end
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
cache.delete_if { |key, _| key[0] == method_name }
|
|
46
|
+
fiber_memo_lru_or_nil&.delete(method_name)
|
|
47
|
+
else
|
|
48
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
49
|
+
cache.delete(cache_key)
|
|
50
|
+
fiber_memo_lru_or_nil&.[](method_name)&.delete(cache_key)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Clears all fiber-local cached entries for this instance in the current fiber.
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
def reset_all_fiber_memos
|
|
58
|
+
store = Fiber[FIBER_STORE_KEY]
|
|
59
|
+
return unless store&.[](:__owner__) == Fiber.current.object_id
|
|
60
|
+
|
|
61
|
+
store.delete(object_id)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Returns the per-fiber top-level store hash, creating a fresh one for
|
|
67
|
+
# this fiber if the current store was inherited from a parent fiber.
|
|
68
|
+
def fiber_root_store!
|
|
69
|
+
store = Fiber[FIBER_STORE_KEY]
|
|
70
|
+
unless store&.[](:__owner__) == Fiber.current.object_id
|
|
71
|
+
store = {__owner__: Fiber.current.object_id}
|
|
72
|
+
Fiber[FIBER_STORE_KEY] = store
|
|
73
|
+
end
|
|
74
|
+
store
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fiber_memo_store!
|
|
78
|
+
fiber_root_store![object_id] ||= {cache: {}, lru: {}}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fiber_memo_cache!
|
|
82
|
+
fiber_memo_store![:cache]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fiber_memo_lru!
|
|
86
|
+
fiber_memo_store![:lru]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def fiber_root_store_or_nil
|
|
90
|
+
store = Fiber[FIBER_STORE_KEY]
|
|
91
|
+
return nil unless store&.[](:__owner__) == Fiber.current.object_id
|
|
92
|
+
|
|
93
|
+
store
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def fiber_memo_store_or_nil
|
|
97
|
+
fiber_root_store_or_nil&.[](object_id)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fiber_memo_cache_or_nil
|
|
101
|
+
fiber_memo_store_or_nil&.[](:cache)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fiber_memo_lru_or_nil
|
|
105
|
+
fiber_memo_store_or_nil&.[](:lru)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
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,6 +40,10 @@ 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)
|
|
@@ -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
|