ractor-pool 0.3.0 → 0.4.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: b97336e3bda02791d66cbef83761b58ff599afca2e58698bd79e7aa5bdedc497
4
- data.tar.gz: 77b0ff65e53978436edc7701c204415709092f96a71f1c834e7ce719c75fc3d4
3
+ metadata.gz: 2d8741738f0de76db49cbbbaccc701b687af3df13ce9802205c7661a10519126
4
+ data.tar.gz: 337ce4d26b7735ec2517c2b88e76f2b11bef87124e3581a74772e68fbd3b6e57
5
5
  SHA512:
6
- metadata.gz: acdd99e1c5c20f0d628353abe9682a9283c1d60d5b5f1247d366c08e91db3dc19e62797a8d3347fd49c8498e1896e57711637a0cea3c05359f4aa300f3f73eee
7
- data.tar.gz: 5cf2b4e221ec273c5ed038c256d36e4b29f58619eee6d7412e68d498288e593373a874118b6b4f58e83c8a20009c800e3000d5aa31dc88cc7b6fc5ed277c33ed
6
+ metadata.gz: 663d3b28aeab7e0341bbfd5d78540c670fbb74e3cfc89679572093067fc72396028d3b1637c224676d4c52d86aaafd688143e28499217f0b513c7d5035ba42c4
7
+ data.tar.gz: 6a9fc3ec933ece355f9bba48ac1de2980a3c105f0baaf2a13684ab74a54028daec5f2695dee25c4d970d3dc71f7bd066d207977424f8f199c44f8f61e9e6ae85
@@ -6,6 +6,7 @@ steps:
6
6
  - bundle exec rake
7
7
 
8
8
  - label: ":ruby: Ruby head"
9
+ soft_fail: true
9
10
  image: "rubylang/ruby:master-dev"
10
11
  commands:
11
12
  - apt-get update && apt-get install -y libyaml-dev
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-05-20
4
+
5
+ - Add `:round_robin` dispatch strategy
6
+
7
+ ## [0.3.1] - 2026-05-12
8
+
9
+ - Fix `result_port` race condition in single-worker shutdown
10
+
3
11
  ## [0.3.0] - 2026-05-08
4
12
 
