ractor-pool 0.2.0 → 0.3.1

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: 73bb38fa65f10e0a6eb11265acef8a298e6d0109a2c5b2c1dba750b38a0d5ed4
4
- data.tar.gz: 4fa41532f78bceea5ddcd33a4c9ce9f9efefe114edb37de5953cd97438f349ad
3
+ metadata.gz: 13aa15dab296025cc8fa8348a4477bd8afd2db09b24ea4e13c43a17cca976cd2
4
+ data.tar.gz: b99432e86a0e93b69aa84867b869a0502469b402a297698599175442987cc23f
5
5
  SHA512:
6
- metadata.gz: fee7cb83e33e99e265484986b1208c0742216322cd7bc0f23a3de14f38f9fe71d21adbccec1179f51cd4aa4f0875c8291ae2b621a0599edbb479357bc2dabe2c
7
- data.tar.gz: 8135a1c1fbc84d70e0b6650d87715a94ee2322a67e6471786df29f8c2c0cd927a4de061301982a1802ad4a5b2f408e8aa34f1ae31679b9a5edbc4e1cfb5481cc
6
+ metadata.gz: 8f85b3c281484b9daf22e6e0fe2c195ca4a5a67640e3913d70f93cd109c022e3138cbae924477de09e2400c552b9b4c19eb386cf9b5b3479ec6bf60e382cbb4d
7
+ data.tar.gz: 6101a788de91cbe8f681cfa3c3c05556154061f0f96910e2474c1b083b5c283455d2ee1c525d30f8e73785148ad6392dbf1464f578cdb8b07670718b911bfba5
@@ -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,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.1] - 2026-05-12
4
+
5
+ - Fix `result_port` race condition in single-worker shutdown
6
+
7
+ ## [0.3.0] - 2026-05-08
8
+
9
+ - Replace state atom with separate `@in_flight` and `@shutdown` atoms
10
+ - Add `on_error:` worker error callback
11
+ - Update `Ractor` warning suppression regex
12
+
3
13
  ## [0.2.0] - 2026-01-07
4
14
 
5
15
  - Require Ruby >= 4.0.0
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class RactorPool
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/ractor-pool.rb CHANGED
@@ -4,7 +4,7 @@
4
4
  require "warning"
5
5
  require "atomic-ruby/atom"
6
6
 
7
- Warning.ignore(/Ractor is experimental/, __FILE__)
7
+ Warning.ignore(/Ractor API is experimental/, __FILE__)
8
8
 
9
9
  # A thread-safe, lock-free pool of Ractor workers with a coordinator pattern for distributing work.
10
10
  #
@@ -35,7 +35,7 @@ Warning.ignore(/Ractor is experimental/, __FILE__)
35
35
  #
36
36
  # p counter.value #=> 10
37
37
  #
38
- # @see https://docs.ruby-lang.org/en/master/ractor_md.html Ractor Guide
38
+ # @see https://docs.ruby-lang.org/en/master/language/ractor_md.html Ractor Guide
39
39
  # @see https://docs.ruby-lang.org/en/master/Ractor.html Ractor API
40
40
  # @see https://docs.ruby-lang.org/en/master/Ractor/Port.html Ractor::Port API
41
41
  # @see https://github.com/joshuay03/atomic-ruby atomic-ruby gem
@@ -54,22 +54,28 @@ class RactorPool
54
54
  # @rbs @size: Integer
55
55
  # @rbs @worker: ^(untyped) -> untyped
56
56
  # @rbs @name: String?
57
+ # @rbs @on_error: (^(Exception) -> void | nil)
57
58
  # @rbs @result_handler: (^(untyped) -> void | nil)
58
- # @rbs @state: Atom[Hash[Symbol, bool | Integer]]
59
+ # @rbs @in_flight: Atom[Integer]
60
+ # @rbs @shutdown: Atom[bool]
59
61
  # @rbs @result_port: Ractor::Port?
