fiber_stream 0.1.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.
@@ -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
@@ -9,7 +9,10 @@ module FiberStream
9
9
  # a coordinator thread so scheduler-managed fibers do not call Ractor wait
10
10
  # APIs directly.
11
11
  class RactorPortSource
12
- WAIT_INTERVAL = 0.001
12
+ ProtocolMessage = Data.define(:message)
13
+ ErrorMessage = Data.define(:error)
14
+ ClosedMessage = Data.define
15
+ private_constant :ProtocolMessage, :ErrorMessage, :ClosedMessage
13
16
 
14
17
  def initialize(port, ack_port, ack_transfer, cancel)
15
18
  @port = port
@@ -81,15 +84,13 @@ module FiberStream
81
84
  end
82
85
 
83
86
  def handle_result(result)
84
- tag = result.fetch(0)
85
-
86
- case tag
87
- when :message
88
- handle_protocol_message(result.fetch(1))
89
- when :error
87
+ case result
88
+ in ProtocolMessage[message:]
89
+ handle_protocol_message(message)
90
+ in ErrorMessage[error:]
90
91
  mark_done
91
- raise_error(result.fetch(1))
92
- when :closed
92
+ raise_error(error)
93
+ in ClosedMessage
93
94
  DONE
94
95
  end
95
96
  end
@@ -151,19 +152,19 @@ module FiberStream
151
152
 
152
153
  ack_error = send_ack
153
154
  if ack_error
154
- deliver_result([:error, ack_error])
155
+ deliver_result(ErrorMessage.new(error: ack_error))
155
156
  break
156
157
  end
157
158
 
158
159
  selected, message = select_message
159
160
  break if selected == @shutdown_port || closed?
160
161
 
161
- deliver_result([:message, message])
162
+ deliver_result(ProtocolMessage.new(message:))
162
163
  end
163
164
  rescue StandardError => error
164
- deliver_result([:error, build_error(:receive, error)])
165
+ deliver_result(ErrorMessage.new(error: build_error(:receive, error)))
165
166
  ensure
166
- deliver_result([:closed]) if closed?
167
+ deliver_result(ClosedMessage.new) if closed?
167
168
  end
168
169
 
169
170
  def select_message
@@ -218,7 +219,6 @@ module FiberStream
218
219
  def wait_for_coordinator
219
220
  return unless @coordinator
220
221
 
221
- sleep WAIT_INTERVAL while @coordinator.alive?
222
222
  @coordinator.join
223
223
  end
