fractor 0.1.4 → 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 (189) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop-https---raw-githubusercontent-com-riboseinc-oss-guides-main-ci-rubocop-yml +552 -0
  3. data/.rubocop.yml +14 -8
  4. data/.rubocop_todo.yml +284 -43
  5. data/README.adoc +111 -950
  6. data/docs/.lycheeignore +16 -0
  7. data/docs/Gemfile +24 -0
  8. data/docs/README.md +157 -0
  9. data/docs/_config.yml +151 -0
  10. data/docs/_features/error-handling.adoc +1192 -0
  11. data/docs/_features/index.adoc +80 -0
  12. data/docs/_features/monitoring.adoc +589 -0
  13. data/docs/_features/signal-handling.adoc +202 -0
  14. data/docs/_features/workflows.adoc +1235 -0
  15. data/docs/_guides/continuous-mode.adoc +736 -0
  16. data/docs/_guides/cookbook.adoc +1133 -0
  17. data/docs/_guides/index.adoc +55 -0
  18. data/docs/_guides/pipeline-mode.adoc +730 -0
  19. data/docs/_guides/troubleshooting.adoc +358 -0
  20. data/docs/_pages/architecture.adoc +1390 -0
  21. data/docs/_pages/core-concepts.adoc +1392 -0
  22. data/docs/_pages/design-principles.adoc +862 -0
  23. data/docs/_pages/getting-started.adoc +290 -0
  24. data/docs/_pages/installation.adoc +143 -0
  25. data/docs/_reference/api.adoc +1080 -0
  26. data/docs/_reference/error-reporting.adoc +670 -0
  27. data/docs/_reference/examples.adoc +181 -0
  28. data/docs/_reference/index.adoc +96 -0
  29. data/docs/_reference/troubleshooting.adoc +862 -0
  30. data/docs/_tutorials/complex-workflows.adoc +1022 -0
  31. data/docs/_tutorials/data-processing-pipeline.adoc +740 -0
  32. data/docs/_tutorials/first-application.adoc +384 -0
  33. data/docs/_tutorials/index.adoc +48 -0
  34. data/docs/_tutorials/long-running-services.adoc +931 -0
  35. data/docs/assets/images/favicon-16.png +0 -0
  36. data/docs/assets/images/favicon-32.png +0 -0
  37. data/docs/assets/images/favicon-48.png +0 -0
  38. data/docs/assets/images/favicon.ico +0 -0
  39. data/docs/assets/images/favicon.png +0 -0
  40. data/docs/assets/images/favicon.svg +45 -0
  41. data/docs/assets/images/fractor-icon.svg +49 -0
  42. data/docs/assets/images/fractor-logo.svg +61 -0
  43. data/docs/index.adoc +131 -0
  44. data/docs/lychee.toml +39 -0
  45. data/examples/api_aggregator/README.adoc +627 -0
  46. data/examples/api_aggregator/api_aggregator.rb +376 -0
  47. data/examples/auto_detection/README.adoc +407 -29
  48. data/examples/auto_detection/auto_detection.rb +9 -9
  49. data/examples/continuous_chat_common/message_protocol.rb +53 -0
  50. data/examples/continuous_chat_fractor/README.adoc +217 -0
  51. data/examples/continuous_chat_fractor/chat_client.rb +303 -0
  52. data/examples/continuous_chat_fractor/chat_common.rb +83 -0
  53. data/examples/continuous_chat_fractor/chat_server.rb +167 -0
  54. data/examples/continuous_chat_fractor/simulate.rb +345 -0
  55. data/examples/continuous_chat_server/README.adoc +135 -0
  56. data/examples/continuous_chat_server/chat_client.rb +303 -0
  57. data/examples/continuous_chat_server/chat_server.rb +359 -0
  58. data/examples/continuous_chat_server/simulate.rb +343 -0
  59. data/examples/error_reporting.rb +207 -0
  60. data/examples/file_processor/README.adoc +170 -0
  61. data/examples/file_processor/file_processor.rb +615 -0
  62. data/examples/file_processor/sample_files/invalid.csv +1 -0
  63. data/examples/file_processor/sample_files/orders.xml +24 -0
  64. data/examples/file_processor/sample_files/products.json +23 -0
  65. data/examples/file_processor/sample_files/users.csv +6 -0
  66. data/examples/hierarchical_hasher/README.adoc +629 -41
  67. data/examples/hierarchical_hasher/hierarchical_hasher.rb +12 -8
  68. data/examples/image_processor/README.adoc +610 -0
  69. data/examples/image_processor/image_processor.rb +349 -0
  70. data/examples/image_processor/processed_images/sample_10_processed.jpg.json +12 -0
  71. data/examples/image_processor/processed_images/sample_1_processed.jpg.json +12 -0
  72. data/examples/image_processor/processed_images/sample_2_processed.jpg.json +12 -0
  73. data/examples/image_processor/processed_images/sample_3_processed.jpg.json +12 -0
  74. data/examples/image_processor/processed_images/sample_4_processed.jpg.json +12 -0
  75. data/examples/image_processor/processed_images/sample_5_processed.jpg.json +12 -0
  76. data/examples/image_processor/processed_images/sample_6_processed.jpg.json +12 -0
  77. data/examples/image_processor/processed_images/sample_7_processed.jpg.json +12 -0
  78. data/examples/image_processor/processed_images/sample_8_processed.jpg.json +12 -0
  79. data/examples/image_processor/processed_images/sample_9_processed.jpg.json +12 -0
  80. data/examples/image_processor/test_images/sample_1.png +1 -0
  81. data/examples/image_processor/test_images/sample_10.png +1 -0
  82. data/examples/image_processor/test_images/sample_2.png +1 -0
  83. data/examples/image_processor/test_images/sample_3.png +1 -0
  84. data/examples/image_processor/test_images/sample_4.png +1 -0
  85. data/examples/image_processor/test_images/sample_5.png +1 -0
  86. data/examples/image_processor/test_images/sample_6.png +1 -0
  87. data/examples/image_processor/test_images/sample_7.png +1 -0
  88. data/examples/image_processor/test_images/sample_8.png +1 -0
  89. data/examples/image_processor/test_images/sample_9.png +1 -0
  90. data/examples/log_analyzer/README.adoc +662 -0
  91. data/examples/log_analyzer/log_analyzer.rb +579 -0
  92. data/examples/log_analyzer/sample_logs/apache.log +20 -0
  93. data/examples/log_analyzer/sample_logs/json.log +15 -0
  94. data/examples/log_analyzer/sample_logs/nginx.log +15 -0
  95. data/examples/log_analyzer/sample_logs/rails.log +29 -0
  96. data/examples/multi_work_type/README.adoc +576 -26
  97. data/examples/multi_work_type/multi_work_type.rb +30 -29
  98. data/examples/performance_monitoring.rb +120 -0
  99. data/examples/pipeline_processing/README.adoc +740 -26
  100. data/examples/pipeline_processing/pipeline_processing.rb +16 -16
  101. data/examples/priority_work_example.rb +155 -0
  102. data/examples/producer_subscriber/README.adoc +889 -46
  103. data/examples/producer_subscriber/producer_subscriber.rb +20 -16
  104. data/examples/scatter_gather/README.adoc +829 -27
  105. data/examples/scatter_gather/scatter_gather.rb +29 -28
  106. data/examples/simple/README.adoc +347 -0
  107. data/examples/simple/sample.rb +5 -5
  108. data/examples/specialized_workers/README.adoc +622 -26
  109. data/examples/specialized_workers/specialized_workers.rb +88 -45
  110. data/examples/stream_processor/README.adoc +206 -0
  111. data/examples/stream_processor/stream_processor.rb +284 -0
  112. data/examples/web_scraper/README.adoc +625 -0
  113. data/examples/web_scraper/web_scraper.rb +285 -0
  114. data/examples/workflow/README.adoc +406 -0
  115. data/examples/workflow/circuit_breaker/README.adoc +360 -0
  116. data/examples/workflow/circuit_breaker/circuit_breaker_workflow.rb +225 -0
  117. data/examples/workflow/conditional/README.adoc +483 -0
  118. data/examples/workflow/conditional/conditional_workflow.rb +215 -0
  119. data/examples/workflow/dead_letter_queue/README.adoc +374 -0
  120. data/examples/workflow/dead_letter_queue/dead_letter_queue_workflow.rb +217 -0
  121. data/examples/workflow/fan_out/README.adoc +381 -0
  122. data/examples/workflow/fan_out/fan_out_workflow.rb +202 -0
  123. data/examples/workflow/retry/README.adoc +248 -0
  124. data/examples/workflow/retry/retry_workflow.rb +195 -0
  125. data/examples/workflow/simple_linear/README.adoc +267 -0
  126. data/examples/workflow/simple_linear/simple_linear_workflow.rb +175 -0
  127. data/examples/workflow/simplified/README.adoc +329 -0
  128. data/examples/workflow/simplified/simplified_workflow.rb +222 -0
  129. data/exe/fractor +10 -0
  130. data/lib/fractor/cli.rb +288 -0
  131. data/lib/fractor/configuration.rb +307 -0
  132. data/lib/fractor/continuous_server.rb +183 -0
  133. data/lib/fractor/error_formatter.rb +72 -0
  134. data/lib/fractor/error_report_generator.rb +152 -0
  135. data/lib/fractor/error_reporter.rb +244 -0
  136. data/lib/fractor/error_statistics.rb +147 -0
  137. data/lib/fractor/execution_tracer.rb +162 -0
  138. data/lib/fractor/logger.rb +230 -0
  139. data/lib/fractor/main_loop_handler.rb +406 -0
  140. data/lib/fractor/main_loop_handler3.rb +135 -0
  141. data/lib/fractor/main_loop_handler4.rb +299 -0
  142. data/lib/fractor/performance_metrics_collector.rb +181 -0
  143. data/lib/fractor/performance_monitor.rb +215 -0
  144. data/lib/fractor/performance_report_generator.rb +202 -0
  145. data/lib/fractor/priority_work.rb +93 -0
  146. data/lib/fractor/priority_work_queue.rb +189 -0
  147. data/lib/fractor/result_aggregator.rb +33 -1
  148. data/lib/fractor/shutdown_handler.rb +168 -0
  149. data/lib/fractor/signal_handler.rb +80 -0
  150. data/lib/fractor/supervisor.rb +430 -144
  151. data/lib/fractor/supervisor_logger.rb +88 -0
  152. data/lib/fractor/version.rb +1 -1
  153. data/lib/fractor/work.rb +12 -0
  154. data/lib/fractor/work_distribution_manager.rb +151 -0
  155. data/lib/fractor/work_queue.rb +88 -0
  156. data/lib/fractor/work_result.rb +181 -9
  157. data/lib/fractor/worker.rb +75 -1
  158. data/lib/fractor/workflow/builder.rb +210 -0
  159. data/lib/fractor/workflow/chain_builder.rb +169 -0
  160. data/lib/fractor/workflow/circuit_breaker.rb +183 -0
  161. data/lib/fractor/workflow/circuit_breaker_orchestrator.rb +208 -0
  162. data/lib/fractor/workflow/circuit_breaker_registry.rb +112 -0
  163. data/lib/fractor/workflow/dead_letter_queue.rb +334 -0
  164. data/lib/fractor/workflow/execution_hooks.rb +39 -0
  165. data/lib/fractor/workflow/execution_strategy.rb +225 -0
  166. data/lib/fractor/workflow/execution_trace.rb +134 -0
  167. data/lib/fractor/workflow/helpers.rb +191 -0
  168. data/lib/fractor/workflow/job.rb +290 -0
  169. data/lib/fractor/workflow/job_dependency_validator.rb +120 -0
  170. data/lib/fractor/workflow/logger.rb +110 -0
  171. data/lib/fractor/workflow/pre_execution_context.rb +193 -0
  172. data/lib/fractor/workflow/retry_config.rb +156 -0
  173. data/lib/fractor/workflow/retry_orchestrator.rb +184 -0
  174. data/lib/fractor/workflow/retry_strategy.rb +93 -0
  175. data/lib/fractor/workflow/structured_logger.rb +30 -0
  176. data/lib/fractor/workflow/type_compatibility_validator.rb +222 -0
  177. data/lib/fractor/workflow/visualizer.rb +211 -0
  178. data/lib/fractor/workflow/workflow_context.rb +132 -0
  179. data/lib/fractor/workflow/workflow_executor.rb +669 -0
  180. data/lib/fractor/workflow/workflow_result.rb +55 -0
  181. data/lib/fractor/workflow/workflow_validator.rb +295 -0
  182. data/lib/fractor/workflow.rb +333 -0
  183. data/lib/fractor/wrapped_ractor.rb +66 -91
  184. data/lib/fractor/wrapped_ractor3.rb +161 -0
  185. data/lib/fractor/wrapped_ractor4.rb +242 -0
  186. data/lib/fractor.rb +93 -3
  187. metadata +192 -6
  188. data/tests/sample.rb.bak +0 -309
  189. data/tests/sample_working.rb.bak +0 -209