62
+ # @rbs @error_port: Ractor::Port?
60
63
  # @rbs @coordinator: Ractor?
61
64
  # @rbs @workers: Array[Ractor]
62
65
  # @rbs @collector: Thread?
66
+ # @rbs @error_collector: Thread?
63
67
 
64
68
  # Creates a new RactorPool with the specified number of workers.
65
69
  #
66
70
  # @param size [Integer] number of worker ractors to create
67
71
  # @param worker [Proc] a shareable proc that processes each work item
68
72
  # @param name [String, nil] optional name for the pool, used in thread/ractor names
73
+ # @param on_error [Proc, nil] optional shareable proc called with the raised exception when a worker raises
69
74
  # @yieldparam result [Object] the result returned by the worker proc
70
75
  # @return [void]
71
76
  # @raise [ArgumentError] if size is not a positive integer
72
77
  # @raise [ArgumentError] if worker is not a proc
78
+ # @raise [ArgumentError] if on_error is given but is not a proc
73
79
  #
74
80
  # @example With result handler
75
81
  # pool = RactorPool.new(size: 4, worker: proc { it }) { |result| puts result }
@@ -77,22 +83,32 @@ class RactorPool
77
83
  # @example Without result handler
78
84
  # pool = RactorPool.new(size: 4, worker: proc { it })
79
85
  #
80
- # @rbs (?size: Integer, worker: ^(untyped) -> untyped, ?name: String?) ?{ (untyped) -> void } -> void
81
- def initialize(size: Etc.nprocessors, worker:, name: nil, &result_handler)
86
+ # @example With error handler
87
+ # error_count = Atom.new(0)
88
+ # on_error = proc { error_count.swap { |count| count + 1 } }
89
+ # pool = RactorPool.new(size: 4, worker: proc { raise }, on_error: on_error)
90
+ #
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)
82
93
  raise ArgumentError, "size must be a positive Integer" unless size.is_a?(Integer) && size > 0
83
94
  raise ArgumentError, "worker must be a Proc" unless worker.is_a?(Proc)
95
+ raise ArgumentError, "on_error must be a Proc" if on_error && !on_error.is_a?(Proc)
84
96
 
85
97
  @size = size
86
98
  @worker = Ractor.shareable_proc(&worker)
87
99
  @name = name
100
+ @on_error = Ractor.shareable_proc(&on_error) if on_error
88
101
  @result_handler = result_handler
89
102
 
90
- @state = Atom.new(in_flight: 0, shutdown: false)
103
+ @in_flight = Atom.new(0)
104
+ @shutdown = Atom.new(false)
91
105
 
92
106
  @result_port = Ractor::Port.new if result_handler
107
+ @error_port = Ractor::Port.new unless on_error
93
108
  @coordinator = start_coordinator if size > 1
94
109
  @workers = start_workers
95
110
  @collector = start_collector
111
+ @error_collector = start_error_collector
96
112
  end
97
113
 
98
114
  # Queues a work item to be processed by an available worker.
@@ -107,21 +123,19 @@ class RactorPool
107
123
  #
108
124
  # @rbs (untyped work) -> void
109
125
  def <<(work)
110
- state = @state.swap do |current_state|
111
- if current_state[:shutdown]
112
- current_state
113
- else
114
- current_state.merge(in_flight: current_state[:in_flight] + 1)
115
- end
126
+ raise EnqueuedWorkAfterShutdownError if @shutdown.value
127
+
128
+ @in_flight.swap { |count| count + 1 }
129
+
130
+ if @shutdown.value
131
+ @in_flight.swap { |count| count - 1 }
132
+ raise EnqueuedWorkAfterShutdownError
116
133
  end
117
- raise EnqueuedWorkAfterShutdownError if state[:shutdown]
118
134
 
119
135
  begin
120
136
  (@coordinator || @workers.first).send(work, move: true)
121
137
  ensure
122
- @state.swap do |current_state|
123
- current_state.merge(in_flight: current_state[:in_flight] - 1)
124
- end
138
+ @in_flight.swap { |count| count - 1 }
125
139
  end
