julewire-ractor 1.0.1 → 1.1.1

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.
@@ -6,6 +6,37 @@ require "concurrent/atomic/atomic_reference"
6
6
  module Julewire
7
7
  module Ractor
8
8
  class Destination # rubocop:disable Metrics/ClassLength -- Owns parent queue, worker lifecycle, and health.
9
+ class QueueSlots
10
+ def initialize(max_queue:)
11
+ @max_queue = max_queue
12
+ @in_flight = Concurrent::AtomicFixnum.new
13
+ end
14
+
15
+ def value = @in_flight.value
16
+
17
+ def reserve
18
+ return true unless @max_queue.positive?
19
+
20
+ result = {}
21
+ @in_flight.update do |current|
22
+ result[:reserved] = current < @max_queue
23
+ result.fetch(:reserved) ? current + 1 : current
24
+ end
25
+ result.fetch(:reserved)
26
+ end
27
+
28
+ def release
29
+ return false unless @max_queue.positive?
30
+
31
+ result = {}
32
+ @in_flight.update do |current|
33
+ result[:underflow] = current.zero?
34
+ current.positive? ? current - 1 : current
35
+ end
36
+ result.fetch(:underflow)
37
+ end
38
+ end
39
+
9
40
  COUNTER_KEYS = %i[
10
41
  closed_dropped
11
42
  queue_full_dropped
@@ -18,8 +49,8 @@ module Julewire
18
49
  ].freeze
19
50
  DEFAULT_MAX_QUEUE = 1024
20
51
  DEFAULT_REQUEST_TIMEOUT = 1
21
- TIMEOUT_THREAD_NAME = "julewire-ractor-destination-timeout"
22
- private_constant :COUNTER_KEYS
52
+ WORKER_STOP_MESSAGE = { command: :close_worker }.freeze
53
+ private_constant :COUNTER_KEYS, :QueueSlots, :WORKER_STOP_MESSAGE
23
54
 
24
55
  attr_reader :name
25
56
 
@@ -42,6 +73,8 @@ module Julewire
42
73
  Core::Validation.validate_byte_limit!(max_record_bytes, name: :max_record_bytes)
43
74
  Core::Validation.validate_non_negative_integer!(max_queue, name: :max_queue)
44
75
  Core::Validation.validate_timeout!(request_timeout, name: :request_timeout)
76
+ raise ArgumentError, "request_timeout must be a non-negative finite Numeric" if request_timeout.nil?
77
+
45
78
  Core::Validation.validate_callable!(on_drop, name: :on_drop, allow_nil: true)
46
79
  Core::Validation.validate_callable!(on_failure, name: :on_failure, allow_nil: true)
47
80
  @output = output
@@ -51,7 +84,6 @@ module Julewire
51
84
  @request_timeout = request_timeout
52
85
  @on_drop = on_drop
53
86
  @on_failure = on_failure
54
- @scheduler = Core::Scheduling::DeadlineScheduler.new(thread_name: TIMEOUT_THREAD_NAME, idle: :exit)
55
87
  initialize_tracking
56
88
  start_worker
57
89
  end
@@ -59,10 +91,11 @@ module Julewire
59
91
  def emit(record)
60
92
  increment(:received)
61
93
  return drop(:closed_dropped, record) if closed?
62
- return drop(:queue_full_dropped, record) unless reserve_slot
94
+ return drop(:queue_full_dropped, record) unless @queue_slots.reserve
63
95
 
64
96
  begin
65
- @port.send({ command: :emit, record: record })
97
+ degradation_marker = @health.degradation_marker
98
+ @port.send({ command: :emit, degradation_marker: degradation_marker, record: record })
66
99
  increment(:queued)
67
100
  rescue StandardError => e
68
101
  release_slot
@@ -73,21 +106,22 @@ module Julewire
73
106
  end
74
107
 
75
108
  def flush(timeout: nil)
76
- timeout = @request_timeout if timeout.nil?
77
- request(:flush, timeout: timeout)
109
+ @health.recover_if_successful { request(:flush, timeout: lifecycle_timeout(timeout)) }
78
110
  end
79
111
 
80
112
  def close(timeout: nil)
81
- timeout = @request_timeout if timeout.nil?
113
+ timeout = lifecycle_timeout(timeout)
82
114
  @closed.set(true)
83
- result = request(:close, timeout: timeout)
84
- close_ports
115
+ result = request(:close, timeout: timeout, allow_closed: true)
116
+ close_ports(timeout: timeout)
85
117
  result
