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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.adoc +755 -0
- data/Rakefile +12 -0
- data/examples/hierarchical_hasher.rb +158 -0
- data/examples/producer_subscriber.rb +300 -0
- data/lib/fractor/result_aggregator.rb +34 -0
- data/lib/fractor/supervisor.rb +174 -0
- data/lib/fractor/version.rb +6 -0
- data/lib/fractor/work.rb +17 -0
- data/lib/fractor/work_result.rb +35 -0
- data/lib/fractor/worker.rb +11 -0
- data/lib/fractor/wrapped_ractor.rb +140 -0
- data/lib/fractor.rb +17 -0
- data/sample.rb +64 -0
- data/sig/fractor.rbs +4 -0
- data/tests/sample.rb.bak +309 -0
- data/tests/sample_working.rb.bak +209 -0
- metadata +66 -0
data/Rakefile
ADDED
@@ -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
|
data/lib/fractor/work.rb
ADDED
@@ -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
|