ractor-pipeline 0.2.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,462 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pipeline/version"
4
+
5
+ # Ractor::Pipeline: a DSL to build stream processing pipelines with Ractors.
6
+ #
7
+ # include Ractor::Pipeline
8
+ #
9
+ # stream(File.foreach(name)).
10
+ # filter_pipe(lanes: 4){ it.include?("foo") }.
11
+ # reduce([0, 0, 0]) do |(lines, words, bytes), line|
12
+ # [lines + 1, words + line.scan(/\S+/).size, bytes + line.bytesize]
13
+ # end
14
+ #
15
+ # Execution model:
16
+ # * stream(source, batch: k) feeds the source into the pipeline from a
17
+ # Thread in the caller Ractor, k elements per message (default 1).
18
+ # Batching is transparent: stage blocks always see single elements.
19
+ # * pipe/filter_pipe/flat_pipe are persistent Ractor stages; each stage
20
+ # spawns `lanes:` (default 1) worker Ractors at terminal-operation
21
+ # time. Workers process many elements; no Ractor is created per
22
+ # element.
23
+ # * Boundaries into a multi-lane stage (lanes > 1) are demand-driven
24
+ # (pull): a lane worker sends a ready token upstream when it can take
25
+ # more work, and producers send a batch only to a worker they hold a
26
+ # token for (CREDIT tokens per producer/consumer pair). A busy worker
27
+ # stops sending tokens, so work never piles up behind it. Boundaries
28
+ # with a single consumer (lanes: 1 stages, and the terminal) are plain
29
+ # push.
30
+ # * When the head stage is multi-lane, the feeder reads the source only
31
+ # while it holds tokens, so a fast or infinite source cannot flood the
32
+ # pipeline.
33
+ # * tee broadcasts each element to every branch; branch outputs are
34
+ # merged (unordered) into one downstream stream.
35
+ # * reduce/each/to_a/count/first are terminal operations executed in the
36
+ # caller Ractor.
37
+ #
38
+ # Semantics notes:
39
+ # * Ordering: a chain of lanes: 1 stages preserves input order (also
40
+ # with batch:). Multi-lane stages are unordered.
41
+ # * End of stream is an in-band EOS message: each worker counts EOS from
42
+ # its upstream producers, drains its buffered output, and then
43
+ # broadcasts EOS downstream, so fan-in and fan-out shut down cleanly.
44
+ # * Cancellation (first, exceptions): the terminal closes its out_port
45
+ # and broadcasts a cancel message to every worker. Workers exit
46
+ # immediately, dropping their backlog; sends to closed ports raise
47
+ # Ractor::ClosedError and cascade the closure upstream (SIGPIPE-style).
48
+ # * Non-shareable objects are deep-copied at each Ractor boundary
49
+ # (Ractor::Port#send default). tee copies an element once per branch.
50
+ # * Stage blocks are isolated with Ractor.shareable_proc at DSL
51
+ # construction time: they may capture shareable outer values (they are
52
+ # snapshotted), and capturing a non-shareable value raises
53
+ # Ractor::IsolationError at pipe/filter_pipe/flat_pipe call time.
54
+ # * An exception raised in a stage block cancels the pipeline and is
55
+ # re-raised by the terminal operation in the caller Ractor.
56
+ # * Push links are unbounded (no backpressure); pull links are bounded
57
+ # by CREDIT batches per producer/consumer pair.
58
+ #
59
+ # Message protocol (one input Port per worker; messages are tagged):
60
+ # [:init, groups, ups, n_producers, batch_size]
61
+ # groups: [[ports, pull?], ...] one per fan-out destination
62
+ # [:data, batch, from] # from: replenish target on pull links, else nil
63
+ # [:ready, port] # demand token (pull links only)
64
+ # [:eos] # counted per upstream producer
65
+ # [:failure, exception] # forwarded to the terminal, which raises it
66
+ # [:cancel]
67
+ class Ractor
68
+ module Pipeline
69
+ class Error < StandardError; end
70
+
71
+ CREDIT = 2 # ready tokens per (producer, consumer) pair on pull links
72
+
73
+ STOP = Object.new # throw tag for early termination (caller Ractor only)
74
+ private_constant :STOP
75
+
76
+ Stage = Struct.new(:kind, :lanes, :job, :branches)
77
+
78
+ # Common stage-building vocabulary for Source and Branch.
79
+ module Stages
80
+ def stages = @stages ||= []
81
+
82
+ # Apply the block to each element and send the return value downstream.
83
+ def pipe(lanes: 1, &block)
84
+ add_stage(:pipe, lanes, block)
85
+ end
86
+
87
+ # Send the element itself downstream iff the block returns truthy.
88
+ def filter_pipe(lanes: 1, &block)
89
+ add_stage(:filter_pipe, lanes, block)
90
+ end
91
+
92
+ # The block returns an each-able object; each of its elements is sent
93
+ # downstream (1 input -> N outputs).
94
+ def flat_pipe(lanes: 1, &block)
95
+ add_stage(:flat_pipe, lanes, block)
96
+ end
97
+
98
+ # Broadcast each element to every branch. Branch outputs are merged
99
+ # (unordered) into one downstream stream.
100
+ #
101
+ # stream(src).tee(
102
+ # pipe{ A(it) },
103
+ # pipe{ B(it) },
104
+ # ).to_a
105
+ def tee(*branches)
106
+ branches.each do |br|
107
+ raise ArgumentError, "tee branch must be a stage list (use pipe{}/filter_pipe{})" unless Branch === br
108
+ raise ArgumentError, "tee branch must have at least one stage" if br.stages.empty?
109
+ end
110
+ stages << Stage.new(:tee, nil, nil, branches.map(&:stages))
111
+ self
112
+ end
113
+
114
+ private def add_stage(kind, lanes, block)
115
+ raise ArgumentError, "no block given" unless block
116
+ raise ArgumentError, "lanes: must be an Integer >= 1" unless Integer === lanes && lanes >= 1
117
+
118
+ # Isolate the block here so that capturing outer variables fails
119
+ # early, at DSL construction time.
120
+ job = Ractor.shareable_proc(&block)
121
+ stages << Stage.new(kind, lanes, job)
122
+ self
123
+ end
124
+ end
125
+
126
+ # A source-less pipeline fragment, used as a tee branch.
127
+ class Branch
128
+ include Stages
129
+ end
130
+
131
+ # A pipeline with a source. Terminal operations are defined here.
132
+ class Source
133
+ include Stages
134
+
135
+ def initialize(source, batch: 1)
136
+ raise ArgumentError, "source must respond to #each" unless source.respond_to?(:each)
137
+ raise ArgumentError, "batch: must be an Integer >= 1" unless Integer === batch && batch >= 1
138
+ @source = source
139
+ @batch = batch
140
+ end
141
+
142
+ # -- terminal operations (executed in the caller Ractor) -----------
143
+
144
+ def each(&block)
145
+ raise ArgumentError, "no block given" unless block
146
+ run { |msg| block.call(msg) }
147
+ self
148
+ end
149
+
150
+ def reduce(initial, &block)
151
+ raise ArgumentError, "no block given" unless block
152
+ acc = initial
153
+ run { |msg| acc = block.call(acc, msg) }
154
+ acc
155
+ end
156
+
157
+ def to_a
158
+ result = []
159
+ run { |msg| result << msg }
160
+ result
161
+ end
162
+
163
+ def count
164
+ n = 0
165
+ run { n += 1 }
166
+ n
167
+ end
168
+
169
+ # Terminates the pipeline as soon as enough elements are received.
170
+ def first(n = nil)
171
+ want = n || 1
172
+ result = []
173
+ if want > 0
174
+ run do |msg|
175
+ result << msg
176
+ throw STOP, true if result.size >= want
177
+ end
178
+ end
179
+ n ? result : result.first
180
+ end
181
+
182
+ private
183
+
184
+ # A concrete stage placement in the wiring graph. downs holds the
185
+ # fan-out destinations (Node or :out); producers the upstream Nodes
186
+ # (empty = fed by the source feeder).
187
+ Node = Struct.new(:kind, :lanes, :job, :downs, :producers, :ports)
188
+
189
+ # Builds Nodes for a stage list, from downstream to upstream.
190
+ # Returns the head destinations (what the previous producer feeds).
191
+ def plan(stage_list, downs, nodes)
192
+ stage_list.reverse_each.inject(downs) do |dwn, st|
193
+ if st.kind == :tee
194
+ st.branches.flat_map { |br_stages| plan(br_stages, dwn, nodes) }
195
+ else
196
+ node = Node.new(st.kind, st.lanes, st.job, dwn, [])
197
+ nodes << node
198
+ [node]
199
+ end
200
+ end
201
+ end
202
+
203
+ def run
204
+ nodes = []
205
+ heads = plan(stages, [:out], nodes)
206
+ nodes.each do |node|
207
+ node.downs.each { |dest| dest.producers << node if Node === dest }
208
+ end
209
+ n_out = nodes.sum { |node| node.downs.include?(:out) ? node.lanes : 0 }
210
+ n_out = 1 if stages.empty? # fed directly by the feeder
211
+
212
+ out_port = Ractor::Port.new
213
+ feed_port = Ractor::Port.new
214
+ ctrl = Ractor::Port.new
215
+
216
+ # spawn workers (they wait for [:init]) and collect their ports
217
+ nodes.each do |node|
218
+ node.lanes.times do |i|
219
+ Ractor.new(ctrl, node.job, node.kind, name: "pipeline/#{node.kind}[#{i}]") do |ctrl, job, kind|
220
+ Ractor::Pipeline.worker_loop(ctrl, job, kind)
221
+ end
222
+ end
223
+ node.ports = node.lanes.times.map { ctrl.receive }
224
+ end
225
+ all_ports = nodes.flat_map(&:ports)
226
+
227
+ # wire up
228
+ group_for = ->(dest) do
229
+ dest == :out ? [[out_port], false] : [dest.ports, dest.lanes > 1]
230
+ end
231
+ nodes.each do |node|
232
+ groups = node.downs.map(&group_for)
233
+ ups = if node.lanes > 1
234
+ node.producers.empty? ? [feed_port] : node.producers.flat_map(&:ports)
235
+ else
236
+ []
237
+ end
238
+ npro = node.producers.empty? ? 1 : node.producers.sum(&:lanes)
239
+ node.ports.each { |port| port << [:init, groups, ups, npro, @batch] }
240
+ end
241
+ head_groups = heads.map(&group_for)
242
+
243
+ feeder = Thread.new { feed(feed_port, head_groups) }
244
+
245
+ eos = 0
246
+ early = catch(STOP) do
247
+ loop do
248
+ msg = out_port.receive
249
+ case msg[0]
250
+ when :data
251
+ msg[1].each { |obj| yield obj }
252
+ when :eos
253
+ eos += 1
254
+ break if eos == n_out
255
+ when :failure
256
+ shutdown(feeder, out_port, all_ports)
257
+ raise msg[1]
258
+ end
259
+ end
260
+ false
261
+ end
262
+
263
+ if early
264
+ shutdown(feeder, out_port, all_ports)
265
+ else
266
+ feeder.join
267
+ out_port.close
268
+ end
269
+ nil
270
+ end
271
+
272
+ # Feed the source, batching elements. Pull destinations receive a
273
+ # batch only when they granted a token, so the feeder never runs far
274
+ # ahead of a multi-lane head stage; push destinations are direct.
275
+ def feed(feed_port, head_groups)
276
+ tokens = Pipeline.token_pools(head_groups)
277
+ port_group = {}
278
+ head_groups.each_with_index do |(ports, pull), gi|
279
+ ports.each { |port| port_group[port] = gi } if pull
280
+ end
281
+
282
+ fill = lambda do |gi|
283
+ until (port = tokens[gi].take)
284
+ msg = feed_port.receive
285
+ case msg[0]
286
+ when :ready then tokens[port_group[msg[1]]].add(msg[1])
287
+ when :cancel then throw :cancelled
288
+ end
289
+ end
290
+ port
291
+ end
292
+ emit = lambda do |buf|
293
+ head_groups.each_with_index do |(ports, pull), gi|
294
+ if pull
295
+ fill.call(gi) << [:data, buf, feed_port]
296
+ else
297
+ ports.first << [:data, buf, nil]
298
+ end
299
+ end
300
+ end
301
+
302
+ catch(:cancelled) do
303
+ begin
304
+ buf = []
305
+ @source.each do |obj|
306
+ buf << obj
307
+ if buf.size >= @batch
308
+ emit.call(buf)
309
+ buf = []
310
+ end
311
+ end
312
+ emit.call(buf) unless buf.empty?
313
+ rescue Ractor::ClosedError
314
+ next # cancelled
315
+ rescue Exception => e
316
+ head_groups.first.first.first << [:failure, e] rescue nil
317
+ end
318
+ head_groups.each { |ports, _| ports.each { |port| port << [:eos] rescue nil } }
319
+ end
320
+ end
321
+
322
+ # Cancel: wake every worker (they may be idle or waiting for demand)
323
+ # and close out_port so in-flight sends fail and cascade upstream.
324
+ def shutdown(feeder, out_port, all_ports)
325
+ feeder.kill
326
+ feeder.join
327
+ all_ports.each { |port| port << [:cancel] rescue nil }
328
+ out_port.close
329
+ end
330
+ end
331
+
332
+ # Demand tokens of one pull group. Tokens are granted round-robin over
333
+ # the consumers (not in token arrival order): each consumer sends its
334
+ # CREDIT initial tokens in a burst, and handing them out in arrival
335
+ # order would cluster consecutive batches on the same worker.
336
+ class TokenPool
337
+ def initialize
338
+ @counts = Hash.new(0)
339
+ @order = []
340
+ end
341
+
342
+ def add(port)
343
+ @order << port unless @counts.key?(port)
344
+ @counts[port] += 1
345
+ end
346
+
347
+ def take
348
+ @order.size.times do
349
+ port = @order.shift
350
+ @order.push(port)
351
+ return port if @counts[port] > 0 && (@counts[port] -= 1 || true)
352
+ end
353
+ nil
354
+ end
355
+ end
356
+
357
+ class << self
358
+ def token_pools(groups)
359
+ groups.map { TokenPool.new }
360
+ end
361
+
362
+ # The main loop of a stage worker. Stateless between streams except
363
+ # for the demand bookkeeping of its (per-run) wiring.
364
+ def worker_loop(ctrl, job, kind)
365
+ in_port = Ractor::Port.new
366
+ ctrl << in_port
367
+
368
+ # wait for [:init]; queue anything that arrives earlier
369
+ early = []
370
+ msg = in_port.receive
371
+ until msg[0] == :init
372
+ early << msg
373
+ msg = in_port.receive
374
+ end
375
+ _, groups, ups, n_producers, batch_size = msg
376
+
377
+ tokens = Pipeline.token_pools(groups) # demand tokens, per pull group
378
+ pending = groups.map { [] } # batches waiting for a token
379
+ port_group = {}
380
+ groups.each_with_index do |(ports, pull), gi|
381
+ ports.each { |port| port_group[port] = gi } if pull
382
+ end
383
+ eos = 0
384
+
385
+ dispatch = lambda do |batch|
386
+ groups.each_with_index do |(ports, pull), gi|
387
+ if !pull
388
+ ports.first << [:data, batch, nil]
389
+ elsif (port = tokens[gi].take)
390
+ port << [:data, batch, in_port]
391
+ else
392
+ pending[gi] << batch
393
+ end
394
+ end
395
+ end
396
+
397
+ handle = lambda do |m|
398
+ case m[0]
399
+ when :data
400
+ begin
401
+ case kind
402
+ when :pipe
403
+ dispatch.call(m[1].map { |obj| job.call(obj) })
404
+ when :filter_pipe
405
+ out = m[1].select { |obj| job.call(obj) }
406
+ dispatch.call(out) unless out.empty?
407
+ when :flat_pipe
408
+ out = []
409
+ m[1].each { |obj| job.call(obj).each { |o| out << o } }
410
+ out.each_slice(batch_size) { |slice| dispatch.call(slice) }
411
+ end
412
+ rescue Ractor::ClosedError
413
+ raise
414
+ rescue Exception => e
415
+ groups.first.first.first << [:failure, e] rescue nil
416
+ end
417
+ (m[2] << [:ready, in_port] rescue nil) if m[2] # replenish credit
418
+ when :ready
419
+ gi = port_group[m[1]]
420
+ if (batch = pending[gi].shift)
421
+ m[1] << [:data, batch, in_port]
422
+ else
423
+ tokens[gi].add(m[1])
424
+ end
425
+ when :eos
426
+ eos += 1
427
+ when :failure
428
+ groups.first.first.first << m rescue nil # pass through
429
+ when :cancel
430
+ throw :cancelled
431
+ end
432
+ end
433
+
434
+ catch(:cancelled) do
435
+ ups.each { |up| CREDIT.times { up << [:ready, in_port] rescue nil } }
436
+ early.each { |m| handle.call(m) }
437
+ handle.call(in_port.receive) until eos == n_producers
438
+ # all producers finished: drain buffered batches, then EOS
439
+ handle.call(in_port.receive) until pending.all?(&:empty?)
440
+ groups.each { |ports, _| ports.each { |port| port << [:eos] rescue nil } }
441
+ end
442
+ rescue Ractor::ClosedError
443
+ # a consumer is gone: the stream was cancelled (SIGPIPE-style)
444
+ end
445
+ end
446
+
447
+ # -- DSL entry points ------------------------------------------------
448
+
449
+ def stream(source, batch: 1) = Source.new(source, batch:)
450
+
451
+ # A stream of exactly one element: stream1(obj) == stream([obj]).
452
+ # Use it to flow an object (even an each-able one) as a single element.
453
+ def stream1(obj) = Source.new([obj])
454
+
455
+ # Source-less fragments for tee branches.
456
+ def pipe(lanes: 1, &block) = Branch.new.pipe(lanes:, &block)
457
+ def filter_pipe(lanes: 1, &block) = Branch.new.filter_pipe(lanes:, &block)
458
+ def flat_pipe(lanes: 1, &block) = Branch.new.flat_pipe(lanes:, &block)
459
+
460
+ module_function :stream, :stream1, :pipe, :filter_pipe, :flat_pipe
461
+ end
462
+ end
@@ -0,0 +1,6 @@
1
+ class Ractor
2
+ module Pipeline
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ractor-pipeline
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Koichi Sasada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Ractor::Pipeline provides a shell-pipeline-like DSL (stream/pipe/filter_pipe/flat_pipe/tee/lanes)
13
+ where each stage is a persistent Ractor, making the execution topology visible in
14
+ the code. Multi-lane stages are fed by demand (pull) and sources can be batched
15
+ transparently.
16
+ email:
17
+ - ko1@atdot.net
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE.txt
23
+ - README.md
24
+ - Rakefile
25
+ - examples/demo.rb
26
+ - examples/logstats.rb
27
+ - examples/perf.rb
28
+ - examples/readme_bench.rb
29
+ - examples/vs_parallel.rb
30
+ - lib/ractor/pipeline.rb
31
+ - lib/ractor/pipeline/version.rb
32
+ - sig/ractor/pipeline.rbs
33
+ homepage: https://github.com/ko1/ractor-pipeline
34
+ licenses:
35
+ - MIT
36
+ metadata:
37
+ homepage_uri: https://github.com/ko1/ractor-pipeline
38
+ source_code_uri: https://github.com/ko1/ractor-pipeline
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '4.0'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 4.0.6
54
+ specification_version: 4
55
+ summary: A DSL to build stream processing pipelines with Ractors.
56
+ test_files: []