86
118
  end
87
119
 
88
120
  def after_fork!
89
- close_ports
90
- @scheduler.after_fork!
121
+ if (@process_id - Process.pid).zero? && !close_ports(timeout: @request_timeout)
122
+ raise Core::Error, "ractor destination worker did not stop within #{@request_timeout} seconds"
123
+ end
124
+
91
125
  initialize_tracking
92
126
  start_worker
93
127
  self
@@ -100,11 +134,17 @@ module Julewire
100
134
 
101
135
  def health
102
136
  worker = request(:health, timeout: @request_timeout)
103
- worker = @worker_health.get unless worker.is_a?(Hash)
104
- @worker_health.set(worker) if worker.is_a?(Hash)
137
+ if worker.equal?(false)
138
+ worker = @worker_health.get
139
+ else
140
+ raise TypeError, "ractor destination health must be a Hash" unless worker.instance_of?(Hash)
141
+
142
+ Core::Integration::Protocol.validate_symbol_keys(worker)
143
+ @worker_health.set(worker)
144
+ end
105
145
 
106
146
  @health.snapshot(
107
- in_flight: @in_flight.value,
147
+ in_flight: @queue_slots.value,
108
148
  max_queue: @max_queue,
109
149
  status: status_for(worker),
110
150
  worker: worker
@@ -119,22 +159,28 @@ module Julewire
119
159
  end
120
160
 
121
161
  def initialize_tracking
122
- @closed = Concurrent::AtomicReference.new(false)
162
+ @process_id = Process.pid
163
+ @scheduler = ReplyTimeoutScheduler.new(timeout_value: false)
164
+ @closed = Concurrent::AtomicReference.new
123
165
  @health = Core::Integration::DestinationHealth.new(counter_keys: COUNTER_KEYS, failure_counter: nil)
124
- @in_flight = Concurrent::AtomicFixnum.new(0)
166
+ @queue_slots = QueueSlots.new(max_queue: @max_queue)
125
167
  @worker_health = Concurrent::AtomicReference.new
126
168
  end
127
169
 
128
170
  def start_worker
129
171
  @ack_port = ::Ractor::Port.new
130
- setup_port = ::Ractor::Port.new
131
- @worker = spawn_worker(setup_port)
132
- @port = receive_worker_port(setup_port)
172
+ PortLifecycle.with_port do |setup_port|
173
+ @worker = spawn_worker(setup_port)
174
+ @port = WorkerHandshake.receive(
175
+ setup_port: setup_port,
176
+ worker: @worker,
177
+ scheduler: @scheduler
178
+ )
179
+ end
133
180
  @ack_thread = start_ack_thread
134
- rescue StandardError => e
135
- raise ArgumentError, "ractor destination collaborators must be ractor-copyable or shareable: #{e.message}"
136
- ensure
137
- PortLifecycle.close(setup_port) if defined?(setup_port) && setup_port
181
+ rescue TypeError, ::Ractor::Error => e
182
+ @port = @worker = @ack_port = @ack_thread = nil
183
+ raise ArgumentError, "ractor destination collaborators must be ractor-copyable or shareable: #{e}"
138
184
  end
139
185
 
140
186
  def spawn_worker(setup_port)
@@ -145,7 +191,7 @@ module Julewire
145
191
  max_record_bytes = @max_record_bytes
146
192
  close_output = @close_output
147
193
 
148
- # :nocov:
194
+ # simplecov:disable
149
195
  ::Ractor.new(setup_port, ack_port, formatter, encoder, output, max_record_bytes, close_output,
150
196
  name: "julewire-ractor-destination") do |worker_port, worker_ack_port, worker_formatter,
151
197
  worker_encoder, worker_output, worker_max_record_bytes,
@@ -162,21 +208,19 @@ module Julewire
162
208
  close_output: worker_close_output
163
209
  )
164
210
  end
165
- # :nocov:
166
- end
167
-
168
- def receive_worker_port(setup_port)
169
- selected, value = ::Ractor.select(setup_port, @worker)
170
- return value if selected.equal?(setup_port) && value.is_a?(::Ractor::Port)
171
-
172
- raise ArgumentError, "ractor destination worker did not start"
211
+ # simplecov:enable
173
212
  end
174
213
 
175
214
  def start_ack_thread
215
+ ack_port = @ack_port
176
216
  thread = Thread.new do
