fiber_stream 0.2.0 → 0.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.
@@ -10,7 +10,18 @@ module FiberStream
10
10
  # is below `workers`, preserving bounded backpressure and ordered output.
11
11
  class RactorMapBoundary
12
12
  TERMINAL_RESULT_CAPACITY = 1
13
- READY_WAIT_INTERVAL = 0.001
13
+ Job = ::Data.define(:sequence, :value)
14
+ Shutdown = ::Data.define
15
+ Ready = ::Data.define(:worker_id)
16
+ WorkerValue = ::Data.define(:worker_id, :sequence, :value)
17
+ WorkerFailure = ::Data.define(:worker_id, :sequence, :kind, :cause_class_name, :cause_message)
18
+ Stopped = ::Data.define(:worker_id)
19
+ ResultValue = ::Data.define(:sequence, :value)
20
+ ResultDone = ::Data.define(:sequence)
21
+ ResultError = ::Data.define(:sequence, :error)
22
+
23
+ private_constant :Job, :Shutdown, :Ready, :WorkerValue, :WorkerFailure, :Stopped
24
+ private_constant :ResultValue, :ResultDone, :ResultError
14
25
 
15
26
  def initialize(upstream, workers, input_transfer, output_transfer, transform)
16
27
  @upstream = upstream
@@ -53,6 +64,8 @@ module FiberStream
53
64
  @done = true
54
65
  close_error = close_upstream
55
66
  close_admission(close_upstream: false)
67
+ close_ready_queue
68
+ close_result_queue
56
69
  request_worker_shutdown
57
70
  wait_for_workers
58
71
  close_error ||= @upstream_close_error
@@ -103,7 +116,7 @@ module FiberStream
103
116
  break unless worker
104
117
 
105
118
  message = pull_job_message
106
- if message.fetch(0) == :job
119
+ if message.is_a?(Job)
107
120
  @in_flight += 1
108
121
  break unless deliver_job(worker, message)
109
122
  else
@@ -120,19 +133,23 @@ module FiberStream
120
133
 
121
134
  sequence = @next_sequence
122
135
  @next_sequence += 1
123
- [:job, sequence, value]
136
+ Job.new(sequence, value)
124
137
  rescue StandardError => error
125
138
  close_upstream(record_error: false)
126
- [:error, @next_sequence, error]
139
+ ResultError.new(sequence: @next_sequence, error:)
127
140
  end
128
141
 
129
142
  def terminal_done_message
130
143
  close_error = close_upstream
131
- close_error ? [:error, @next_sequence, close_error] : [:done, @next_sequence]
144
+ if close_error
145
+ ResultError.new(sequence: @next_sequence, error: close_error)
146
+ else
147
+ ResultDone.new(sequence: @next_sequence)
148
+ end
132
149
  end
133
150
 
134
151
  def deliver_job(worker, message)
135
- sequence = message.fetch(1)
152
+ sequence = message.sequence
136
153
  track_worker_job(worker, sequence)
137
154
 
138
155
  if @input_transfer == :move
@@ -143,8 +160,7 @@ module FiberStream
143
160
  true
144
161
  rescue StandardError => error
145
162
  clear_worker_job(worker)
146
- sequence = message.fetch(1)
147
- record_result([:error, sequence, build_ractor_map_error(sequence, :input_transfer, error)])
163
+ record_result(ResultError.new(sequence:, error: build_ractor_map_error(sequence, :input_transfer, error)))
148
164
  false
149
165
  end
150
166
 
@@ -162,28 +178,23 @@ module FiberStream
162
178
  end
163
179
 
164
180
  def emit(message)
165
- case message.fetch(0)
166
- when :value
167
- emit_value(message)
168
- when :done
181
+ case message
182
+ in ResultValue[sequence:, value:]
183
+ emit_value(sequence, value)
184
+ in ResultDone
169
185
  complete
170
- when :error
171
- fail_with_ordered_error(message)
186
+ in ResultError[sequence:, error:]
187
+ fail_with_ordered_error(sequence, error)
172
188
  end
173
189
  end
174
190
 