126
140
  end
127
141
 
@@ -132,7 +146,7 @@ class RactorPool
132
146
  # 2. Waits for all in-flight work submissions to complete
133
147
  # 3. Allows all queued work to complete
134
148
  # 4. Waits for all workers to finish
135
- # 5. Waits for all results to be processed
149
+ # 5. Waits for all results and errors to be processed
136
150
  #
137
151
  # This method is idempotent and can be called multiple times safely.
138
152
  #
@@ -144,22 +158,29 @@ class RactorPool
144
158
  # @rbs () -> void
145
159
  def shutdown
146
160
  already_shutdown = false
147
- @state.swap do |current_state|
148
- if current_state[:shutdown]
161
+ @shutdown.swap do |current|
162
+ if current
149
163
  already_shutdown = true
150
- current_state
164
+ current
151
165
  else
152
- current_state.merge(shutdown: true)
166
+ true
153
167
  end
154
168
  end
155
169
  return if already_shutdown
156
170
 
157
- Thread.pass until @state.value[:in_flight] == 0
171
+ Thread.pass until @in_flight.value.zero?
158
172
 
159
- @coordinator&.send(SHUTDOWN, move: true) ||
160
- (@workers.first.send(SHUTDOWN, move: true) && @result_port&.send(SHUTDOWN, move: true))
161
- @workers.each(&:join)
162
- @coordinator&.join
173
+ if @coordinator
174
+ @coordinator.send(SHUTDOWN, move: true)
175
+ @workers.each(&:join)
176
+ @coordinator.join
177
+ else
178
+ @workers.first.send(SHUTDOWN, move: true)
179
+ @workers.each(&:join)
180
+ @result_port&.send(SHUTDOWN, move: true)
181
+ end
182
+ @error_port&.send(SHUTDOWN, move: true)
183
+ @error_collector&.join
163
184
  @collector&.join
164
185
  end
165
186
 
@@ -225,7 +246,7 @@ class RactorPool
225
246
  ractor_name = String.new("#{self.class.name} ractor #{index}")
226
247
  ractor_name << " for #{@name}" if @name
227
248
 
228
- Ractor.new(@worker, @coordinator, @result_port, name: ractor_name) do |worker, coordinator, result_port|
249
+ Ractor.new(@worker, @on_error, @error_port, @coordinator, @result_port, name: ractor_name) do |worker, on_error, error_port, coordinator, result_port|
229
250
  loop do
230
251
  coordinator&.send(Ractor.current, move: true)
231
252
 
@@ -237,9 +258,7 @@ class RactorPool
237
258
 
238
259
  result_port&.send(result, move: true)
239
260
  rescue => error
240
- puts "#{Ractor.current.name} rescued:"
241
- puts "#{error.class}: #{error.message}"
242
- puts error.backtrace.join("\n")
261
+ on_error ? on_error.call(error) : error_port.send(error.full_message, move: true)
243
262
  end
244
263
  end
245
264
  end
@@ -264,4 +283,23 @@ class RactorPool
264
283
  end
265
284
  end
266
285
  end
286
+
287
+ # @rbs () -> Thread?
288
+ def start_error_collector
289
+ return if @on_error
290
+
291
+ thread_name = String.new("#{self.class.name} error collector thread")
292
+ thread_name << " for #{@name}" if @name
293
+
294
+ Thread.new(@error_port, thread_name) do |error_port, name|
295
+ Thread.current.name = name
296
+
297
+ loop do
298
+ message = error_port.receive
299
+ break if message == SHUTDOWN
300
+
301
+ warn message
302
+ end
303
+ end
304
+ end
267
305
  end
@@ -29,7 +29,7 @@
29
29
  #
30
30
  # p counter.value #=> 10
31
31
  #
