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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 621d7215c299abc1fbe5da7396b259e3ac77d73a04d38d1195a13dac0ea0aea2
4
+ data.tar.gz: 146547cf104500b5907af8e5629f5ac18344b0a294c61c3eacaba3ebdfec363f
5
+ SHA512:
6
+ metadata.gz: f4859558ee7d05ed135ad67bed0af8394078ff55676d5003700a5cfa792d697bacd49d65656b25544a9f6cf9599be649daaf66459b35a241f3633018512841aa
7
+ data.tar.gz: 3961a0667e933e77bdb4f1108426eae47b5fb1becc16661edcc55ec1c425b1f5d589a64f2946870af0deec889b2bc2b902519aee27497d65ed16c4a22dbe2539
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Koichi Sasada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,367 @@
1
+ # Ractor::Pipeline
2
+
3
+ A DSL to build stream processing pipelines with Ractors. The basic model is
4
+ close to a Unix shell pipeline: `stream` produces an input stream, each
5
+ `pipe`/`filter_pipe` stage is a persistent Ractor connected by
6
+ `Ractor::Port`s, and terminal operations such as `reduce` consume the output
7
+ in the caller Ractor.
8
+
9
+ Unlike `enum.map{}.filter{}`, which describes only data operations, this DSL
10
+ intentionally couples data operations with the execution topology: what you
11
+ see in the code is the Ractor graph that runs.
12
+
13
+ ```ruby
14
+ require "ractor/pipeline"
15
+ include Ractor::Pipeline
16
+
17
+ # conceptually: cat FILE | grep foo | wc
18
+ stream(File.foreach(name)).
19
+ filter_pipe(lanes: 4){ it.include?("foo") }.
20
+ reduce([0, 0, 0]) do |(lines, words, bytes), line|
21
+ [lines + 1, words + line.scan(/\S+/).size, bytes + line.bytesize]
22
+ end
23
+ ```
24
+
25
+ ```text
26
+ File.foreach ──> filter Ractor x4 ──> Port ──> reduce in caller
27
+ ```
28
+
29
+ Key properties:
30
+
31
+ * **1 stage = `lanes:` persistent Ractors** — workers process many
32
+ elements; no Ractor is created per element.
33
+ * **Demand-driven scheduling** — a multi-lane stage is fed by pull: a
34
+ worker gets a new batch only when it asks for one, so a stuck worker
35
+ never has work piling up behind it, and a fast or infinite source is
36
+ read only as fast as the pipeline consumes it.
37
+ * **Transparent batching** — `stream(src, batch: 1000)` packs 1000
38
+ elements per message to amortize the per-message cost; stage blocks
39
+ still see single elements.
40
+
41
+ Requires Ruby 4.0+ (`Ractor::Port`, `Ractor.shareable_proc`). The Ractor API
42
+ is experimental, and so is this library.
43
+
44
+ ## Overview
45
+
46
+ | DSL | Enumerable analogue | topology |
47
+ |---|---|---|
48
+ | `stream(src, batch: k)` | `src.each` | source (fed from the caller) |
49
+ | `stream1(obj)` | `[obj].each` | single-element source |
50
+ | `.pipe{ f(it) }` | `map` | 1 persistent Ractor |
51
+ | `.pipe(lanes: n){}` | `map` | n Ractors, demand-driven, unordered |
52
+ | `.filter_pipe{ pred(it) }` | `filter` | 1 Ractor (sends the original element) |
53
+ | `.flat_pipe{ enum }` | `flat_map` | 1 input -> N outputs |
54
+ | `.tee(pipe{}, pipe{})` | — | broadcast to branches, merged output |
55
+ | `.reduce(init){}` `.each{}` `.to_a` `.count` `.first(n)` | same | terminal, runs in the caller, no Ractor |
56
+
57
+ ## API guide
58
+
59
+ `include Ractor::Pipeline` makes the vocabulary available
60
+ (`Ractor::Pipeline.stream(...)` also works). Stage-building methods return
61
+ the pipeline object, so they chain; nothing runs until a terminal operation
62
+ is called.
63
+
64
+ ### `stream(source, batch: 1)`
65
+
66
+ Creates a pipeline whose input is each element of `source` (anything that
67
+ responds to `each`). Elements are fed from a background Thread in the
68
+ caller Ractor, concurrently with the terminal operation, so producing and
69
+ consuming overlap.
70
+
71
+ ```ruby
72
+ stream([1, 2, 3]) # three elements
73
+ stream(1..) # infinite stream (terminate with .first etc.)
74
+ stream(File.foreach(name)) # one element per line
75
+ ```
76
+
77
+ `batch: k` packs k elements into one message. This is transparent — stage
78
+ blocks still receive one element at a time — and it amortizes the
79
+ per-message cost (sync + copy + envelope) over k elements, which matters
80
+ whenever the work per element is small (see
81
+ [Measured performance](#measured-performance)). Batches shrink through
82
+ `filter_pipe` and are re-split to `<= k` after `flat_pipe`; the message
83
+ *count* is unchanged, so the amortization survives filtering.
84
+
85
+ When the first stage is multi-lane, the source is read on demand: the
86
+ feeder only reads ahead by the workers' open demand tokens, so
87
+ `stream(huge_or_infinite_source)` does not balloon memory.
88
+
89
+ `stream1(obj)` is a shorthand for `stream([obj])`: it flows `obj` as a
90
+ single element, even when `obj` itself is each-able.
91
+
92
+ ```ruby
93
+ stream1(config).pipe{ build(it) }.first
94
+ ```
95
+
96
+ ### `pipe(lanes: 1){ block }`
97
+
98
+ A processing stage: applies the block to each element and sends the return
99
+ value downstream. The current element is `it`.
100
+
101
+ ```ruby
102
+ stream([1, 2, 3]).pipe{ it * 2 }.to_a #=> [2, 4, 6]
103
+ ```
104
+
105
+ With `lanes: n`, the stage becomes n worker Ractors and the boundary into
106
+ it becomes demand-driven: each worker grants a small number of demand
107
+ tokens (2 per producer) and producers send a batch only to a worker they
108
+ hold a token for. A worker that is stuck on an expensive element simply
109
+ stops granting tokens, so the work is distributed by actual availability,
110
+ not round-robin. Completion order is not preserved (see
111
+ [Ordering](#ordering)).
112
+
113
+ ```ruby
114
+ stream(rows).pipe(lanes: 8){ expensive(it) }
115
+ ```
116
+
117
+ Consecutive stages form a pipeline and run concurrently — while stage 2
118
+ processes element k, stage 1 processes element k+1:
119
+
120
+ ```ruby
121
+ stream(src).pipe{ parse(it) }.pipe{ format(it) }
122
+ # src ──> Ractor(parse) ──> Ractor(format) ──> ...
123
+ ```
124
+
125
+ Boundaries into a single consumer (a `lanes: 1` stage, or the terminal
126
+ operation) are plain push over the port's FIFO: no demand round-trip, no
127
+ reordering.
128
+
129
+ ### `filter_pipe(lanes: 1){ block }`
130
+
131
+ Like `pipe`, but the block is a predicate: the *original element* (not the
132
+ block's return value) is sent downstream iff the block returns truthy.
133
+
134
+ ```ruby
135
+ stream(1..10).filter_pipe{ it.even? }.to_a #=> [2, 4, 6, 8, 10]
136
+ ```
137
+
138
+ ### `flat_pipe(lanes: 1){ block }`
139
+
140
+ The block returns an each-able object; each of its elements is sent
141
+ downstream individually (1 input -> N outputs). Useful for feeding many
142
+ files into one fixed-size Ractor graph:
143
+
144
+ ```ruby
145
+ stream(file_names).
146
+ flat_pipe{ File.foreach(it) }. # 1 file -> N lines
147
+ filter_pipe(lanes: 4){ it.include?("Ractor") }.
148
+ count
149
+ ```
150
+
151
+ ### `tee(branch, branch, ...)`
152
+
153
+ Broadcasts each element to *every* branch (not load balancing). Branches
154
+ are receiver-less fragments; their outputs are merged, unordered, into one
155
+ downstream stream. Non-shareable elements are copied once per branch.
156
+
157
+ ```ruby
158
+ evens, odds = stream(1..10).
159
+ tee(
160
+ filter_pipe{ it.even? }.pipe{ [:even, it] },
161
+ filter_pipe{ it.odd? }.pipe{ [:odd, it] },
162
+ ).
163
+ reduce([[], []]) do |(evens, odds), (tag, n)|
164
+ tag == :even ? [evens << n, odds] : [evens, odds << n]
165
+ end
166
+ ```
167
+
168
+ ### Terminal operations
169
+
170
+ Terminal operations run in the caller Ractor (no Ractor is created for
171
+ them) and start the pipeline. Their blocks are ordinary blocks — no
172
+ isolation restrictions, so a mutable accumulator is fine.
173
+
174
+ ```ruby
175
+ pl.reduce(initial){ |acc, elem| ... } # returns the final accumulator
176
+ pl.each{ |elem| ... } # yields each element, returns self
177
+ pl.to_a # collects into an Array
178
+ pl.count # number of output elements
179
+ pl.first # first element (stops the pipeline)
180
+ pl.first(n) # first n elements as an Array
181
+ ```
182
+
183
+ `first` cancels the rest of the stream: it stops the feeder, closes its
184
+ output port, and broadcasts a cancel message, so workers drop their
185
+ backlog and terminate immediately (in-flight sends to closed ports raise
186
+ `Ractor::ClosedError` and cascade the shutdown upstream, like SIGPIPE in a
187
+ shell pipeline). Safe to use with an infinite `stream(1..)`.
188
+
189
+ ### Errors
190
+
191
+ * An exception raised in a stage block cancels the pipeline and is
192
+ re-raised by the terminal operation in the caller:
193
+
194
+ ```ruby
195
+ stream(1..10).pipe{ raise "boom" if it == 5; it }.to_a
196
+ #=> RuntimeError "boom" raised from .to_a
197
+ ```
198
+
199
+ * Stage blocks are isolated with `Ractor.shareable_proc` at construction
200
+ time. Capturing a *shareable* outer value snapshots it; capturing a
201
+ non-shareable value raises `Ractor::IsolationError` immediately at
202
+ `.pipe` time (not at run time). `self` inside a stage block is `nil`,
203
+ so top-level helper methods are callable but instance methods are not.
204
+
205
+ ```ruby
206
+ factor = 10
207
+ stream(1..3).pipe{ it * factor }.to_a #=> [10, 20, 30] (snapshot)
208
+
209
+ buf = String.new
210
+ stream(1..3).pipe{ buf << it.to_s } #=> Ractor::IsolationError at .pipe
211
+ ```
212
+
213
+ * Everything a stage block returns must be sendable through a
214
+ `Ractor::Port`. A common gotcha: a Hash built with `Hash.new{ ... }`
215
+ holds a Proc as its default and cannot cross the boundary — build plain
216
+ hashes (`(h[k] ||= 0) += 1` style) in stage blocks.
217
+
218
+ ## Samples
219
+
220
+ ### wc: `cat README.md | grep Ruby | wc`
221
+
222
+ ```ruby
223
+ lines, words, bytes =
224
+ stream(File.foreach("README.md")).
225
+ filter_pipe(lanes: 4){ it.include?("Ruby") }.
226
+ reduce([0, 0, 0]) do |(l, w, b), line|
227
+ [l + 1, w + line.scan(/\S+/).size, b + line.bytesize]
228
+ end
229
+ ```
230
+
231
+ ### Log crunching: parse JSONL, aggregate per path
232
+
233
+ One message carries a chunk of lines; each stage worker parses its chunk
234
+ and returns a small pre-aggregated hash, so the caller-side reduce merges
235
+ `#chunks` hashes instead of touching every record
236
+ (runnable version: [examples/logstats.rb](examples/logstats.rb)):
237
+
238
+ ```ruby
239
+ stats = stream(lines.each_slice(1000)).
240
+ pipe(lanes: 8){ aggregate(it) }. # chunk -> {path => [req, err, ms]}
241
+ reduce({}){ |acc, st| merge(acc, st) }
242
+ ```
243
+
244
+ ### Unordered results: carry an index and reassemble
245
+
246
+ ```ruby
247
+ picture = stream(0...height).
248
+ pipe(lanes: 8){ [it, render_row(it)] }.
249
+ reduce(Array.new(height)){ |acc, (y, row)| acc[y] = row; acc }
250
+ ```
251
+
252
+ ### Early termination of an infinite stream
253
+
254
+ ```ruby
255
+ stream(1..).pipe{ it * it }.first(5) #=> [1, 4, 9, 16, 25]
256
+ ```
257
+
258
+ ### Fine-grained sources: use batch:
259
+
260
+ ```ruby
261
+ stream(File.foreach(name), batch: 1000).
262
+ filter_pipe(lanes: 4){ it.include?("VALUE") }.
263
+ count
264
+ ```
265
+
266
+ More runnable samples: [examples/demo.rb](examples/demo.rb) (feature tour),
267
+ [examples/perf.rb](examples/perf.rb) and
268
+ [examples/readme_bench.rb](examples/readme_bench.rb) (the measurements
269
+ below).
270
+
271
+ ## Semantics notes
272
+
273
+ * <a name="ordering"></a>**Ordering**: a chain of `lanes: 1` stages
274
+ preserves input order, with or without `batch:`. Multi-lane stages are
275
+ unordered (elements overtake each other across lanes).
276
+ * **Boundaries copy**: non-shareable objects are deep-copied at each Ractor
277
+ boundary (`Ractor::Port#send` default); shareable objects are passed by
278
+ reference.
279
+ * **End of stream** propagates via an in-band EOS message: each worker
280
+ counts EOS from its upstream producers, drains its buffered output, and
281
+ broadcasts EOS downstream, so fan-in and fan-out shut down cleanly.
282
+ * **Cancellation** (`first`, exceptions) closes the terminal's port and
283
+ broadcasts cancel; workers drop their backlog and exit, and the closure
284
+ cascades upstream via `Ractor::ClosedError` (SIGPIPE-style).
285
+ * **Backpressure**: demand-driven boundaries are bounded (2 batches per
286
+ producer/consumer pair), and a multi-lane head stage throttles the
287
+ source itself. Push boundaries (into `lanes: 1` stages and the terminal)
288
+ are unbounded FIFO queues.
289
+
290
+ ## Measured performance
291
+
292
+ Numbers from `examples/readme_bench.rb` (min of 5 runs) on Ruby 4.1.0dev
293
+ (2026-08-18 master, 98b3b8034d) on a 20-thread laptop (Core i7-1370P,
294
+ 6P+8E cores, WSL2). Absolute numbers vary a lot with machine, clocks, and
295
+ Ruby version — treat them as shape, not truth. Ruby 4.0.2 behaves
296
+ similarly except that multi-Ractor CPU throughput at high lane counts is
297
+ ~25% lower.
298
+
299
+ **Lanes sweep** — speedup over serial for `pipe(lanes: n)`:
300
+
301
+ | workload (serial time) | lanes 2 | lanes 4 | lanes 8 | lanes 16 |
302
+ |---|---|---|---|---|
303
+ | fib(28) x 32, uniform CPU (0.52s) | x1.9 | x3.3 | x2.6 | x5.5 |
304
+ | skewed load, 1 heavy + 31 light (0.85s) | x1.8 | x3.2 | x2.8 | x2.4 |
305
+ | mandelbrot, 48 uneven rows (0.32s) | x2.4 | x4.5 | x5.3 | x6.9 |
306
+ | JSONL aggregation, 400k lines (0.25s) | x1.6 | x2.8 | x2.4 | x2.0 |
307
+
308
+ Notes:
309
+
310
+ * **Skewed load** is bounded by the heavy element itself (optimal makespan
311
+ ~x2.8 here); demand-driven distribution saturates that bound from
312
+ `lanes: 4` on. Blind round-robin caps at ~x2.4 because elements queued
313
+ behind the heavy one cannot move to an idle worker.
314
+ * **JSONL parsing is allocation-bound**, and allocation throughput scales
315
+ worse across Ractors than pure computation, so extra lanes stop helping
316
+ early. Pre-aggregate in the workers and keep boundary objects small.
317
+ * The recurring dip at `lanes: 8` is a machine artifact of this hybrid
318
+ P/E-core laptop under WSL2 (eight busy workers get placed badly), not a
319
+ property of the lane count. Expect ±20% run-to-run variance from clock
320
+ scaling in every cell.
321
+
322
+ **Message granularity** — trivial filter over 100k short lines:
323
+
324
+ | serial | 1 line = 1 message | `batch: 1000` |
325
+ |---|---|---|
326
+ | 0.011s | 1.56s | 0.021s |
327
+
328
+ Per-element messages cost ~1µs each (sync + copy + envelope); batching
329
+ amortizes that ~75x. Rule of thumb: aim for >= 1ms of work per message,
330
+ via `batch:` or by chunking the source.
331
+
332
+ **Source throttling** — infinite source, `pipe(lanes: 2){ it }.first(3)`:
333
+ the source's `each` is invoked a few dozen times at most (5–40 across
334
+ runs). A push-fed pipeline reads hundreds of thousands of elements before
335
+ the cancellation lands.
336
+
337
+ ## vs the parallel gem
338
+
339
+ The [parallel](https://github.com/grosser/parallel) gem is a data-parallel
340
+ `map` over a ready-made collection using forked processes (or threads);
341
+ Ractor::Pipeline is an in-process streaming topology. On the overlapping
342
+ map-shaped workloads (`examples/vs_parallel.rb`, same machine/Ruby as
343
+ above, parallel 2.1.0):
344
+
345
+ | workload | Ractor::Pipeline | Parallel processes | Parallel threads |
346
+ |---|---|---|---|
347
+ | 32 x fib(28), 16-way | **x6.6** | x4.6 | x1.0 (GVL) |
348
+ | skewed load, 4-way | x3.3 | x3.4 | — |
349
+ | JSONL aggregation, 8-way | **x2.5** | x1.7 | x0.8 |
350
+ | 10k trivial jobs, 4-way | 0.002s | 0.510s | — |
351
+
352
+ * Threads cannot help CPU-bound work under the GVL; processes and Ractors
353
+ both can.
354
+ * Both distribute work by demand, so skewed load balances equally well.
355
+ * Ractor messages are in-process copies, cheaper than fork + Marshal over
356
+ pipes: the gap widens when jobs carry data (JSONL: x2.5 vs x1.7) and
357
+ becomes decisive for small jobs (250x on trivial jobs, thanks to
358
+ `batch:` and reusable workers).
359
+ * What Parallel cannot express at all: multi-stage pipelines (stages
360
+ running concurrently), infinite/throttled sources, and early
361
+ cancellation of a running stream. What Parallel gives you instead:
362
+ full process isolation and compatibility with any Ruby object without
363
+ shareability rules.
364
+
365
+ ## License
366
+
367
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
data/examples/demo.rb ADDED
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Feature tour of Ractor::Pipeline. Self-contained: file-based samples read
4
+ # this file itself.
5
+ #
6
+ # ruby -I lib examples/demo.rb
7
+
8
+ Warning[:experimental] = false # suppress "Ractor API is experimental"
9
+
10
+ require "ractor/pipeline"
11
+
12
+ include Ractor::Pipeline
13
+
14
+ def section(title)
15
+ puts
16
+ puts "== #{title}"
17
+ yield
18
+ end
19
+
20
+ section "basic: pipe + reduce" do
21
+ sum = stream([1, 2, 3]).
22
+ pipe{ it * 2 }.
23
+ reduce(0){ |acc, n| acc + n }
24
+ p sum #=> 12
25
+ end
26
+
27
+ section "filter_pipe + pipe + to_a (lanes: 1 keeps order)" do
28
+ result = stream(1..10).
29
+ filter_pipe{ it.even? }.
30
+ pipe{ it * 10 }.
31
+ to_a
32
+ p result #=> [20, 40, 60, 80, 100]
33
+ end
34
+
35
+ section "wc: cat #{File.basename(__FILE__)} | grep Ractor | wc" do
36
+ lines, words, bytes =
37
+ stream(File.foreach(__FILE__)).
38
+ filter_pipe(lanes: 4){ it.include?("Ractor") }.
39
+ reduce([0, 0, 0]) do |(l, w, b), line|
40
+ [l + 1, w + line.scan(/\S+/).size, b + line.bytesize]
41
+ end
42
+ puts "lines: #{lines}, words: #{words}, bytes: #{bytes}"
43
+ end
44
+
45
+ section "pipeline parallelism: two stages overlap" do
46
+ n = 6
47
+ wait = 0.05
48
+
49
+ t = Time.now
50
+ stream(1..n).
51
+ pipe{ sleep(0.05); it }. # stage 1
52
+ pipe{ sleep(0.05); it }. # stage 2 works while stage 1 handles the next element
53
+ each{}
54
+ pipeline_time = Time.now - t
55
+
56
+ printf "naive: %.3fs (= n * 2 stages * wait)\n", n * 2 * wait
57
+ printf "pipeline: %.3fs\n", pipeline_time
58
+ end
59
+
60
+ section "tee: broadcast to branches, merged output (unordered)" do
61
+ result = stream(1..5).
62
+ tee(
63
+ pipe{ [:double, it * 2] },
64
+ pipe{ [:square, it * it] },
65
+ ).
66
+ to_a
67
+ p result.sort_by{ |tag, v| [tag.to_s, v] }
68
+ end
69
+
70
+ section "tee with filter branches" do
71
+ evens, odds = stream(1..10).
72
+ tee(
73
+ filter_pipe{ it.even? }.pipe{ [:even, it] },
74
+ filter_pipe{ it.odd? }.pipe{ [:odd, it] },
75
+ ).
76
+ reduce([[], []]) do |(evens, odds), (tag, n)|
77
+ tag == :even ? [evens << n, odds] : [evens, odds << n]
78
+ end
79
+ p evens: evens.sort, odds: odds.sort
80
+ end
81
+
82
+ section "flat_pipe: 1 input -> N outputs" do
83
+ count = stream([__FILE__] * 3). # the same file, three times
84
+ flat_pipe{ File.foreach(it) }. # 1 file -> N lines
85
+ filter_pipe(lanes: 2){ it.include?("section") }.
86
+ count
87
+ puts "lines containing 'section' (x3): #{count}"
88
+ end
89
+
90
+ section "batch: transparent batching (blocks still see single elements)" do
91
+ result = stream(1..10, batch: 3).
92
+ filter_pipe{ it.even? }.
93
+ pipe{ it * 10 }.
94
+ to_a
95
+ p result #=> [20, 40, 60, 80, 100] (batch 越しでも lanes: 1 は順序保持)
96
+ end
97
+
98
+ section "throttling: a multi-lane head reads the source on demand" do
99
+ reads = 0
100
+ counting = Enumerator.new do |y|
101
+ loop { y << (reads += 1) }
102
+ end
103
+ stream(counting).pipe(lanes: 2){ it }.first(3)
104
+ puts "source reads: #{reads} (push だと数十万読む)"
105
+ end
106
+
107
+ section "first: early termination of an infinite stream" do
108
+ result = stream(1..).
109
+ pipe{ it * it }.
110
+ first(5)
111
+ p result #=> [1, 4, 9, 16, 25]
112
+ end
113
+
114
+ section "exception: raised in a stage, re-raised at the terminal" do
115
+ begin
116
+ stream(1..10).
117
+ pipe{ raise "boom at #{it}" if it == 5; it }.
118
+ to_a
119
+ rescue RuntimeError => e
120
+ puts "caught: #{e.message} (#{e.class})"
121
+ end
122
+ end
123
+
124
+ section "block isolation: outer variables are snapshotted at stage construction" do
125
+ factor = 10
126
+ p stream(1..3).pipe{ it * factor }.to_a #=> [10, 20, 30]
127
+
128
+ # Referencing a non-shareable value fails early, at .pipe time.
129
+ buf = String.new("mutable")
130
+ begin
131
+ stream(1..3).pipe{ buf + it.to_s }
132
+ rescue Ractor::IsolationError => e
133
+ puts "caught: #{e.message}"
134
+ end
135
+ end
136
+
137
+ puts
138
+ puts "done."
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A realistic-ish benchmark: aggregate statistics from JSONL access logs.
4
+ #
5
+ # ruby -I lib examples/logstats.rb
6
+ #
7
+ # Each line is a JSON object like
8
+ # {"path":"/api/users","status":200,"ms":12.3}
9
+ # and the task is: parse every line, count requests and errors per path,
10
+ # and sum latencies (i.e. the shape of a typical log-crunching script).
11
+ #
12
+ # The pipeline version chunks lines (1000 lines = 1 message), parses and
13
+ # pre-aggregates each chunk in parallel stage workers, and merges the
14
+ # small per-chunk hashes in the caller.
15
+
16
+ Warning[:experimental] = false # suppress "Ractor API is experimental"
17
+
18
+ require "ractor/pipeline"
19
+ require "json"
20
+
21
+ include Ractor::Pipeline
22
+
23
+ N_LINES = 400_000
24
+ CHUNK = 1_000
25
+ LANES = 8
26
+
27
+ def make_log(n)
28
+ rng = Random.new(42)
29
+ paths = ["/", "/api/users", "/api/items", "/api/search", "/login", "/assets/app.js"]
30
+ Array.new(n) do
31
+ path = paths[rng.rand(paths.size)]
32
+ status = rng.rand(100) < 3 ? 500 : 200
33
+ ms = (rng.rand * 300).round(1)
34
+ %({"path":"#{path}","status":#{status},"ms":#{ms}})
35
+ end
36
+ end
37
+
38
+ # chunk of lines -> small aggregate hash {path => [requests, errors, total_ms]}
39
+ # NOTE: no Hash.new{} here — a Hash with a default_proc cannot cross a
40
+ # Ractor boundary (Procs are not copyable).
41
+ def aggregate(lines)
42
+ stats = {}
43
+ lines.each do |line|
44
+ rec = JSON.parse(line)
45
+ s = (stats[rec["path"]] ||= [0, 0, 0.0])
46
+ s[0] += 1
47
+ s[1] += 1 if rec["status"] >= 500
48
+ s[2] += rec["ms"]
49
+ end
50
+ stats
51
+ end
52
+
53
+ def merge(acc, stats)
54
+ stats.each do |path, (req, err, ms)|
55
+ a = (acc[path] ||= [0, 0, 0.0])
56
+ a[0] += req
57
+ a[1] += err
58
+ a[2] += ms
59
+ end
60
+ acc
61
+ end
62
+
63
+ def report(label, stats, dt, base = nil)
64
+ total = stats.values.sum(&:first)
65
+ speedup = base ? " (x%.2f)" % (base / dt) : ""
66
+ printf "%-28s %8.3fs%s (%d reqs, %d errors)\n",
67
+ label, dt, speedup, total, stats.values.sum{ |_, e, _| e }
68
+ end
69
+
70
+ lines = make_log(N_LINES)
71
+ puts "#{N_LINES} JSONL lines, batch/chunk=#{CHUNK}, lanes=#{LANES}"
72
+
73
+ t = Time.now
74
+ serial = lines.each_slice(CHUNK).map{ aggregate(it) }.reduce({}){ |a, s| merge(a, s) }
75
+ serial_dt = Time.now - t
76
+ report "serial", serial, serial_dt
77
+
78
+ # Style 1: transparent batching. Blocks see single lines/records: parse in
79
+ # the stage workers and project each record down to a small tuple before
80
+ # it crosses the boundary (copying the whole parsed Hash would cost about
81
+ # as much as parsing it).
82
+ t = Time.now
83
+ per_record = stream(lines, batch: CHUNK).
84
+ pipe(lanes: LANES){ r = JSON.parse(it); [r["path"], r["status"], r["ms"]] }.
85
+ reduce({}) do |acc, (path, status, ms)|
86
+ s = (acc[path] ||= [0, 0, 0.0])
87
+ s[0] += 1
88
+ s[1] += 1 if status >= 500
89
+ s[2] += ms
90
+ acc
91
+ end
92
+ per_record_dt = Time.now - t
93
+ report "batch + per-record reduce", per_record, per_record_dt, serial_dt
94
+
95
+ # Style 2: chunk-aggregate. One message carries a chunk of lines and the
96
+ # stage returns a small pre-aggregated hash, so the reduce merges
97
+ # #chunks hashes instead of touching every record.
98
+ t = Time.now
99
+ parallel = stream(lines.each_slice(CHUNK)).
100
+ pipe(lanes: LANES){ aggregate(it) }.
101
+ reduce({}){ |acc, stats| merge(acc, stats) }
102
+ parallel_dt = Time.now - t
103
+ report "chunk-aggregate + merge", parallel, parallel_dt, serial_dt
104
+
105
+ # Float sums depend on addition order (multi-lane stages are unordered),
106
+ # so compare with rounded latency totals.
107
+ def canon(stats) = stats.transform_values{ |req, err, ms| [req, err, ms.round(3)] }.sort
108
+ raise "mismatch" unless canon(serial) == canon(parallel) && canon(serial) == canon(per_record)
109
+
110
+ puts
111
+ puts "per-path stats:"
112
+ serial.sort_by{ |_, (req, _, _)| -req }.each do |path, (req, err, ms)|
113
+ printf " %-16s %7d reqs %5d errors %8.1f ms avg\n", path, req, err, ms / req
114
+ end