175
- def emit_value(message)
176
- sequence = message.fetch(1)
177
- value = message.fetch(2)
191
+ def emit_value(sequence, value)
178
192
  @next_emit_sequence = sequence + 1
179
193
  @in_flight -= 1 if @in_flight.positive?
180
194
  value
181
195
  end
182
196
 
183
- def fail_with_ordered_error(message)
184
- sequence = message.fetch(1)
185
- error = message.fetch(2)
186
-
197
+ def fail_with_ordered_error(sequence, error)
187
198
  if @failure_sequence && sequence > @failure_sequence
188
199
  @next_emit_sequence = sequence + 1
189
200
  @in_flight -= 1 if @in_flight.positive?
@@ -207,14 +218,14 @@ module FiberStream
207
218
  end
208
219
 
209
220
  def record_result(message)
210
- if message.fetch(0) == :error
211
- sequence = message.fetch(1)
221
+ if message.is_a?(ResultError)
222
+ sequence = message.sequence
212
223
  @failure_sequence = sequence if @failure_sequence.nil? || sequence < @failure_sequence
213
224
  close_admission
214
225
  request_worker_shutdown
215
226
  end
216
227
 
217
- @pending[message.fetch(1)] = message
228
+ @pending[message.sequence] = message
218
229
  end
219
230
 
220
231
  def drain_available_results
@@ -282,39 +293,39 @@ module FiberStream
282
293
  end
283
294
 
284
295
  def handle_worker_message(message, live_workers)
285
- case message.fetch(0)
286
- when :ready
287
- deliver_ready_worker(message.fetch(1))
296
+ case message
297
+ in Ready[worker_id]
298
+ deliver_ready_worker(worker_id)
288
299
  0
289
- when :value
300
+ in WorkerValue
290
301
  handle_worker_value_message(message)
291
302
  0
292
- when :error
303
+ in WorkerFailure
293
304
  handle_worker_error_message(message)
294
305
  0
295
- when :stopped
306
+ in Stopped
296
307
  handle_worker_stopped_message(message, live_workers)
297
308
  end
298
309
  end
299
310
 
300
311
  def handle_worker_value_message(message)
301
- worker = worker_for_id(message.fetch(1))
302
- sequence = message.fetch(2)
303
- value = message.fetch(3)
312
+ worker = worker_for_id(message.worker_id)
313
+ sequence = message.sequence
314
+ value = message.value
304
315
 
305
316
  clear_worker_job(worker)
306
- deliver_result([:value, sequence, value])
317
+ deliver_result(ResultValue.new(sequence:, value:))
307
318
  end
308
319
 
309
320
  def handle_worker_error_message(message)
310
- worker = worker_for_id(message.fetch(1))
321
+ worker = worker_for_id(message.worker_id)
311
322
 
312
323
  clear_worker_job(worker)
313
324
  deliver_result(normalize_worker_error_message(message))
314
325
  end
315
326
 
316
327
  def handle_worker_stopped_message(message, live_workers)
317
- worker = worker_for_id(message.fetch(1))
328
+ worker = worker_for_id(message.worker_id)
318
329
  live_workers.delete(worker)
319
330
  sequence = clear_worker_job(worker)
320
331
  deliver_worker_termination_error(worker, sequence) if sequence && !@closed && !@worker_shutdown_sent
@@ -339,38 +350,34 @@ module FiberStream
339
350
  cause: cause
340
351
  )
341
352
 
342
- deliver_result([:error, sequence, error])
353
+ deliver_result(ResultError.new(sequence:, error:))
343
354
  end
344
355
 
345
356
  def deliver_ready_worker(worker_id)
346
357
  return if @closed
347
358
 
348
- push_until_delivered_or_closed(@ready_workers, worker_for_id(worker_id), suppress_data: false)
359
+ push_until_delivered_or_closed(@ready_workers, worker_for_id(worker_id))
349
360
  end
350
361
 
351
362
  def deliver_result(message)
352
363
  return if @closed
353
364
 
354
- push_until_delivered_or_closed(@results, message, suppress_data: true)
365
+ push_until_delivered_or_closed(@results, message)
355
366
  end
356
367
 