5
13
  - Replace state atom with separate `@in_flight` and `@shutdown` atoms
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  ![Version](https://img.shields.io/gem/v/ractor-pool)
4
4
  ![Build](https://badge.buildkite.com/f5f08eba1c869dee6e9e87dd66b241059d8fbefaaed6c56d86.svg)
5
5
 
6
- A thread-safe, lock-free pool of Ractor workers with a coordinator pattern for distributing work.
6
+ A thread-safe, lock-free pool of Ractor workers with coordinator or round-robin dispatch for distributing work.
7
7
 
8
8
  ## Installation
9
9
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class RactorPool
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/ractor-pool.rb CHANGED
@@ -6,11 +6,16 @@ require "atomic-ruby/atom"
6
6
 
7
7
  Warning.ignore(/Ractor API is experimental/, __FILE__)
8
8
 
9
- # A thread-safe, lock-free pool of Ractor workers with a coordinator pattern for distributing work.
9
+ # A thread-safe, lock-free pool of Ractor workers with coordinator or round-robin dispatch for distributing work.
10
10
  #
11
11
  # RactorPool manages a fixed number of worker ractors that process work items in parallel.
12
- # Work is distributed on-demand to idle workers, ensuring efficient utilisation. Results
13
- # are collected and passed to a result handler running in a separate thread.
12
+ # The +:coordinator+ strategy (the default) routes each work item to whichever worker is
13
+ # currently idle via a dedicated coordinator Ractor, so a slow item on one worker does not
14
+ # block faster items from being picked up by other workers. Use it when work items have
15
+ # variable cost. The +:round_robin+ strategy dispatches work to workers in turn, so a slow
16
+ # item on one worker queues the next item destined for that worker behind it, even if other
17
+ # workers are idle. Use it when work items have uniform cost. Results are collected and
18
+ # passed to a result handler running in a separate thread.
14
19
  #
15
20
  # @example Basic usage
16
21
  # results = []
@@ -35,7 +40,7 @@ Warning.ignore(/Ractor API is experimental/, __FILE__)
35
40
  #
36
41
  # p counter.value #=> 10
37
42
  #
38
- # @see https://docs.ruby-lang.org/en/master/ractor_md.html Ractor Guide
43
+ # @see https://docs.ruby-lang.org/en/master/language/ractor_md.html Ractor Guide
39
44
  # @see https://docs.ruby-lang.org/en/master/Ractor.html Ractor API
40
45
  # @see https://docs.ruby-lang.org/en/master/Ractor/Port.html Ractor::Port API
41
46
  # @see https://github.com/joshuay03/atomic-ruby atomic-ruby gem
@@ -48,33 +53,40 @@ class RactorPool
48
53
  def message = "cannot queue work after shutdown"
49
54
  end
50
55
 
56
+ STRATEGIES = %i[coordinator round_robin].freeze
57
+ private_constant :STRATEGIES
58
+
51
59
  SHUTDOWN = :shutdown
52
60
  private_constant :SHUTDOWN
53
61
 
54
62
  # @rbs @size: Integer
55
63
  # @rbs @worker: ^(untyped) -> untyped
64
+ # @rbs @strategy: Symbol
56
65
  # @rbs @name: String?
57
66
  # @rbs @on_error: (^(Exception) -> void | nil)
58
67
  # @rbs @result_handler: (^(untyped) -> void | nil)
59
68
  # @rbs @in_flight: Atom[Integer]
60
69
  # @rbs @shutdown: Atom[bool]
70
+ # @rbs @next_worker_index: Atom[Integer]?
61
71
  # @rbs @result_port: Ractor::Port?
62
72
  # @rbs @error_port: Ractor::Port?
63
73
  # @rbs @coordinator: Ractor?
64
74
  # @rbs @workers: Array[Ractor]
65
- # @rbs @error_collector: Thread?
66
75
  # @rbs @collector: Thread?
76
+ # @rbs @error_collector: Thread?
67
77
 
68
78
  # Creates a new RactorPool with the specified number of workers.
69
79
  #
70
80
  # @param size [Integer] number of worker ractors to create
71
81
  # @param worker [Proc] a shareable proc that processes each work item
82
+ # @param strategy [Symbol] dispatch strategy, either +:coordinator+ (the default) or +:round_robin+
72
83
  # @param name [String, nil] optional name for the pool, used in thread/ractor names
73
84
  # @param on_error [Proc, nil] optional shareable proc called with the raised exception when a worker raises
74
85
  # @yieldparam result [Object] the result returned by the worker proc
75
86
  # @return [void]
76
87
  # @raise [ArgumentError] if size is not a positive integer
77
88
  # @raise [ArgumentError] if worker is not a proc
89
+ # @raise [ArgumentError] if strategy is not +:coordinator+ or +:round_robin+
78
90
  # @raise [ArgumentError] if on_error is given but is not a proc
79
91
  #
80
92
  # @example With result handler
@@ -83,32 +95,38 @@ class RactorPool
83
95
  # @example Without result handler
84
96
  # pool = RactorPool.new(size: 4, worker: proc { it })
85
97
  #
98
+ # @example With round-robin strategy
99
+ # pool = RactorPool.new(size: 4, strategy: :round_robin, worker: proc { it })
100
+ #
86
101
  # @example With error handler
87
102
  # error_count = Atom.new(0)
88
103
  # on_error = proc { error_count.swap { |count| count + 1 } }
89
104
  # pool = RactorPool.new(size: 4, worker: proc { raise }, on_error: on_error)
90
105
  #
91
- # @rbs (?size: Integer, worker: ^(untyped) -> untyped, ?name: String?, ?on_error: (^(Exception) -> void | nil)) ?{ (untyped) -> void } -> void
92
- def initialize(size: Etc.nprocessors, worker:, name: nil, on_error: nil, &result_handler)
106
+ # @rbs (?size: Integer, worker: ^(untyped) -> untyped, ?strategy: Symbol, ?name: String?, ?on_error: (^(Exception) -> void | nil)) ?{ (untyped) -> void } -> void
107
+ def initialize(size: Etc.nprocessors, worker:, strategy: :coordinator, name: nil, on_error: nil, &result_handler)
93
108
  raise ArgumentError, "size must be a positive Integer" unless size.is_a?(Integer) && size > 0
94
109
  raise ArgumentError, "worker must be a Proc" unless worker.is_a?(Proc)
110
+ raise ArgumentError, "strategy must be one of #{STRATEGIES.inspect}" unless STRATEGIES.include?(strategy)
95
111
  raise ArgumentError, "on_error must be a Proc" if on_error && !on_error.is_a?(Proc)
96
112
 
97
113
  @size = size
98
114
  @worker = Ractor.shareable_proc(&worker)
115
+ @strategy = strategy
99
116
  @name = name
100
117
  @on_error = Ractor.shareable_proc(&on_error) if on_error
101
118
  @result_handler = result_handler
102
119
 
103
120
  @in_flight = Atom.new(0)
104
121
  @shutdown = Atom.new(false)
122
+ @next_worker_index = Atom.new(-1) if size > 1 && strategy == :round_robin
105
123
 
106
124
  @result_port = Ractor::Port.new if result_handler
107
125
  @error_port = Ractor::Port.new unless on_error
108
- @coordinator = start_coordinator if size > 1
126
+ @coordinator = start_coordinator if size > 1 && strategy == :coordinator
109
127
  @workers = start_workers
110
- @error_collector = start_error_collector
111
128
  @collector = start_collector
129
+ @error_collector = start_error_collector
112
130
  end
113
131
 
114
132
  # Queues a work item to be processed by an available worker.
@@ -132,8 +150,16 @@ class RactorPool
132
150
  raise EnqueuedWorkAfterShutdownError
133
151
  end
134
152
 
153
+ target = if @coordinator
154
+ @coordinator
155
+ elsif @next_worker_index
156
+ @workers[@next_worker_index.swap { |index| (index + 1) % @size }]
157
+ else
158
+ @workers.first
159
+ end
160
+
135
161
  begin
136
- (@coordinator || @workers.first).send(work, move: true)
162
+ target.send(work, move: true)
137
163
  ensure
138
164
  @in_flight.swap { |count| count - 1 }
139
165
  end
@@ -170,10 +196,15 @@ class RactorPool
170
196
 
171
197
  Thread.pass until @in_flight.value.zero?
172
198
 
173
- @coordinator&.send(SHUTDOWN, move: true) ||
174
- (@workers.first.send(SHUTDOWN, move: true) && @result_port&.send(SHUTDOWN, move: true))
175
- @workers.each(&:join)
176
- @coordinator&.join
199
+ if @coordinator
200
+ @coordinator.send(SHUTDOWN, move: true)
201
+ @workers.each(&:join)
202
+ @coordinator.join
203
+ else
204
+ @workers.each { |worker| worker.send(SHUTDOWN, move: true) }
205
+ @workers.each(&:join)
206
+ @result_port&.send(SHUTDOWN, move: true)
207
+ end
177
208
  @error_port&.send(SHUTDOWN, move: true)
178
209
  @error_collector&.join
179
210
  @collector&.join
@@ -261,39 +292,39 @@ class RactorPool
261
292
  end
262
293
 
263
294
  # @rbs () -> Thread?
264
- def start_error_collector
265
- return if @on_error
295
+ def start_collector
296
+ return unless @result_handler
266
297
 
267
- thread_name = String.new("#{self.class.name} error collector thread")
298
+ thread_name = String.new("#{self.class.name} collector thread")
268
299
  thread_name << " for #{@name}" if @name
269
300
 
270
- Thread.new(@error_port, thread_name) do |error_port, name|
301
+ Thread.new(@result_port, @result_handler, thread_name) do |result_port, result_handler, name|
271
302
  Thread.current.name = name
272
303
 
273
304
  loop do
274
- message = error_port.receive
275
- break if message == SHUTDOWN
305
+ result = result_port.receive
306
+ break if result == SHUTDOWN
276
307
 
277
- warn message
308
+ result_handler.call(result)
278
309
  end
279
310
  end
280
311
  end
281
312
 
282
313
  # @rbs () -> Thread?
283
- def start_collector
284
- return unless @result_handler
314
+ def start_error_collector
315
+ return if @on_error
285
316
 
286
- thread_name = String.new("#{self.class.name} collector thread")
317
+ thread_name = String.new("#{self.class.name} error collector thread")
287
318
  thread_name << " for #{@name}" if @name
288
319
 
289
- Thread.new(@result_port, @result_handler, thread_name) do |result_port, result_handler, name|
320
+ Thread.new(@error_port, thread_name) do |error_port, name|
290
321
  Thread.current.name = name
291
322
 
292
323
  loop do
293
- result = result_port.receive
294
- break if result == SHUTDOWN
324
+ message = error_port.receive
325
+ break if message == SHUTDOWN
295
326
 
296
- result_handler.call(result)
327
+ warn message
297
328
  end
298
329
  end
299
330
  end
@@ -1,10 +1,15 @@
1
1
  # Generated from lib/ractor-pool.rb with RBS::Inline
2
2
 
3
- # A thread-safe, lock-free pool of Ractor workers with a coordinator pattern for distributing work.
3
+ # A thread-safe, lock-free pool of Ractor workers with coordinator or round-robin dispatch for distributing work.
4
4
  #
5
5
  # RactorPool manages a fixed number of worker ractors that process work items in parallel.
6
- # Work is distributed on-demand to idle workers, ensuring efficient utilisation. Results
7
- # are collected and passed to a result handler running in a separate thread.
6
+ # The +:coordinator+ strategy (the default) routes each work item to whichever worker is
7
+ # currently idle via a dedicated coordinator Ractor, so a slow item on one worker does not
8
+ # block faster items from being picked up by other workers. Use it when work items have
9
+ # variable cost. The +:round_robin+ strategy dispatches work to workers in turn, so a slow
10
+ # item on one worker queues the next item destined for that worker behind it, even if other
11
+ # workers are idle. Use it when work items have uniform cost. Results are collected and
12
+ # passed to a result handler running in a separate thread.
8
13
  #
9
14
  # @example Basic usage
10
15
  # results = []
@@ -29,7 +34,7 @@
29
34
  #
30
35
  # p counter.value #=> 10
31
36
  #
32
- # @see https://docs.ruby-lang.org/en/master/ractor_md.html Ractor Guide
37
+ # @see https://docs.ruby-lang.org/en/master/language/ractor_md.html Ractor Guide
33
38
  # @see https://docs.ruby-lang.org/en/master/Ractor.html Ractor API
34
39
  # @see https://docs.ruby-lang.org/en/master/Ractor/Port.html Ractor::Port API
35
40
  # @see https://github.com/joshuay03/atomic-ruby atomic-ruby gem
@@ -42,12 +47,14 @@ class RactorPool
42
47
  def message: () -> String
43
48
  end
44
49
 
45
- SHUTDOWN: ::Symbol
50
+ STRATEGIES: untyped
46
51
 
47
- @collector: Thread?
52
+ SHUTDOWN: ::Symbol
48
53
 
49
54
  @error_collector: Thread?
50
55
 
56
+ @collector: Thread?
57
+
51
58
  @workers: Array[Ractor]
52
59
 
53
60
  @coordinator: Ractor?
@@ -56,6 +63,8 @@ class RactorPool
56
63
 
57
64
  @result_port: Ractor::Port?
58
65
 
66
+ @next_worker_index: Atom[Integer]?
67
+
59
68
  @shutdown: Atom[bool]
60
69
 
61
70
  @in_flight: Atom[Integer]
@@ -66,6 +75,8 @@ class RactorPool
66
75
 
67
76
  @name: String?
68
77
 
78
+ @strategy: Symbol
79
+
69
80
  @worker: ^(untyped) -> untyped
70
81
 
71
82
  @size: Integer
@@ -74,12 +85,14 @@ class RactorPool
74
85
  #
75
86
  # @param size [Integer] number of worker ractors to create
76
87
  # @param worker [Proc] a shareable proc that processes each work item
88
+ # @param strategy [Symbol] dispatch strategy, either +:coordinator+ (the default) or +:round_robin+
77
89
  # @param name [String, nil] optional name for the pool, used in thread/ractor names
78
90
  # @param on_error [Proc, nil] optional shareable proc called with the raised exception when a worker raises
79
91
  # @yieldparam result [Object] the result returned by the worker proc
80
92
  # @return [void]
81
93
  # @raise [ArgumentError] if size is not a positive integer
82
94
  # @raise [ArgumentError] if worker is not a proc
95
+ # @raise [ArgumentError] if strategy is not +:coordinator+ or +:round_robin+
83
96
  # @raise [ArgumentError] if on_error is given but is not a proc
84
97
  #
85
98
  # @example With result handler
@@ -88,13 +101,16 @@ class RactorPool
88
101
  # @example Without result handler
89
102
  # pool = RactorPool.new(size: 4, worker: proc { it })
90
103
  #
104
+ # @example With round-robin strategy
105
+ # pool = RactorPool.new(size: 4, strategy: :round_robin, worker: proc { it })
106
+ #
91
107
  # @example With error handler
92
108
  # error_count = Atom.new(0)
93
109
  # on_error = proc { error_count.swap { |count| count + 1 } }
94
110
  # pool = RactorPool.new(size: 4, worker: proc { raise }, on_error: on_error)
95
111
  #
96
- # @rbs (?size: Integer, worker: ^(untyped) -> untyped, ?name: String?, ?on_error: (^(Exception) -> void | nil)) ?{ (untyped) -> void } -> void
97
- def initialize: (worker: ^(untyped) -> untyped, ?size: Integer, ?name: String?, ?on_error: ^(Exception) -> void | nil) ?{ (untyped) -> void } -> void
112
+ # @rbs (?size: Integer, worker: ^(untyped) -> untyped, ?strategy: Symbol, ?name: String?, ?on_error: (^(Exception) -> void | nil)) ?{ (untyped) -> void } -> void
113
+ def initialize: (worker: ^(untyped) -> untyped, ?size: Integer, ?strategy: Symbol, ?name: String?, ?on_error: ^(Exception) -> void | nil) ?{ (untyped) -> void } -> void
98
114
 
99
115
  # Queues a work item to be processed by an available worker.
100
116
  #
@@ -137,8 +153,8 @@ class RactorPool
137
153
  def start_workers: () -> Array[Ractor]
138
154
 
139
155
  # @rbs () -> Thread?
140
- def start_error_collector: () -> Thread?
156
+ def start_collector: () -> Thread?
141
157
 
142
158
  # @rbs () -> Thread?
143
- def start_collector: () -> Thread?
159
+ def start_error_collector: () -> Thread?
144
160
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ractor-pool
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young
@@ -73,8 +73,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
73
73
  - !ruby/object:Gem::Version
74
74
  version: '0'
75
75
  requirements: []
76
- rubygems_version: 4.0.6
76
+ rubygems_version: 4.0.10
77
77
  specification_version: 4
78
- summary: A thread-safe, lock-free pool of Ractor workers with a coordinator pattern
79
- for distributing work
78
+ summary: A thread-safe, lock-free pool of Ractor workers with coordinator or round-robin
79
+ dispatch for distributing work
80
80
  test_files: []