@@ -1,28 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "etc"
4
+ require "timeout"
5
+ require_relative "signal_handler"
6
+ require_relative "error_formatter"
4
7
 
5
8
  module Fractor
9
+ # Custom exception for shutdown signal handling
10
+ class ShutdownSignal < StandardError; end
11
+
6
12
  # Supervises multiple WrappedRactors, distributes work, and aggregates results.
7
13
  class Supervisor
8
- attr_reader :work_queue, :workers, :results, :worker_pools
14
+ attr_reader :work_queue, :workers, :results, :worker_pools, :debug,
15
+ :error_reporter, :logger, :performance_monitor
9
16
 
10
17
  # Initializes the Supervisor.
11
18
  # - worker_pools: An array of worker pool configurations, each containing:
12
19
  # - worker_class: The class inheriting from Fractor::Worker (e.g., MyWorker).
13
20
  # - num_workers: The number of Ractors to spawn for this worker class.
14
21
  # - continuous_mode: Whether to run in continuous mode without expecting a fixed work count.
15
- def initialize(worker_pools: [], continuous_mode: false)
16
- @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|
17
35
  worker_class = pool_config[:worker_class]
18
36
  num_workers = pool_config[:num_workers] || detect_num_workers
19
37
 
20
- raise ArgumentError, "#{worker_class} must inherit from Fractor::Worker" unless worker_class < Fractor::Worker
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
+
49
+ unless worker_class < Fractor::Worker
50
+ raise ArgumentError,
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"
68
+ end
21
69
 