32
- # @see https://docs.ruby-lang.org/en/master/ractor_md.html Ractor Guide
32
+ # @see https://docs.ruby-lang.org/en/master/language/ractor_md.html Ractor Guide
33
33
  # @see https://docs.ruby-lang.org/en/master/Ractor.html Ractor API
34
34
  # @see https://docs.ruby-lang.org/en/master/Ractor/Port.html Ractor::Port API
35
35
  # @see https://github.com/joshuay03/atomic-ruby atomic-ruby gem
@@ -44,18 +44,26 @@ class RactorPool
44
44
 
45
45
  SHUTDOWN: ::Symbol
46
46
 
47
+ @error_collector: Thread?
48
+
47
49
  @collector: Thread?
48
50
 
49
51
  @workers: Array[Ractor]
50
52
 
51
53
  @coordinator: Ractor?
52
54
 
55
+ @error_port: Ractor::Port?
56
+
53
57
  @result_port: Ractor::Port?
54
58
 
55
- @state: Atom[Hash[Symbol, bool | Integer]]
59
+ @shutdown: Atom[bool]
60
+
61
+ @in_flight: Atom[Integer]
56
62
 
57
63
  @result_handler: ^(untyped) -> void | nil
58
64
 
65
+ @on_error: ^(Exception) -> void | nil
66
+
59
67
  @name: String?
60
68
 
61
69
  @worker: ^(untyped) -> untyped
@@ -67,10 +75,12 @@ class RactorPool
67
75
  # @param size [Integer] number of worker ractors to create
68
76
  # @param worker [Proc] a shareable proc that processes each work item
69
77
  # @param name [String, nil] optional name for the pool, used in thread/ractor names
78
+ # @param on_error [Proc, nil] optional shareable proc called with the raised exception when a worker raises
70
79
  # @yieldparam result [Object] the result returned by the worker proc
71
80
  # @return [void]
72
81
  # @raise [ArgumentError] if size is not a positive integer
73
82
  # @raise [ArgumentError] if worker is not a proc
83
+ # @raise [ArgumentError] if on_error is given but is not a proc
74
84
  #
75
85
  # @example With result handler
76
86
  # pool = RactorPool.new(size: 4, worker: proc { it }) { |result| puts result }
@@ -78,8 +88,13 @@ class RactorPool
78
88
  # @example Without result handler
79
89
  # pool = RactorPool.new(size: 4, worker: proc { it })
80
90
  #
81
- # @rbs (?size: Integer, worker: ^(untyped) -> untyped, ?name: String?) ?{ (untyped) -> void } -> void
82
- def initialize: (worker: ^(untyped) -> untyped, ?size: Integer, ?name: String?) ?{ (untyped) -> void } -> void
91
+ # @example With error handler
92
+ # error_count = Atom.new(0)
93
+ # on_error = proc { error_count.swap { |count| count + 1 } }
94
+ # pool = RactorPool.new(size: 4, worker: proc { raise }, on_error: on_error)
95
+ #
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
83
98
 
84
99
  # Queues a work item to be processed by an available worker.
85
100
  #
@@ -101,7 +116,7 @@ class RactorPool
101
116
  # 2. Waits for all in-flight work submissions to complete
102
117
  # 3. Allows all queued work to complete
103
118
  # 4. Waits for all workers to finish
104
- # 5. Waits for all results to be processed
119
+ # 5. Waits for all results and errors to be processed
105
120
  #
106
121
  # This method is idempotent and can be called multiple times safely.
107
122
  #
@@ -123,4 +138,7 @@ class RactorPool
123
138
 
124
139
  # @rbs () -> Thread?
125
140
  def start_collector: () -> Thread?
141
+
142
+ # @rbs () -> Thread?
143
+ def start_error_collector: () -> Thread?
126
144
  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.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young
@@ -73,7 +73,7 @@ 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.3
76
+ rubygems_version: 4.0.10
77
77
  specification_version: 4
78
78
  summary: A thread-safe, lock-free pool of Ractor workers with a coordinator pattern
79
79
  for distributing work