357
- def push_until_delivered_or_closed(queue, message, suppress_data:)
358
- loop do
359
- return if @closed && suppress_data
360
- return if @closed && !suppress_data
368
+ def push_until_delivered_or_closed(queue, message)
369
+ return if @closed
361
370
 
362
- queue.push(message, true)
363
- return
364
- rescue ThreadError, ClosedQueueError
365
- sleep READY_WAIT_INTERVAL
366
- end
371
+ queue.push(message)
372
+ rescue ThreadError, ClosedQueueError
373
+ nil
367
374
  end
368
375
 
369
376
  def normalize_worker_error_message(message)
370
- sequence = message.fetch(2)
371
- kind = message.fetch(3)
372
- cause_class_name = message.fetch(4)
373
- cause_message = message.fetch(5)
377
+ sequence = message.sequence
378
+ kind = message.kind
379
+ cause_class_name = message.cause_class_name
380
+ cause_message = message.cause_message
374
381
  error =
375
382
  RactorMapError.new(
376
383
  sequence: sequence,
@@ -379,7 +386,7 @@ module FiberStream
379
386
  cause_message: cause_message
380
387
  )
381
388
 
382
- [:error, sequence, error]
389
+ ResultError.new(sequence:, error:)
383
390
  end
384
391
 
385
392
  def worker_for_id(worker_id)
@@ -411,7 +418,7 @@ module FiberStream
411
418
 
412
419
  @worker_shutdown_sent = true
413
420
  @workers.each do |worker|
414
- worker.send([:shutdown])
421
+ worker.send(Shutdown.new)
415
422
  rescue StandardError
416
423
  nil
417
424
  end
@@ -420,7 +427,6 @@ module FiberStream
420
427
  def wait_for_workers
421
428
  return unless @coordinator
422
429
 
423
- sleep READY_WAIT_INTERVAL while @coordinator.alive?
424
430
  @coordinator.join
425
431
  end
426
432
 
@@ -456,40 +462,58 @@ module FiberStream
456
462
  def self.spawn_worker(worker_id, result_port, transform, output_transfer)
457
463
  Ractor.new(worker_id, result_port, transform, output_transfer) do |id, port, mapper, transfer|
458
464
  current_sequence = nil
465
+ send_control =
466
+ lambda do |message|
467
+ port.send(message)
468
+ true
469
+ rescue Exception # rubocop:disable Lint/RescueException
470
+ false
471
+ end
472
+ send_failure =
473
+ lambda do |sequence, kind, error|
474
+ send_control.call(WorkerFailure.new(id, sequence, kind, error.class.name, error.message))
475
+ rescue Exception # rubocop:disable Lint/RescueException
476
+ false
477
+ end
459
478
 
460
479
  begin
461
- port.send([:ready, id])
462
-
463
- loop do
464
- message = Ractor.receive
465
- break if message.fetch(0) == :shutdown
466
-
467
- current_sequence = message.fetch(1)
468
- value = message.fetch(2)
469
- begin
470
- mapped_value = mapper.call(value)
471
- rescue Exception => error # rubocop:disable Lint/RescueException
472
- port.send([:error, id, current_sequence, :worker, error.class.name, error.message])
473
- else
480
+ if send_control.call(Ready.new(id))
481
+ loop do
482
+ message = Ractor.receive
483
+ case message
484
+ in Shutdown
485
+ break
486
+ in Job[sequence, value]
487
+ current_sequence = sequence
488
+ else
489
+ raise TypeError, "invalid ractor_map worker message: #{message.class}"
490
+ end
491
+
474
492
  begin
475
- if transfer == :move
476
- port.send([:value, id, current_sequence, mapped_value], move: true)
477
- else
478
- port.send([:value, id, current_sequence, mapped_value])
479
- end
493
+ mapped_value = mapper.call(value)
480
494
  rescue Exception => error # rubocop:disable Lint/RescueException
481
- port.send([:error, id, current_sequence, :output_transfer, error.class.name, error.message])
495
+ break unless send_failure.call(current_sequence, :worker, error)
496
+ else
497
+ begin
498
+ if transfer == :move
499
+ port.send(WorkerValue.new(id, current_sequence, mapped_value), move: true)
500
+ else
501
+ port.send(WorkerValue.new(id, current_sequence, mapped_value))
502
+ end
503
+ rescue Exception => error # rubocop:disable Lint/RescueException
504
+ break unless send_failure.call(current_sequence, :output_transfer, error)
505
+ end
482
506
  end