22
70
  {
23
71
  worker_class: worker_class,
24
72
  num_workers: num_workers,
25
- workers: [] # Will hold the WrappedRactor instances
73
+ workers: [], # Will hold the WrappedRactor instances
26
74
  }
27
75
  end
28
76
 
@@ -34,16 +82,81 @@ module Fractor
34
82
  @continuous_mode = continuous_mode
35
83
  @running = false
36
84
  @work_callbacks = []
85
+ @wakeup_ractor = nil # Control ractor for unblocking select
86
+ @timer_thread = nil # Timer thread for periodic wakeup
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
37
127
  end
38
128
 
39
129
  # Adds a single work item to the queue.
40
130
  # The item must be an instance of Fractor::Work or a subclass.
41
131
  def add_work_item(work)
42
- raise ArgumentError, "#{work.class} must be an instance of Fractor::Work" unless work.is_a?(Fractor::Work)
132
+ unless work.is_a?(Fractor::Work)
133
+ raise ArgumentError,
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)"
144
+ end
43
145
 
44
146
  @work_queue << work
45
147
  @total_work_count += 1
46
- 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
47
160
 
48
161
  puts "Work item added. Initial work count: #{@total_work_count}, Queue size: #{@work_queue.size}"
49
162
  end
@@ -62,14 +175,72 @@ module Fractor
62
175
  @work_callbacks << callback
