fractor 0.1.6 → 0.1.7

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.
Files changed (172) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +227 -102
  3. data/README.adoc +113 -1940
  4. data/docs/.lycheeignore +16 -0
  5. data/docs/Gemfile +24 -0
  6. data/docs/README.md +157 -0
  7. data/docs/_config.yml +151 -0
  8. data/docs/_features/error-handling.adoc +1192 -0
  9. data/docs/_features/index.adoc +80 -0
  10. data/docs/_features/monitoring.adoc +589 -0
  11. data/docs/_features/signal-handling.adoc +202 -0
  12. data/docs/_features/workflows.adoc +1235 -0
  13. data/docs/_guides/continuous-mode.adoc +736 -0
  14. data/docs/_guides/cookbook.adoc +1133 -0
  15. data/docs/_guides/index.adoc +55 -0
  16. data/docs/_guides/pipeline-mode.adoc +730 -0
  17. data/docs/_guides/troubleshooting.adoc +358 -0
  18. data/docs/_pages/architecture.adoc +1390 -0
  19. data/docs/_pages/core-concepts.adoc +1392 -0
  20. data/docs/_pages/design-principles.adoc +862 -0
  21. data/docs/_pages/getting-started.adoc +290 -0
  22. data/docs/_pages/installation.adoc +143 -0
  23. data/docs/_reference/api.adoc +1080 -0
  24. data/docs/_reference/error-reporting.adoc +670 -0
  25. data/docs/_reference/examples.adoc +181 -0
  26. data/docs/_reference/index.adoc +96 -0
  27. data/docs/_reference/troubleshooting.adoc +862 -0
  28. data/docs/_tutorials/complex-workflows.adoc +1022 -0
  29. data/docs/_tutorials/data-processing-pipeline.adoc +740 -0
  30. data/docs/_tutorials/first-application.adoc +384 -0
  31. data/docs/_tutorials/index.adoc +48 -0
  32. data/docs/_tutorials/long-running-services.adoc +931 -0
  33. data/docs/assets/images/favicon-16.png +0 -0
  34. data/docs/assets/images/favicon-32.png +0 -0
  35. data/docs/assets/images/favicon-48.png +0 -0
  36. data/docs/assets/images/favicon.ico +0 -0
  37. data/docs/assets/images/favicon.png +0 -0
  38. data/docs/assets/images/favicon.svg +45 -0
  39. data/docs/assets/images/fractor-icon.svg +49 -0
  40. data/docs/assets/images/fractor-logo.svg +61 -0
  41. data/docs/index.adoc +131 -0
  42. data/docs/lychee.toml +39 -0
  43. data/examples/api_aggregator/README.adoc +627 -0
  44. data/examples/api_aggregator/api_aggregator.rb +376 -0
  45. data/examples/auto_detection/README.adoc +407 -29
  46. data/examples/continuous_chat_common/message_protocol.rb +1 -1
  47. data/examples/error_reporting.rb +207 -0
  48. data/examples/file_processor/README.adoc +170 -0
  49. data/examples/file_processor/file_processor.rb +615 -0
  50. data/examples/file_processor/sample_files/invalid.csv +1 -0
  51. data/examples/file_processor/sample_files/orders.xml +24 -0
  52. data/examples/file_processor/sample_files/products.json +23 -0
  53. data/examples/file_processor/sample_files/users.csv +6 -0
  54. data/examples/hierarchical_hasher/README.adoc +629 -41
  55. data/examples/image_processor/README.adoc +610 -0
  56. data/examples/image_processor/image_processor.rb +349 -0
  57. data/examples/image_processor/processed_images/sample_10_processed.jpg.json +12 -0
  58. data/examples/image_processor/processed_images/sample_1_processed.jpg.json +12 -0
  59. data/examples/image_processor/processed_images/sample_2_processed.jpg.json +12 -0
  60. data/examples/image_processor/processed_images/sample_3_processed.jpg.json +12 -0
  61. data/examples/image_processor/processed_images/sample_4_processed.jpg.json +12 -0
  62. data/examples/image_processor/processed_images/sample_5_processed.jpg.json +12 -0
  63. data/examples/image_processor/processed_images/sample_6_processed.jpg.json +12 -0
  64. data/examples/image_processor/processed_images/sample_7_processed.jpg.json +12 -0
  65. data/examples/image_processor/processed_images/sample_8_processed.jpg.json +12 -0
  66. data/examples/image_processor/processed_images/sample_9_processed.jpg.json +12 -0
  67. data/examples/image_processor/test_images/sample_1.png +1 -0
  68. data/examples/image_processor/test_images/sample_10.png +1 -0
  69. data/examples/image_processor/test_images/sample_2.png +1 -0
  70. data/examples/image_processor/test_images/sample_3.png +1 -0
  71. data/examples/image_processor/test_images/sample_4.png +1 -0
  72. data/examples/image_processor/test_images/sample_5.png +1 -0
  73. data/examples/image_processor/test_images/sample_6.png +1 -0
  74. data/examples/image_processor/test_images/sample_7.png +1 -0
  75. data/examples/image_processor/test_images/sample_8.png +1 -0
  76. data/examples/image_processor/test_images/sample_9.png +1 -0
  77. data/examples/log_analyzer/README.adoc +662 -0
  78. data/examples/log_analyzer/log_analyzer.rb +579 -0
  79. data/examples/log_analyzer/sample_logs/apache.log +20 -0
  80. data/examples/log_analyzer/sample_logs/json.log +15 -0
  81. data/examples/log_analyzer/sample_logs/nginx.log +15 -0
  82. data/examples/log_analyzer/sample_logs/rails.log +29 -0
  83. data/examples/multi_work_type/README.adoc +576 -26
  84. data/examples/performance_monitoring.rb +120 -0
  85. data/examples/pipeline_processing/README.adoc +740 -26
  86. data/examples/pipeline_processing/pipeline_processing.rb +2 -2
  87. data/examples/priority_work_example.rb +155 -0
  88. data/examples/producer_subscriber/README.adoc +889 -46
  89. data/examples/scatter_gather/README.adoc +829 -27
  90. data/examples/simple/README.adoc +347 -0
  91. data/examples/specialized_workers/README.adoc +622 -26
  92. data/examples/specialized_workers/specialized_workers.rb +44 -8
  93. data/examples/stream_processor/README.adoc +206 -0
  94. data/examples/stream_processor/stream_processor.rb +284 -0
  95. data/examples/web_scraper/README.adoc +625 -0
  96. data/examples/web_scraper/web_scraper.rb +285 -0
  97. data/examples/workflow/README.adoc +406 -0
  98. data/examples/workflow/circuit_breaker/README.adoc +360 -0
  99. data/examples/workflow/circuit_breaker/circuit_breaker_workflow.rb +225 -0
  100. data/examples/workflow/conditional/README.adoc +483 -0
  101. data/examples/workflow/conditional/conditional_workflow.rb +215 -0
  102. data/examples/workflow/dead_letter_queue/README.adoc +374 -0
  103. data/examples/workflow/dead_letter_queue/dead_letter_queue_workflow.rb +217 -0
  104. data/examples/workflow/fan_out/README.adoc +381 -0
  105. data/examples/workflow/fan_out/fan_out_workflow.rb +202 -0
  106. data/examples/workflow/retry/README.adoc +248 -0
  107. data/examples/workflow/retry/retry_workflow.rb +195 -0
  108. data/examples/workflow/simple_linear/README.adoc +267 -0
  109. data/examples/workflow/simple_linear/simple_linear_workflow.rb +175 -0
  110. data/examples/workflow/simplified/README.adoc +329 -0
  111. data/examples/workflow/simplified/simplified_workflow.rb +222 -0
  112. data/exe/fractor +10 -0
  113. data/lib/fractor/cli.rb +288 -0
  114. data/lib/fractor/configuration.rb +307 -0
  115. data/lib/fractor/continuous_server.rb +60 -65
  116. data/lib/fractor/error_formatter.rb +72 -0
  117. data/lib/fractor/error_report_generator.rb +152 -0
  118. data/lib/fractor/error_reporter.rb +244 -0
  119. data/lib/fractor/error_statistics.rb +147 -0
  120. data/lib/fractor/execution_tracer.rb +162 -0
  121. data/lib/fractor/logger.rb +230 -0
  122. data/lib/fractor/main_loop_handler.rb +406 -0
  123. data/lib/fractor/main_loop_handler3.rb +135 -0
  124. data/lib/fractor/main_loop_handler4.rb +299 -0
  125. data/lib/fractor/performance_metrics_collector.rb +181 -0
  126. data/lib/fractor/performance_monitor.rb +215 -0
  127. data/lib/fractor/performance_report_generator.rb +202 -0
  128. data/lib/fractor/priority_work.rb +93 -0
  129. data/lib/fractor/priority_work_queue.rb +189 -0
  130. data/lib/fractor/result_aggregator.rb +32 -0
  131. data/lib/fractor/shutdown_handler.rb +168 -0
  132. data/lib/fractor/signal_handler.rb +80 -0
  133. data/lib/fractor/supervisor.rb +382 -269
  134. data/lib/fractor/supervisor_logger.rb +88 -0
  135. data/lib/fractor/version.rb +1 -1
  136. data/lib/fractor/work.rb +12 -0
  137. data/lib/fractor/work_distribution_manager.rb +151 -0
  138. data/lib/fractor/work_queue.rb +20 -0
  139. data/lib/fractor/work_result.rb +181 -9
  140. data/lib/fractor/worker.rb +73 -0
  141. data/lib/fractor/workflow/builder.rb +210 -0
  142. data/lib/fractor/workflow/chain_builder.rb +169 -0
  143. data/lib/fractor/workflow/circuit_breaker.rb +183 -0
  144. data/lib/fractor/workflow/circuit_breaker_orchestrator.rb +208 -0
  145. data/lib/fractor/workflow/circuit_breaker_registry.rb +112 -0
  146. data/lib/fractor/workflow/dead_letter_queue.rb +334 -0
  147. data/lib/fractor/workflow/execution_hooks.rb +39 -0
  148. data/lib/fractor/workflow/execution_strategy.rb +225 -0
  149. data/lib/fractor/workflow/execution_trace.rb +134 -0
  150. data/lib/fractor/workflow/helpers.rb +191 -0
  151. data/lib/fractor/workflow/job.rb +290 -0
  152. data/lib/fractor/workflow/job_dependency_validator.rb +120 -0
  153. data/lib/fractor/workflow/logger.rb +110 -0
  154. data/lib/fractor/workflow/pre_execution_context.rb +193 -0
  155. data/lib/fractor/workflow/retry_config.rb +156 -0
  156. data/lib/fractor/workflow/retry_orchestrator.rb +184 -0
  157. data/lib/fractor/workflow/retry_strategy.rb +93 -0
  158. data/lib/fractor/workflow/structured_logger.rb +30 -0
  159. data/lib/fractor/workflow/type_compatibility_validator.rb +222 -0
  160. data/lib/fractor/workflow/visualizer.rb +211 -0
  161. data/lib/fractor/workflow/workflow_context.rb +132 -0
  162. data/lib/fractor/workflow/workflow_executor.rb +669 -0
  163. data/lib/fractor/workflow/workflow_result.rb +55 -0
  164. data/lib/fractor/workflow/workflow_validator.rb +295 -0
  165. data/lib/fractor/workflow.rb +333 -0
  166. data/lib/fractor/wrapped_ractor.rb +66 -101
  167. data/lib/fractor/wrapped_ractor3.rb +161 -0
  168. data/lib/fractor/wrapped_ractor4.rb +242 -0
  169. data/lib/fractor.rb +92 -4
  170. metadata +179 -6
  171. data/tests/sample.rb.bak +0 -309
  172. data/tests/sample_working.rb.bak +0 -209
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "etc"
4
4
  require "timeout"