483
- end
484
507
 
485
- current_sequence = nil
486
- port.send([:ready, id])
508
+ current_sequence = nil
509
+ break unless send_control.call(Ready.new(id))
510
+ end
487
511
  end
488
512
  rescue Exception => error # rubocop:disable Lint/RescueException
489
513
  sequence = current_sequence || -1
490
- port.send([:error, id, sequence, :worker_termination, error.class.name, error.message])
514
+ send_failure.call(sequence, :worker_termination, error)
491
515
  ensure
492
- port.send([:stopped, id])
516
+ send_control.call(Stopped.new(id))
493
517
  end
494
518
  end
495
519
  end
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberStream
4
+ module Pull
5
+ # Pull stream for `Source.ractor_merge_ports`.
6
+ #
7
+ # A coordinator thread owns blocking Ractor waits and forwards producer
8
+ # messages through a bounded result mailbox. Each producer receives at most
9
+ # one outstanding ack, and downstream demand replenishes only the producer
10
+ # that emitted the previous value.
11
+ class RactorMergePortsSource
12
+ PortPair = Data.define(:side, :port, :ack_port)
13
+ StartCommand = Data.define
14
+ RequestAckCommand = Data.define(:side)
15
+ ShutdownCommand = Data.define
16
+ ValueResult = Data.define(:side, :value)
17
+ DoneResult = Data.define(:side)
18
+ ErrorResult = Data.define(:side, :error)
19
+ private_constant :PortPair, :StartCommand, :RequestAckCommand, :ShutdownCommand
20
+ private_constant :ValueResult, :DoneResult, :ErrorResult
21
+
22
+ def initialize(port_pairs, ack_transfer, cancel)
23
+ @pairs = port_pairs.each_with_index.map do |pair, side|
24
+ PortPair.new(side:, port: pair.port, ack_port: pair.ack_port)
25
+ end.freeze
26
+ @ack_transfer = ack_transfer
27
+ @cancel_enabled = cancel
28
+ @result_mailbox = RactorMergeResultMailbox.new(@pairs.size)
29
+ @control_port = nil
30
+ @coordinator = nil
31
+ @state_mutex = Mutex.new
32
+ @producer_terminal = @pairs.to_h { |pair| [pair.side, false] }
33
+ @side_done = @pairs.to_h { |pair| [pair.side, false] }
34
+ @cancel_sent = @pairs.to_h { |pair| [pair.side, false] }
35
+ @pending_ack_sides = {}
36
+ @started = false
37
+ @closed = false
38
+ @done = false
39
+ end
40
+
41
+ def next
42
+ return DONE if closed_or_done?
43
+
44
+ start
45
+ request_pending_acks
46
+ next_result
47
+ end
48
+
49
+ def close
50
+ started = mark_closed
51
+ return if started.nil?
52
+ return unless started
53
+
54
+ close_result_mailbox
55
+ wake_coordinator
56
+ wait_for_coordinator
57
+ cancel_error = cancel_non_terminal_producers
58
+ raise cancel_error if cancel_error
59
+ end
60
+
61
+ private
62
+
63
+ def closed_or_done?
64
+ @state_mutex.synchronize { @closed || @done }
65
+ end
66
+
67
+ def mark_closed
68
+ already_closed = false
69
+ started = false
70
+
71
+ @state_mutex.synchronize do
72
+ already_closed = @closed
73
+ started = @started
74
+ @closed = true
75
+ @done = true
76
+ end
77
+
78
+ already_closed ? nil : started
79
+ end
80
+
81
+ def start
82
+ started_now = false
83
+
84
+ @state_mutex.synchronize do
85
+ return if @started || @closed
86
+
87
+ @control_port = Ractor::Port.new
88
+ @started = true
89
+ @coordinator = Thread.new { run_coordinator }
90
+ started_now = true
91
+ end
92
+
93
+ send_control_command(StartCommand.new) if started_now && !closed_or_done?
94
+ end
95
+
96
+ def request_pending_acks
97
+ sides =
98
+ @state_mutex.synchronize do
99
+ pending = @pending_ack_sides.keys
100
+ @pending_ack_sides.clear
101
+ pending
102
+ end
103
+
104
+ sides.each { |side| send_control_command(RequestAckCommand.new(side:)) }
105
+ end
106
+
107
+ def next_result
108
+ loop do
109
+ result = @result_mailbox.pop
110
+ return complete if result.nil?
111
+
112
+ case result
113
+ in ValueResult[side:, value:]
114
+ record_pending_ack(side)
115
+ return value
116
+ in DoneResult[side:]
117
+ mark_side_done(side)
118
+ return complete if all_done?
119
+ in ErrorResult[error:]
120
+ mark_done
121
+ raise_error(error)
122
+ end
123
+ end
124
+ rescue RactorMergeResultMailbox::Closed
125
+ complete
126
+ end
127
+
128
+ def record_pending_ack(side)
129
+ @state_mutex.synchronize do
130
+ @pending_ack_sides[side] = true unless @closed || @producer_terminal.fetch(side)
131
+ end
132
+ end
133
+
134
+ def mark_side_done(side)
135
+ @state_mutex.synchronize { @side_done[side] = true }
136
+ end
137
+
138
+ def all_done?
139
+ @state_mutex.synchronize { @side_done.values.all? }
140
+ end
141
+
142
+ def mark_done
143
+ @state_mutex.synchronize { @done = true }
144
+ end
145
+
146
+ def complete
147
+ mark_done
148
+ DONE
149
+ end
150
+
151
+ def run_coordinator
152
+ outstanding_ack = @pairs.to_h { |pair| [pair.side, false] }
153
+ active_ports = @pairs.map(&:port)
154
+ pair_by_port = @pairs.to_h { |pair| [pair.port, pair] }
155
+
156
+ loop do
157
+ break if active_ports.empty?
158
+
159
+ selected, message = Ractor.select(@control_port, *active_ports)
160
+ if selected == @control_port
161
+ break if handle_control_message(message, outstanding_ack)
162
+ else
163
+ pair = pair_by_port.fetch(selected)
164
+ outstanding_ack[pair.side] = false
165
+ result = build_result(pair, message)
166
+ active_ports.delete(pair.port) if producer_terminal?(pair.side)
167
+ deliver_result(result)
168
+ end
169
+ end
170
+ rescue StandardError => error
171
+ deliver_result(ErrorResult.new(side: nil, error: build_error(:receive, error)))
172
+ end
173
+
174
+ def handle_control_message(message, outstanding_ack)
175
+ case message
176
+ in StartCommand
177
+ @pairs.each { |pair| ack_pair(pair, outstanding_ack) }
178
+ false
179
+ in RequestAckCommand[side:]
180
+ pair = @pairs.fetch(side)
181
+ ack_pair(pair, outstanding_ack)
182
+ false
183
+ in ShutdownCommand
184
+ true
185
+ else
186
+ false
187
+ end
188
+ end
189
+
190
+ def ack_pair(pair, outstanding_ack)
191
+ return if outstanding_ack.fetch(pair.side)
192
+ return if producer_terminal?(pair.side)
193
+
194
+ ack_error = send_ack(pair)
195
+ if ack_error
196
+ deliver_result(ErrorResult.new(side: pair.side, error: ack_error))
197
+ else
198
+ outstanding_ack[pair.side] = true
199
+ end
200
+ end
201
+
202
+ def send_ack(pair)
203
+ send_control(pair.ack_port, RactorPort::Ack.new)
204
+ nil
205
+ rescue StandardError => error
206
+ build_error(:ack_transfer, error)
207
+ end
208
+
209
+ def build_result(pair, message)
210
+ side = pair.side
211
+
212
+ case message
213
+ in RactorPort::Element[value]
214
+ ValueResult.new(side:, value:)
215
+ in RactorPort::Complete
216
+ mark_producer_terminal(side)
217
+ DoneResult.new(side:)
218
+ in RactorPort::Failure[String => cause_class_name, String => cause_message]
219
+ mark_producer_terminal(side)
220
+ ErrorResult.new(
221
+ side:,
222
+ error: RactorPortSourceError.new(
223
+ kind: :producer_failure,
224
+ cause_class_name: cause_class_name,
225
+ cause_message: cause_message
226
+ )
227
+ )
228
+ in RactorPort::Failure
229
+ ErrorResult.new(side:, error: invalid_message_error(message, "Failure payloads must be Strings"))
230
+ else
231
+ ErrorResult.new(side:, error: invalid_message_error(message, "invalid RactorPort message"))
232
+ end
233
+ end
234
+
235
+ def invalid_message_error(message, cause_message)
236
+ RactorPortSourceError.new(
237
+ kind: :invalid_message,
238
+ cause_class_name: message.class.name,
239
+ cause_message: cause_message
240
+ )
241
+ end
242
+
243
+ def build_error(kind, error)
244
+ RactorPortSourceError.new(
245
+ kind: kind,
246
+ cause_class_name: error.class.name,
247
+ cause_message: error.message,
248
+ cause: error
249
+ )
250
+ end
251
+
252
+ def mark_producer_terminal(side)
253
+ @state_mutex.synchronize { @producer_terminal[side] = true }
254
+ end
255
+
256
+ def producer_terminal?(side)
257
+ @state_mutex.synchronize { @producer_terminal.fetch(side) }
258
+ end
259
+
260
+ def cancel_non_terminal_producers
261
+ first_error = nil
262
+
263
+ @pairs.each do |pair|
264
+ next unless should_cancel_pair?(pair)
265
+
266
+ cancel_error = send_cancel(pair)
267
+ first_error ||= cancel_error
268
+ end
269
+
270
+ first_error
271
+ end
272
+
273
+ def should_cancel_pair?(pair)
274
+ @state_mutex.synchronize do
275
+ return false unless @cancel_enabled
276
+ return false if @producer_terminal.fetch(pair.side)
277
+ return false if @cancel_sent.fetch(pair.side)
278
+
279
+ @cancel_sent[pair.side] = true
280
+ true
281
+ end
282
+ end
283
+
284
+ def send_cancel(pair)
285
+ send_control(pair.ack_port, RactorPort::Cancel.new(:closed))
286
+ nil
287
+ rescue StandardError => error
288
+ build_error(:cancel_transfer, error)
289
+ end
290
+
291
+ def send_control(port, message)
292
+ if @ack_transfer == :move
293
+ port.send(message, move: true)
294
+ else
295
+ port.send(message)
296
+ end
297
+ end
298
+
299
+ def raise_error(error)
300
+ if error.is_a?(RactorPortSourceError) && error.original_cause
301
+ raise error, cause: error.original_cause
302
+ end
303
+
304
+ raise error
305
+ end
306
+
307
+ def deliver_result(result)
308
+ @result_mailbox.push(result)
309
+ rescue RactorMergeResultMailbox::Closed
310
+ nil
311
+ end
312
+
313
+ def send_control_command(command)
314
+ @control_port&.send(command)
315
+ rescue StandardError
316
+ nil
317
+ end
318
+
319
+ def wake_coordinator
320
+ send_control_command(ShutdownCommand.new)
321
+ end
322
+
323
+ def wait_for_coordinator
324
+ return unless @coordinator
325
+
326
+ @coordinator.join
327
+ end
328
+
329
+ def close_result_mailbox
330
+ @result_mailbox.close
331
+ end
332
+
333
+ class RactorMergeResultMailbox
334
+ Closed = Class.new(StandardError)
335
+
336
+ def initialize(capacity)
337
+ @queue = Thread::SizedQueue.new(capacity)
338
+ end
339
+
340
+ def push(result)
341
+ @queue << result
342
+ rescue ClosedQueueError
343
+ raise Closed
344
+ end
345
+
346
+ def pop
347
+ @queue.pop
348
+ rescue ClosedQueueError
349
+ raise Closed
350
+ end
351
+
352
+ def close
353
+ @queue.close
354
+ end
355
+ end
356
+ end
357
+ end
358
+ end