63
176
  end
64
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
+
65
185
  # Starts the worker Ractors for all worker pools.
66
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
+
194
+ # Create a wakeup Ractor for unblocking Ractor.select
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
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
222
+ end
223
+ end
224
+
225
+ # Add wakeup ractor to the map with a special marker
226
+ @ractors_map[@wakeup_ractor] = :wakeup
227
+
67
228
  @worker_pools.each do |pool|
68
229
  worker_class = pool[:worker_class]
69
230
  num_workers = pool[:num_workers]
70
231
 
71
232
  pool[:workers] = (1..num_workers).map do |i|
72
- 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
+ )
73
244
  wrapped_ractor.start # Start the underlying Ractor
74
245
  # Map the actual Ractor object to the WrappedRactor instance
75
246
  @ractors_map[wrapped_ractor.ractor] = wrapped_ractor if wrapped_ractor.ractor
@@ -80,40 +251,60 @@ module Fractor
80
251
  # Flatten all workers for easier access
81
252
  @workers = @worker_pools.flat_map { |pool| pool[:workers] }
82
253
  @ractors_map.compact! # Ensure map doesn't contain nil keys/values
83
- 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
84
267
 
85
268
  puts "Workers started: #{@workers.size} active across #{@worker_pools.size} pools."
269
+ puts "All workers marked as idle and ready for work."
86
270
  end