5
+ require_relative "signal_handler"
6
+ require_relative "error_formatter"
5
7
 
6
8
  module Fractor
7
9
  # Custom exception for shutdown signal handling
@@ -9,21 +11,60 @@ module Fractor
9
11
 
10
12
  # Supervises multiple WrappedRactors, distributes work, and aggregates results.
11
13
  class Supervisor
12
- attr_reader :work_queue, :workers, :results, :worker_pools
14
+ attr_reader :work_queue, :workers, :results, :worker_pools, :debug,
15
+ :error_reporter, :logger, :performance_monitor
13
16
 
14
17
  # Initializes the Supervisor.
15
18
  # - worker_pools: An array of worker pool configurations, each containing:
16
19
  # - worker_class: The class inheriting from Fractor::Worker (e.g., MyWorker).
17
20
  # - num_workers: The number of Ractors to spawn for this worker class.
18
21
  # - continuous_mode: Whether to run in continuous mode without expecting a fixed work count.
19
- def initialize(worker_pools: [], continuous_mode: false)
20
- @worker_pools = worker_pools.map do |pool_config|
22
+ # - debug: Enable verbose debugging output for all state changes.
23
+ # - logger: Optional logger instance for this Supervisor (defaults to Fractor.logger).
24
+ # Provides isolation when multiple gems use Fractor in the same process.
25
+ # - tracer_enabled: Optional override for ExecutionTracer (nil uses global setting).
26
+ # - tracer_stream: Optional trace stream for this Supervisor (nil uses global setting).
27
+ # - enable_performance_monitoring: Enable performance monitoring (latency, throughput, etc.).
28
+ def initialize(worker_pools: [], continuous_mode: false, debug: false, logger: nil,
29
+ tracer_enabled: nil, tracer_stream: nil, enable_performance_monitoring: false)
30
+ @debug = debug || ENV["FRACTOR_DEBUG"] == "1"
31
+ @logger = logger # Store instance-specific logger for isolation
32
+ @tracer_enabled = tracer_enabled
33
+ @tracer_stream = tracer_stream
34
+ @worker_pools = worker_pools.map.with_index do |pool_config, index|
21
35
  worker_class = pool_config[:worker_class]
