fiber_stream 0.4.0 → 0.6.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.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberStream
4
+ module Pull
5
+ # One-to-many mapping stage.
6
+ #
7
+ # It expands one upstream element into the values yielded by one returned
8
+ # `#each` object. Only one expansion is active at a time, and the stage
9
+ # never pulls the next upstream element until the active expansion is
10
+ # exhausted.
11
+ class MapConcat
12
+ def initialize(upstream, transform)
13
+ @upstream = upstream
14
+ @transform = transform
15
+ @current_enumerator = nil
16
+ @closed = false
17
+ @done = false
18
+ end
19
+
20
+ def next
21
+ return DONE if @closed || @done
22
+
23
+ loop do
24
+ if @current_enumerator
25
+ begin
26
+ return @current_enumerator.next
27
+ rescue StopIteration
28
+ @current_enumerator = nil
29
+ end
30
+ end
31
+
32
+ value = @upstream.next
33
+ if Pull.done?(value)
34
+ @done = true
35
+ return DONE
36
+ end
37
+
38
+ result = @transform.call(value)
39
+ unless result.respond_to?(:each)
40
+ raise TypeError, "map_concat block result must respond to each"
41
+ end
42
+
43
+ @current_enumerator = result.to_enum(:each)
44
+ end
45
+ end
46
+
47
+ def close
48
+ return if @closed
49
+
50
+ @closed = true
51
+ @current_enumerator = nil
52
+ @upstream.close
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,521 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberStream
4
+ module Pull
5
+ # Unordered Ractor-backed worker boundary for
6
+ # `Flow.ractor_unordered_map`.
7
+ #
8
+ # Upstream is pulled by the downstream caller, while blocking waits for
9
+ # Ractor worker messages are isolated in a coordinator thread. Downstream
10
+ # emits worker results in completion order and admission stays bounded by
11
+ # the number of workers.
12
+ class RactorUnorderedMapBoundary
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
21
+ ResultCloseError = ::Data.define(:sequence, :error)
22
+ ResultError = ::Data.define(:sequence, :error)
23
+
24
+ private_constant :Job, :Shutdown, :Ready, :WorkerValue, :WorkerFailure, :Stopped
25
+ private_constant :ResultValue, :ResultDone, :ResultCloseError, :ResultError
26
+
27
+ def initialize(upstream, workers, input_transfer, output_transfer, transform)
28
+ @upstream = upstream
29
+ @workers_count = workers
30
+ @input_transfer = input_transfer
31
+ @output_transfer = output_transfer
32
+ @transform = transform
33
+ @result_port = nil
34
+ @ready_workers = Thread::SizedQueue.new(workers)
35
+ @results = Thread::SizedQueue.new(workers)
36
+ @workers = []
37
+ @active_sequences = {}
38
+ @worker_state_mutex = Mutex.new
39
+ @coordinator = nil
40
+ @next_sequence = 0
41
+ @outstanding_jobs = 0
42
+ @terminal_message = nil
43
+ @started = false
44
+ @closed = false
45
+ @done = false
46
+ @admission_closed = false
47
+ @worker_shutdown_sent = false
48
+ @upstream_closed = false
49
+ @upstream_close_error = nil
50
+ end
51
+
52
+ def next
53
+ return DONE if @closed || @done
54
+
55
+ start
56
+ next_message
57
+ end
58
+
59
+ def close
60
+ return if @closed
61
+
62
+ @closed = true
63
+ @done = true
64
+ close_error = close_upstream
65
+ close_admission(close_upstream: false)
66
+ close_ready_queue
67
+ close_result_queue
68
+ request_worker_shutdown
69
+ wait_for_workers
70
+ close_error ||= @upstream_close_error
71
+ raise close_error if close_error
72
+ end
73
+
74
+ private
75
+
76
+ def start
77
+ return if @started
78
+
79
+ @started = true
80
+ @result_port = Ractor::Port.new
81
+ @workers_count.times do |worker_id|
82
+ @workers << self.class.spawn_worker(
83
+ worker_id,
84
+ @result_port,
85
+ @transform,
86
+ @output_transfer
87
+ )
88
+ end
89
+ @coordinator = Thread.new { run_coordinator }
90
+ end
91
+
92
+ def next_message
93
+ return emit_terminal(@terminal_message) if terminal_ready?
94
+
95
+ ready = take_result(block: false)
96
+ return emit(ready) if ready
97
+
98
+ ready = fill_capacity
99
+ return emit(ready) if ready
100
+
101
+ return emit_terminal(@terminal_message) if terminal_ready?
102
+
103
+ message = take_result(block: true)
104
+ return complete unless message
105
+
106
+ emit(message)
107
+ end
108
+
109
+ def fill_capacity
110
+ return if @admission_closed
111
+
112
+ while @outstanding_jobs < @workers_count
113
+ ready = take_result(block: false)
114
+ return ready if ready
115
+
116
+ worker = take_ready_worker(block: @outstanding_jobs.zero? && @terminal_message.nil?)
117
+ break unless worker
118
+
119
+ message = pull_job_message
120
+ if message.is_a?(Job)
121
+ @outstanding_jobs += 1
122
+ break unless deliver_job(worker, message)
123
+ elsif upstream_failure?(message)
124
+ fail_with_error(message.sequence, message.error)
125
+ else
126
+ close_admission(close_upstream: false)
127
+ @terminal_message = message
128
+ break
129
+ end
130
+ end
131
+
132
+ nil
133
+ end
134
+
135
+ def pull_job_message
136
+ value = @upstream.next
137
+ return terminal_done_message if Pull.done?(value)
138
+
139
+ sequence = @next_sequence
140
+ @next_sequence += 1
141
+ Job.new(sequence, value)
142
+ rescue StandardError => error
143
+ close_upstream(record_error: false)
144
+ ResultError.new(sequence: @next_sequence, error:)
145
+ end
146
+
147
+ def terminal_done_message
148
+ close_error = close_upstream
149
+ if close_error
150
+ ResultCloseError.new(sequence: @next_sequence, error: close_error)
151
+ else
152
+ ResultDone.new
153
+ end
154
+ end
155
+
156
+ def deliver_job(worker, message)
157
+ sequence = message.sequence
158
+ track_worker_job(worker, sequence)
159
+
160
+ if @input_transfer == :move
161
+ worker.send(message, move: true)
162
+ else
163
+ worker.send(message)
164
+ end
165
+ true
166
+ rescue StandardError => error
167
+ clear_worker_job(worker)
168
+ deliver_result(ResultError.new(sequence:, error: build_ractor_map_error(sequence, :input_transfer, error)))
169
+ close_admission
170
+ request_worker_shutdown
171
+ false
172
+ end
173
+
174
+ def take_ready_worker(block:)
175
+ if block
176
+ loop do
177
+ worker = @ready_workers.pop
178
+ return worker if worker || @closed || @admission_closed || @ready_workers.closed?
179
+ end
180
+ else
181
+ @ready_workers.pop(true)
182
+ end
183
+ rescue ThreadError, ClosedQueueError
184
+ nil
185
+ end
186
+
187
+ def take_result(block:)
188
+ block ? @results.pop : @results.pop(true)
189
+ rescue ThreadError, ClosedQueueError
190
+ nil
191
+ end
192
+
193
+ def emit(message)
194
+ case message
195
+ in ResultValue[_sequence, value]
196
+ emit_value(value)
197
+ in ResultError[sequence:, error:]
198
+ fail_with_error(sequence, error)
199
+ end
200
+ end
201
+
202
+ def emit_value(value)
203
+ @outstanding_jobs -= 1 if @outstanding_jobs.positive?
204
+ value
205
+ end
206
+
207
+ def terminal_ready?
208
+ @terminal_message && @outstanding_jobs.zero?
209
+ end
210
+
211
+ def emit_terminal(message)
212
+ case message
213
+ in ResultDone
214
+ complete
215
+ in ResultCloseError[sequence:, error:]
216
+ fail_with_error(sequence, error, close_admission: false)
217
+ in ResultError[sequence:, error:]
218
+ fail_with_error(sequence, error, close_admission: false)
219
+ end
220
+ end
221
+
222
+ def upstream_failure?(message)
223
+ message.is_a?(ResultError)
224
+ end
225
+
226
+ def fail_with_error(_sequence, error, close_admission: true)
227
+ @done = true
228
+ close_admission() if close_admission
229
+ close_result_queue
230
+ request_worker_shutdown
231
+ if error.is_a?(RactorMapError) && error.original_cause
232
+ raise error, cause: error.original_cause
233
+ end
234
+
235
+ raise error
236
+ end
237
+
238
+ def complete
239
+ @done = true
240
+ request_worker_shutdown
241
+ DONE
242
+ end
243
+
244
+ def run_coordinator
245
+ stopped = 0
246
+ live_workers = @workers.dup
247
+
248
+ until stopped == @workers_count
249
+ selected, message = select_worker_message(live_workers)
250
+ if selected == :worker_remote_error
251
+ stopped += 1
252
+ elsif selected == @result_port
253
+ stopped += handle_worker_message(message, live_workers)
254
+ else
255
+ live_workers.delete(selected)
256
+ handle_worker_termination(selected)
257
+ stopped += 1
258
+ end
259
+ end
260
+ ensure
261
+ close_ready_queue
262
+ close_result_queue if @closed
263
+ end
264
+
265
+ def select_worker_message(live_workers)
266
+ Ractor.select(@result_port, *live_workers)
267
+ rescue Ractor::RemoteError => error
268
+ worker = remote_error_worker(error, live_workers) || failed_worker_for_remote_error(live_workers)
269
+ live_workers.delete(worker) if worker
270
+ handle_worker_remote_error(worker, error)
271
+ [:worker_remote_error, nil]
272
+ end
273
+
274
+ def remote_error_worker(error, live_workers)
275
+ return unless error.respond_to?(:ractor)
276
+
277
+ worker = error.ractor
278
+ live_workers.include?(worker) ? worker : nil
279
+ end
280
+
281
+ def failed_worker_for_remote_error(live_workers)
282
+ @worker_state_mutex.synchronize do
283
+ live_workers
284
+ .select { |worker| @active_sequences.key?(worker) }
285
+ .min_by { |worker| @active_sequences.fetch(worker) }
286
+ end || live_workers.first
287
+ end
288
+
289
+ def handle_worker_remote_error(worker, error)
290
+ sequence = worker ? clear_worker_job(worker) : nil
291
+ sequence ||= @next_sequence
292
+ return if @closed || @worker_shutdown_sent
293
+
294
+ deliver_worker_termination_error(worker, sequence, cause: error)
295
+ end
296
+
297
+ def handle_worker_message(message, live_workers)
298
+ case message
299
+ in Ready[worker_id]
300
+ deliver_ready_worker(worker_id)
301
+ 0
302
+ in WorkerValue
303
+ handle_worker_value_message(message)
304
+ 0
305
+ in WorkerFailure
306
+ handle_worker_error_message(message)
307
+ 0
308
+ in Stopped
309
+ handle_worker_stopped_message(message, live_workers)
310
+ end
311
+ end
312
+
313
+ def handle_worker_value_message(message)
314
+ worker = worker_for_id(message.worker_id)
315
+
316
+ clear_worker_job(worker)
317
+ deliver_result(ResultValue.new(sequence: message.sequence, value: message.value))
318
+ end
319
+
320
+ def handle_worker_error_message(message)
321
+ worker = worker_for_id(message.worker_id)
322
+
323
+ clear_worker_job(worker)
324
+ deliver_result(normalize_worker_error_message(message))
325
+ end
326
+
327
+ def handle_worker_stopped_message(message, live_workers)
328
+ worker = worker_for_id(message.worker_id)
329
+ live_workers.delete(worker)
330
+ sequence = clear_worker_job(worker)
331
+ deliver_worker_termination_error(worker, sequence) if sequence && !@closed && !@worker_shutdown_sent
332
+ 1
333
+ end
334
+
335
+ def handle_worker_termination(worker)
336
+ sequence = clear_worker_job(worker) || @next_sequence
337
+ return if @closed || @worker_shutdown_sent
338
+
339
+ deliver_worker_termination_error(worker, sequence)
340
+ end
341
+
342
+ def deliver_worker_termination_error(worker, sequence, cause: nil)
343
+ close_ready_queue
344
+ error =
345
+ RactorMapError.new(
346
+ sequence: sequence,
347
+ kind: :worker_termination,
348
+ cause_class_name: cause&.class&.name || worker.class.name,
349
+ cause_message: cause&.message || "worker terminated without a lifecycle message",
350
+ cause: cause
351
+ )
352
+
353
+ deliver_result(ResultError.new(sequence:, error:))
354
+ end
355
+
356
+ def deliver_ready_worker(worker_id)
357
+ return if @closed
358
+
359
+ push_until_delivered_or_closed(@ready_workers, worker_for_id(worker_id))
360
+ end
361
+
362
+ def deliver_result(message)
363
+ return if @closed
364
+
365
+ push_until_delivered_or_closed(@results, message)
366
+ end
367
+
368
+ def push_until_delivered_or_closed(queue, message)
369
+ return if @closed
370
+
371
+ queue.push(message)
372
+ rescue ThreadError, ClosedQueueError
373
+ nil
374
+ end
375
+
376
+ def normalize_worker_error_message(message)
377
+ sequence = message.sequence
378
+ error =
379
+ RactorMapError.new(
380
+ sequence: sequence,
381
+ kind: message.kind,
382
+ cause_class_name: message.cause_class_name,
383
+ cause_message: message.cause_message
384
+ )
385
+
386
+ ResultError.new(sequence:, error:)
387
+ end
388
+
389
+ def worker_for_id(worker_id)
390
+ @workers.fetch(worker_id)
391
+ end
392
+
393
+ def track_worker_job(worker, sequence)
394
+ @worker_state_mutex.synchronize do
395
+ @active_sequences[worker] = sequence
396
+ end
397
+ end
398
+
399
+ def clear_worker_job(worker)
400
+ @worker_state_mutex.synchronize do
401
+ @active_sequences.delete(worker)
402
+ end
403
+ end
404
+
405
+ def close_admission(close_upstream: true)
406
+ return if @admission_closed
407
+
408
+ @admission_closed = true
409
+ close_upstream(record_error: false) if close_upstream
410
+ end
411
+
412
+ def request_worker_shutdown
413
+ return unless @started
414
+ return if @worker_shutdown_sent
415
+
416
+ @worker_shutdown_sent = true
417
+ @workers.each do |worker|
418
+ worker.send(Shutdown.new)
419
+ rescue StandardError
420
+ nil
421
+ end
422
+ end
423
+
424
+ def wait_for_workers
425
+ return unless @coordinator
426
+
427
+ @coordinator.join
428
+ end
429
+
430
+ def close_upstream(record_error: true)
431
+ return nil if @upstream_closed
432
+
433
+ @upstream_closed = true
434
+ @upstream.close
435
+ nil
436
+ rescue StandardError => error
437
+ @upstream_close_error ||= error if record_error
438
+ error
439
+ end
440
+
441
+ def close_ready_queue
442
+ @ready_workers.close
443
+ end
444
+
445
+ def close_result_queue
446
+ @results.close
447
+ end
448
+
449
+ def build_ractor_map_error(sequence, kind, error)
450
+ RactorMapError.new(
451
+ sequence: sequence,
452
+ kind: kind,
453
+ cause_class_name: error.class.name,
454
+ cause_message: error.message,
455
+ cause: error
456
+ )
457
+ end
458
+
459
+ class << self
460
+ def spawn_worker(worker_id, result_port, transform, output_transfer) # :nodoc:
461
+ Ractor.new(worker_id, result_port, transform, output_transfer) do |id, port, mapper, transfer|
462
+ current_sequence = nil
463
+ send_control =
464
+ lambda do |message|
465
+ port.send(message)
466
+ true
467
+ rescue Exception # rubocop:disable Lint/RescueException
468
+ false
469
+ end
470
+ send_failure =
471
+ lambda do |sequence, kind, error|
472
+ send_control.call(WorkerFailure.new(id, sequence, kind, error.class.name, error.message))
473
+ rescue Exception # rubocop:disable Lint/RescueException
474
+ false
475
+ end
476
+
477
+ begin
478
+ if send_control.call(Ready.new(id))
479
+ loop do
480
+ message = Ractor.receive
481
+ case message
482
+ in Shutdown
483
+ break
484
+ in Job[sequence, value]
485
+ current_sequence = sequence
486
+ else
487
+ raise TypeError, "invalid ractor_unordered_map worker message: #{message.class}"
488
+ end
489
+
490
+ begin
491
+ mapped_value = mapper.call(value)
492
+ rescue Exception => error # rubocop:disable Lint/RescueException
493
+ break unless send_failure.call(current_sequence, :worker, error)
494
+ else
495
+ begin
496
+ if transfer == :move
497
+ port.send(WorkerValue.new(id, current_sequence, mapped_value), move: true)
498
+ else
499
+ port.send(WorkerValue.new(id, current_sequence, mapped_value))
500
+ end
501
+ rescue Exception => error # rubocop:disable Lint/RescueException
502
+ break unless send_failure.call(current_sequence, :output_transfer, error)
503
+ end
504
+ end
505
+
506
+ current_sequence = nil
507
+ break unless send_control.call(Ready.new(id))
508
+ end
509
+ end
510
+ rescue Exception => error # rubocop:disable Lint/RescueException
511
+ sequence = current_sequence || -1
512
+ send_failure.call(sequence, :worker_termination, error)
513
+ ensure
514
+ send_control.call(Stopped.new(id))
515
+ end
516
+ end
517
+ end
518
+ end
519
+ end
520
+ end
521
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberStream
4
+ module Pull
5
+ # Complement filtering stage.
6
+ #
7
+ # A single downstream demand may pull multiple upstream elements until the
8
+ # predicate retains a value or upstream completes. Rejected elements are
9
+ # discarded immediately and are not buffered.
10
+ class Reject
11
+ def initialize(upstream, predicate)
12
+ @upstream = upstream
13
+ @predicate = predicate
14
+ @closed = false
15
+ @done = false
16
+ end
17
+
18
+ def next
19
+ return DONE if @closed || @done
20
+
21
+ loop do
22
+ value = @upstream.next
23
+ if Pull.done?(value)
24
+ @done = true
25
+ return DONE
26
+ end
27
+
28
+ return value unless @predicate.call(value)
29
+ end
30
+ end
31
+
32
+ def close
33
+ return if @closed
34
+
35
+ @closed = true
36
+ @upstream.close
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberStream
4
+ module Pull
5
+ # Stateless observing stage.
6
+ #
7
+ # It pulls one upstream element for each downstream demand, calls the
8
+ # observer for real elements, and emits the original element unchanged.
9
+ class Tap
10
+ def initialize(upstream, observer)
11
+ @upstream = upstream
12
+ @observer = observer
13
+ @closed = false
14
+ @done = false
15
+ end
16
+
17
+ def next
18
+ return DONE if @closed || @done
19
+
20
+ value = @upstream.next
21
+ if Pull.done?(value)
22
+ @done = true
23
+ return DONE
24
+ end
25
+
26
+ @observer.call(value)
27
+ value
28
+ end
29
+
30
+ def close
31
+ return if @closed
32
+
33
+ @closed = true
34
+ @upstream.close
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberStream
4
+ module Pull
5
+ # Pull-driven rate-limiting stage.
6
+ #
7
+ # The stage pulls at most one upstream value, acquires one permit, and then
8
+ # emits that value unless the stage was closed while waiting.
9
+ class Throttle
10
+ def initialize(upstream, limiter)
11
+ @upstream = upstream
12
+ @limiter = limiter
13
+ @closed = false
14
+ @done = false
15
+ end
16
+
17
+ def next
18
+ return DONE if @closed || @done
19
+
20
+ value = @upstream.next
21
+ if Pull.done?(value)
22
+ @done = true
23
+ return DONE
24
+ end
25
+
26
+ @limiter.acquire(permits: 1)
27
+ if @closed
28
+ @done = true
29
+ return DONE
30
+ end
31
+
32
+ value
33
+ end
34
+
35
+ def close
36
+ return if @closed
37
+
38
+ @closed = true
39
+ @upstream.close
40
+ end
41
+ end
42
+ end
43
+ end