87
271
 
88
- # Sets up a signal handler for graceful shutdown (Ctrl+C).
272
+ # Sets up signal handlers for graceful shutdown.
273
+ # Uses SignalHandler to manage signal handling logic.
89
274
  def setup_signal_handler
90
- # Store instance variables in local variables for the signal handler
91
- workers_ref = @workers
92
-
93
- # Trap INT signal (Ctrl+C)
94
- Signal.trap("INT") do
95
- puts "\nCtrl+C received. Initiating immediate shutdown..." if ENV["FRACTOR_DEBUG"]
96
-
97
- # Set running to false to break the main loop
98
- @running = false
275
+ @signal_handler.setup
276
+ end
99
277
 
100
- puts "Sending shutdown message to all Ractors..." if ENV["FRACTOR_DEBUG"]
278
+ # Callback for signal handler shutdown requests.
279
+ def handle_shutdown_callback(mode)
280
+ if mode == :graceful
281
+ stop
282
+ else
283
+ # Immediate shutdown - raise signal in current thread
284
+ Thread.current.raise(ShutdownSignal, "Interrupted by signal")
285
+ end
286
+ end
101
287
 
102
- # Send shutdown message to each worker Ractor
103
- workers_ref.each do |w|
104
- w.send(:shutdown)
105
- puts "Sent shutdown to Ractor: #{w.name}" if ENV["FRACTOR_DEBUG"]
106
- rescue StandardError => e
107
- puts "Error sending shutdown to Ractor #{w.name}: #{e.message}" if ENV["FRACTOR_DEBUG"]
108
- end
288
+ # Prints current supervisor status
289
+ def print_status
290
+ status = @work_distribution_manager.status_summary
291
+ puts "\n=== Fractor Supervisor Status ==="
292
+ puts "Mode: #{@continuous_mode ? 'Continuous' : 'Batch'}"
293
+ puts "Running: #{@running}"
294
+ puts "Workers: #{@workers.size}"
295
+ puts "Idle workers: #{status[:idle]}"
296
+ puts "Queue size: #{@work_queue.size}"
297
+ puts "Results: #{@results.results.size}"
298
+ puts "Errors: #{@results.errors.size}"
299
+ puts "================================\n"
300
+ end
109
301
 
110
- puts "Exiting now." if ENV["FRACTOR_DEBUG"]
111
- exit!(1) # Use exit! to exit immediately without running at_exit handlers
112
- rescue Exception => e
113
- puts "Error in signal handler: #{e.class}: #{e.message}" if ENV["FRACTOR_DEBUG"]
114
- puts e.backtrace.join("\n") if ENV["FRACTOR_DEBUG"]
115
- exit!(1)
116
- end
302
+ # Starts the supervisor (alias for run).
303
+ # Provides a consistent API with stop method.
304
+ #
305
+ # @see #run
306
+ def start
307
+ run
117
308
  end
118
309
 
119
310
  # Runs the main processing loop.
@@ -122,147 +313,242 @@ module Fractor
122
313
  start_workers
123
314
 