177
217
  loop do
178
- message = @ack_port.receive
179
- break if message.is_a?(Hash) && message[:event] == :closed
218
+ message = ack_port.receive
219
+ Core::Integration::Protocol.validate_symbol_hash(message)
220
+ event = message.fetch(:event)
221
+ unless event == :ack
222
+ raise ArgumentError, "unknown ractor destination acknowledgement event: #{event.inspect}"
223
+ end
180
224
 
181
225
  handle_ack(message)
182
226
  end
@@ -184,73 +228,86 @@ module Julewire
184
228
  record_failure(e, phase: :ack)
185
229
  end
186
230
  thread.name = "julewire-ractor-destination-ack"
187
- thread.report_on_exception = true
188
231
  thread
189
232
  end
190
233
 
191
234
  def handle_ack(message)
192
- return unless message.is_a?(Hash) && message[:event] == :ack
193
-
194
- decrement_in_flight
195
- case message[:status]
235
+ release_slot
236
+ case message.fetch(:status)
196
237
  when :accepted
197
238
  increment(:worker_accepted)
239
+ @health.clear_degradation_if_unchanged(message.fetch(:degradation_marker))
198
240
  when :dropped
199
241
  increment(:worker_dropped)
242
+ else
243
+ raise ArgumentError, "unknown ractor destination acknowledgement status: #{message.fetch(:status).inspect}"
200
244
  end
201
245
  end
202
246
 
203
- def request(command, timeout:)
204
- return false if closed? && command != :health && command != :close
247
+ def request(command, timeout:, allow_closed: false)
248
+ return false if closed? && !allow_closed
205
249
 
206
- reply = ::Ractor::Port.new
207
- @port.send({ command: command, reply: reply })
208
- wait_for_reply(reply, timeout)
250
+ PortLifecycle.with_port do |reply|
251
+ @port.send({ command: command, reply: reply })
252
+ wait_for_reply(reply, timeout, command)
253
+ end
209
254
  rescue StandardError => e
210
255
  record_failure(e, phase: :request, command: command)
211
256
  false
212
- ensure
213
- PortLifecycle.close(reply) if reply
214
257
  end
215
258
 
216
- def wait_for_reply(reply, timeout)
217
- return reply.receive unless timeout
259
+ def wait_for_reply(reply, timeout, command)
260
+ @scheduler.with_timeout(reply, timeout: timeout) do
261
+ selected, value = ::Ractor.select(reply, @worker)
262
+ return value if selected.equal?(reply)
218
263
 
219
- timeout_port = ::Ractor::Port.new
220
- token = @scheduler.schedule(timeout) do
221
- timeout_port.send(:timeout)
222
- rescue StandardError
223
- nil
264
+ raise Core::Error, "ractor destination worker stopped before #{command} replied"
224
265
  end
225
- selected, response = ::Ractor.select(reply, timeout_port)
226
-
227
- selected.equal?(timeout_port) ? false : response
228
- ensure
229
- @scheduler.cancel(token) if defined?(token)
230
- PortLifecycle.close(timeout_port) if defined?(timeout_port) && timeout_port
266
+ rescue ::Ractor::RemoteError
267
+ raise Core::Error, "ractor destination worker stopped before #{command} replied"
231
268
  end
232
269
 
233
- def reserve_slot
234
- return true unless @max_queue.positive?
270
+ def closed? = @closed.get
235
271
 
236
- # MRI normally settles this in one pass under the GVL; the CAS loop keeps
237
- # the reservation honest when multiple emitters race.
238
- loop do
239
- current = @in_flight.value
240
- return false if current >= @max_queue
241
- return true if @in_flight.compare_and_set(current, current + 1)
272
+ def close_ports(timeout:)
273
+ worker_stopped = true
274
+ begin
275
+ if @worker
276
+ begin
277
+ @port.send(WORKER_STOP_MESSAGE)
278
+ rescue ::Ractor::ClosedError
279
+ # A stopped worker no longer accepts commands. Still collect it
280
+ # below so an abnormal exit remains observable.
281
+ end
282
+ worker_stopped = wait_for_worker(timeout)
283
+ end
284
+ rescue ::Ractor::RemoteError => e
285
+ record_failure(e, phase: :worker_stop)
286
+ worker_stopped = true
287
+ ensure
288
+ @ack_thread&.kill
289
+ @ack_thread&.join
290
+ PortLifecycle.close(@ack_port)
291
+ @port = @worker = nil if worker_stopped
292
+ @ack_port = nil
293
+ @ack_thread = nil
242
294
  end