22
36
  num_workers = pool_config[:num_workers] || detect_num_workers
23
37
 
38
+ # Validate worker_class
39
+ unless worker_class.is_a?(Class)
40
+ raise ArgumentError,
41
+ "worker_class must be a Class (got #{worker_class.class}), in worker_pools[#{index}]\n\n" \
42
+ "Expected: { worker_class: MyWorker }\n" \
43
+ "Got: { worker_class: #{worker_class.inspect} }\n\n" \
44
+ "Fix: Use the class itself, not a symbol or string.\n" \
45
+ "Example: { worker_class: MyWorker } # Correct\n" \
46
+ " { worker_class: 'MyWorker' } # Wrong - this is a string"
47
+ end
48
+
24
49
  unless worker_class < Fractor::Worker
25
50
  raise ArgumentError,
26
- "#{worker_class} must inherit from Fractor::Worker"
51
+ "#{worker_class} must inherit from Fractor::Worker, in worker_pools[#{index}]\n\n" \
52
+ "Your worker class must be defined as:\n" \
53
+ " class #{worker_class} < Fractor::Worker\n" \
54
+ " def process(work)\n" \
55
+ " # ...\n" \
56
+ " end\n" \
57
+ " end\n\n" \
58
+ "Did you forget to inherit from Fractor::Worker?"
59
+ end
60
+
61
+ # Validate num_workers
62
+ unless num_workers.is_a?(Integer) && num_workers.positive?
63
+ raise ArgumentError,
64
+ "num_workers must be a positive integer (got #{num_workers.inspect}), in worker_pools[#{index}]\n\n" \
65
+ "Valid values: Integer >= 1\n" \
66
+ "Examples: { num_workers: 4 } # Use 4 workers\n" \
67
+ " { num_workers: Etc.nprocessors } # Use available CPUs"
27
68
  end
28
69
 