124
315
  @running = true
125
- processed_count = 0
126
-
127
- # Main loop: Process events until conditions are met for termination
128
- while @running && (@continuous_mode || processed_count < @total_work_count)
129
- processed_count = @results.results.size + @results.errors.size
130
-
131
- if ENV["FRACTOR_DEBUG"]
132
- if @continuous_mode
133
- puts "Continuous mode: Waiting for Ractor results. Processed: #{processed_count}, Queue size: #{@work_queue.size}"
134
- else
135
- puts "Waiting for Ractor results. Processed: #{processed_count}/#{@total_work_count}, Queue size: #{@work_queue.size}"
136
- end
137
- end
138
-
139
- # Get active Ractor objects from the map keys
140
- active_ractors = @ractors_map.keys
141
-
142
- # Check for new work from callbacks if in continuous mode and queue is empty
143
- if @continuous_mode && @work_queue.empty? && !@work_callbacks.empty?
144
- @work_callbacks.each do |callback|
145
- new_work = callback.call
146
- add_work_items(new_work) if new_work && !new_work.empty?
147
- end
148
- end
149
-
150
- # Break if no active workers and queue is empty, but work remains (indicates potential issue)
151
- if active_ractors.empty? && @work_queue.empty? && !@continuous_mode && processed_count < @total_work_count
152
- puts "Warning: No active workers and queue is empty, but not all work is processed. Exiting loop." if ENV["FRACTOR_DEBUG"]
153
- break
154
- end
155
-
156
- # In continuous mode, just wait if no active ractors but keep running
157
- if active_ractors.empty?
158
- break unless @continuous_mode
159
316
 
160
- sleep(0.1) # Small delay to avoid CPU spinning
161
- next
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
322
+ end
162
323
 
163
- end
324
+ # Start timer thread for continuous mode to periodically check work sources
325
+ start_timer_thread if @continuous_mode && !@work_callbacks.empty?
164
326
 
165
- # Ractor.select blocks until a message is available from any active Ractor
166
- ready_ractor_obj, message = Ractor.select(*active_ractors)
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
331
+ rescue ShutdownSignal => e
332
+ puts "Shutdown signal caught: #{e.message}" if @debug
333
+ puts "Sending shutdown message to all Ractors..." if @debug
167
334
 
168
- # Find the corresponding WrappedRactor instance
169
- wrapped_ractor = @ractors_map[ready_ractor_obj]
170
- unless wrapped_ractor
171
- puts "Warning: Received message from unknown Ractor: #{ready_ractor_obj}. Ignoring." if ENV["FRACTOR_DEBUG"]
172
- next
335
+ # Send shutdown message to each worker Ractor
336
+ @workers.each do |w|
337
+ w.send(:shutdown)
338
+ puts "Sent shutdown to Ractor: #{w.name}" if @debug
339
+ rescue StandardError => send_error
340
+ puts "Error sending shutdown to Ractor #{w.name}: #{send_error.message}" if @debug
173
341
  end
174
342
 
175
- puts "Selected Ractor: #{wrapped_ractor.name}, Message Type: #{message[:type]}" if ENV["FRACTOR_DEBUG"]
176
-
177
- # Process the received message
178
- case message[:type]
179
- when :initialize
180
- puts "Ractor initialized: #{message[:processor]}" if ENV["FRACTOR_DEBUG"]
181
- # Send work immediately upon initialization if available
182
- send_next_work_if_available(wrapped_ractor)
183
- when :result
184
- # The message[:result] should be a WorkResult object
185
- work_result = message[:result]
186
- puts "Completed work: #{work_result.inspect} in Ractor: #{message[:processor]}" if ENV["FRACTOR_DEBUG"]
187
- @results.add_result(work_result)
188
- if ENV["FRACTOR_DEBUG"]
189
- puts "Result processed. Total processed: #{@results.results.size + @results.errors.size}"
190
- puts "Aggregated Results: #{@results.inspect}" unless @continuous_mode
191
- end
192
- # Send next piece of work
193
- send_next_work_if_available(wrapped_ractor)
194
- when :error
195
- # The message[:result] should be a WorkResult object containing the error
196
- error_result = message[:result]
197
- puts "Error processing work #{error_result.work&.inspect} in Ractor: #{message[:processor]}: #{error_result.error}" if ENV["FRACTOR_DEBUG"]
198
- @results.add_result(error_result) # Add error to aggregator
199
- if ENV["FRACTOR_DEBUG"]
200
- puts "Error handled. Total processed: #{@results.results.size + @results.errors.size}"
201
- puts "Aggregated Results (including errors): #{@results.inspect}" unless @continuous_mode
202
- end
203
- # Send next piece of work even after an error
204
- send_next_work_if_available(wrapped_ractor)
205
- else
206
- puts "Unknown message type received: #{message[:type]} from #{wrapped_ractor.name}" if ENV["FRACTOR_DEBUG"]
207
- end
208
- # Update processed count for the loop condition
209
- processed_count = @results.results.size + @results.errors.size
343
+ puts "Exiting due to shutdown signal." if @debug
344
+ exit!(1) # Force exit immediately
210
345
  end
