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.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative '../lib/fractor'
5
+
6
+ module HierarchicalHasher
7
+ class ChunkWork < Fractor::Work
8
+ work_type :chunk_hash
9
+ attr_reader :start, :length, :data
10
+
11
+ def initialize(start, length, data)
12
+ super(nil, { start: start, length: length, data: data })
13
+ @start = start
14
+ @length = length
15
+ @data = data
16
+ @retry_count = 0
17
+ @max_retries = 3
18
+ end
19
+
20
+ def should_retry?
21
+ @retry_count < @max_retries
22
+ end
23
+
24
+ def failed
25
+ @retry_count += 1
26
+ end
27
+ end
28
+
29
+ class ChunkResult < Fractor::WorkResult
30
+ attr_reader :start, :length, :hash_result
31
+
32
+ def initialize(work, hash_result)
33
+ super(work, hash_result)
34
+ @start = work.start
35
+ @length = work.length
36
+ @hash_result = hash_result
37
+ end
38
+ end
39
+
40
+ class HashWorker < Fractor::Worker
41
+ work_type_accepted :chunk_hash
42
+
43
+ def process_work(work)
44
+ # Simulate some processing time
45
+ sleep(rand(0.01..0.05))
46
+
47
+ # Calculate SHA-3 hash for the chunk
48
+ hash = Digest::SHA3.hexdigest(work.data)
49
+
50
+ # Return the result
51
+ ChunkResult.new(work, hash)
52
+ end
53
+ end
54
+
55
+ class HashResultAssembler < Fractor::ResultAssembler
56
+ def finalize
57
+ return nil if @results.empty?
58
+
59
+ # Sort results by start position
60
+ sorted_results = @results.sort_by { |result| result.start }
61
+
62
+ # Concatenate all hashes with newlines
63
+ combined_hash_string = sorted_results.map(&:hash_result).join("\n")
64
+
65
+ # Calculate final SHA-3 hash
66
+ Digest::SHA3.hexdigest(combined_hash_string)
67
+ end
68
+ end
69
+
70
+ class HashSupervisor < Fractor::Supervisor
71
+ def initialize(file_path, chunk_size = 1024, worker_count = 4)
72
+ super()
73
+ @file_path = file_path
74
+ @chunk_size = chunk_size
75
+ @worker_count = worker_count
76
+ @assembler = HashResultAssembler.new
77
+
78
+ # Create a queue for chunk work
79
+ chunk_queue = Fractor::Queue.new(work_types: [:chunk_hash])
80
+ add_queue(chunk_queue)
81
+
82
+ # Create a worker pool
83
+ worker_pool = Fractor::Pool.new(size: @worker_count)
84
+ @worker_count.times do
85
+ worker_pool.add_worker(HashWorker.new)
86
+ end
87
+ add_pool(worker_pool)
88
+
89
+ # Load the file and create work chunks
90
+ load_file_chunks
91
+ end
92
+
93
+ def load_file_chunks
94
+ File.open(@file_path, 'rb') do |file|
95
+ start_pos = 0
96
+
97
+ while chunk = file.read(@chunk_size)
98
+ work = ChunkWork.new(start_pos, chunk.length, chunk)
99
+ @queues.first.push(work)
100
+ start_pos += chunk.length
101
+ end
102
+ end
103
+ end
104
+
105
+ def process_results
106
+ until @result_queue.empty? && @queues.all?(&:empty?) && @idle_workers == @active_workers.size
107
+ result_data = next_result
108
+ next if result_data.nil?
109
+
110
+ type, *data = result_data
111
+
112
+ case type
113
+ when :result
114
+ result = data.first
115
+ @assembler.add_result(result)
116
+ when :error
117
+ work, error = data
118
+ @assembler.add_failed_work(work, error)
119
+ end
120
+
121
+ # Small sleep to prevent CPU spinning
122
+ sleep 0.001
123
+ end
124
+
125
+ # Return the final hash
126
+ @assembler.finalize
127
+ end
128
+ end
129
+ end
130
+
131
+ # Example usage
132
+ if __FILE__ == $PROGRAM_NAME
133
+ if ARGV.empty?
134
+ puts "Usage: ruby hierarchical_hasher.rb <file_path> [worker_count]"
135
+ exit 1
136
+ end
137
+
138
+ file_path = ARGV[0]
139
+ worker_count = (ARGV[1] || 4).to_i
140
+
141
+ unless File.exist?(file_path)
142
+ puts "Error: File '#{file_path}' not found"
143
+ exit 1
144
+ end
145
+
146
+ puts "Starting hierarchical hasher with #{worker_count} workers..."
147
+ puts "Processing file: #{file_path}"
148
+
149
+ start_time = Time.now
150
+ supervisor = HierarchicalHasher::HashSupervisor.new(file_path, 1024, worker_count)
151
+ final_hash = supervisor.start
152
+ end_time = Time.now
153
+
154
+ puts "Final SHA-3 hash: #{final_hash}"
155
+ puts "Processing completed in #{end_time - start_time} seconds"
156
+
157
+ supervisor.shutdown
158
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lib/fractor'
4
+
5
+ module ProducerSubscriber
6
+ # Define work types
7
+ class InitialWork < Fractor::Work
8
+ work_type :initial_processing
9
+ attr_reader :data, :depth
10
+
11
+ def initialize(data, depth = 0)
12
+ super(data: { data: data, depth: depth })
13
+ @data = data
14
+ @depth = depth
15
+ @retry_count = 0
16
+ @max_retries = 2
17
+ end
18
+
19
+ def should_retry?
20
+ @retry_count < @max_retries
21
+ end
22
+
23
+ def failed
24
+ @retry_count += 1
25
+ end
26
+ end
27
+
28
+ class SubWork < Fractor::Work
29
+ work_type :sub_processing
30
+ attr_reader :data, :parent_id, :depth
31
+
32
+ def initialize(data, parent_id, depth)
33
+ super(data: { data: data, parent_id: parent_id, depth: depth })
34
+ @data = data
35
+ @parent_id = parent_id
36
+ @depth = depth
37
+ @retry_count = 0
38
+ @max_retries = 2
39
+ end
40
+
41
+ def should_retry?
42
+ @retry_count < @max_retries
43
+ end
44
+
45
+ def failed
46
+ @retry_count += 1
47
+ end
48
+ end
49
+
50
+ # Define work results
51
+ class InitialWorkResult < Fractor::WorkResult
52
+ attr_reader :processed_data, :sub_works
53
+
54
+ def initialize(work, processed_data, sub_works = [])
55
+ super(work, processed_data)
56
+ @processed_data = processed_data
57
+ @sub_works = sub_works
58
+ end
59
+ end
60
+
61
+ class SubWorkResult < Fractor::WorkResult
62
+ attr_reader :processed_data, :parent_id
63
+
64
+ def initialize(work, processed_data)
65
+ super(work, processed_data)
66
+ @processed_data = processed_data
67
+ @parent_id = work.parent_id
68
+ end
69
+ end
70
+
71
+ # Define worker that can handle both types of work
72
+ class MultiWorker < Fractor::Worker
73
+ work_type_accepted [:initial_processing, :sub_processing]
74
+
75
+ def process_work(work)
76
+ case work.work_type
77
+ when :initial_processing
78
+ process_initial_work(work)
79
+ when :sub_processing
80
+ process_sub_work(work)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def process_initial_work(work)
87
+ # Simulate processing time
88
+ sleep(rand(0.01..0.05))
89
+
90
+ # Process the data
91
+ processed_data = "Processed: #{work.data}"
92
+
93
+ # Create sub-works if we're not too deep
94
+ sub_works = []
95
+ if work.depth < 2 # Limit recursion depth
96
+ # Split the work into smaller chunks
97
+ 3.times do |i|
98
+ sub_data = "#{work.data}-#{i}"
99
+ sub_works << SubWork.new(sub_data, work.object_id, work.depth + 1)
100
+ end
101
+ end
102
+
103
+ # Return result with sub-works
104
+ InitialWorkResult.new(work, processed_data, sub_works)
105
+ end
106
+
107
+ def process_sub_work(work)
108
+ # Simulate processing time
109
+ sleep(rand(0.01..0.03))
110
+
111
+ # Process the data
112
+ processed_data = "Sub-processed: #{work.data} (depth: #{work.depth})"
113
+
114
+ # Create more sub-works if we're not too deep
115
+ sub_works = []
116
+ if work.depth < 3 # Limit recursion depth
117
+ # Create fewer sub-works as we go deeper
118
+ (4 - work.depth).times do |i|
119
+ sub_data = "#{work.data}-#{i}"
120
+ sub_works << SubWork.new(sub_data, work.parent_id, work.depth + 1)
121
+ end
122
+ end
123
+
124
+ # Return result with any new sub-works
125
+ result = SubWorkResult.new(work, processed_data)
126
+ [result, sub_works]
127
+ end
128
+ end
129
+
130
+ # Define result assembler
131
+ class TreeResultAssembler < Fractor::ResultAssembler
132
+ def initialize
133
+ super()
134
+ @result_tree = {}
135
+ @pending_work_count = 0
136
+ end
137
+
138
+ def track_new_work(count = 1)
139
+ @pending_work_count += count
140
+ end
141
+
142
+ def work_completed
143
+ @pending_work_count -= 1
144
+ end
145
+
146
+ def add_result(result)
147
+ super(result)
148
+ work_completed
149
+
150
+ case result
151
+ when InitialWorkResult
152
+ @result_tree[result.work.object_id] = {
153
+ data: result.processed_data,
154
+ children: []
155
+ }
156
+ when SubWorkResult
157
+ parent = @result_tree[result.parent_id]
158
+ if parent
159
+ parent[:children] << result.processed_data
160
+ end
161
+ end
162
+ end
163
+
164
+ def all_work_complete?
165
+ @pending_work_count <= 0
166
+ end
167
+
168
+ def finalize
169
+ # Build a formatted tree representation
170
+ format_tree
171
+ end
172
+
173
+ private
174
+
175
+ def format_tree
176
+ result = []
177
+ @result_tree.each do |id, node|
178
+ result << "Root: #{node[:data]}"
179
+ node[:children].each_with_index do |child, index|
180
+ result << " ├─ Child #{index + 1}: #{child}"
181
+ end
182
+ result << ""
183
+ end
184
+ result.join("\n")
185
+ end
186
+ end
187
+
188
+ # Define supervisor
189
+ class TreeSupervisor < Fractor::Supervisor
190
+ def initialize(initial_data, worker_count = 4)
191
+ super()
192
+ @initial_data = initial_data
193
+ @worker_count = worker_count
194
+ @assembler = TreeResultAssembler.new
195
+
196
+ # Create a queue that can handle both work types
197
+ work_queue = Fractor::Queue.new(work_types: [:initial_processing, :sub_processing])
198
+ add_queue(work_queue)
199
+
200
+ # Create a worker pool
201
+ worker_pool = Fractor::Pool.new(size: @worker_count)
202
+ @worker_count.times do
203
+ worker_pool.add_worker(MultiWorker.new)
204
+ end
205
+ add_pool(worker_pool)
206
+
207
+ # Create initial work
208
+ initial_data.each do |data|
209
+ work = InitialWork.new(data)
210
+ @queues.first.push(work)
211
+ @assembler.track_new_work
212
+ end
213
+ end
214
+
215
+ def process_results
216
+ until @assembler.all_work_complete? && @queues.all?(&:empty?)
217
+ result_data = next_result
218
+ next if result_data.nil?
219
+
220
+ type, *data = result_data
221
+
222
+ case type
223
+ when :result
224
+ result = data.first
225
+
226
+ if result.is_a?(Array)
227
+ # Handle the case where we get a result and sub-works
228
+ sub_result, sub_works = result
229
+ @assembler.add_result(sub_result)
230
+
231
+ # Add new sub-works to the queue
232
+ if sub_works && !sub_works.empty?
233
+ @assembler.track_new_work(sub_works.size)
234
+ sub_works.each do |work|
235
+ @queues.first.push(work)
236
+ end
237
+ end
238
+ else
239
+ # Handle regular result
240
+ @assembler.add_result(result)
241
+
242
+ # If this result generated sub-works, add them to the queue
243
+ if result.respond_to?(:sub_works) && !result.sub_works.empty?
244
+ @assembler.track_new_work(result.sub_works.size)
245
+ result.sub_works.each do |work|
246
+ @queues.first.push(work)
247
+ end
248
+ end
249
+ end
250
+
251
+ when :error
252
+ work, error = data
253
+ @assembler.add_failed_work(work, error)
254
+ @assembler.work_completed
255
+ end
256
+
257
+ # Small sleep to prevent CPU spinning
258
+ sleep 0.001
259
+ end
260
+
261
+ # Return the final assembled result
262
+ @assembler.finalize
263
+ end
264
+ end
265
+ end
266
+
267
+ # Example usage: Document processing system
268
+ if __FILE__ == $PROGRAM_NAME
269
+ puts "Starting producer-subscriber example: Document Processing System"
270
+ puts "This example simulates a document processing system where:"
271
+ puts "1. Initial documents are broken down into sections"
272
+ puts "2. Sections are further broken down into paragraphs"
273
+ puts "3. Paragraphs are processed individually"
274
+ puts "4. Results are assembled into a hierarchical structure"
275
+ puts
276
+
277
+ # Sample documents to process
278
+ documents = [
279
+ "Annual Report 2025",
280
+ "Technical Documentation",
281
+ "Research Paper"
282
+ ]
283
+
284
+ worker_count = 4
285
+ puts "Using #{worker_count} workers to process #{documents.size} documents"
286
+ puts
287
+
288
+ start_time = Time.now
289
+ supervisor = ProducerSubscriber::TreeSupervisor.new(documents, worker_count)
290
+ result = supervisor.start
291
+ end_time = Time.now
292
+
293
+ puts "Processing Results:"
294
+ puts "==================="
295
+ puts result
296
+ puts
297
+ puts "Processing completed in #{end_time - start_time} seconds"
298
+
299
+ supervisor.shutdown
300
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractor
4
+ # Aggregates results and errors from worker Ractors.
5
+ class ResultAggregator
6
+ attr_reader :results, :errors
7
+
8
+ def initialize
9
+ @results = []
10
+ @errors = []
11
+ end
12
+
13
+ def add_result(result)
14
+ if result.success?
15
+ puts "Work completed successfully: #{result}"
16
+ @results << result
17
+ else
18
+ puts "Error processing work: #{result}"
19
+ @errors << result
20
+ end
21
+ end
22
+
23
+ def to_s
24
+ "Results: #{@results.size}, Errors: #{@errors.size}"
25
+ end
26
+
27
+ def inspect
28
+ {
29
+ results: @results.map(&:inspect),
30
+ errors: @errors.map(&:inspect)
31
+ }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread' # Required for Queue
4
+
5
+ module Fractor
6
+ # Supervises multiple WrappedRactors, distributes work, and aggregates results.
7
+ class Supervisor
8
+ attr_reader :work_queue, :workers, :results, :worker_class, :work_class
9
+
10
+ # Initializes the Supervisor.
11
+ # - worker_class: The class inheriting from Fractor::Worker (e.g., MyWorker).
12
+ # - work_class: The class inheriting from Fractor::Work (e.g., MyWork).
13
+ # - num_workers: The number of Ractors to spawn.
14
+ def initialize(worker_class:, work_class:, num_workers: 2)
15
+ unless worker_class < Fractor::Worker
16
+ raise ArgumentError, "#{worker_class} must inherit from Fractor::Worker"
17
+ end
18
+ unless work_class < Fractor::Work
19
+ raise ArgumentError, "#{work_class} must inherit from Fractor::Work"
20
+ end
21
+
22
+ @worker_class = worker_class
23
+ @work_class = work_class
24
+ @work_queue = Queue.new
25
+ @results = ResultAggregator.new
26
+ @num_workers = num_workers
27
+ @workers = []
28
+ @total_work_count = 0 # Track total items initially added
29
+ @ractors_map = {} # Map Ractor object to WrappedRactor instance
30
+ end
31
+
32
+ # Adds work items to the queue.
33
+ # Items should be the raw input data, not Work objects yet.
34
+ def add_work(items)
35
+ items.each { |item| @work_queue << item }
36
+ @total_work_count += items.size
37
+ puts "Work added. Initial work count: #{@total_work_count}, Queue size: #{@work_queue.size}"
38
+ end
39
+
40
+ # Starts the worker Ractors.
41
+ def start_workers
42
+ @workers = (1..@num_workers).map do |i|
43
+ # Pass the client's worker class (e.g., MyWorker) to WrappedRactor
44
+ wrapped_ractor = WrappedRactor.new("worker #{i}", @worker_class)
45
+ wrapped_ractor.start # Start the underlying Ractor
46
+ # Map the actual Ractor object to the WrappedRactor instance
47
+ @ractors_map[wrapped_ractor.ractor] = wrapped_ractor if wrapped_ractor.ractor
48
+ wrapped_ractor
49
+ end
50
+ # Filter out any workers that failed to start properly
51
+ @workers.compact!
52
+ @ractors_map.compact! # Ensure map doesn't contain nil keys/values
53
+ puts "Workers started: #{@workers.size} active."
54
+ end
55
+
56
+ # Sets up a signal handler for graceful shutdown (Ctrl+C).
57
+ def setup_signal_handler
58
+ # Need access to @workers within the trap block
59
+ workers_ref = @workers
60
+ Signal.trap("INT") do
61
+ puts "\nCtrl+C received. Initiating immediate shutdown..."
62
+ puts "Attempting to close worker Ractors..."
63
+ workers_ref.each do |w|
64
+ begin
65
+ w.close # Use the close method of WrappedRactor
66
+ puts "Closed Ractor: #{w.name}"
67
+ rescue => e
68
+ puts "Error closing Ractor #{w.name}: #{e.message}"
69
+ end
70
+ end
71
+ puts "Exiting now."
72
+ exit(1) # Exit immediately
73
+ end
74
+ end
75
+
76
+ # Runs the main processing loop.
77
+ def run
78
+ setup_signal_handler
79
+ start_workers
80
+
81
+ processed_count = 0
82
+ # Main loop: Process events until the number of results equals the initial work count.
83
+ while processed_count < @total_work_count
84
+ processed_count = @results.results.size + @results.errors.size
85
+ puts "Waiting for Ractor results. Processed: #{processed_count}/#{@total_work_count}, Queue size: #{@work_queue.size}"
86
+
87
+ # Get active Ractor objects from the map keys
88
+ # Use keys from ractors_map for the active ractors
89
+ active_ractors = @ractors_map.keys
90
+
91
+ # Break if no active workers and queue is empty, but work remains (indicates potential issue)
92
+ if active_ractors.empty? && @work_queue.empty? && processed_count < @total_work_count
93
+ puts "Warning: No active workers and queue is empty, but not all work is processed. Exiting loop."
94
+ break
95
+ end
96
+
97
+ # Skip selection if no active ractors are available but loop should continue (e.g., waiting for final results)
98
+ next if active_ractors.empty?
99
+
100
+ # Ractor.select blocks until a message is available from any active Ractor
101
+ ready_ractor_obj, message = Ractor.select(*active_ractors)
102
+
103
+ # Find the corresponding WrappedRactor instance
104
+ wrapped_ractor = @ractors_map[ready_ractor_obj]
105
+ unless wrapped_ractor
106
+ puts "Warning: Received message from unknown Ractor: #{ready_ractor_obj}. Ignoring."
107
+ next
108
+ end
109
+
110
+ puts "Selected Ractor: #{wrapped_ractor.name}, Message Type: #{message[:type]}"
111
+
112
+ # Process the received message
113
+ case message[:type]
114
+ when :initialize
115
+ puts "Ractor initialized: #{message[:processor]}"
116
+ # Send work immediately upon initialization if available
117
+ send_next_work_if_available(wrapped_ractor)
118
+ when :result
119
+ # The message[:result] should be a WorkResult object
120
+ work_result = message[:result]
121
+ puts "Completed work: #{work_result.inspect} in Ractor: #{message[:processor]}"
122
+ @results.add_result(work_result)
123
+ puts "Result processed. Total processed: #{@results.results.size + @results.errors.size}/#{@total_work_count}"
124
+ puts "Aggregated Results: #{@results.inspect}"
125
+ # Send next piece of work
126
+ send_next_work_if_available(wrapped_ractor)
127
+ when :error
128
+ # The message[:result] should be a WorkResult object containing the error
129
+ error_result = message[:result]
130
+ puts "Error processing work #{error_result.work&.inspect} in Ractor: #{message[:processor]}: #{error_result.error}"
131
+ @results.add_result(error_result) # Add error to aggregator
132
+ puts "Error handled. Total processed: #{@results.results.size + @results.errors.size}/#{@total_work_count}"
133
+ puts "Aggregated Results (including errors): #{@results.inspect}"
134
+ # Send next piece of work even after an error
135
+ send_next_work_if_available(wrapped_ractor)
136
+ else
137
+ puts "Unknown message type received: #{message[:type]} from #{wrapped_ractor.name}"
138
+ end
139
+ # Update processed count for the loop condition
140
+ processed_count = @results.results.size + @results.errors.size
141
+ end
142
+
143
+ puts "Main loop finished."
144
+ puts "Final Aggregated Results: #{@results.inspect}"
145
+ end
146
+
147
+ private
148
+
149
+ # Helper method to send the next available work item to a specific Ractor.
150
+ def send_next_work_if_available(wrapped_ractor)
151
+ # Ensure the wrapped_ractor instance is valid and its underlying ractor is not closed
152
+ if wrapped_ractor && !wrapped_ractor.closed?
153
+ if !@work_queue.empty?
154
+ raw_input = @work_queue.pop # Get raw input data
155
+ # Create an instance of the client's Work class (e.g., MyWork)
156
+ work_item = @work_class.new(raw_input)
157
+ puts "Sending next work #{work_item.inspect} to Ractor: #{wrapped_ractor.name}"
158
+ wrapped_ractor.send(work_item) # Send the Work object
159
+ puts "Work sent to #{wrapped_ractor.name}."
160
+ else
161
+ puts "Work queue empty. Not sending new work to Ractor #{wrapped_ractor.name}."
162
+ # Consider closing the Ractor if the queue is empty and no more work is expected.
163
+ # wrapped_ractor.close
164
+ # @ractors_map.delete(wrapped_ractor.ractor)
165
+ # puts "Closed idle Ractor: #{wrapped_ractor.name}"
166
+ end
167
+ else
168
+ puts "Attempted to send work to an invalid or closed Ractor: #{wrapped_ractor&.name || 'unknown'}."
169
+ # Remove from map if found but closed
170
+ @ractors_map.delete(wrapped_ractor.ractor) if wrapped_ractor && @ractors_map.key?(wrapped_ractor.ractor)
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractor
4
+ # Fractor version
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractor
4
+ # Base class for defining work items.
5
+ # Contains the input data for a worker.
6
+ class Work
7
+ attr_reader :input
8
+
9
+ def initialize(input)
10
+ @input = input
11
+ end
12
+
13
+ def to_s
14
+ "Work: #{@input}"
15
+ end
16
+ end
17
+ end