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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cc93666d0610e659313a12dc756fca935579e62711972a9ea65d9ad818f6020
4
- data.tar.gz: 97f315765ba573a5c047752fd083006db89404eff06e92bda5afbfa0f1933ed1
3
+ metadata.gz: b729811e8b39efdfa4203d0e6167709105d524ec098f018fdc120986c0b73b14
4
+ data.tar.gz: 162a3bf8a893fe13ae994ec77c8a2df7c8c44f9c5ab11895ee3b3b0ddfe31192
5
5
  SHA512:
6
- metadata.gz: 5e635531f9e34510ef0eab76254c33e70f36124be779f48d2f9692c351a2f26ed36c7c14fa0d1d596c5645b511bb4fa1abe2bb773fc434c1f4da39a6e19dbefc
7
- data.tar.gz: 67eb50ae0a1d727c65fd172be572f7bf08ba82b3ee0cf4d9094c7e9ad579aac327155cc772bcd4e94b778cef53f63decf64a08e12e7ea5ef6949a6d813336b04
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
- It builds lazy Source definitions, transforms values with Flow stages, and materializes results with Sink objects.
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
  [![Gem Version](https://badge.fury.io/rb/fiber_stream.svg)](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, limiting, predicate-based limiting and dropping,
34
- fixed-prefix dropping, fixed-size grouping, line splitting, buffering, async
35
- boundaries, ordered and unordered parallel mapping, and ordered
36
- Ractor-backed mapping
37
- - array, first-element, fold, foreach, and IO sinks
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. The mapper
253
- must be shareable, usually by creating it with `Ractor.shareable_proc`.
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.async`, `Flow.buffer`,
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
- FiberStream does not install a scheduler and does not depend on Async at
481
- runtime.
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."
@@ -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