224
224
 
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberStream
4
+ module Pull
5
+ # Delimiter-framing stage for `Flow.split`.
6
+ #
7
+ # The stage keeps an internal byte buffer because frames and separators can
8
+ # cross chunk boundaries. Length checks are per frame body, not against the
9
+ # aggregate buffer, so already complete valid frames can be emitted before a
10
+ # later over-limit frame fails.
11
+ class Split
12
+ def initialize(upstream, separator, keep_separator, max_length)
13
+ @upstream = upstream
14
+ @separator = separator.b.freeze
15
+ @keep_separator = keep_separator
16
+ @max_length = max_length
17
+ @buffer = +"".b
18
+ @closed = false
19
+ @upstream_done = false
20
+ end
21
+
22
+ def next
23
+ return DONE if @closed
24
+
25
+ loop do
26
+ frame = next_buffered_frame
27
+ return frame if frame
28
+
29
+ validate_pending_frame_length!
30
+ return complete_from_buffer if @upstream_done
31
+
32
+ append_next_chunk
33
+ end
34
+ end
35
+
36
+ def close
37
+ return if @closed
38
+
39
+ @closed = true
40
+ @buffer.clear
41
+ @upstream.close
42
+ end
43
+
44
+ private
45
+
46
+ def next_buffered_frame
47
+ separator_index = @buffer.index(@separator)
48
+ return nil unless separator_index
49
+
50
+ frame = @buffer.slice!(0, separator_index)
51
+ @buffer.slice!(0, @separator.bytesize)
52
+ validate_frame_length!(frame)
53
+ format_frame(frame)
54
+ end
55
+
56
+ def complete_from_buffer
57
+ return DONE if @buffer.empty?
58
+
59
+ frame = @buffer
60
+ @buffer = +"".b
61
+ validate_frame_length!(frame)
62
+ frame
63
+ end
64
+
65
+ def append_next_chunk
66
+ chunk = @upstream.next
67
+ if Pull.done?(chunk)
68
+ @upstream_done = true
69
+ return
70
+ end
71
+
72
+ unless chunk.is_a?(String)
73
+ raise TypeError, "Flow.split elements must be String"
74
+ end
75
+
76
+ @buffer << chunk.b
77
+ validate_pending_frame_length!
78
+ end
79
+
80
+ def validate_pending_frame_length!
81
+ return unless @max_length
82
+ return if pending_frame_body_bytesize <= @max_length
83
+
84
+ fail_frame_too_long
85
+ end
86
+
87
+ def validate_frame_length!(frame)
88
+ return unless @max_length
89
+ return if frame.bytesize <= @max_length
90
+
91
+ fail_frame_too_long
92
+ end
93
+
94
+ def pending_frame_body_bytesize
95
+ separator_index = @buffer.index(@separator)
96
+ return separator_index if separator_index
97
+
98
+ @buffer.bytesize - partial_separator_suffix_bytesize
99
+ end
100
+
101
+ def partial_separator_suffix_bytesize
102
+ max_suffix_bytesize = [@separator.bytesize - 1, @buffer.bytesize].min
103
+ return 0 if max_suffix_bytesize.zero?
104
+
105
+ max_suffix_bytesize.downto(1) do |bytesize|
106
+ suffix = @buffer.byteslice(@buffer.bytesize - bytesize, bytesize)
107
+ return bytesize if @separator.start_with?(suffix)
108
+ end
109
+
110
+ 0
111
+ end
112
+
113
+ def fail_frame_too_long
114
+ @closed = true
115
+ close_upstream
116
+ error = FrameTooLongError.new("frame exceeded max_length #{@max_length}")
117
+ raise error
118
+ end
119
+
120
+ def close_upstream
121
+ @upstream.close
122
+ nil
123
+ rescue StandardError => error
124
+ error
125
+ end
126
+
127
+ def format_frame(frame)
128
+ return frame unless @keep_separator
129
+
130
+ frame + @separator
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberStream
4
+ module Pull
5
+ # Predicate-based limiting stage.
6
+ #
7
+ # It forwards the leading prefix whose predicate results are truthy. The
8
+ # first falsey predicate result is consumed, not emitted, and closes
9
+ # upstream immediately.
10
+ class TakeWhile
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
+ value = @upstream.next
22
+ if Pull.done?(value)
23
+ @done = true
24
+ return DONE
25
+ end
26
+
27
+ return value if @predicate.call(value)
28
+
29
+ @done = true
30
+ close
31
+ DONE
32
+ end
33
+
34
+ def close
35
+ return if @closed
36
+
37
+ @closed = true
38
+ @upstream.close
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberStream
4
+ module Pull
5
+ # Pull stream that emits pairs from two source definitions.
6
+ #
7
+ # The receiver side is materialized on first downstream demand. The other
8
+ # side is materialized only after the receiver produces a value for a pair.
9
+ class Zip
10
+ def initialize(left_materializer, right_materializer)
11
+ @left_materializer = left_materializer
12
+ @right_materializer = right_materializer
13
+ @left = nil
14
+ @right = nil
15
+ @closed = false
16
+ @done = false
17
+ end
18
+
19
+ def next
20
+ return DONE if @closed || @done
21
+
22
+ left = materialize_left
23
+ left_value = left.next
24
+ if Pull.done?(left_value)
25
+ @done = true
26
+ close_materialized_streams
27
+ return DONE
28
+ end
29
+
30
+ right = materialize_right
31
+ right_value = right.next
32
+ if Pull.done?(right_value)
33
+ @done = true
34
+ close_materialized_streams
35
+ return DONE
36
+ end
37
+
38
+ [left_value, right_value]
39
+ rescue StandardError
40
+ @done = true
41
+ close_materialized_streams(raise_error: false)
42
+ raise
43
+ end
44
+
45
+ def close
46
+ return if @closed
47
+
48
+ @closed = true
49
+ close_materialized_streams
50
+ end
51
+
52
+ private
53
+
54
+ def materialize_left
55
+ @left ||= @left_materializer.call
56
+ end
57
+
58
+ def materialize_right
59
+ @right ||= @right_materializer.call
60
+ end
61
+
62
+ def close_materialized_streams(raise_error: true)
63
+ streams = [@left, @right]
64
+ @left = nil
65
+ @right = nil
66
+
67
+ first_error = nil
68
+
69
+ streams.each do |stream|
70
+ next unless stream
71
+
72
+ begin
73
+ stream.close
74
+ rescue StandardError => error
75
+ first_error ||= error
76
+ end
77
+ end
78
+
79
+ raise first_error if raise_error && first_error
80
+ end
81
+ end
82
+ end
83
+ end