fractor 0.1.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.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractor
4
+ # Represents the result of processing a Work item.
5
+ # Can hold either a successful result or an error.
6
+ class WorkResult
7
+ attr_reader :result, :error, :work
8
+
9
+ def initialize(result: nil, error: nil, work: nil)
10
+ @result = result
11
+ @error = error
12
+ @work = work
13
+ end
14
+
15
+ def success?
16
+ !@error
17
+ end
18
+
19
+ def to_s
20
+ if success?
21
+ "Result: #{@result}"
22
+ else
23
+ "Error: #{@error}, Work: #{@work}"
24
+ end
25
+ end
26
+
27
+ def inspect
28
+ {
29
+ result: @result,
30
+ error: @error,
31
+ work: @work&.to_s # Use safe navigation for work
32
+ }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractor
4
+ # Base class for defining work processors.
5
+ # Subclasses must implement the `process` method.
6
+ class Worker
7
+ def process(work)
8
+ raise NotImplementedError, "Subclasses must implement the 'process' method."
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractor
4
+ # Wraps a Ruby Ractor to manage a worker instance.
5
+ # Handles communication and error propagation.
6
+ class WrappedRactor
7
+ attr_reader :ractor, :name
8
+
9
+ # Initializes the WrappedRactor with a name and the Worker class to instantiate.
10
+ # The worker_class parameter allows flexibility in specifying the worker type.
11
+ def initialize(name, worker_class)
12
+ puts "Creating Ractor #{name} with worker #{worker_class}"
13
+ @name = name
14
+ @worker_class = worker_class # Store the worker class
15
+ @ractor = nil # Initialize ractor as nil
16
+ end
17
+
18
+ # Starts the underlying Ractor.
19
+ def start
20
+ puts "Starting Ractor #{@name}"
21
+ # Pass worker_class to the Ractor block
22
+ @ractor = Ractor.new(@name, @worker_class) do |name, worker_cls|
23
+ puts "Ractor #{name} started with worker class #{worker_cls}"
24
+ # Yield an initialization message
25
+ Ractor.yield({ type: :initialize, processor: name })
26
+
27
+ # Instantiate the specific worker inside the Ractor
28
+ worker = worker_cls.new
29
+
30
+ loop do
31
+ # Ractor.receive will block until a message is received
32
+ puts "Waiting for work in #{name}"
33
+ work = Ractor.receive
34
+ puts "Received work #{work.inspect} in #{name}"
35
+
36
+ begin
37
+ # Process the work using the instantiated worker
38
+ result = worker.process(work)
39
+ puts "Sending result #{result.inspect} from Ractor #{name}"
40
+ # Yield the result back
41
+ Ractor.yield({ type: :result, result: result, processor: name })
42
+ rescue StandardError => e
43
+ # Handle errors during processing
44
+ puts "Error processing work #{work.inspect} in Ractor #{name}: #{e.message}\n#{e.backtrace.join("\n")}"
45
+ # Yield an error message back
46
+ # Ensure the original work object is included in the error result
47
+ error_result = Fractor::WorkResult.new(error: e.message, work: work)
48
+ Ractor.yield({ type: :error, result: error_result, processor: name })
49
+ end
50
+ end
51
+ rescue Ractor::ClosedError
52
+ puts "Ractor #{name} closed."
53
+ rescue StandardError => e
54
+ puts "Unexpected error in Ractor #{name}: #{e.message}\n#{e.backtrace.join("\n")}"
55
+ # Optionally yield a critical error message if needed
56
+ ensure
57
+ puts "Ractor #{name} shutting down."
58
+ end
59
+ puts "Ractor #{@name} instance created: #{@ractor}"
60
+ end
61
+
62
+ # Sends work to the Ractor if it's active.
63
+ def send(work)
64
+ if @ractor
65
+ begin
66
+ @ractor.send(work)
67
+ true
68
+ rescue Exception => e
69
+ puts "Warning: Error sending work to Ractor #{@name}: #{e.message}"
70
+ false
71
+ end
72
+ else
73
+ puts "Warning: Attempted to send work to nil Ractor #{@name}"
74
+ false
75
+ end
76
+ end
77
+
78
+ # Closes the Ractor.
79
+ # Ruby 3.0+ has different ways to terminate Ractors, we try the available methods
80
+ def close
81
+ return true if @ractor.nil?
82
+
83
+ begin
84
+ # Send a nil message to signal we're done - this might be processed
85
+ # if the Ractor is waiting for input
86
+ begin
87
+ begin
88
+ @ractor.send(nil)
89
+ rescue StandardError
90
+ nil
91
+ end
92
+ rescue StandardError
93
+ # Ignore errors when sending nil
94
+ end
95
+
96
+ # Mark as closed in our object
97
+ old_ractor = @ractor
98
+ @ractor = nil
99
+
100
+ # If available in this Ruby version, we'll try kill
101
+ if old_ractor.respond_to?(:kill)
102
+ begin
103
+ old_ractor.kill
104
+ rescue StandardError
105
+ nil
106
+ end
107
+ end
108
+
109
+ true
110
+ rescue Exception => e
111
+ puts "Warning: Error closing Ractor #{@name}: #{e.message}"
112
+ # Consider it closed even if there was an error
113
+ @ractor = nil
114
+ true
115
+ end
116
+ end
117
+
118
+ # Checks if the Ractor is closed or unavailable.
119
+ def closed?
120
+ return true if @ractor.nil?
121
+
122
+ begin
123
+ # Check if the Ractor is terminated using Ractor#inspect
124
+ # This is safer than calling methods on the Ractor
125
+ r_status = @ractor.inspect
126
+ if r_status.include?("terminated")
127
+ # If terminated, clean up our reference
128
+ @ractor = nil
129
+ return true
130
+ end
131
+ false
132
+ rescue Exception => e
133
+ # If we get an exception, the Ractor is likely terminated
134
+ puts "Ractor #{@name} appears to be terminated: #{e.message}"
135
+ @ractor = nil
136
+ true
137
+ end
138
+ end
139
+ end
140
+ end
data/lib/fractor.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread' # Required for Queue
4
+
5
+ # Require all component files
6
+ require_relative 'fractor/version'
7
+ require_relative 'fractor/worker'
8
+ require_relative 'fractor/work'
9
+ require_relative 'fractor/work_result'
10
+ require_relative 'fractor/result_aggregator'
11
+ require_relative 'fractor/wrapped_ractor'
12
+ require_relative 'fractor/supervisor'
13
+
14
+ # Fractor: Function-driven Ractors framework
15
+ module Fractor
16
+ # The module is defined in the individual files
17
+ end
data/sample.rb ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative 'fractor'
4
+
5
+ # Client-specific worker implementation inheriting from Fractor::Worker
6
+ class MyWorker < Fractor::Worker
7
+ # This method is called by the Ractor to process the work
8
+ # It should return a Fractor::WorkResult object
9
+ # If there is an error, it should raise an exception
10
+ # The Ractor will catch the exception and send it back to the main thread
11
+ def process(work)
12
+ puts "Working on '#{work.inspect}'"
13
+
14
+ if work.input == 5
15
+ # Return a Fractor::WorkResult for errors
16
+ return Fractor::WorkResult.new(error: "Error processing work #{work.input}", work: work)
17
+ end
18
+
19
+ calculated = work.input * 2
20
+ # Return a Fractor::WorkResult for success
21
+ Fractor::WorkResult.new(result: calculated, work: work)
22
+ end
23
+ end
24
+
25
+ # Client-specific work item implementation inheriting from Fractor::Work
26
+ class MyWork < Fractor::Work
27
+ def initialize(input)
28
+ super
29
+ end
30
+
31
+ def to_s
32
+ "MyWork: #{@input}"
33
+ end
34
+ end
35
+
36
+ # --- Main Execution ---
37
+ # This section demonstrates how to use the Fractor framework with custom
38
+ # MyWorker and MyWork classes.
39
+ if __FILE__ == $0
40
+ # Create supervisor, passing the client-specific worker and work classes
41
+ supervisor = Fractor::Supervisor.new(
42
+ worker_class: MyWorker,
43
+ work_class: MyWork,
44
+ num_workers: 2 # Specify the number of worker Ractors
45
+ )
46
+
47
+ # Add work items (raw data) - the Supervisor will wrap these in MyWork objects
48
+ work_items = (1..10).to_a
49
+ supervisor.add_work(work_items)
50
+
51
+ # Run the supervisor to start processing work
52
+ supervisor.run
53
+
54
+ puts "Processing complete."
55
+ puts "Final Aggregated Results:"
56
+ # Access the results aggregator from the supervisor
57
+ puts supervisor.results.inspect
58
+
59
+ # Print failed items directly from the Fractor::ResultAggregator's errors array
60
+ failed_items = supervisor.results.errors # Access the errors array
61
+ puts "\nFailed Work Items (#{failed_items.size}):"
62
+ # Display each Fractor::WorkResult object in the errors array
63
+ puts failed_items.map(&:to_s).join("\n") # Use to_s on the WorkResult objects
64
+ end
data/sig/fractor.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Fractor
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env ruby
2
+
3
+
4
+ class Worker
5
+ def process(work)
6
+ raise NotImplementedError, "This #{self.class} cannot respond to:"
7
+ end
8
+ end
9
+
10
+ class MyWorker < Worker
11
+ # This method is called by the Ractor to process the work
12
+ # It should return a WorkResult object
13
+ # If there is an error, it should raise an exception
14
+ # The Ractor will catch the exception and send it back to the main thread
15
+ def process(work)
16
+ puts "Working on '#{work.inspect}'"
17
+
18
+ if work.input == 5
19
+ return WorkResult.new(error: "Error processing work #{work.input}", work: work)
20
+ end
21
+
22
+ calculated = work.input * 2
23
+ WorkResult.new(result: calculated, work: work)
24
+ end
25
+ end
26
+
27
+ class Work
28
+ attr_reader :input
29
+ def initialize(input)
30
+ @input = input
31
+ end
32
+
33
+ def to_s
34
+ "Work: #{@input}"
35
+ end
36
+ end
37
+
38
+ class MyWork < Work
39
+ def initialize(input)
40
+ super
41
+ end
42
+
43
+ def to_s
44
+ "MyWork: #{@input}"
45
+ end
46
+ end
47
+
48
+ class WorkResult
49
+ attr_reader :result, :error, :work
50
+ def initialize(result: nil, error: nil, work: nil)
51
+ @result = result
52
+ @error = error
53
+ @work = work
54
+ end
55
+
56
+ def success?
57
+ !@error
58
+ end
59
+
60
+ def to_s
61
+ if success?
62
+ "Result: #{@result}"
63
+ else
64
+ "Error: #{@error}, Work: #{@work}"
65
+ end
66
+ end
67
+ end
68
+
69
+
70
+ class ResultAggregator
71
+ attr_reader :results, :errors
72
+
73
+ # This class is used to aggregate the results and errors from the Ractors
74
+ # It will store the results and errors in separate arrays
75
+ # It will also provide a method to print the results and errors
76
+ def initialize
77
+ @results = []
78
+ @errors = []
79
+ end
80
+
81
+ def add_result(result)
82
+ if result.success?
83
+ puts "Work completed successfully: #{result}"
84
+ @results << result
85
+ else
86
+ puts "Error processing work: #{result}"
87
+ @errors << result
88
+ end
89
+ end
90
+
91
+ def to_s
92
+ "Results: #{@results.each(&:to_s).join(", ")}, Errors: #{@errors.each(&:to_s).join(", ")}"
93
+ end
94
+
95
+ def inspect
96
+ {
97
+ results: @results.map(&:to_s),
98
+ errors: @errors.map(&:to_s)
99
+ }
100
+ end
101
+ end
102
+
103
+ class MyRactor
104
+ def initialize(name)
105
+ puts "Creating Ractor #{name}"
106
+ @name = name
107
+ end
108
+
109
+ def start
110
+ puts "Starting Ractor #{@name}"
111
+ @ractor = Ractor.new(@name) do |name|
112
+ puts "Ractor #{name} started"
113
+ Ractor.yield({ type: :initialize, processor: name })
114
+ worker = MyWorker.new
115
+
116
+ loop do
117
+ puts "Waiting for work in #{name}"
118
+ work = Ractor.receive
119
+ puts "Received work #{work} in #{name}"
120
+ begin
121
+ result = worker.process(work)
122
+ puts "Sending result #{result} from Ractor #{name}"
123
+ Ractor.yield({ type: :result, result: result })
124
+ rescue StandardError => e
125
+ puts "Error processing work #{work} in Ractor #{name}: #{e.message}"
126
+ Ractor.yield({ type: :error, error: e.message, processor: name, work: work })
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ def ractor
133
+ @ractor
134
+ end
135
+ end
136
+
137
+ class Supervisor
138
+ # Removed failed_queue from attr_reader
139
+ attr_reader :work_queue, :workers, :results
140
+
141
+ def initialize(num_workers = 2)
142
+ @work_queue = Queue.new
143
+ @results = ResultAggregator.new
144
+ # @failed_queue = Queue.new # Removed failed_queue
145
+ @num_workers = num_workers
146
+ @workers = []
147
+ @total_work_count = 0 # Track total items initially added
148
+ # @shutdown_requested = false # Removed shutdown flag
149
+ end
150
+
151
+ def add_work(items)
152
+ items.each { |item| @work_queue << item }
153
+ @total_work_count += items.size # Increment initial work count
154
+ puts "Work added. Initial work count: #{@total_work_count}, Queue size: #{@work_queue.size}"
155
+ end
156
+
157
+ def start_workers
158
+ @workers = (1..@num_workers).map do |i|
159
+ MyRactor.new("worker #{i}")
160
+ end
161
+ @workers.each(&:start)
162
+ puts "Workers started"
163
+ end
164
+
165
+ def setup_signal_handler
166
+ # No need for Ractor.current here anymore
167
+ # Need access to @workers within the trap block
168
+ workers_ref = @workers
169
+ Signal.trap("INT") do
170
+ puts "\nCtrl+C received. Initiating immediate shutdown..."
171
+ # Attempt to close worker Ractors before exiting
172
+ puts "Attempting to close worker Ractors..."
173
+ workers_ref.each do |w|
174
+ begin
175
+ # Check if ractor exists and is not closed
176
+ if w && w.respond_to?(:ractor) && w.ractor && !w.ractor.closed?
177
+ w.ractor.close
178
+ puts "Closed Ractor: #{w.ractor}"
179
+ end
180
+ rescue => e # Catch potential errors during close
181
+ puts "Error closing Ractor #{w.ractor rescue 'unknown'}: #{e.message}"
182
+ end
183
+ end
184
+ puts "Exiting now."
185
+ exit(1) # Exit immediately
186
+ end
187
+ end
188
+
189
+ def run
190
+ setup_signal_handler # Sets up the immediate exit trap
191
+ start_workers
192
+
193
+ # Removed the initial work distribution loop.
194
+ # The main loop will handle sending work upon receiving :initialize message.
195
+
196
+ # Main loop: Process events until the number of results equals the initial work count.
197
+ # The signal trap handles immediate exit.
198
+ while (@results.results.size + @results.errors.size) < @total_work_count
199
+ processed_count = @results.results.size + @results.errors.size
200
+ puts "Waiting for Ractor results. Processed: #{processed_count}/#{@total_work_count}, Queue size: #{@work_queue.size}"
201
+
202
+ # Only select from worker ractors now
203
+ ready_ractors = @workers.map(&:ractor).compact
204
+ # Safety break if all workers somehow finished/closed unexpectedly AND no work left
205
+ # This condition might need refinement depending on exact desired behavior if workers die.
206
+ break if ready_ractors.empty? && @work_queue.empty? && processed_count < @total_work_count
207
+
208
+ # Ractor.select will block until a worker sends a message
209
+ # If ready_ractors is empty but loop continues, select would raise error. Added break above.
210
+ next if ready_ractors.empty? # Skip iteration if no workers available but loop condition met (e.g., waiting for final results)
211
+
212
+ ractor, completed_work = Ractor.select(*ready_ractors)
213
+
214
+ puts "Selected Ractor returned: #{ractor}, completed work: #{completed_work}"
215
+
216
+ # Process the received message
217
+ case completed_work[:type]
218
+ when :initialize
219
+ puts "Initializing Ractor: #{completed_work[:processor]}"
220
+ # Send work if available
221
+ if !@work_queue.empty?
222
+ queued_work = @work_queue.pop # Pop before sending
223
+ puts "Sending initial work #{queued_work} to initialized Ractor: #{ractor}"
224
+ ractor.send(MyWork.new(queued_work))
225
+ puts "Initial work sent to #{completed_work[:processor]}."
226
+ else
227
+ puts "Work queue empty when Ractor #{completed_work[:processor]} initialized."
228
+ end
229
+ when :result
230
+ puts "Completed work: #{completed_work[:result]} in Ractor: #{completed_work[:processor]}"
231
+ @results.add_result(completed_work[:result])
232
+ # No need to decrement a counter here, loop condition checks total results
233
+ puts "Result processed. Total processed: #{@results.results.size + @results.errors.size}/#{@total_work_count}"
234
+ puts "Results: #{@results.inspect}"
235
+ # Call helper to send next work
236
+ send_next_work_if_available(ractor)
237
+ when :error
238
+ error_result = WorkResult.new(error: completed_work[:error], work: completed_work[:work])
239
+ puts "Error processing work #{error_result.work} in Ractor: #{completed_work[:processor]}: #{error_result.error}"
240
+ # Removed adding to failed_queue
241
+ @results.add_result(error_result) # This adds it to the errors array in the aggregator
242
+ # No need to decrement a counter here, loop condition checks total results
243
+ puts "Error handled. Total processed: #{@results.results.size + @results.errors.size}/#{@total_work_count}"
244
+ # Removed Failed Queue size log
245
+ puts "Results (including errors): #{@results.inspect}"
246
+ # Call helper to send next work
247
+ send_next_work_if_available(ractor)
248
+ else
249
+ # Log unknown message types from workers
250
+ puts "Unknown message type received: #{completed_work[:type]} from #{ractor}"
251
+ end
252
+ # Loop continues based on the while condition at the top
253
+ end
254
+
255
+ # Removed DEBUG LOG for failed_queue
256
+
257
+ # This part might not be reached if exit(1) is called in the trap
258
+ puts "Main loop finished."
259
+ puts "Final Results: #{@results.inspect}"
260
+ # Removed Failed Work Queue size log
261
+ # Optionally print failed items
262
+ # until @failed_queue.empty?
263
+ # puts "Failed: #{@failed_queue.pop.inspect}"
264
+ # end
265
+ end
266
+
267
+ private
268
+
269
+ # Helper method to send next work item if available
270
+ def send_next_work_if_available(ractor)
271
+ # Ensure the ractor is valid before attempting to send
272
+ # Ractor.select should only return active ractors, so closed? check is removed.
273
+ unless ractor.nil?
274
+ if !@work_queue.empty?
275
+ queued_work = @work_queue.pop # Pop before sending
276
+ puts "Sending next work #{queued_work} to Ractor: #{ractor}"
277
+ ractor.send(MyWork.new(queued_work))
278
+ puts "Work sent."
279
+ else
280
+ puts "Work queue empty. Not sending new work to Ractor #{ractor}."
281
+ end
282
+ else
283
+ puts "Attempted to send work to an invalid or closed Ractor."
284
+ end
285
+ end
286
+ end
287
+
288
+ # --- Main Execution ---
289
+ if __FILE__ == $0
290
+ supervisor = Supervisor.new(2) # Create supervisor with 2 workers
291
+
292
+ # Add work items
293
+ work_items = (1..10).to_a
294
+ supervisor.add_work(work_items)
295
+
296
+ # Run the supervisor
297
+ supervisor.run
298
+
299
+ puts "Processing complete."
300
+ puts "Final Aggregated Results:"
301
+ puts supervisor.results.inspect
302
+
303
+ # Print failed items directly from the ResultAggregator's errors array
304
+ failed_items = supervisor.results.errors # Access the errors array
305
+ puts "\nFailed Work Items (#{failed_items.size}):"
306
+ # Inspect each item individually for better readability if they are objects
307
+ # The items are already WorkResult objects
308
+ puts failed_items.map(&:inspect).inspect
309
+ end