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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +168 -12
- data/examples/README.md +5 -0
- data/examples/ractor_unordered_map_hashing.rb +62 -0
- data/lib/fiber_stream/flow.rb +123 -0
- 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/ractor_unordered_map_boundary.rb +521 -0
- data/lib/fiber_stream/pull/reject.rb +40 -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 +39 -3
- data/lib/fiber_stream/rate_limiter.rb +163 -0
- data/lib/fiber_stream/sink.rb +68 -0
- data/lib/fiber_stream/source.rb +66 -0
- data/lib/fiber_stream/version.rb +1 -1
- data/lib/fiber_stream.rb +1 -0
- data/sig/fiber_stream.rbs +30 -0
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b729811e8b39efdfa4203d0e6167709105d524ec098f018fdc120986c0b73b14
|
|
4
|
+
data.tar.gz: 162a3bf8a893fe13ae994ec77c8a2df7c8c44f9c5ab11895ee3b3b0ddfe31192
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: edd97c825b99250b4169443764cb384330fd8c3176e4086c6a1a93834c67fd288b5f0583fce50b68277936de1fe9f303162063ff182523beed3af935b568d2e0
|
|
7
|
+
data.tar.gz: dcb33221af26887b3a914f428c0b58e6d8ecf8511c384c0282b889e8b74b1d17fecd6e5ba86b465dfc466252bef0bb960f9bcfd66229c9e6ccd0b12cf7b3a768
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.6.0 - 2026-07-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `Flow.ractor_unordered_map(workers:)` and `Source#ractor_unordered_map` for
|
|
10
|
+
Ractor-backed CPU-bound mapping that emits results in completion order.
|
|
11
|
+
- `Sink.find { |element| ... }` for predicate-based terminal search with
|
|
12
|
+
early upstream completion.
|
|
13
|
+
- `Sink.any? { |element| ... }` for predicate-based existential checks with
|
|
14
|
+
early upstream completion.
|
|
15
|
+
- `Sink.all? { |element| ... }` for predicate-based universal checks with
|
|
16
|
+
early upstream completion.
|
|
17
|
+
- Example and benchmark coverage for unordered Ractor-backed mapping.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Added a rate-limiting tutorial covering local, shared in-process,
|
|
22
|
+
database-backed, and Redis-backed quota policies.
|
|
23
|
+
- Centralized the local and release verification gates and added documentation
|
|
24
|
+
index validation.
|
|
25
|
+
|
|
26
|
+
## 0.5.0 - 2026-06-21
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `Flow.tap { |element| ... }` for lazy pass-through observation without
|
|
31
|
+
changing emitted elements.
|
|
32
|
+
- `Flow.filter_map { |element| ... }` and `Source#filter_map` for combined
|
|
33
|
+
transformation and falsey-value dropping.
|
|
34
|
+
- `Flow.reject { |element| ... }` and `Source#reject` for complement
|
|
35
|
+
predicate filtering.
|
|
36
|
+
- `Flow.compact` and `Source#compact` for nil-only filtering while preserving
|
|
37
|
+
`false`.
|
|
38
|
+
- `Flow.map_concat { |element| enumerable }` and `Source#map_concat` for
|
|
39
|
+
one-to-many element expansion.
|
|
40
|
+
- `Flow.throttle(...)`, `Source#throttle(...)`, and `RateLimiter` for
|
|
41
|
+
pull-driven rate limiting with optional shared quota state.
|
|
42
|
+
- `Sink.count` for counting stream elements without accumulating them.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Expanded README and website reference coverage for the flow operators and
|
|
47
|
+
rate limiter added in 0.5.0.
|
|
48
|
+
- Promoted completed flow product specs and design docs from draft to accepted
|
|
49
|
+
status.
|
|
50
|
+
|
|
3
51
|
## 0.4.0 - 2026-06-09
|
|
4
52
|
|
|
5
53
|
### Added
|
data/README.md
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# FiberStream
|
|
2
|
-
FiberStream is a Ruby library for linear stream processing with pull-based backpressure.
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
FiberStream is a Ruby library for linear stream processing with pull-based
|
|
4
|
+
backpressure.
|
|
5
|
+
|
|
6
|
+
It builds lazy `Source` definitions, transforms values with `Flow` stages, and
|
|
7
|
+
materializes results with `Sink` objects.
|
|
5
8
|
|
|
6
9
|
[](https://badge.fury.io/rb/fiber_stream)
|
|
7
10
|
|
|
@@ -30,11 +33,13 @@ Implemented capabilities:
|
|
|
30
33
|
- in-memory, IO, FiberStream-owned Ractor producer, backpressure-aware Ractor
|
|
31
34
|
port, and Ractor port merge sources
|
|
32
35
|
- lazy source concatenation, zipping, and scheduler-backed merging
|
|
33
|
-
- mapping, filtering,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
- mapping, filtering, transform-and-filter, nil compaction, side-effect
|
|
37
|
+
observation, one-to-many expansion, limiting, predicate-based limiting and
|
|
38
|
+
dropping, fixed-prefix dropping, fixed-size grouping, line splitting,
|
|
39
|
+
buffering, async boundaries, throttling, ordered and unordered parallel
|
|
40
|
+
mapping, and ordered and unordered Ractor-backed mapping
|
|
41
|
+
- array, first-element, predicate search/check, count, fold, foreach, and IO
|
|
42
|
+
sinks
|
|
38
43
|
- reusable flow composition and runnable pipelines
|
|
39
44
|
- foreground and scheduler-backed background pipeline execution
|
|
40
45
|
- public RBS signatures
|
|
@@ -205,6 +210,62 @@ FiberStream::Source.each([" a ", "", " b "])
|
|
|
205
210
|
# => ["a", "b"]
|
|
206
211
|
```
|
|
207
212
|
|
|
213
|
+
Use `reject` when the predicate names values to drop. Truthy predicate results
|
|
214
|
+
drop the original element; `false` and `nil` results pass it through unchanged:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
result =
|
|
218
|
+
FiberStream::Source.each([1, 2, 3, 4])
|
|
219
|
+
.reject(&:even?)
|
|
220
|
+
.run_with(FiberStream::Sink.to_a)
|
|
221
|
+
|
|
222
|
+
result # => [1, 3]
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Use `filter_map` when filtering and transformation are one decision. Truthy
|
|
226
|
+
block results are emitted as transformed values; `false` and `nil` are
|
|
227
|
+
dropped:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
ids =
|
|
231
|
+
FiberStream::Source.each([{ id: 1 }, {}, { id: 3 }])
|
|
232
|
+
.filter_map { |record| record[:id] }
|
|
233
|
+
.run_with(FiberStream::Sink.to_a)
|
|
234
|
+
|
|
235
|
+
ids # => [1, 3]
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Use `compact` to drop only `nil` while keeping `false`, and `map_concat` to
|
|
239
|
+
expand one upstream element into zero or more downstream elements:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
tokens =
|
|
243
|
+
FiberStream::Source.each(["alpha beta", nil, "gamma"])
|
|
244
|
+
.compact
|
|
245
|
+
.map_concat { |line| line.split }
|
|
246
|
+
.run_with(FiberStream::Sink.to_a)
|
|
247
|
+
|
|
248
|
+
tokens # => ["alpha", "beta", "gamma"]
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Use `Flow.tap` for observation inside a reusable flow without changing the
|
|
252
|
+
element:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
seen = []
|
|
256
|
+
|
|
257
|
+
observed =
|
|
258
|
+
FiberStream::Flow.tap { |value| seen << value }
|
|
259
|
+
.via(FiberStream::Flow.map { |value| value * 10 })
|
|
260
|
+
|
|
261
|
+
FiberStream::Source.each([1, 2])
|
|
262
|
+
.via(observed)
|
|
263
|
+
.run_with(FiberStream::Sink.to_a)
|
|
264
|
+
# => [10, 20]
|
|
265
|
+
|
|
266
|
+
seen # => [1, 2]
|
|
267
|
+
```
|
|
268
|
+
|
|
208
269
|
Use `parallel_map` for ordered scheduler-backed mapping when each element
|
|
209
270
|
waits on non-blocking IO. It preserves input order while allowing up to
|
|
210
271
|
`concurrency` mapping operations to be in flight:
|
|
@@ -249,8 +310,10 @@ responses =
|
|
|
249
310
|
responses
|
|
250
311
|
```
|
|
251
312
|
|
|
252
|
-
Use `ractor_map` for ordered CPU-bound mapping in Ractor workers
|
|
253
|
-
|
|
313
|
+
Use `ractor_map` for ordered CPU-bound mapping in Ractor workers, and
|
|
314
|
+
`ractor_unordered_map` when later completed CPU-bound results should not wait
|
|
315
|
+
behind a slower earlier input. The mapper must be shareable, usually by
|
|
316
|
+
creating it with `Ractor.shareable_proc`.
|
|
254
317
|
|
|
255
318
|
```ruby
|
|
256
319
|
require "digest"
|
|
@@ -283,6 +346,19 @@ digests =
|
|
|
283
346
|
or `output_transfer: :move` only when the moved object will not be reused by
|
|
284
347
|
the sender.
|
|
285
348
|
|
|
349
|
+
`ractor_unordered_map` uses the same Ractor transfer and shareability rules,
|
|
350
|
+
but emits values in worker completion order:
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
fastest_first =
|
|
354
|
+
FiberStream::Source.each(records)
|
|
355
|
+
.ractor_unordered_map(workers: 2, input_transfer: :move, &HASH_RECORD)
|
|
356
|
+
.run_with(FiberStream::Sink.to_a)
|
|
357
|
+
|
|
358
|
+
# Results are in completion order, not necessarily input order.
|
|
359
|
+
fastest_first
|
|
360
|
+
```
|
|
361
|
+
|
|
286
362
|
### Sinks
|
|
287
363
|
|
|
288
364
|
A `Sink` consumes the stream and returns a materialized value.
|
|
@@ -293,6 +369,38 @@ FiberStream::Source.each([1, 2, 3])
|
|
|
293
369
|
# => 6
|
|
294
370
|
```
|
|
295
371
|
|
|
372
|
+
Use `Sink.count` when only the number of elements matters:
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
FiberStream::Source.each([1, 2, 3])
|
|
376
|
+
.run_with(FiberStream::Sink.count)
|
|
377
|
+
# => 3
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Use `Sink.find` when the stream should stop after the first matching element:
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
FiberStream::Source.each([1, 2, 3, 4])
|
|
384
|
+
.run_with(FiberStream::Sink.find(&:even?))
|
|
385
|
+
# => 2
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Use `Sink.any?` when only the existence of a matching element matters:
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
FiberStream::Source.each([1, 2, 3, 4])
|
|
392
|
+
.run_with(FiberStream::Sink.any?(&:even?))
|
|
393
|
+
# => true
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Use `Sink.all?` when every element must satisfy a predicate:
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
FiberStream::Source.each([2, 4, 6])
|
|
400
|
+
.run_with(FiberStream::Sink.all?(&:even?))
|
|
401
|
+
# => true
|
|
402
|
+
```
|
|
403
|
+
|
|
296
404
|
Use `Sink.foreach` when the terminal operation is a side effect and the stream
|
|
297
405
|
values should not be accumulated:
|
|
298
406
|
|
|
@@ -473,12 +581,15 @@ does not provide CPU parallelism. Use producer ractors with
|
|
|
473
581
|
`Source.ractor_producer` or `Source.ractor_merge_producers` when producer work
|
|
474
582
|
needs true isolation.
|
|
475
583
|
|
|
476
|
-
`Flow.buffer(count)` allows bounded prefetch. `Flow.
|
|
584
|
+
`Flow.buffer(count)` allows bounded prefetch. `Flow.throttle(rate:, per:)`
|
|
585
|
+
paces elements before downstream side effects. `Flow.async`, `Flow.buffer`,
|
|
477
586
|
`Flow.parallel_map`, `Flow.parallel_unordered_map`, `Source.io`,
|
|
478
587
|
`Source#merge`, `Sink.io`, and `Pipeline#run_async` require an installed
|
|
479
588
|
`Fiber.scheduler` and a non-blocking current fiber when demanded or started.
|
|
480
|
-
|
|
481
|
-
|
|
589
|
+
`Flow.throttle` requires that scheduler context only when it needs to wait.
|
|
590
|
+
Pass `throttle(limiter:)` with a `FiberStream::RateLimiter` when multiple
|
|
591
|
+
pipelines or repeated runs should share quota state. FiberStream does not
|
|
592
|
+
install a scheduler and does not depend on Async at runtime.
|
|
482
593
|
|
|
483
594
|
## API Surface
|
|
484
595
|
|
|
@@ -498,10 +609,15 @@ Source convenience methods:
|
|
|
498
609
|
- `Source#zip(source)`
|
|
499
610
|
- `Source#merge(source)`
|
|
500
611
|
- `Source#map { |element| ... }`
|
|
612
|
+
- `Source#filter_map { |element| ... }`
|
|
613
|
+
- `Source#compact`
|
|
614
|
+
- `Source#map_concat { |element| enumerable }`
|
|
501
615
|
- `Source#parallel_map(concurrency:) { |element| ... }`
|
|
502
616
|
- `Source#parallel_unordered_map(concurrency:) { |element| ... }`
|
|
503
617
|
- `Source#ractor_map(workers:, input_transfer: :copy, output_transfer: :copy) { |element| ... }`
|
|
618
|
+
- `Source#ractor_unordered_map(workers:, input_transfer: :copy, output_transfer: :copy) { |element| ... }`
|
|
504
619
|
- `Source#select { |element| ... }`
|
|
620
|
+
- `Source#reject { |element| ... }`
|
|
505
621
|
- `Source#take(count)`
|
|
506
622
|
- `Source#drop(count)`
|
|
507
623
|
- `Source#grouped(count)`
|
|
@@ -510,6 +626,8 @@ Source convenience methods:
|
|
|
510
626
|
- `Source#drop_while { |element| ... }`
|
|
511
627
|
- `Source#async`
|
|
512
628
|
- `Source#buffer(count)`
|
|
629
|
+
- `Source#throttle(rate:, per: 1, burst: nil)`
|
|
630
|
+
- `Source#throttle(limiter:)`
|
|
513
631
|
- `Source#lines(chomp: true, max_length: nil)`
|
|
514
632
|
- `Source#split(separator, keep_separator: false, max_length: nil)`
|
|
515
633
|
- `Source#to(sink)`
|
|
@@ -518,10 +636,16 @@ Source convenience methods:
|
|
|
518
636
|
Flows:
|
|
519
637
|
|
|
520
638
|
- `FiberStream::Flow.map { |element| ... }`
|
|
639
|
+
- `FiberStream::Flow.filter_map { |element| ... }`
|
|
640
|
+
- `FiberStream::Flow.compact`
|
|
641
|
+
- `FiberStream::Flow.map_concat { |element| enumerable }`
|
|
642
|
+
- `FiberStream::Flow.tap { |element| ... }`
|
|
521
643
|
- `FiberStream::Flow.parallel_map(concurrency:) { |element| ... }`
|
|
522
644
|
- `FiberStream::Flow.parallel_unordered_map(concurrency:) { |element| ... }`
|
|
523
645
|
- `FiberStream::Flow.ractor_map(workers:, input_transfer: :copy, output_transfer: :copy) { |element| ... }`
|
|
646
|
+
- `FiberStream::Flow.ractor_unordered_map(workers:, input_transfer: :copy, output_transfer: :copy) { |element| ... }`
|
|
524
647
|
- `FiberStream::Flow.select { |element| ... }`
|
|
648
|
+
- `FiberStream::Flow.reject { |element| ... }`
|
|
525
649
|
- `FiberStream::Flow.take(count)`
|
|
526
650
|
- `FiberStream::Flow.drop(count)`
|
|
527
651
|
- `FiberStream::Flow.grouped(count)`
|
|
@@ -530,10 +654,14 @@ Flows:
|
|
|
530
654
|
- `FiberStream::Flow.drop_while { |element| ... }`
|
|
531
655
|
- `FiberStream::Flow.async`
|
|
532
656
|
- `FiberStream::Flow.buffer(count)`
|
|
657
|
+
- `FiberStream::Flow.throttle(rate:, per: 1, burst: nil)`
|
|
658
|
+
- `FiberStream::Flow.throttle(limiter:)`
|
|
533
659
|
- `FiberStream::Flow.lines(chomp: true, max_length: nil)`
|
|
534
660
|
- `FiberStream::Flow.split(separator, keep_separator: false, max_length: nil)`
|
|
535
661
|
- `Flow#via(flow)`
|
|
536
662
|
- `Flow#to(sink)`
|
|
663
|
+
- `FiberStream::RateLimiter.new(rate:, per: 1, burst: nil)`
|
|
664
|
+
- `FiberStream::RateLimiter#acquire(permits: 1)`
|
|
537
665
|
|
|
538
666
|
`lines` and `split` default to `max_length: nil`, which allows one
|
|
539
667
|
unterminated line or frame to buffer without bound. Set a positive
|
|
@@ -543,6 +671,10 @@ Sinks:
|
|
|
543
671
|
|
|
544
672
|
- `FiberStream::Sink.to_a`
|
|
545
673
|
- `FiberStream::Sink.first`
|
|
674
|
+
- `FiberStream::Sink.find { |element| truthy_or_falsey }`
|
|
675
|
+
- `FiberStream::Sink.any? { |element| truthy_or_falsey }`
|
|
676
|
+
- `FiberStream::Sink.all? { |element| truthy_or_falsey }`
|
|
677
|
+
- `FiberStream::Sink.count`
|
|
546
678
|
- `FiberStream::Sink.fold(initial) { |accumulator, element| ... }`
|
|
547
679
|
- `FiberStream::Sink.foreach { |element| ... }`
|
|
548
680
|
- `FiberStream::Sink.io(io, close: false, flush: false)`
|
|
@@ -568,6 +700,7 @@ bundle exec ruby examples/file_copy.rb
|
|
|
568
700
|
bundle exec ruby examples/backpressure_buffer.rb
|
|
569
701
|
bundle exec ruby examples/background_execution.rb
|
|
570
702
|
bundle exec ruby examples/ractor_map_hashing.rb
|
|
703
|
+
bundle exec ruby examples/ractor_unordered_map_hashing.rb
|
|
571
704
|
bundle exec ruby examples/ractor_producer_sources.rb
|
|
572
705
|
bundle exec ruby examples/ractor_port_source.rb
|
|
573
706
|
bundle exec ruby examples/ractor_merge_ports_and_map.rb
|
|
@@ -581,6 +714,9 @@ events so the difference between direct demand and bounded prefetch is visible.
|
|
|
581
714
|
`examples/ractor_map_hashing.rb` demonstrates ordered Ractor-backed hashing
|
|
582
715
|
with a shareable mapper proc and `input_transfer: :move`.
|
|
583
716
|
|
|
717
|
+
`examples/ractor_unordered_map_hashing.rb` demonstrates completion-order
|
|
718
|
+
Ractor-backed hashing for uneven CPU-bound work.
|
|
719
|
+
|
|
584
720
|
`examples/ractor_producer_sources.rb` demonstrates high-level owned producer
|
|
585
721
|
Ractors with `Source.ractor_producer` and `Source.ractor_merge_producers`.
|
|
586
722
|
|
|
@@ -618,6 +754,14 @@ Install dependencies:
|
|
|
618
754
|
bundle install
|
|
619
755
|
```
|
|
620
756
|
|
|
757
|
+
Install website dependencies when building documentation locally:
|
|
758
|
+
|
|
759
|
+
```sh
|
|
760
|
+
cd website
|
|
761
|
+
npm ci
|
|
762
|
+
cd ..
|
|
763
|
+
```
|
|
764
|
+
|
|
621
765
|
Run the test suite:
|
|
622
766
|
|
|
623
767
|
```sh
|
|
@@ -642,6 +786,18 @@ Run all default checks:
|
|
|
642
786
|
bundle exec rake
|
|
643
787
|
```
|
|
644
788
|
|
|
789
|
+
Run the default local verification gate explicitly:
|
|
790
|
+
|
|
791
|
+
```sh
|
|
792
|
+
bundle exec rake verify
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
Run the full release-readiness gate, including the website build:
|
|
796
|
+
|
|
797
|
+
```sh
|
|
798
|
+
bundle exec rake verify:full
|
|
799
|
+
```
|
|
800
|
+
|
|
645
801
|
Build the gem:
|
|
646
802
|
|
|
647
803
|
```sh
|
data/examples/README.md
CHANGED
|
@@ -10,6 +10,7 @@ bundle exec ruby examples/file_copy.rb
|
|
|
10
10
|
bundle exec ruby examples/backpressure_buffer.rb
|
|
11
11
|
bundle exec ruby examples/background_execution.rb
|
|
12
12
|
bundle exec ruby examples/ractor_map_hashing.rb
|
|
13
|
+
bundle exec ruby examples/ractor_unordered_map_hashing.rb
|
|
13
14
|
bundle exec ruby examples/ractor_port_source.rb
|
|
14
15
|
bundle exec ruby examples/ractor_producer_sources.rb
|
|
15
16
|
bundle exec ruby examples/ractor_merge_ports_and_map.rb
|
|
@@ -44,6 +45,10 @@ the foreground fiber keeps doing scheduler-managed work.
|
|
|
44
45
|
`input_transfer: :move` because the input records are not reused after the
|
|
45
46
|
pipeline runs.
|
|
46
47
|
|
|
48
|
+
`ractor_unordered_map_hashing.rb` hashes independent payloads in Ractor workers
|
|
49
|
+
and emits results as workers finish. It shows how a slower earlier input no
|
|
50
|
+
longer holds back later completed CPU-bound work.
|
|
51
|
+
|
|
47
52
|
`ractor_port_source.rb` demonstrates a producer Ractor connected to
|
|
48
53
|
`Source.ractor_port`. The producer creates its acknowledgment port, waits for
|
|
49
54
|
`RactorPort::Ack`, and sends one typed `RactorPort::Element` per downstream
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
4
|
+
|
|
5
|
+
require "digest"
|
|
6
|
+
require "fiber_stream"
|
|
7
|
+
|
|
8
|
+
records = [
|
|
9
|
+
{ name: "slow-alpha.bin", payload: +"A" * 160_000, rounds: 1_200 },
|
|
10
|
+
{ name: "fast-bravo.bin", payload: +"B" * 120_000, rounds: 120 },
|
|
11
|
+
{ name: "fast-charlie.bin", payload: +"C" * 140_000, rounds: 120 },
|
|
12
|
+
{ name: "medium-delta.bin", payload: +"D" * 100_000, rounds: 450 }
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
HASH_RECORD =
|
|
16
|
+
Ractor.shareable_proc do |record|
|
|
17
|
+
payload = record.fetch(:payload)
|
|
18
|
+
digest = Digest::SHA256.hexdigest(payload)
|
|
19
|
+
|
|
20
|
+
record.fetch(:rounds).times do
|
|
21
|
+
digest = Digest::SHA256.hexdigest(digest)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
name: record.fetch(:name),
|
|
26
|
+
bytes: payload.bytesize,
|
|
27
|
+
rounds: record.fetch(:rounds),
|
|
28
|
+
sha256: digest
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
puts "Input order"
|
|
33
|
+
records.each.with_index(1) do |record, index|
|
|
34
|
+
puts format(
|
|
35
|
+
"%<index>2d. %-16<name>s %4<rounds>d rounds",
|
|
36
|
+
index: index,
|
|
37
|
+
name: record.fetch(:name),
|
|
38
|
+
rounds: record.fetch(:rounds)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
digests =
|
|
43
|
+
FiberStream::Source.each(records)
|
|
44
|
+
.ractor_unordered_map(workers: 2, input_transfer: :move, &HASH_RECORD)
|
|
45
|
+
.run_with(FiberStream::Sink.to_a)
|
|
46
|
+
|
|
47
|
+
puts
|
|
48
|
+
puts "Completion order"
|
|
49
|
+
digests.each.with_index(1) do |digest, index|
|
|
50
|
+
puts format(
|
|
51
|
+
"%<index>2d. %-16<name>s %7<bytes>d bytes %4<rounds>d rounds %<sha256>s",
|
|
52
|
+
index: index,
|
|
53
|
+
name: digest.fetch(:name),
|
|
54
|
+
bytes: digest.fetch(:bytes),
|
|
55
|
+
rounds: digest.fetch(:rounds),
|
|
56
|
+
sha256: digest.fetch(:sha256)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts
|
|
61
|
+
puts "Results are emitted as Ractor workers finish, not by input position."
|
|
62
|
+
puts "input_transfer: :move is safe here because the input records are not reused."
|
data/lib/fiber_stream/flow.rb
CHANGED
|
@@ -13,6 +13,51 @@ module FiberStream
|
|
|
13
13
|
new { |upstream| Pull.map(upstream, block) }
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Creates a transform-and-filter flow.
|
|
17
|
+
#
|
|
18
|
+
# The block is called once for each upstream element observed by this
|
|
19
|
+
# stage. Truthy block results are emitted downstream as transformed values;
|
|
20
|
+
# false and nil results are dropped. Exceptions raised by the block fail the
|
|
21
|
+
# stream and are re-raised from `Source#run_with`.
|
|
22
|
+
def self.filter_map(&block)
|
|
23
|
+
raise ArgumentError, "missing block" unless block
|
|
24
|
+
|
|
25
|
+
new { |upstream| Pull.filter_map(upstream, block) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Creates a nil-dropping flow.
|
|
29
|
+
#
|
|
30
|
+
# The flow drops `nil` elements and passes every non-`nil` element through
|
|
31
|
+
# unchanged, including `false`.
|
|
32
|
+
def self.compact
|
|
33
|
+
new { |upstream| Pull.compact(upstream) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Creates a one-to-many mapping flow.
|
|
37
|
+
#
|
|
38
|
+
# The block is called once for each upstream element whose expansion is
|
|
39
|
+
# needed. It must return an object that responds to `#each`; yielded values
|
|
40
|
+
# are emitted in order before the next upstream element is pulled.
|
|
41
|
+
# Exceptions raised by the block or by the returned object's `#each` fail
|
|
42
|
+
# the stream and are re-raised from `Source#run_with`.
|
|
43
|
+
def self.map_concat(&block)
|
|
44
|
+
raise ArgumentError, "missing block" unless block
|
|
45
|
+
|
|
46
|
+
new { |upstream| Pull.map_concat(upstream, block) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Creates a pass-through observing flow.
|
|
50
|
+
#
|
|
51
|
+
# The block is called once for each element before that element is emitted
|
|
52
|
+
# downstream. The block return value is ignored and the original element is
|
|
53
|
+
# passed through unchanged. Exceptions raised by the block fail the stream
|
|
54
|
+
# and are re-raised from `Source#run_with`.
|
|
55
|
+
def self.tap(&block)
|
|
56
|
+
raise ArgumentError, "missing block" unless block
|
|
57
|
+
|
|
58
|
+
new { |upstream| Pull.tap(upstream, block) }
|
|
59
|
+
end
|
|
60
|
+
|
|
16
61
|
# Creates an ordered scheduler-backed parallel mapping flow.
|
|
17
62
|
#
|
|
18
63
|
# The stage starts internal scheduled fibers on first downstream demand and
|
|
@@ -66,6 +111,26 @@ module FiberStream
|
|
|
66
111
|
new { |upstream| Pull.ractor_map(upstream, workers, input_transfer, output_transfer, block) }
|
|
67
112
|
end
|
|
68
113
|
|
|
114
|
+
# Creates an unordered Ractor-backed mapping flow.
|
|
115
|
+
#
|
|
116
|
+
# The mapper runs inside worker ractors and must be shareable, typically
|
|
117
|
+
# created with `Ractor.shareable_proc`. Results are emitted in worker
|
|
118
|
+
# completion order, and at most `workers` upstream elements are pulled but
|
|
119
|
+
# not yet emitted. `input_transfer` and `output_transfer` must be `:copy`
|
|
120
|
+
# or `:move` and are passed to Ractor message sends for element and result
|
|
121
|
+
# transfer.
|
|
122
|
+
def self.ractor_unordered_map(workers:, input_transfer: :copy, output_transfer: :copy, &block)
|
|
123
|
+
raise ArgumentError, "missing block" unless block
|
|
124
|
+
raise TypeError, "workers must be an Integer" unless workers.is_a?(Integer)
|
|
125
|
+
raise ArgumentError, "workers must be positive" unless workers.positive?
|
|
126
|
+
|
|
127
|
+
Internal::RactorTransferPolicy.validate!(:input_transfer, input_transfer)
|
|
128
|
+
Internal::RactorTransferPolicy.validate!(:output_transfer, output_transfer)
|
|
129
|
+
raise TypeError, "block must be shareable" unless Ractor.shareable?(block)
|
|
130
|
+
|
|
131
|
+
new { |upstream| Pull.ractor_unordered_map(upstream, workers, input_transfer, output_transfer, block) }
|
|
132
|
+
end
|
|
133
|
+
|
|
69
134
|
# Creates a filtering flow.
|
|
70
135
|
#
|
|
71
136
|
# The block is called for upstream elements until it returns a truthy value
|
|
@@ -78,6 +143,19 @@ module FiberStream
|
|
|
78
143
|
new { |upstream| Pull.select(upstream, block) }
|
|
79
144
|
end
|
|
80
145
|
|
|
146
|
+
# Creates a complement filtering flow.
|
|
147
|
+
#
|
|
148
|
+
# The block is called for upstream elements until it returns `false` or
|
|
149
|
+
# `nil`, or upstream completes. Truthy predicate results drop the original
|
|
150
|
+
# element; false and nil results pass the element through unchanged.
|
|
151
|
+
# Exceptions raised by the block fail the stream and are re-raised from
|
|
152
|
+
# `Source#run_with`.
|
|
153
|
+
def self.reject(&block)
|
|
154
|
+
raise ArgumentError, "missing block" unless block
|
|
155
|
+
|
|
156
|
+
new { |upstream| Pull.reject(upstream, block) }
|
|
157
|
+
end
|
|
158
|
+
|
|
81
159
|
# Creates a limiting flow.
|
|
82
160
|
#
|
|
83
161
|
# The flow emits at most `count` elements. `take(0)` completes without
|
|
@@ -180,6 +258,19 @@ module FiberStream
|
|
|
180
258
|
new { |upstream| Pull.buffer(upstream, count) }
|
|
181
259
|
end
|
|
182
260
|
|
|
261
|
+
# Creates a scheduler-aware throttling flow.
|
|
262
|
+
#
|
|
263
|
+
# The `rate:` form creates a fresh `RateLimiter` for each materialization.
|
|
264
|
+
# The `limiter:` form uses the supplied limiter object, which must respond
|
|
265
|
+
# to `acquire(permits:)` and return only after permits are acquired. When
|
|
266
|
+
# FiberStream-owned waiting is required, the current fiber must be
|
|
267
|
+
# non-blocking with an installed `Fiber.scheduler`.
|
|
268
|
+
def self.throttle(**options)
|
|
269
|
+
limiter = build_throttle_limiter(options)
|
|
270
|
+
|
|
271
|
+
new { |upstream| Pull.throttle(upstream, limiter.call) }
|
|
272
|
+
end
|
|
273
|
+
|
|
183
274
|
# Creates a line-splitting flow.
|
|
184
275
|
#
|
|
185
276
|
# The flow accepts String chunks and emits lines split on "\n". By default
|
|
@@ -222,6 +313,38 @@ module FiberStream
|
|
|
222
313
|
new(&attach)
|
|
223
314
|
end
|
|
224
315
|
|
|
316
|
+
def self.build_throttle_limiter(options)
|
|
317
|
+
unknown_keywords = options.keys - [:rate, :per, :burst, :limiter]
|
|
318
|
+
raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" unless unknown_keywords.empty?
|
|
319
|
+
|
|
320
|
+
rate_given = options.key?(:rate)
|
|
321
|
+
per_given = options.key?(:per)
|
|
322
|
+
burst_given = options.key?(:burst)
|
|
323
|
+
limiter_given = options.key?(:limiter)
|
|
324
|
+
|
|
325
|
+
if limiter_given
|
|
326
|
+
raise ArgumentError, "cannot pass rate and limiter together" if rate_given
|
|
327
|
+
raise ArgumentError, "cannot pass per with limiter" if per_given
|
|
328
|
+
raise ArgumentError, "cannot pass burst with limiter" if burst_given
|
|
329
|
+
|
|
330
|
+
limiter = options.fetch(:limiter)
|
|
331
|
+
raise TypeError, "limiter must respond to acquire" unless limiter.respond_to?(:acquire)
|
|
332
|
+
|
|
333
|
+
return -> { limiter }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
raise ArgumentError, "missing rate or limiter" unless rate_given
|
|
337
|
+
|
|
338
|
+
rate = options.fetch(:rate)
|
|
339
|
+
per = options.fetch(:per, 1)
|
|
340
|
+
burst = options.fetch(:burst, nil)
|
|
341
|
+
RateLimiter.validate_options!(rate:, per:, burst:)
|
|
342
|
+
|
|
343
|
+
-> { RateLimiter.new(rate:, per:, burst:) }
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
private_class_method :build_throttle_limiter
|
|
347
|
+
|
|
225
348
|
# Returns a reusable flow that applies this flow and then `flow`.
|
|
226
349
|
#
|
|
227
350
|
# Construction is lazy. No upstream stream is attached and no elements are
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FiberStream
|
|
4
|
+
module Pull
|
|
5
|
+
# Nil-dropping stage.
|
|
6
|
+
#
|
|
7
|
+
# A single downstream demand may pull multiple upstream elements until a
|
|
8
|
+
# non-nil value is observed or upstream completes. Dropped nil values are
|
|
9
|
+
# discarded immediately and are not buffered.
|
|
10
|
+
class Compact
|
|
11
|
+
def initialize(upstream)
|
|
12
|
+
@upstream = upstream
|
|
13
|
+
@closed = false
|
|
14
|
+
@done = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def next
|
|
18
|
+
return DONE if @closed || @done
|
|
19
|
+
|
|
20
|
+
loop do
|
|
21
|
+
value = @upstream.next
|
|
22
|
+
if Pull.done?(value)
|
|
23
|
+
@done = true
|
|
24
|
+
return DONE
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
return value unless value.nil?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def close
|
|
32
|
+
return if @closed
|
|
33
|
+
|
|
34
|
+
@closed = true
|
|
35
|
+
@upstream.close
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FiberStream
|
|
4
|
+
module Pull
|
|
5
|
+
# Transform-and-filter stage.
|
|
6
|
+
#
|
|
7
|
+
# A single downstream demand may pull multiple upstream elements until the
|
|
8
|
+
# transform returns a truthy value or upstream completes. Falsey transform
|
|
9
|
+
# results are discarded immediately and are not buffered.
|
|
10
|
+
class FilterMap
|
|
11
|
+
def initialize(upstream, transform)
|
|
12
|
+
@upstream = upstream
|
|
13
|
+
@transform = transform
|
|
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
|
+
result = @transform.call(value)
|
|
29
|
+
return result if result
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def close
|
|
34
|
+
return if @closed
|
|
35
|
+
|
|
36
|
+
@closed = true
|
|
37
|
+
@upstream.close
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|