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.
@@ -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
- def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil)
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
- handler = SafeMemoize.configuration.on_hook_error
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)
@@ -13,5 +13,6 @@ module SafeMemoize
13
13
  include CustomKeyMethods
14
14
  include PublicCustomKeyMethods
15
15
  include LruMethods
16
+ include FiberLocalMethods
16
17
  end
17
18
  end
@@ -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