fiber_stream 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +179 -61
- data/examples/README.md +6 -0
- data/examples/ractor_producer_sources.rb +43 -0
- data/lib/fiber_stream/flow.rb +141 -15
- data/lib/fiber_stream/internal/ractor_transfer_policy.rb +17 -0
- data/lib/fiber_stream/pipeline.rb +5 -1
- data/lib/fiber_stream/pull/compact.rb +39 -0
- data/lib/fiber_stream/pull/filter_map.rb +41 -0
- data/lib/fiber_stream/pull/map_concat.rb +56 -0
- data/lib/fiber_stream/pull/parallel_unordered_map_boundary.rb +311 -0
- data/lib/fiber_stream/pull/ractor_map_boundary.rb +50 -51
- data/lib/fiber_stream/pull/ractor_merge_ports_source.rb +18 -3
- data/lib/fiber_stream/pull/ractor_port_source.rb +39 -6
- data/lib/fiber_stream/pull/ractor_producer_source.rb +349 -0
- data/lib/fiber_stream/pull/reject.rb +40 -0
- data/lib/fiber_stream/pull/scan.rb +38 -0
- data/lib/fiber_stream/pull/tap.rb +38 -0
- data/lib/fiber_stream/pull/throttle.rb +43 -0
- data/lib/fiber_stream/pull.rb +84 -5
- data/lib/fiber_stream/ractor_producer.rb +167 -0
- data/lib/fiber_stream/rate_limiter.rb +163 -0
- data/lib/fiber_stream/running_pipeline.rb +4 -0
- data/lib/fiber_stream/sink.rb +25 -19
- data/lib/fiber_stream/source.rb +125 -22
- data/lib/fiber_stream/version.rb +1 -1
- data/lib/fiber_stream.rb +3 -0
- data/sig/fiber_stream.rbs +43 -1
- metadata +16 -3
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FiberStream
|
|
4
|
+
# Producer-side context for `Source.ractor_producer`.
|
|
5
|
+
#
|
|
6
|
+
# Producer blocks call `emit`, `complete`, or `fail` to send one protocol
|
|
7
|
+
# message after receiving one downstream acknowledgment. A `false` return
|
|
8
|
+
# means cooperative cancellation was observed before the requested message
|
|
9
|
+
# could be sent.
|
|
10
|
+
class RactorProducer
|
|
11
|
+
def initialize(data_port, ack_port, transfer)
|
|
12
|
+
@data_port = data_port
|
|
13
|
+
@ack_port = ack_port
|
|
14
|
+
@transfer = transfer
|
|
15
|
+
@terminal = false
|
|
16
|
+
@cancelled = false
|
|
17
|
+
@send_failed = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def emit(value, transfer: nil)
|
|
21
|
+
return false if terminal? || cancelled?
|
|
22
|
+
|
|
23
|
+
message_transfer = validate_transfer_override!(transfer)
|
|
24
|
+
return false unless wait_for_ack
|
|
25
|
+
|
|
26
|
+
send_emitted_message(RactorPort::Element.new(value), message_transfer)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def complete
|
|
30
|
+
return false if terminal? || cancelled?
|
|
31
|
+
return false unless wait_for_ack
|
|
32
|
+
|
|
33
|
+
send_terminal_message(RactorPort::Complete.new)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fail(error = nil, cause_class_name: nil, cause_message: nil)
|
|
37
|
+
return false if terminal? || cancelled?
|
|
38
|
+
|
|
39
|
+
failure = failure_message(error, cause_class_name, cause_message)
|
|
40
|
+
return false unless wait_for_ack
|
|
41
|
+
|
|
42
|
+
send_terminal_message(failure)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def cancelled?
|
|
46
|
+
@cancelled
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def terminal? # :nodoc:
|
|
50
|
+
@terminal
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def send_failed? # :nodoc:
|
|
54
|
+
@send_failed
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def send_emitted_message(message, transfer)
|
|
60
|
+
send_data_message(message, transfer)
|
|
61
|
+
true
|
|
62
|
+
rescue Exception => error # rubocop:disable Lint/RescueException
|
|
63
|
+
report_same_ack_failure(error)
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def send_terminal_message(message)
|
|
68
|
+
send_data_message(message, @transfer)
|
|
69
|
+
@terminal = true
|
|
70
|
+
true
|
|
71
|
+
rescue Exception => send_error # rubocop:disable Lint/RescueException
|
|
72
|
+
report_same_ack_failure(send_error)
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_transfer_override!(transfer)
|
|
77
|
+
return @transfer if transfer.nil?
|
|
78
|
+
return transfer if [:copy, :move].include?(transfer)
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "transfer must be :copy or :move"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def wait_for_ack
|
|
84
|
+
case @ack_port.receive
|
|
85
|
+
in RactorPort::Ack
|
|
86
|
+
true
|
|
87
|
+
in RactorPort::Cancel
|
|
88
|
+
@cancelled = true
|
|
89
|
+
false
|
|
90
|
+
else
|
|
91
|
+
raise TypeError, "invalid ractor producer control message"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def send_data_message(message, transfer)
|
|
96
|
+
if transfer == :move
|
|
97
|
+
@data_port.send(message, move: true)
|
|
98
|
+
else
|
|
99
|
+
@data_port.send(message)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def failure_message(error, cause_class_name, cause_message)
|
|
104
|
+
if error
|
|
105
|
+
return RactorPort::Failure.new(safe_class_name(error), safe_message(error))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
unless cause_class_name.is_a?(String) && cause_message.is_a?(String)
|
|
109
|
+
raise ArgumentError, "fail requires an error or String failure metadata"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
RactorPort::Failure.new(cause_class_name, cause_message)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def safe_class_name(error)
|
|
116
|
+
name = error.class.name
|
|
117
|
+
name.is_a?(String) && !name.empty? ? name : "Exception"
|
|
118
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
119
|
+
"Exception"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def safe_message(error)
|
|
123
|
+
message = error.message
|
|
124
|
+
message.is_a?(String) ? message : ""
|
|
125
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
126
|
+
""
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def report_same_ack_failure(error)
|
|
130
|
+
send_data_message(RactorPort::Failure.new(safe_class_name(error), safe_message(error)), :copy)
|
|
131
|
+
@terminal = true
|
|
132
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
133
|
+
@terminal = true
|
|
134
|
+
@send_failed = true
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Builder passed to `Source.ractor_merge_producers`.
|
|
139
|
+
#
|
|
140
|
+
# Each `producer` call records one lazily started owned producer definition.
|
|
141
|
+
# Registration validates producer block isolation and transfer policy but
|
|
142
|
+
# does not create Ractor ports or start producer code.
|
|
143
|
+
class RactorProducerGroup
|
|
144
|
+
Definition = Data.define(:args, :transfer, :block)
|
|
145
|
+
private_constant :Definition
|
|
146
|
+
|
|
147
|
+
def initialize(default_transfer)
|
|
148
|
+
@default_transfer = default_transfer
|
|
149
|
+
@definitions = []
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def producer(*args, transfer: nil, &block)
|
|
153
|
+
raise ArgumentError, "missing block" unless block
|
|
154
|
+
unless transfer.nil? || [:copy, :move].include?(transfer)
|
|
155
|
+
raise ArgumentError, "transfer must be :copy or :move"
|
|
156
|
+
end
|
|
157
|
+
raise TypeError, "block must be shareable" unless Ractor.shareable?(block)
|
|
158
|
+
|
|
159
|
+
@definitions << Definition.new(args:, transfer: transfer || @default_transfer, block:)
|
|
160
|
+
self
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def definitions
|
|
164
|
+
@definitions.dup.freeze
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FiberStream
|
|
4
|
+
# Scheduler-aware token-bucket rate limiter.
|
|
5
|
+
#
|
|
6
|
+
# `rate` permits refill every `per` seconds. `burst` is the maximum token
|
|
7
|
+
# capacity and defaults to `rate`. Immediate permit grants do not require a
|
|
8
|
+
# scheduler. When FiberStream must sleep, the current fiber must be
|
|
9
|
+
# non-blocking with an installed `Fiber.scheduler`.
|
|
10
|
+
class RateLimiter
|
|
11
|
+
Request = Data.define(:rate, :per, :burst, :permits, :now)
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def validate_options!(rate:, per: 1, burst: nil) # :nodoc:
|
|
15
|
+
rate = validate_rate(rate)
|
|
16
|
+
validate_duration(:per, per)
|
|
17
|
+
validate_burst(burst.nil? ? rate : burst)
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def validate_rate(rate)
|
|
24
|
+
raise TypeError, "rate must be an Integer" unless rate.is_a?(Integer)
|
|
25
|
+
raise ArgumentError, "rate must be positive" unless rate.positive?
|
|
26
|
+
|
|
27
|
+
rate
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_burst(burst)
|
|
31
|
+
raise TypeError, "burst must be an Integer" unless burst.is_a?(Integer)
|
|
32
|
+
raise ArgumentError, "burst must be positive" unless burst.positive?
|
|
33
|
+
|
|
34
|
+
burst
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate_duration(name, duration)
|
|
38
|
+
raise TypeError, "#{name} must be Numeric" unless duration.is_a?(Numeric)
|
|
39
|
+
raise ArgumentError, "#{name} must be finite and real" unless finite_real?(duration)
|
|
40
|
+
raise ArgumentError, "#{name} must be positive" unless duration.positive?
|
|
41
|
+
|
|
42
|
+
duration
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def finite_real?(value)
|
|
46
|
+
return false if value.is_a?(Complex)
|
|
47
|
+
return value.finite? if value.respond_to?(:finite?)
|
|
48
|
+
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(rate:, per: 1, burst: nil, &block)
|
|
54
|
+
self.class.validate_options!(rate:, per:, burst:)
|
|
55
|
+
|
|
56
|
+
@rate = rate
|
|
57
|
+
@per = per
|
|
58
|
+
@burst = burst.nil? ? @rate : burst
|
|
59
|
+
@policy = block
|
|
60
|
+
@mutex = Mutex.new
|
|
61
|
+
@tokens = @burst.to_f
|
|
62
|
+
@updated_at = monotonic_now
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Acquires `permits`, waiting when necessary.
|
|
66
|
+
#
|
|
67
|
+
# Waits are scheduler-backed and non-blocking. Requests larger than `burst`
|
|
68
|
+
# are rejected because the local token bucket could never satisfy them.
|
|
69
|
+
def acquire(permits: 1)
|
|
70
|
+
permits = validate_permits(permits)
|
|
71
|
+
|
|
72
|
+
if @policy
|
|
73
|
+
acquire_with_policy(permits)
|
|
74
|
+
else
|
|
75
|
+
acquire_with_token_bucket(permits)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def acquire_with_policy(permits)
|
|
84
|
+
loop do
|
|
85
|
+
wait = normalize_policy_wait(@policy.call(request_for(permits)))
|
|
86
|
+
return if wait <= 0
|
|
87
|
+
|
|
88
|
+
scheduler_sleep(wait)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def acquire_with_token_bucket(permits)
|
|
93
|
+
loop do
|
|
94
|
+
wait = nil
|
|
95
|
+
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
refill_tokens
|
|
98
|
+
|
|
99
|
+
if @tokens >= permits
|
|
100
|
+
@tokens -= permits
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
wait = (permits - @tokens) / permits_per_second
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
scheduler_sleep(wait)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def request_for(permits)
|
|
112
|
+
Request.new(rate: @rate, per: @per, burst: @burst, permits:, now: monotonic_now)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def refill_tokens
|
|
116
|
+
now = monotonic_now
|
|
117
|
+
elapsed = now - @updated_at
|
|
118
|
+
@updated_at = now
|
|
119
|
+
@tokens = [@burst, @tokens + (elapsed * permits_per_second)].min
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def permits_per_second
|
|
123
|
+
@rate.to_f / @per.to_f
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def scheduler_sleep(duration)
|
|
127
|
+
validate_scheduler!
|
|
128
|
+
sleep(duration)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_scheduler!
|
|
132
|
+
return if Fiber.scheduler && !Fiber.current.blocking?
|
|
133
|
+
|
|
134
|
+
message =
|
|
135
|
+
if Fiber.scheduler
|
|
136
|
+
"RateLimiter#acquire requires a non-blocking fiber"
|
|
137
|
+
else
|
|
138
|
+
"RateLimiter#acquire requires Fiber.scheduler"
|
|
139
|
+
end
|
|
140
|
+
raise SchedulerRequiredError, message
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validate_permits(permits)
|
|
144
|
+
raise TypeError, "permits must be an Integer" unless permits.is_a?(Integer)
|
|
145
|
+
raise ArgumentError, "permits must be positive" unless permits.positive?
|
|
146
|
+
raise ArgumentError, "permits must be less than or equal to burst" if permits > @burst
|
|
147
|
+
|
|
148
|
+
permits
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def normalize_policy_wait(wait)
|
|
152
|
+
return 0 if wait.nil?
|
|
153
|
+
raise TypeError, "custom wait duration must be Numeric or nil" unless wait.is_a?(Numeric)
|
|
154
|
+
raise ArgumentError, "custom wait duration must be finite and real" unless self.class.send(:finite_real?, wait)
|
|
155
|
+
|
|
156
|
+
wait.positive? ? wait : 0
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def monotonic_now
|
|
160
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -7,6 +7,10 @@ module FiberStream
|
|
|
7
7
|
CancelledMessage = Data.define(:error)
|
|
8
8
|
private_constant :ValueMessage, :ErrorMessage, :CancelledMessage
|
|
9
9
|
|
|
10
|
+
def self.start(scheduler, &run) # :nodoc:
|
|
11
|
+
new(scheduler, &run)
|
|
12
|
+
end
|
|
13
|
+
|
|
10
14
|
def initialize(scheduler, &run)
|
|
11
15
|
@scheduler = scheduler
|
|
12
16
|
@completion = nil
|
data/lib/fiber_stream/sink.rb
CHANGED
|
@@ -10,10 +10,7 @@ module FiberStream
|
|
|
10
10
|
new do |stream|
|
|
11
11
|
values = []
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
value = stream.next
|
|
15
|
-
break if Pull.done?(value)
|
|
16
|
-
|
|
13
|
+
Pull.each_value(stream) do |value|
|
|
17
14
|
values << value
|
|
18
15
|
end
|
|
19
16
|
|
|
@@ -32,6 +29,22 @@ module FiberStream
|
|
|
32
29
|
end
|
|
33
30
|
end
|
|
34
31
|
|
|
32
|
+
# Creates a sink that counts all stream elements.
|
|
33
|
+
#
|
|
34
|
+
# The sink consumes upstream until normal completion and returns the number
|
|
35
|
+
# of elements observed. It does not store consumed elements.
|
|
36
|
+
def self.count
|
|
37
|
+
new do |stream|
|
|
38
|
+
count = 0
|
|
39
|
+
|
|
40
|
+
Pull.each_value(stream) do
|
|
41
|
+
count += 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
count
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
35
48
|
# Creates a sink that folds all stream elements into an accumulator.
|
|
36
49
|
#
|
|
37
50
|
# The sink consumes upstream until normal completion. It returns the final
|
|
@@ -45,10 +58,7 @@ module FiberStream
|
|
|
45
58
|
new do |stream|
|
|
46
59
|
accumulator = initial
|
|
47
60
|
|
|
48
|
-
|
|
49
|
-
value = stream.next
|
|
50
|
-
break if Pull.done?(value)
|
|
51
|
-
|
|
61
|
+
Pull.each_value(stream) do |value|
|
|
52
62
|
accumulator = block.call(accumulator, value)
|
|
53
63
|
end
|
|
54
64
|
|
|
@@ -68,10 +78,7 @@ module FiberStream
|
|
|
68
78
|
new do |stream|
|
|
69
79
|
count = 0
|
|
70
80
|
|
|
71
|
-
|
|
72
|
-
value = stream.next
|
|
73
|
-
break if Pull.done?(value)
|
|
74
|
-
|
|
81
|
+
Pull.each_value(stream) do |value|
|
|
75
82
|
block.call(value)
|
|
76
83
|
count += 1
|
|
77
84
|
end
|
|
@@ -99,15 +106,17 @@ module FiberStream
|
|
|
99
106
|
end
|
|
100
107
|
end
|
|
101
108
|
|
|
109
|
+
def self.build(&run) # :nodoc:
|
|
110
|
+
new(&run)
|
|
111
|
+
end
|
|
112
|
+
|
|
102
113
|
def initialize(&run)
|
|
103
114
|
@run = run
|
|
104
115
|
end
|
|
105
116
|
|
|
106
117
|
private_class_method :new
|
|
107
118
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def run(stream)
|
|
119
|
+
def run_stream(stream) # :nodoc:
|
|
111
120
|
@run.call(stream)
|
|
112
121
|
end
|
|
113
122
|
|
|
@@ -121,10 +130,7 @@ module FiberStream
|
|
|
121
130
|
end
|
|
122
131
|
|
|
123
132
|
def run(stream)
|
|
124
|
-
|
|
125
|
-
value = stream.next
|
|
126
|
-
break if Pull.done?(value)
|
|
127
|
-
|
|
133
|
+
Pull.each_value(stream) do |value|
|
|
128
134
|
write(value)
|
|
129
135
|
end
|
|
130
136
|
|
data/lib/fiber_stream/source.rb
CHANGED
|
@@ -47,7 +47,7 @@ module FiberStream
|
|
|
47
47
|
raise TypeError, "ack_port must provide Ractor-style send"
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
Internal::RactorTransferPolicy.validate!(:ack_transfer, ack_transfer)
|
|
51
51
|
raise TypeError, "cancel must be true or false" unless [true, false].include?(cancel)
|
|
52
52
|
|
|
53
53
|
new(-> { Pull.ractor_port(port, ack_port, ack_transfer, cancel) })
|
|
@@ -65,12 +65,56 @@ module FiberStream
|
|
|
65
65
|
def self.ractor_merge_ports(ports, ack_transfer: :copy, cancel: true)
|
|
66
66
|
pairs = normalize_ractor_merge_port_pairs(ports)
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
Internal::RactorTransferPolicy.validate!(:ack_transfer, ack_transfer)
|
|
69
69
|
raise TypeError, "cancel must be true or false" unless [true, false].include?(cancel)
|
|
70
70
|
|
|
71
71
|
new(-> { Pull.ractor_merge_ports(pairs, ack_transfer, cancel) })
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# Creates a source backed by one FiberStream-owned producer ractor.
|
|
75
|
+
#
|
|
76
|
+
# The producer ractor is started lazily on first downstream demand. The
|
|
77
|
+
# shareable block receives a `RactorProducer` context and the provided
|
|
78
|
+
# arguments. Calls to the context preserve one-outstanding-ack
|
|
79
|
+
# backpressure, and cleanup always requests cooperative cancellation.
|
|
80
|
+
def self.ractor_producer(*args, transfer: :copy, ack_transfer: :copy, &block)
|
|
81
|
+
raise ArgumentError, "missing block" unless block
|
|
82
|
+
|
|
83
|
+
Internal::RactorTransferPolicy.validate!(:transfer, transfer)
|
|
84
|
+
Internal::RactorTransferPolicy.validate!(:ack_transfer, ack_transfer)
|
|
85
|
+
raise TypeError, "block must be shareable" unless Ractor.shareable?(block)
|
|
86
|
+
|
|
87
|
+
group = RactorProducerGroup.new(transfer)
|
|
88
|
+
group.producer(*args, &block)
|
|
89
|
+
definitions = group.definitions
|
|
90
|
+
|
|
91
|
+
new(-> { Pull.ractor_producer(definitions, ack_transfer) })
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Creates a source backed by multiple FiberStream-owned producer ractors.
|
|
95
|
+
#
|
|
96
|
+
# The registration block runs at construction to collect producer
|
|
97
|
+
# definitions, but producer ractors and ports are started lazily on first
|
|
98
|
+
# downstream demand. Outputs are merged with the same ready-order semantics
|
|
99
|
+
# as `Source.ractor_merge_ports`.
|
|
100
|
+
def self.ractor_merge_producers(transfer: :copy, ack_transfer: :copy, &block)
|
|
101
|
+
raise ArgumentError, "missing block" unless block
|
|
102
|
+
|
|
103
|
+
Internal::RactorTransferPolicy.validate!(:transfer, transfer)
|
|
104
|
+
Internal::RactorTransferPolicy.validate!(:ack_transfer, ack_transfer)
|
|
105
|
+
|
|
106
|
+
group = RactorProducerGroup.new(transfer)
|
|
107
|
+
block.call(group)
|
|
108
|
+
definitions = group.definitions
|
|
109
|
+
raise ArgumentError, "ractor_merge_producers requires at least two producers" if definitions.size < 2
|
|
110
|
+
|
|
111
|
+
new(-> { Pull.ractor_merge_producers(definitions, ack_transfer) })
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.build(source_factory, flows = []) # :nodoc:
|
|
115
|
+
new(source_factory, flows)
|
|
116
|
+
end
|
|
117
|
+
|
|
74
118
|
def initialize(source_factory, flows = [])
|
|
75
119
|
@source_factory = source_factory
|
|
76
120
|
@flows = flows
|
|
@@ -83,7 +127,7 @@ module FiberStream
|
|
|
83
127
|
def via(flow)
|
|
84
128
|
raise TypeError, "expected FiberStream::Flow" unless flow.is_a?(Flow)
|
|
85
129
|
|
|
86
|
-
self.class.
|
|
130
|
+
self.class.build(@source_factory, @flows + [flow])
|
|
87
131
|
end
|
|
88
132
|
|
|
89
133
|
# Returns a new source definition that emits this source, then `source`.
|
|
@@ -95,10 +139,7 @@ module FiberStream
|
|
|
95
139
|
def concat(source)
|
|
96
140
|
raise TypeError, "expected FiberStream::Source" unless source.is_a?(Source)
|
|
97
141
|
|
|
98
|
-
self.class.
|
|
99
|
-
:new,
|
|
100
|
-
-> { Pull.concat(materializer, source.__send__(:materializer)) }
|
|
101
|
-
)
|
|
142
|
+
self.class.build(-> { Pull.concat(to_pull_materializer, source.to_pull_materializer) })
|
|
102
143
|
end
|
|
103
144
|
|
|
104
145
|
# Returns a new source definition that emits pairs from this source and
|
|
@@ -111,10 +152,7 @@ module FiberStream
|
|
|
111
152
|
def zip(source)
|
|
112
153
|
raise TypeError, "expected FiberStream::Source" unless source.is_a?(Source)
|
|
113
154
|
|
|
114
|
-
self.class.
|
|
115
|
-
:new,
|
|
116
|
-
-> { Pull.zip(materializer, source.__send__(:materializer)) }
|
|
117
|
-
)
|
|
155
|
+
self.class.build(-> { Pull.zip(to_pull_materializer, source.to_pull_materializer) })
|
|
118
156
|
end
|
|
119
157
|
|
|
120
158
|
# Returns a new source definition that emits values from this source and
|
|
@@ -128,10 +166,7 @@ module FiberStream
|
|
|
128
166
|
def merge(source)
|
|
129
167
|
raise TypeError, "expected FiberStream::Source" unless source.is_a?(Source)
|
|
130
168
|
|
|
131
|
-
self.class.
|
|
132
|
-
:new,
|
|
133
|
-
-> { Pull.merge(materializer, source.__send__(:materializer)) }
|
|
134
|
-
)
|
|
169
|
+
self.class.build(-> { Pull.merge(to_pull_materializer, source.to_pull_materializer) })
|
|
135
170
|
end
|
|
136
171
|
|
|
137
172
|
# Returns a new source definition that maps each element with `block`.
|
|
@@ -143,6 +178,35 @@ module FiberStream
|
|
|
143
178
|
via(Flow.map(&block))
|
|
144
179
|
end
|
|
145
180
|
|
|
181
|
+
# Returns a new source definition that emits truthy transformed values.
|
|
182
|
+
#
|
|
183
|
+
# This is a convenience wrapper around
|
|
184
|
+
# `via(FiberStream::Flow.filter_map { ... })` and has the same falsey-drop,
|
|
185
|
+
# lazy construction, error, and backpressure behavior as the underlying
|
|
186
|
+
# flow.
|
|
187
|
+
def filter_map(&block)
|
|
188
|
+
via(Flow.filter_map(&block))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Returns a new source definition that drops nil elements.
|
|
192
|
+
#
|
|
193
|
+
# This is a convenience wrapper around `via(FiberStream::Flow.compact)` and
|
|
194
|
+
# preserves the same nil-only filtering, lazy construction, and
|
|
195
|
+
# backpressure behavior as the underlying flow.
|
|
196
|
+
def compact
|
|
197
|
+
via(Flow.compact)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Returns a new source definition that emits each mapped expansion.
|
|
201
|
+
#
|
|
202
|
+
# This is a convenience wrapper around
|
|
203
|
+
# `via(FiberStream::Flow.map_concat { ... })` and has the same one-level
|
|
204
|
+
# flattening, lazy construction, error, and backpressure behavior as the
|
|
205
|
+
# underlying flow.
|
|
206
|
+
def map_concat(&block)
|
|
207
|
+
via(Flow.map_concat(&block))
|
|
208
|
+
end
|
|
209
|
+
|
|
146
210
|
# Returns a new source definition that maps elements concurrently.
|
|
147
211
|
#
|
|
148
212
|
# This is a convenience wrapper around
|
|
@@ -153,6 +217,18 @@ module FiberStream
|
|
|
153
217
|
via(Flow.parallel_map(concurrency: concurrency, &block))
|
|
154
218
|
end
|
|
155
219
|
|
|
220
|
+
# Returns a new source definition that maps elements concurrently and emits
|
|
221
|
+
# mapped values in completion order.
|
|
222
|
+
#
|
|
223
|
+
# This is a convenience wrapper around
|
|
224
|
+
# `via(FiberStream::Flow.parallel_unordered_map(concurrency:) { ... })`.
|
|
225
|
+
# The operation preserves the same scheduler requirement, validation,
|
|
226
|
+
# bounded upstream run-ahead, and cancellation behavior while making no
|
|
227
|
+
# input-order guarantee.
|
|
228
|
+
def parallel_unordered_map(concurrency:, &block)
|
|
229
|
+
via(Flow.parallel_unordered_map(concurrency: concurrency, &block))
|
|
230
|
+
end
|
|
231
|
+
|
|
156
232
|
# Returns a new source definition that maps elements in Ractor workers.
|
|
157
233
|
#
|
|
158
234
|
# This is a convenience wrapper around
|
|
@@ -179,6 +255,15 @@ module FiberStream
|
|
|
179
255
|
via(Flow.select(&block))
|
|
180
256
|
end
|
|
181
257
|
|
|
258
|
+
# Returns a new source definition that drops elements matching `block`.
|
|
259
|
+
#
|
|
260
|
+
# This is a convenience wrapper around
|
|
261
|
+
# `via(FiberStream::Flow.reject { ... })` and has the same truthiness and
|
|
262
|
+
# lazy construction behavior as the underlying flow.
|
|
263
|
+
def reject(&block)
|
|
264
|
+
via(Flow.reject(&block))
|
|
265
|
+
end
|
|
266
|
+
|
|
182
267
|
# Returns a new source definition that emits at most `count` elements.
|
|
183
268
|
#
|
|
184
269
|
# This is a convenience wrapper around `via(FiberStream::Flow.take(count))`
|
|
@@ -204,6 +289,15 @@ module FiberStream
|
|
|
204
289
|
via(Flow.grouped(count))
|
|
205
290
|
end
|
|
206
291
|
|
|
292
|
+
# Returns a new source definition that emits running accumulators.
|
|
293
|
+
#
|
|
294
|
+
# This is a convenience wrapper around
|
|
295
|
+
# `via(FiberStream::Flow.scan(initial) { ... })` and preserves the same
|
|
296
|
+
# reducer order, lazy construction, and pull-driven backpressure behavior.
|
|
297
|
+
def scan(initial, &block)
|
|
298
|
+
via(Flow.scan(initial, &block))
|
|
299
|
+
end
|
|
300
|
+
|
|
207
301
|
# Returns a new source definition that emits leading elements while `block`
|
|
208
302
|
# is truthy.
|
|
209
303
|
#
|
|
@@ -241,6 +335,15 @@ module FiberStream
|
|
|
241
335
|
via(Flow.buffer(count))
|
|
242
336
|
end
|
|
243
337
|
|
|
338
|
+
# Returns a new source definition that rate-limits emitted elements.
|
|
339
|
+
#
|
|
340
|
+
# This is a convenience wrapper around `via(FiberStream::Flow.throttle(...))`.
|
|
341
|
+
# The `rate:` form creates a fresh default limiter for each materialization;
|
|
342
|
+
# pass `limiter:` to share quota state across sources or runs.
|
|
343
|
+
def throttle(**options)
|
|
344
|
+
via(Flow.throttle(**options))
|
|
345
|
+
end
|
|
346
|
+
|
|
244
347
|
# Returns a new source definition that splits String chunks into lines.
|
|
245
348
|
#
|
|
246
349
|
# This is a convenience wrapper around
|
|
@@ -270,7 +373,7 @@ module FiberStream
|
|
|
270
373
|
def to(sink)
|
|
271
374
|
raise TypeError, "expected FiberStream::Sink" unless sink.is_a?(Sink)
|
|
272
375
|
|
|
273
|
-
Pipeline.
|
|
376
|
+
Pipeline.build(self, sink)
|
|
274
377
|
end
|
|
275
378
|
|
|
276
379
|
# Materializes and runs this source with `sink`.
|
|
@@ -286,7 +389,7 @@ module FiberStream
|
|
|
286
389
|
begin
|
|
287
390
|
stream = materialize
|
|
288
391
|
|
|
289
|
-
sink.
|
|
392
|
+
sink.run_stream(stream)
|
|
290
393
|
rescue StandardError => error
|
|
291
394
|
primary_error = error
|
|
292
395
|
raise
|
|
@@ -301,6 +404,10 @@ module FiberStream
|
|
|
301
404
|
|
|
302
405
|
private_class_method :new
|
|
303
406
|
|
|
407
|
+
def to_pull_materializer # :nodoc:
|
|
408
|
+
method(:materialize)
|
|
409
|
+
end
|
|
410
|
+
|
|
304
411
|
def self.normalize_ractor_merge_port_pairs(ports)
|
|
305
412
|
raise TypeError, "ports must respond to each" unless ports.respond_to?(:each)
|
|
306
413
|
|
|
@@ -341,17 +448,13 @@ module FiberStream
|
|
|
341
448
|
|
|
342
449
|
private
|
|
343
450
|
|
|
344
|
-
def materializer
|
|
345
|
-
-> { materialize }
|
|
346
|
-
end
|
|
347
|
-
|
|
348
451
|
def materialize
|
|
349
452
|
stream = nil
|
|
350
453
|
|
|
351
454
|
begin
|
|
352
455
|
stream = @source_factory.call
|
|
353
456
|
@flows.each do |flow|
|
|
354
|
-
stream = flow.
|
|
457
|
+
stream = flow.attach_to(stream)
|
|
355
458
|
end
|
|
356
459
|
stream
|
|
357
460
|
rescue StandardError
|
data/lib/fiber_stream/version.rb
CHANGED
data/lib/fiber_stream.rb
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
require_relative "fiber_stream/pull"
|
|
4
4
|
require_relative "fiber_stream/version"
|
|
5
5
|
require_relative "fiber_stream/errors"
|
|
6
|
+
require_relative "fiber_stream/rate_limiter"
|
|
7
|
+
require_relative "fiber_stream/internal/ractor_transfer_policy"
|
|
6
8
|
require_relative "fiber_stream/ractor_port"
|
|
9
|
+
require_relative "fiber_stream/ractor_producer"
|
|
7
10
|
require_relative "fiber_stream/flow"
|
|
8
11
|
require_relative "fiber_stream/sink"
|
|
9
12
|
require_relative "fiber_stream/running_pipeline"
|