29
70
  {
@@ -43,7 +84,46 @@ module Fractor
43
84
  @work_callbacks = []
44
85
  @wakeup_ractor = nil # Control ractor for unblocking select
45
86
  @timer_thread = nil # Timer thread for periodic wakeup
46
- @idle_workers = [] # Track workers waiting for work
87
+ @error_reporter = ErrorReporter.new # Track errors and statistics
88
+ @error_callbacks = [] # Custom error callbacks
89
+ @performance_monitor = nil # Performance monitor instance
90
+
91
+ # Initialize performance monitor if enabled
92
+ if enable_performance_monitoring
93
+ require_relative "performance_monitor"
94
+ @performance_monitor = PerformanceMonitor.new(self)
95
+ @performance_monitor.start
96
+ end
97
+
98
+ # Initialize work distribution manager (handles idle workers and work assignment)
99
+ @work_distribution_manager = WorkDistributionManager.new(
100
+ @work_queue,
101
+ @workers,
102
+ @ractors_map,
103
+ debug: @debug,
104
+ continuous_mode: @continuous_mode,
105
+ performance_monitor: @performance_monitor,
106
+ )
107
+
108
+ # Initialize shutdown handler (manages graceful shutdown)
109
+ @shutdown_handler = ShutdownHandler.new(
110
+ @workers,
111
+ @wakeup_ractor,
112
+ @timer_thread,
113
+ @performance_monitor,
114
+ debug: @debug,
115
+ )
116
+
117
+ # Initialize signal handler for graceful shutdown
118
+ @signal_handler = SignalHandler.new(
119
+ continuous_mode: @continuous_mode,
120
+ debug: @debug,
121
+ status_callback: -> { print_status },
122
+ shutdown_callback: ->(mode) { handle_shutdown_callback(mode) },
123
+ )
124
+
125
+ # Initialize error formatter for error messages
126
+ @error_formatter = ErrorFormatter.new
47
127
  end
48
128
 
49
129
  # Adds a single work item to the queue.
@@ -51,12 +131,32 @@ module Fractor
51
131
  def add_work_item(work)
52
132
  unless work.is_a?(Fractor::Work)
53
133
  raise ArgumentError,
54
- "#{work.class} must be an instance of Fractor::Work"
134
+ "#{work.class} must be an instance of Fractor::Work.\n\n" \
135
+ "Received: #{work.inspect}\n\n" \
136
+ "To create a valid work item:\n" \
137
+ " class MyWork < Fractor::Work\n" \
138
+ " def initialize(data)\n" \
139
+ " super({ value: data })\n" \
140
+ " end\n" \
141
+ " end\n\n" \
142
+ " work = MyWork.new(42)\n" \
143
+ " supervisor.add_work_item(work)"
55
144
  end
56
145
 
57
146
  @work_queue << work
58
147
  @total_work_count += 1
59
- return unless ENV["FRACTOR_DEBUG"]
148
+
149
+ # Trace work item queued
150
+ trace_work(:queued, work, queue_size: @work_queue.size)
151
+
152
+ # Distribute to idle workers if work_distribution_manager is available
153
+ # This ensures work added from callbacks gets picked up immediately
154
+ if @work_distribution_manager && @running
155
+ distributed = @work_distribution_manager.distribute_to_idle_workers
156
+ puts "Distributed work to #{distributed} idle workers after add_work_item" if @debug && distributed.positive?
157
+ end
158
+
159
+ return unless @debug
60
160
 
61
161
  puts "Work item added. Initial work count: #{@total_work_count}, Queue size: #{@work_queue.size}"
62
162
  end
@@ -75,20 +175,51 @@ module Fractor
75
175
  @work_callbacks << callback
76
176
  end
77
177
 
178
+ # Register a callback to handle errors
179
+ # The callback receives (error_result, worker_name, worker_class)
180
+ # Example: supervisor.on_error { |err, worker, klass| puts "Error in #{klass}: #{err.error}" }
181
+ def on_error(&callback)
182
+ @error_callbacks << callback
183
+ end
184
+
78
185
  # Starts the worker Ractors for all worker pools.
79
186
  def start_workers
187
+ # Capture debug flag for Ractor isolation (Ruby 4.0)
188
+ # Pass as parameter to avoid isolation error
189
+ debug_mode = @debug
190
+
191
+ # Check if running on Ruby 4.0
192
+ ruby_4_0 = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("4.0.0")
193
+
80
194
  # Create a wakeup Ractor for unblocking Ractor.select
81
- @wakeup_ractor = Ractor.new do
82
- puts "Wakeup Ractor started" if ENV["FRACTOR_DEBUG"]
83
- loop do
84
- msg = Ractor.receive
85
- puts "Wakeup Ractor received: #{msg.inspect}" if ENV["FRACTOR_DEBUG"]
86
- if %i[wakeup shutdown].include?(msg)
87
- Ractor.yield({ type: :wakeup, message: msg })
88
- break if msg == :shutdown
195
+ if ruby_4_0
196
+ # In Ruby 4.0, wakeup uses ports too
197
+ @wakeup_port = Ractor::Port.new
198
+ @wakeup_ractor = Ractor.new(@wakeup_port, debug_mode) do |port, debug|
199
+ puts "Wakeup Ractor started" if debug
200
+ loop do
201
+ msg = Ractor.receive
202
+ puts "Wakeup Ractor received: #{msg.inspect}" if debug
203
+ if %i[wakeup shutdown].include?(msg)
204
+ port << { type: :wakeup, message: msg }
205
+ break if msg == :shutdown
206
+ end
89
207
  end
208
+ puts "Wakeup Ractor shutting down" if debug
209
+ end
210
+ else
211
+ @wakeup_ractor = Ractor.new(debug_mode) do |debug|
212
+ puts "Wakeup Ractor started" if debug
213
+ loop do
214
+ msg = Ractor.receive
215
+ puts "Wakeup Ractor received: #{msg.inspect}" if debug
216
+ if %i[wakeup shutdown].include?(msg)
217
+ Ractor.yield({ type: :wakeup, message: msg })
218
+ break if msg == :shutdown
219
+ end
220
+ end
221
+ puts "Wakeup Ractor shutting down" if debug
90
222
  end
91
- puts "Wakeup Ractor shutting down" if ENV["FRACTOR_DEBUG"]
92
223
  end
93
224
 
94
225
  # Add wakeup ractor to the map with a special marker
@@ -99,7 +230,17 @@ module Fractor
99
230
  num_workers = pool[:num_workers]
100
231
 
101
232
  pool[:workers] = (1..num_workers).map do |i|
102
- wrapped_ractor = WrappedRactor.new("worker #{worker_class}:#{i}", worker_class)
233
+ # In Ruby 4.0, create a response port for each worker
234
+ response_port = if ruby_4_0
235
+ Ractor::Port.new
236
+ end
237
+
238
+ # Use the factory method to create the appropriate implementation
239
+ wrapped_ractor = WrappedRactor.create(
240
+ "worker #{worker_class}:#{i}",
241
+ worker_class,
242
+ response_port: response_port,
243
+ )
103
244
  wrapped_ractor.start # Start the underlying Ractor
104
245
  # Map the actual Ractor object to the WrappedRactor instance
105
246
  @ractors_map[wrapped_ractor.ractor] = wrapped_ractor if wrapped_ractor.ractor
@@ -110,332 +251,304 @@ module Fractor
110
251
  # Flatten all workers for easier access
111
252
  @workers = @worker_pools.flat_map { |pool| pool[:workers] }
112
253
  @ractors_map.compact! # Ensure map doesn't contain nil keys/values
113
- return unless ENV["FRACTOR_DEBUG"]
254
+
255
+ # Update work distribution manager's workers reference
256
+ # This is critical because @workers was reassigned, and WorkDistributionManager
257
+ # needs the updated reference to properly track idle workers
258
+ @work_distribution_manager.update_workers(@workers)
259
+
260
+ # Mark all workers as idle initially so they can receive work
261
+ # This is critical for Ruby 4.0 where workers don't send :initialize messages
262
+ @workers.each do |worker|
263
+ @work_distribution_manager.mark_worker_idle(worker)
264
+ end
265
+
266
+ return unless @debug
114
267
 
115
268
  puts "Workers started: #{@workers.size} active across #{@worker_pools.size} pools."
269
+ puts "All workers marked as idle and ready for work."
116
270
  end
117
271
 
118
272
  # Sets up signal handlers for graceful shutdown.
119
- # Handles SIGINT (Ctrl+C), SIGTERM (systemd/docker), and platform-specific status signals.
273
+ # Uses SignalHandler to manage signal handling logic.
120
274
  def setup_signal_handler
121
- # Universal signals (work on all platforms)
122
- Signal.trap("INT") { handle_shutdown("SIGINT") }
123
- Signal.trap("TERM") { handle_shutdown("SIGTERM") }
124
-
125
- # Platform-specific status monitoring
126
- setup_status_signal
275
+ @signal_handler.setup
127
276
  end
128
277
 
129
- # Handles shutdown signal by mode (continuous vs batch)
130
- def handle_shutdown(signal_name)
131
- if @continuous_mode
132
- puts "\n#{signal_name} received. Initiating graceful shutdown..." if ENV["FRACTOR_DEBUG"]
278
+ # Callback for signal handler shutdown requests.
279
+ def handle_shutdown_callback(mode)
280
+ if mode == :graceful
133
281
  stop
134
282
  else
135
- puts "\n#{signal_name} received. Initiating immediate shutdown..." if ENV["FRACTOR_DEBUG"]
136
- Thread.current.raise(ShutdownSignal, "Interrupted by #{signal_name}")
137
- end
138
- rescue Exception => e
139
- puts "Error in signal handler: #{e.class}: #{e.message}" if ENV["FRACTOR_DEBUG"]
140
- puts e.backtrace.join("\n") if ENV["FRACTOR_DEBUG"]
141
- exit!(1)
142
- end
143
-
144
- # Sets up platform-specific status monitoring signal
145
- def setup_status_signal
146
- if Gem.win_platform?
147
- # Windows: Try SIGBREAK (Ctrl+Break) if available
148
- begin
149
- Signal.trap("BREAK") { print_status }
150
- rescue ArgumentError
151
- # SIGBREAK not supported on this Ruby version/platform
152
- # Status monitoring unavailable on Windows
153
- end
154
- else
155
- # Unix/Linux/macOS: Use SIGUSR1
156
- begin
157
- Signal.trap("USR1") { print_status }
158
- rescue ArgumentError
159
- # SIGUSR1 not supported on this platform
160
- end
283
+ # Immediate shutdown - raise signal in current thread
284
+ Thread.current.raise(ShutdownSignal, "Interrupted by signal")
161
285
  end
162
286
  end
163
287
 
164
288
  # Prints current supervisor status
165
289
  def print_status
290
+ status = @work_distribution_manager.status_summary
166
291
  puts "\n=== Fractor Supervisor Status ==="
167
292
  puts "Mode: #{@continuous_mode ? 'Continuous' : 'Batch'}"
168
293
  puts "Running: #{@running}"
169
294
  puts "Workers: #{@workers.size}"
170
- puts "Idle workers: #{@idle_workers.size}"
295
+ puts "Idle workers: #{status[:idle]}"
171
296
  puts "Queue size: #{@work_queue.size}"
172
297
  puts "Results: #{@results.results.size}"
173
298
  puts "Errors: #{@results.errors.size}"
174
299
  puts "================================\n"
175
300
  end
176
301
 
302
+ # Starts the supervisor (alias for run).
303
+ # Provides a consistent API with stop method.
304
+ #
305
+ # @see #run
306
+ def start
307
+ run
308
+ end
309
+
177
310
  # Runs the main processing loop.
178
311
  def run
179
312
  setup_signal_handler
180
313
  start_workers
181
314
 
182
315
  @running = true
183
- processed_count = 0
184
316
 
185
- # Start timer thread for continuous mode to periodically check work sources
186
- if @continuous_mode && !@work_callbacks.empty?
187
- @timer_thread = Thread.new do
188
- while @running
189
- sleep(0.1) # Check work sources every 100ms
190
- if @wakeup_ractor && @running
191
- begin
192
- @wakeup_ractor.send(:wakeup)
193
- rescue StandardError => e
194
- puts "Timer thread error sending wakeup: #{e.message}" if ENV["FRACTOR_DEBUG"]
195
- break
196
- end
197
- end
198
- end
199
- puts "Timer thread shutting down" if ENV["FRACTOR_DEBUG"]
200
- end
317
+ # Distribute any work that was added before run() was called
318
+ # This is critical for Ruby 4.0 where workers need explicit work distribution
319
+ if @work_distribution_manager
320
+ distributed = @work_distribution_manager.distribute_to_idle_workers
321
+ puts "Distributed initial work to #{distributed} idle workers (work_queue.size: #{@work_queue.size})" if @debug || true
201
322
  end
202
323
 
203
- begin
204
- # Main loop: Process events until conditions are met for termination
205
- while @running && (@continuous_mode || processed_count < @total_work_count)
206
- processed_count = @results.results.size + @results.errors.size
207
-
208
- if ENV["FRACTOR_DEBUG"]
209
- if @continuous_mode
210
- puts "Continuous mode: Waiting for Ractor results. Processed: #{processed_count}, Queue size: #{@work_queue.size}"
211
- else
212
- puts "Waiting for Ractor results. Processed: #{processed_count}/#{@total_work_count}, Queue size: #{@work_queue.size}"
213
- end
214
- end
215
-
216
- # Get active Ractor objects from the map keys
217
- active_ractors = @ractors_map.keys
218
-
219
- # Check for new work from callbacks if in continuous mode
220
- if @continuous_mode && !@work_callbacks.empty?
221
- @work_callbacks.each do |callback|
222
- new_work = callback.call
223
- if new_work && !new_work.empty?
224
- add_work_items(new_work)
225
- puts "Work source provided #{new_work.size} new items" if ENV["FRACTOR_DEBUG"]
226
-
227
- # Try to send work to idle workers first
228
- while !@work_queue.empty? && !@idle_workers.empty?
229
- worker = @idle_workers.shift
230
- if send_next_work_if_available(worker)
231
- puts "Sent work to idle worker #{worker.name}" if ENV["FRACTOR_DEBUG"]
232
- else
233
- # Worker couldn't accept work, don't re-add to idle list
234
- end
235
- end
236
- end
237
- end
238
- end
239
-
240
- # Break if no active workers and queue is empty, but work remains (indicates potential issue)
241
- if active_ractors.empty? && @work_queue.empty? && !@continuous_mode && processed_count < @total_work_count
242
- puts "Warning: No active workers and queue is empty, but not all work is processed. Exiting loop." if ENV["FRACTOR_DEBUG"]
243
- break
244
- end
245
-
246
- # In continuous mode, just wait if no active ractors but keep running
247
- if active_ractors.empty?
248
- break unless @continuous_mode
249
-
250
- sleep(0.1) # Small delay to avoid CPU spinning
251
- next
252
- end
253
-
254
- # Ractor.select blocks until a message is available from any active Ractor
255
- # The wakeup ractor ensures we can unblock this call when needed
256
- ready_ractor_obj, message = Ractor.select(*active_ractors)
257
-
258
- # Check if this is the wakeup ractor
259
- if ready_ractor_obj == @wakeup_ractor
260
- puts "Wakeup signal received: #{message[:message]}" if ENV["FRACTOR_DEBUG"]
261
- # Remove wakeup ractor from map if shutting down
262
- if message[:message] == :shutdown
263
- @ractors_map.delete(@wakeup_ractor)
264
- @wakeup_ractor = nil
265
- end
266
- # Continue loop to check @running flag
267
- next
268
- end
269
-
270
- # Find the corresponding WrappedRactor instance
271
- wrapped_ractor = @ractors_map[ready_ractor_obj]
272
- unless wrapped_ractor
273
- puts "Warning: Received message from unknown Ractor: #{ready_ractor_obj}. Ignoring." if ENV["FRACTOR_DEBUG"]
274
- next
275
- end
276
-
277
- puts "Selected Ractor: #{wrapped_ractor.name}, Message Type: #{message[:type]}" if ENV["FRACTOR_DEBUG"]
278
-
279
- # Process the received message
280
- case message[:type]
281
- when :initialize
282
- puts "Ractor initialized: #{message[:processor]}" if ENV["FRACTOR_DEBUG"]
283
- # Send work immediately upon initialization if available
284
- if send_next_work_if_available(wrapped_ractor)
285
- # Work was sent
286
- else
287
- # No work available, mark worker as idle
288
- @idle_workers << wrapped_ractor unless @idle_workers.include?(wrapped_ractor)
289
- puts "Worker #{wrapped_ractor.name} marked as idle" if ENV["FRACTOR_DEBUG"]
290
- end
291
- when :shutdown
292
- puts "Ractor #{wrapped_ractor.name} acknowledged shutdown" if ENV["FRACTOR_DEBUG"]
293
- # Remove from active ractors
294
- @ractors_map.delete(ready_ractor_obj)
295
- when :result
296
- # The message[:result] should be a WorkResult object
297
- work_result = message[:result]
298
- puts "Completed work: #{work_result.inspect} in Ractor: #{message[:processor]}" if ENV["FRACTOR_DEBUG"]
299
- @results.add_result(work_result)
300
- if ENV["FRACTOR_DEBUG"]
301
- puts "Result processed. Total processed: #{@results.results.size + @results.errors.size}"
302
- puts "Aggregated Results: #{@results.inspect}" unless @continuous_mode
303
- end
304
- # Send next piece of work
305
- if send_next_work_if_available(wrapped_ractor)
306
- # Work was sent
307
- else
308
- # No work available, mark worker as idle
309
- @idle_workers << wrapped_ractor unless @idle_workers.include?(wrapped_ractor)
310
- puts "Worker #{wrapped_ractor.name} marked as idle after completing work" if ENV["FRACTOR_DEBUG"]
311
- end
312
- when :error
313
- # The message[:result] should be a WorkResult object containing the error
314
- error_result = message[:result]
315
- puts "Error processing work #{error_result.work&.inspect} in Ractor: #{message[:processor]}: #{error_result.error}" if ENV["FRACTOR_DEBUG"]
316
- @results.add_result(error_result) # Add error to aggregator
317
- if ENV["FRACTOR_DEBUG"]
318
- puts "Error handled. Total processed: #{@results.results.size + @results.errors.size}"
319
- puts "Aggregated Results (including errors): #{@results.inspect}" unless @continuous_mode
320
- end
321
- # Send next piece of work even after an error
322
- if send_next_work_if_available(wrapped_ractor)
323
- # Work was sent
324
- else
325
- # No work available, mark worker as idle
326
- @idle_workers << wrapped_ractor unless @idle_workers.include?(wrapped_ractor)
327
- puts "Worker #{wrapped_ractor.name} marked as idle after error" if ENV["FRACTOR_DEBUG"]
328
- end
329
- else
330
- puts "Unknown message type received: #{message[:type]} from #{wrapped_ractor.name}" if ENV["FRACTOR_DEBUG"]
331
- end
332
- # Update processed count for the loop condition
333
- processed_count = @results.results.size + @results.errors.size
334
- end
324
+ # Start timer thread for continuous mode to periodically check work sources
325
+ start_timer_thread if @continuous_mode && !@work_callbacks.empty?
335
326
 
336
- puts "Main loop finished." if ENV["FRACTOR_DEBUG"]
327
+ begin
328
+ # Run the main event loop through MainLoopHandler
329
+ @main_loop_handler = MainLoopHandler.create(self, debug: @debug)
330
+ @main_loop_handler.run_loop
337
331
  rescue ShutdownSignal => e
338
- puts "Shutdown signal caught: #{e.message}" if ENV["FRACTOR_DEBUG"]
339
- puts "Sending shutdown message to all Ractors..." if ENV["FRACTOR_DEBUG"]
332
+ puts "Shutdown signal caught: #{e.message}" if @debug
333
+ puts "Sending shutdown message to all Ractors..." if @debug
340
334
 
341
335
  # Send shutdown message to each worker Ractor
342
336
  @workers.each do |w|
343
337
  w.send(:shutdown)
344
- puts "Sent shutdown to Ractor: #{w.name}" if ENV["FRACTOR_DEBUG"]
338
+ puts "Sent shutdown to Ractor: #{w.name}" if @debug
345
339
  rescue StandardError => send_error
346
- puts "Error sending shutdown to Ractor #{w.name}: #{send_error.message}" if ENV["FRACTOR_DEBUG"]
340
+ puts "Error sending shutdown to Ractor #{w.name}: #{send_error.message}" if @debug
347
341
  end
348
342
 
349
- puts "Exiting due to shutdown signal." if ENV["FRACTOR_DEBUG"]
343
+ puts "Exiting due to shutdown signal." if @debug
350
344
  exit!(1) # Force exit immediately
351
345
  end
352
346
 
353
347
  return if @continuous_mode
354
348
 
355
- return unless ENV["FRACTOR_DEBUG"]
349
+ return unless @debug
356
350
 
357
351
  puts "Final Aggregated Results: #{@results.inspect}"
358
352
  end
359
353
 
360
354
  # Stop the supervisor (for continuous mode)
361
355
  def stop
356
+ puts "Stopping supervisor..." if @debug
357
+
358
+ # Initiate shutdown in main loop handler first, so it continues
359
+ # processing shutdown acknowledgments even after @running = false
360
+ @main_loop_handler&.initiate_shutdown
361
+
362
362
  @running = false
363
- puts "Stopping supervisor..." if ENV["FRACTOR_DEBUG"]
364
363
 
365
- # Wait for timer thread to finish if it exists
366
- if @timer_thread&.alive?
367
- @timer_thread.join(1) # Wait up to 1 second
368
- puts "Timer thread stopped" if ENV["FRACTOR_DEBUG"]
369
- end
364
+ # Update shutdown handler with current references before shutdown
365
+ @shutdown_handler.instance_variable_set(:@workers, @workers)
366
+ @shutdown_handler.instance_variable_set(:@wakeup_ractor, @wakeup_ractor)
367
+ @shutdown_handler.instance_variable_set(:@timer_thread, @timer_thread)
368
+ @shutdown_handler.instance_variable_set(:@performance_monitor,
369
+ @performance_monitor)
370
370
 
371
- # Signal the wakeup ractor first to unblock Ractor.select
372
- if @wakeup_ractor
373
- begin
374
- @wakeup_ractor.send(:shutdown)
375
- puts "Sent shutdown signal to wakeup ractor" if ENV["FRACTOR_DEBUG"]
376
- rescue StandardError => e
377
- puts "Error sending shutdown to wakeup ractor: #{e.message}" if ENV["FRACTOR_DEBUG"]
378
- end
379
- end
371
+ # Send shutdown signals but don't wait for workers to close
372
+ # The caller (e.g., ContinuousServer) should wait for the main loop thread
373
+ @shutdown_handler.shutdown
374
+ end
380
375
 
381
- # Send shutdown signal to all workers
382
- @workers.each do |w|
383
- begin
384
- w.send(:shutdown)
385
- rescue StandardError
386
- nil
376
+ private
377
+
378
+ # Start the timer thread for continuous mode.
379
+ # This thread periodically wakes up the main loop to check for new work.
380
+ #
381
+ # @return [void]
382
+ def start_timer_thread
383
+ @timer_thread = Thread.new do
384
+ while @running
385
+ sleep(0.1) # Check work sources every 100ms
386
+ if @wakeup_ractor && @running
387
+ begin
388
+ @wakeup_ractor.send(:wakeup)
389
+ rescue StandardError => e
390
+ puts "Timer thread error sending wakeup: #{e.message}" if @debug
391
+ break
392
+ end
393
+ end
387
394
  end
388
- puts "Sent shutdown signal to #{w.name}" if ENV["FRACTOR_DEBUG"]
395
+ puts "Timer thread shutting down" if @debug
389
396
  end
390
397
  end
391
398
 
392
- private
399
+ # Format error context with rich information for debugging.
400
+ # Uses ErrorFormatter to generate formatted error messages.
401
+ #
402
+ # @param wrapped_ractor [WrappedRactor] The worker that encountered the error
403
+ # @param error_result [WorkResult] The error result
404
+ # @return [String] Formatted error message with context
405
+ def format_error_context(wrapped_ractor, error_result)
406
+ @error_formatter.format(wrapped_ractor, error_result)
407
+ end
408
+
409
+ # Trace a work event using instance-specific or global tracer configuration.
410
+ # This allows multiple Supervisors to have independent tracer settings.
411
+ # @param event [Symbol] The event type (:queued, :completed, :failed, etc.)
412
+ # @param work [Work] The work item
413
+ # @param context [Hash] Additional context (worker_name, worker_class, etc.)
414
+ def trace_work(event, work = nil, context = {})
415
+ # Check if instance-specific tracing is configured
416
+ if @tracer_enabled.nil? && @tracer_stream.nil?
417
+ # No instance config - use global ExecutionTracer
418
+ Fractor::ExecutionTracer.trace(event, work, context)
419
+ return
420
+ end
421
+
422
+ # Instance-specific tracing - do it here
423
+ enabled = @tracer_enabled.nil? ? ExecutionTracer.enabled? : @tracer_enabled
424
+ return unless enabled
425
+
426
+ stream = @tracer_stream || ExecutionTracer.trace_stream
427
+ timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S.%3N")
428
+ thread_id = Thread.current.object_id
429
+
430
+ # Build trace line (simplified version of ExecutionTracer logic)
431
+ parts = [
432
+ "[TRACE]",
433
+ timestamp,
434
+ "[T#{thread_id}]",
435
+ event.to_s.upcase,
436
+ ]
437
+
438
+ if work
439
+ work_info = work.instance_of?(::Fractor::Work) ? "Work" : work.class.name
440
+ parts << "#{work_info}:#{work.object_id}"
441
+ end
442
+
443
+ if context[:worker_name]
444
+ parts << "worker=#{context[:worker_name]}"
445
+ end
446
+ if context[:worker_class]
447
+ parts << "class=#{context[:worker_class]}"
448
+ end
449
+ if context[:duration_ms]
450
+ parts << "duration=#{context[:duration_ms]}ms"
451
+ end
452
+ if context[:queue_size]
453
+ parts << "queue_size=#{context[:queue_size]}"
454
+ end
455
+
456
+ stream.puts(parts.join(" "))
457
+ end
393
458
 
394
459
  # Detects the number of available processors on the system.
395
460
  # Returns the number of processors, or 2 as a fallback if detection fails.
396
461
  def detect_num_workers
397
462
  num_processors = Etc.nprocessors
398
- puts "Auto-detected #{num_processors} available processors" if ENV["FRACTOR_DEBUG"]
463
+ puts "Auto-detected #{num_processors} available processors" if @debug
399
464
  num_processors
400
465
  rescue StandardError => e
401
- puts "Failed to detect processors: #{e.message}. Using default of 2 workers." if ENV["FRACTOR_DEBUG"]
466
+ puts "Failed to detect processors: #{e.message}. Using default of 2 workers." if @debug
402
467
  2
403
468
  end
404
469
 
405
- # Helper method to send the next available work item to a specific Ractor.
406
- # Returns true if work was sent, false otherwise.
407
- def send_next_work_if_available(wrapped_ractor)
408
- # Ensure the wrapped_ractor instance is valid and its underlying ractor is not closed
409
- if wrapped_ractor && !wrapped_ractor.closed?
410
- if @work_queue.empty?
411
- puts "Work queue empty. Not sending new work to Ractor #{wrapped_ractor.name}." if ENV["FRACTOR_DEBUG"]
412
- # In continuous mode, don't close workers as more work may come
413
- unless @continuous_mode
414
- # Consider closing the Ractor if the queue is empty and no more work is expected.
415
- # wrapped_ractor.close
416
- # @ractors_map.delete(wrapped_ractor.ractor)
417
- # if ENV["FRACTOR_DEBUG"]
418
- # puts "Closed idle Ractor: #{wrapped_ractor.name}"
419
- # end
420
- end
421
- false
422
- else
423
- work_item = @work_queue.pop # Now directly a Work object
470
+ public
471
+
472
+ # ============================================
473
+ # DEBUGGING METHODS
474
+ # ============================================
475
+
476
+ # Inspect the current state of the work queue
477
+ # Returns a hash with queue information and items
478
+ def inspect_queue
479
+ items = []
480
+ # Queue doesn't have to_a, need to iterate
481
+ temp_queue = Queue.new
482
+ until @work_queue.empty?
483
+ item = @work_queue.pop
484
+ items << item
485
+ temp_queue.push(item)
486
+ end
487
+ # Restore the queue
488
+ until temp_queue.empty?
489
+ @work_queue.push(temp_queue.pop)
490
+ end
424
491
 
425
- puts "Sending next work #{work_item.inspect} to Ractor: #{wrapped_ractor.name}" if ENV["FRACTOR_DEBUG"]
426
- wrapped_ractor.send(work_item) # Send the Work object
427
- puts "Work sent to #{wrapped_ractor.name}." if ENV["FRACTOR_DEBUG"]
492
+ {
493
+ size: @work_queue.size,
494
+ total_added: @total_work_count,
495
+ items: items.map do |work|
496
+ {
497
+ class: work.class.name,
498
+ input: work.input,
499
+ inspect: work.inspect,
500
+ }
501
+ end,
502
+ }
503
+ end
428
504
 
429
- # Remove from idle workers list since it's now busy
430
- @idle_workers.delete(wrapped_ractor)
431
- true
432
- end
433
- else
434
- puts "Attempted to send work to an invalid or closed Ractor: #{wrapped_ractor&.name || 'unknown'}." if ENV["FRACTOR_DEBUG"]
435
- # Remove from map if found but closed
436
- @ractors_map.delete(wrapped_ractor.ractor) if wrapped_ractor && @ractors_map.key?(wrapped_ractor.ractor)
437
- false
438
- end
505
+ # Get current worker status
506
+ # Returns a hash with worker statistics
507
+ def workers_status
508
+ status = @work_distribution_manager.status_summary
509
+ idle_count = status[:idle]
510
+ busy_count = status[:busy]
511
+
512
+ {
513
+ total: @workers.size,
514
+ idle: idle_count,
515
+ busy: busy_count,
516
+ pools: @worker_pools.map do |pool|
517
+ {
518
+ worker_class: pool[:worker_class].name,
519
+ num_workers: pool[:num_workers],
520
+ workers: pool[:workers].map do |w|
521
+ {
522
+ name: w.name,
523
+ idle: @work_distribution_manager.idle_workers_list.include?(w),
524
+ }
525
+ end,
526
+ }
527
+ end,
528
+ }
529
+ end
530
+
531
+ # Enable debug mode for verbose output
532
+ def debug!
533
+ @debug = true
534
+ end
535
+
536
+ # Disable debug mode
537
+ def debug_off!
538
+ @debug = false
539
+ end
540
+
541
+ # Check if debug mode is enabled
542
+ def debug?
543
+ @debug
544
+ end
545
+
546
+ # Get performance metrics snapshot if performance monitoring is enabled
547
+ # Returns nil if performance monitoring is not enabled
548
+ def performance_metrics
549
+ return nil unless @performance_monitor
550
+
551
+ @performance_monitor.snapshot
439
552
  end
440
553
  end
441
554
  end