295
+
296
+ worker_stopped
243
297
  end
244
298
 
245
- def closed? = @closed.get
299
+ def wait_for_worker(timeout)
300
+ PortLifecycle.with_port do |timeout_port|
301
+ @scheduler.with_timeout(timeout_port, timeout: timeout) do
302
+ selected, = ::Ractor.select(@worker, timeout_port)
303
+ selected.equal?(@worker)
304
+ end
305
+ end
306
+ end
246
307
 
247
- def close_ports
248
- @port&.send({ command: :close_worker }) unless @port&.closed?
249
- rescue StandardError
250
- nil
251
- ensure
252
- PortLifecycle.close(@port) if @port
253
- PortLifecycle.close(@ack_port) if @ack_port
308
+ def lifecycle_timeout(timeout)
309
+ Core::Validation.validate_timeout!(timeout, name: :timeout)
310
+ timeout || @request_timeout
254
311
  end
255
312
 
256
313
  def drop(reason, record)
@@ -264,39 +321,23 @@ module Julewire
264
321
  end
265
322
 
266
323
  def record_loss(reason, record)
267
- metadata = Core::Records::Metadata.call(record)
268
324
  @health.record_loss(
269
325
  reason: reason,
270
- event: metadata[:event],
271
- severity: metadata[:severity],
272
- source: metadata[:source]
326
+ event: record.event,
327
+ severity: record.severity,
328
+ source: record.source
273
329
  )
274
330
  end
275
331
 
276
332
  def record_failure(error, **metadata)
277
- @health.record_failure(error, counter: nil, **metadata)
333
+ @health.record_failure(error, **metadata)
278
334
  Core::Diagnostics::CallbackNotifier.call(@on_failure, error, { destination: name }.merge(metadata))
279
- rescue StandardError
280
- nil
281
335
  end
282
336
 
283
337
  def release_slot
284
- return unless @max_queue.positive?
285
-
286
- loop do
287
- current = @in_flight.value
288
- if current <= 0
289
- # Late or duplicate ACKs can arrive after teardown/reset; keep them visible
290
- # without treating the ignored underflow as an operator-facing defect.
291
- increment(:slot_underflow_ignored)
292
- return
293
- end
294
- return if @in_flight.compare_and_set(current, current - 1)
295
- end
296
- end
297
-
298
- def decrement_in_flight
299
- release_slot
338
+ # Late or duplicate ACKs can arrive after teardown/reset; keep them visible
339
+ # without treating the ignored underflow as an operator-facing defect.
340
+ increment(:slot_underflow_ignored) if @queue_slots.release
300
341
  end
301
342
 
302
343
  def increment(key)
@@ -305,10 +346,8 @@ module Julewire
305
346
 
306
347
  def status_for(worker)
307
348
  return :closed if closed?
308
- return :degraded if @health.degraded?
309
- return :degraded if worker.is_a?(Hash) && worker[:status] == :degraded
310
349
 
311
- :ok
350
+ :degraded if worker&.fetch(:status) == :degraded
312
351
  end
313
352
  end
314
353
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # :nocov:
4
3
  module Julewire
5
4
  module Ractor
6
5
  class DestinationWorker
@@ -47,27 +46,24 @@ module Julewire
47
46
  @ack_port = ack_port
48
47
  loop do
49
48
  message = command_port.receive
50
- break if close_message?(message)
49
+ current_command = command(message)
50
+ break if current_command == :close_worker
51
51
 
52
- break if dispatch(message) == :close
52
+ break if dispatch(message, current_command) == :close
53
53
  end
54
54
  ensure
55
55
  close_output
56
- ack(:closed)
57
56
  end
58
57
 
59
58
  private
60
59
 
61
- def close_message?(message)
62
- message.is_a?(Hash) && message[:command] == :close_worker
63
- end
64
-
65
- def dispatch(message)
66
- return unless message.is_a?(Hash)
67
-
68
- case message[:command]
60
+ def dispatch(message, current_command)
61
+ case current_command
69
62
  when :emit
70
- emit(message[:record])
63
+ emit(
64
+ message.fetch(:record),
65
+ parent_degradation_marker: message.fetch(:degradation_marker)
66
+ )
71
67
  when :flush