211
346
 
212
- puts "Main loop finished." if ENV["FRACTOR_DEBUG"]
213
347
  return if @continuous_mode
214
348
 
215
- return unless ENV["FRACTOR_DEBUG"]
349
+ return unless @debug
216
350
 
217
351
  puts "Final Aggregated Results: #{@results.inspect}"
218
352
  end
219
353
 
220
354
  # Stop the supervisor (for continuous mode)
221
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
+
222
362
  @running = false
223
- puts "Stopping supervisor..." if ENV["FRACTOR_DEBUG"]
363
+
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
+
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
224
374
  end
225
375
 
226
376
  private
227
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
394
+ end
395
+ puts "Timer thread shutting down" if @debug
396
+ end
397
+ end
398
+
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
458
+
228
459
  # Detects the number of available processors on the system.
229
460
  # Returns the number of processors, or 2 as a fallback if detection fails.
230
461
  def detect_num_workers
231
462
  num_processors = Etc.nprocessors
232
- puts "Auto-detected #{num_processors} available processors" if ENV["FRACTOR_DEBUG"]
463
+ puts "Auto-detected #{num_processors} available processors" if @debug
233
464
  num_processors
234
465
  rescue StandardError => e
235
- 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
236
467
  2
237
468
  end
238
469
 
239
- # Helper method to send the next available work item to a specific Ractor.
240
- def send_next_work_if_available(wrapped_ractor)
241
- # Ensure the wrapped_ractor instance is valid and its underlying ractor is not closed
242
- if wrapped_ractor && !wrapped_ractor.closed?
243
- if !@work_queue.empty?
244
- work_item = @work_queue.pop # Now directly a Work object
245
-
246
- puts "Sending next work #{work_item.inspect} to Ractor: #{wrapped_ractor.name}" if ENV["FRACTOR_DEBUG"]
247
- wrapped_ractor.send(work_item) # Send the Work object
248
- puts "Work sent to #{wrapped_ractor.name}." if ENV["FRACTOR_DEBUG"]
249
- else
250
- puts "Work queue empty. Not sending new work to Ractor #{wrapped_ractor.name}." if ENV["FRACTOR_DEBUG"]
251
- # In continuous mode, don't close workers as more work may come
252
- unless @continuous_mode
253
- # Consider closing the Ractor if the queue is empty and no more work is expected.
254
- # wrapped_ractor.close
255
- # @ractors_map.delete(wrapped_ractor.ractor)
256
- # if ENV["FRACTOR_DEBUG"]
257
- # puts "Closed idle Ractor: #{wrapped_ractor.name}"
258
- # end
259
- end
260
- end
261
- else
262
- puts "Attempted to send work to an invalid or closed Ractor: #{wrapped_ractor&.name || "unknown"}." if ENV["FRACTOR_DEBUG"]
263
- # Remove from map if found but closed
264
- @ractors_map.delete(wrapped_ractor.ractor) if wrapped_ractor && @ractors_map.key?(wrapped_ractor.ractor)
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)
265
490
  end
491
+
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
504
+
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
266
552
  end
267
553
  end
268
554
  end