72
68
  reply_to(message, call_output_lifecycle(:flush))
73
69
  when :close
@@ -75,51 +71,47 @@ module Julewire
75
71
  :close
76
72
  when :health
77
73
  reply_to(message, health)
74
+ else
75
+ raise ArgumentError, "unknown ractor destination command: #{current_command.inspect}"
78
76
  end
79
- rescue StandardError => e
80
- record_failure(e, phase: :dispatch)
81
- reply_to(message, false)
82
77
  end
83
78
 
84
- def emit(record)
85
- ack(@write_step.call(record) == :accepted ? :accepted : :dropped)
86
- rescue StandardError => e
87
- record_failure(e, phase: :emit)
88
- ack(:dropped)
79
+ def command(message)
80
+ Core::Integration::Protocol.validate_symbol_hash(message)
81
+ message.fetch(:command)
82
+ end
83
+
84
+ def emit(record, parent_degradation_marker:)
85
+ accepted = @health.recover_if_successful { @write_step.call(record) }
86
+ ack(accepted ? :accepted : :dropped, degradation_marker: parent_degradation_marker)
89
87
  end
90
88
 
91
89
  def call_output_lifecycle(method_name)
92
- return close_lifecycle if method_name == :close
93
- return true unless @output.respond_to?(method_name)
90
+ return close_lifecycle_succeeds? if method_name == :close
94
91
 
95
- @output.public_send(method_name) != false
92
+ @health.recover_if_successful do
93
+ !@output.respond_to?(method_name) || @output.public_send(method_name) != false
94
+ end
96
95
  rescue StandardError => e
97
96
  record_failure(e, phase: :output_lifecycle, action: method_name)
98
97
  false
99
98
  end
100
99
 
101
- def close_lifecycle
102
- return true if output_closed?
100
+ def close_lifecycle_succeeds?
101
+ return true if @output.respond_to?(:closed?) && @output.closed?
103
102
  return @output.close != false if @close_output && @output.respond_to?(:close)
104
103
  return @output.flush != false if @output.respond_to?(:flush)
105
104
 
106
105
  true
107
- rescue StandardError => e
108
- record_failure(e, phase: :output_lifecycle, action: :close)
109
- false
110
106
  end
111
107
 
112
108
  def close_output
113
- return if output_closed?
109
+ return if @output.respond_to?(:closed?) && @output.closed?
114
110
  return unless @close_output && @output.respond_to?(:close)
115
111
 
116
112
  @output.close
117
- rescue StandardError => e
118
- record_failure(e, phase: :output_lifecycle, action: :close)
119
- end
120
-
121
- def output_closed?
122
- @output.respond_to?(:closed?) ? @output.closed? : false
113
+ rescue StandardError
114
+ nil
123
115
  end
124
116
 
125
117
  def health
@@ -131,7 +123,7 @@ module Julewire
131
123
  end
132
124
 
133
125
  def record_failure(error, **metadata)
134
- @health.record_failure(error, counter: nil, **metadata)
126
+ @health.record_failure(error, **metadata)
135
127
  end
136
128
 
137
129
  def record_loss(reason, **metadata)
@@ -139,7 +131,7 @@ module Julewire
139
131
  end
140
132
 
141
133
  def record_write_step_failure(error, metadata)
142
- record_failure(error, **recordless_metadata(metadata))
134
+ record_failure(error, **metadata)
143
135
  end
144
136
 
145
137
  def record_write_step_loss(reason, metadata)
@@ -156,15 +148,19 @@ module Julewire
156
148
  metadata.except(:record)
157
149
  end
158
150
 
159
- def ack(status)
160
- @ack_port.send({ event: :ack, status: status })
161
- rescue StandardError
162
- nil
151
+ def ack(status, degradation_marker:)
152
+ @ack_port.send({ degradation_marker: degradation_marker, event: :ack, status: status })
163
153
  end
164
154
 
165
155
  def reply_to(message, response)
166
- reply = message[:reply]
167
- reply.send(response) if reply.is_a?(::Ractor::Port)
156
+ reply = message.fetch(:reply)
157
+ raise TypeError, "ractor destination reply must be a Ractor::Port" unless reply.is_a?(::Ractor::Port)
158
+
159
+ send_reply(reply, response)
160
+ end
161
+
162
+ def send_reply(reply, response)
163
+ reply.send(response)
168
164
  rescue StandardError => e
169
165
  record_failure(e, phase: :reply)
170
166
  end
@@ -173,4 +169,3 @@ module Julewire
173
169
  private_constant :DestinationWorker
174
170
  end
175
171
  end
176
- # :nocov:
@@ -7,7 +7,9 @@ module Julewire
7
7
 
8
8
  def initialize(destinations:, name: :ractor_fanout, on_failure: nil)
9
9
  @name = Core::Destinations.normalize_name(name)
10
- @destinations = Array(destinations).map { normalize_destination(it) }.freeze
10
+ raise ArgumentError, "destinations must be an Array" unless destinations.instance_of?(Array)
11
+
12
+ @destinations = destinations.map { normalize_destination(it) }
11
13
  raise ArgumentError, "destinations must not be empty" if @destinations.empty?
12
14
 
13
15
  Core::Validation.validate_callable!(on_failure, name: :on_failure, allow_nil: true)
@@ -16,7 +18,9 @@ module Julewire
16
18
  end
17
19
 
18
20
  def emit(record)
19
- @destinations.each { emit_to_destination(it, record) }
21
+ @health.recover_if_successful do
22
+ @destinations.each { emit_to_destination(it, record) }
23
+ end
20
24
  nil
21
25
  end
22
26
 
@@ -29,10 +33,12 @@ module Julewire
29
33
  end
30
34
 
31
35
  def after_fork!
32
- @destinations.each do |destination|
33
- destination.after_fork! if destination.respond_to?(:after_fork!)
34
- rescue StandardError => e
35
- record_failure(e, action: :after_fork, destination: destination.name)
36
+ @health.recover_if_successful do
37
+ @destinations.each do |destination|
38
+ destination.after_fork! if destination.respond_to?(:after_fork!)
39
+ rescue StandardError => e
40
+ record_failure(e, action: :after_fork, destination: destination.name)
41
+ end
36
42
  end
37
43
  self
38
44
  end
@@ -61,14 +67,16 @@ module Julewire
61
67
  end
62
68
 
63
69
  def call_lifecycle(method_name, timeout:)
64
- ok = true
65
- @destinations.each do |destination|
66
- ok = false if destination.public_send(method_name, timeout: timeout) == false
67
- rescue StandardError => e
68
- record_failure(e, action: method_name, destination: destination.name)
69
- ok = false
70
+ @health.recover_if_successful do
71
+ ok = true
72
+ @destinations.each do |destination|
73
+ ok = false if destination.public_send(method_name, timeout: timeout) == false
74
+ rescue StandardError => e
75
+ record_failure(e, action: method_name, destination: destination.name)
76
+ ok = false
77
+ end
78
+ ok
70
79
  end
71
- ok
72
80
  end
73
81
 
74
82
  def destination_health(destination)
@@ -78,17 +86,16 @@ module Julewire
78
86
  end
79
87
 
80
88
  def health_status(destinations)
81
- return :degraded if @health.last_failure
82
- return :degraded if destinations.any? { |_name, health| health[:status] == :degraded || health[:phase] }
83
-
84
- :ok
89
+ :degraded if destinations.any? { |_name, health| health[:status] == :degraded || health[:phase] }
85
90
  end
86
91
 
87
92
  def record_failure(error, **metadata)
88
- @health.record_failure(error, counter: nil, phase: :ractor_fanout, **metadata)
89
- @on_failure&.call(error, **metadata, phase: :ractor_fanout)
90
- rescue StandardError
91
- nil
93
+ @health.record_failure(error, phase: :ractor_fanout, **metadata)
94
+ callback_result = Core::Diagnostics::CallbackNotifier.call(@on_failure, error,
95
+ metadata.merge(phase: :ractor_fanout))
96
+ return unless Core::Diagnostics::CallbackNotifier.failure?(callback_result)
97
+
98
+ @health.record_callback_failure(callback_result)
92
99
  end
93
100
  end
94
101
  end
@@ -4,11 +4,17 @@ module Julewire
4
4
  module Ractor
5
5
  module PortLifecycle
6
6
  class << self
7
+ def with_port(port = ::Ractor::Port.new)
8
+ yield port
9
+ ensure
10
+ close(port)
11
+ end
12
+
7
13
  def close(port)
8
14
  return unless port.respond_to?(:close)
9
- return if port.respond_to?(:closed?) && port.closed?
10
15
 
11
16
  port.close
17
+ nil
12
18
  rescue StandardError
13
19
  nil
14
20
  end