fractor 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractor
4
+ # Configuration schema validation module.
5
+ #
6
+ # Provides declarative configuration schemas with validation.
7
+ # Useful for validating configuration options at initialization time.
8
+ #
9
+ # @example Define a schema
10
+ # class MyConfig
11
+ # extend Fractor::ConfigSchema
12
+ #
13
+ # schema :worker_pools do
14
+ # type Array
15
+ # default []
16
+ # description "Array of worker pool configurations"
17
+ # end
18
+ #
19
+ # schema :continuous_mode do
20
+ # type :boolean
21
+ # default false
22
+ # description "Whether to run in continuous mode"
23
+ # end
24
+ # end
25
+ #
26
+ # @example Validate configuration
27
+ # MyConfig.validate!(worker_pools: [...], continuous_mode: true)
28
+ module ConfigSchema
29
+ # Schema entry definition
30
+ class SchemaEntry
31
+ attr_reader :name, :type, :default, :optional, :description
32
+
33
+ def initialize(name, **options)
34
+ @name = name
35
+ @type = options[:type]
36
+ @default = options[:default]
37
+ @optional = options.fetch(:optional, true)
38
+ @description = options[:description]
39
+ end
40
+
41
+ # Validate a value against this schema entry
42
+ # @return [Array<String>] Array of error messages (empty if valid)
43
+ def validate(value)
44
+ errors = []
45
+
46
+ # Check nil for optional fields
47
+ if value.nil?
48
+ errors << "#{name} is required" unless @optional
49
+ return errors
50
+ end
51
+
52
+ # Type validation
53
+ if @type && !type_matches?(value)
54
+ errors << "#{name} must be of type #{type_description}, got #{value.class}"
55
+ end
56
+
57
+ errors
58
+ end
59
+
60
+ private
61
+
62
+ def type_matches?(value)
63
+ case @type
64
+ when :boolean
65
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
66
+ when Class
67
+ value.is_a?(@type)
68
+ when Array
69
+ @type.any? { |t| value.is_a?(t) }
70
+ else
71
+ true # No type constraint
72
+ end
73
+ end
74
+
75
+ def type_description
76
+ case @type
77
+ when :boolean
78
+ "boolean"
79
+ when Class
80
+ @type.name
81
+ when Array
82
+ @type.map { |t| t.is_a?(Class) ? t.name : t.to_s }.join(" or ")
83
+ else
84
+ "any"
85
+ end
86
+ end
87
+ end
88
+
89
+ # Module-level methods for defining schemas
90
+ def self.extended(base)
91
+ base.instance_variable_set(:@schema_entries, {})
92
+ base.singleton_class.prepend(SchemaClassMethods)
93
+ end
94
+
95
+ module SchemaClassMethods
96
+ # Define a configuration schema entry
97
+ # @param name [Symbol] Configuration key name
98
+ # @param options [Hash] Schema options (type, default, optional, description)
99
+ def schema(name, **options)
100
+ @schema_entries ||= {}
101
+ @schema_entries[name] = SchemaEntry.new(name, **options)
102
+ end
103
+
104
+ # Get all schema entries
105
+ # @return [Hash] Schema entries by name
106
+ def schema_entries
107
+ @schema_entries || {}
108
+ end
109
+
110
+ # Validate configuration against schema
111
+ # @param config [Hash] Configuration to validate
112
+ # @raise [ArgumentError] If configuration is invalid
113
+ # @return [Hash] Validated and normalized configuration
114
+ def validate!(config = {})
115
+ all_errors = []
116
+
117
+ schema_entries.each do |name, entry|
118
+ value = config.fetch(name, entry.default)
119
+ errors = entry.validate(value)
120
+ all_errors.concat(errors.map { |e| "- #{e}" })
121
+ end
122
+
123
+ # Check for unknown keys
124
+ unknown_keys = config.keys - schema_entries.keys.symbolize_keys
125
+ unknown_keys.each do |key|
126
+ all_errors << "- Unknown configuration option: #{key}"
127
+ end
128
+
129
+ unless all_errors.empty?
130
+ raise ArgumentError,
131
+ "Invalid configuration:\n#{all_errors.join("\n")}\n\n" \
132
+ "Valid options:\n#{schema_help}"
133
+ end
134
+
135
+ config
136
+ end
137
+
138
+ # Generate schema help text
139
+ # @return [String] Help text describing all schema entries
140
+ def schema_help
141
+ lines = []
142
+ schema_entries.each_value do |entry|
143
+ type_desc = case entry.type
144
+ when :boolean then "boolean"
145
+ when Class then entry.type.name
146
+ when Array then entry.type.map(&:name).join(" | ")
147
+ else "any"
148
+ end
149
+ default_desc = entry.optional ? " (default: #{entry.default.inspect})" : " (required)"
150
+ desc = entry.description ? " - #{entry.description}" : ""
151
+ lines << " #{entry.name}: #{type_desc}#{default_desc}#{desc}"
152
+ end
153
+ lines.join("\n")
154
+ end
155
+
156
+ # Get schema as a hash (for documentation purposes)
157
+ # @return [Hash] Schema definition
158
+ def schema_definition
159
+ schema_entries.transform_values do |entry|
160
+ {
161
+ type: entry.type,
162
+ default: entry.default,
163
+ optional: entry.optional,
164
+ description: entry.description,
165
+ }
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -20,8 +20,7 @@ module Fractor
20
20
  # @param debug [Boolean] Whether debug mode is enabled
21
21
  # @return [MainLoopHandler] The appropriate subclass instance
22
22
  def self.create(supervisor, debug: false)
23
- ruby_4_0 = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("4.0.0")
24
- if ruby_4_0
23
+ if Fractor::RUBY_4_0_OR_HIGHER
25
24
  MainLoopHandler4.new(supervisor, debug: debug)
26
25
  else
27
26
  MainLoopHandler3.new(supervisor, debug: debug)
@@ -350,11 +349,11 @@ module Fractor
350
349
  end
351
350
 
352
351
  def work_callbacks
353
- @supervisor.instance_variable_get(:@work_callbacks)
352
+ @supervisor.callback_registry.work_callbacks
354
353
  end
355
354
 
356
355
  def error_callbacks
357
- @supervisor.instance_variable_get(:@error_callbacks)
356
+ @supervisor.callback_registry.error_callbacks
358
357
  end
359
358
 
360
359
  # Check if running on Windows with Ruby 3.4
@@ -362,10 +361,7 @@ module Fractor
362
361
  #
363
362
  # @return [Boolean]
364
363
  def windows_ruby_34?
365
- return false unless RUBY_PLATFORM.match?(/mswin|mingw|cygwin/)
366
-
367
- ruby_version = Gem::Version.new(RUBY_VERSION)
368
- ruby_version >= Gem::Version.new("3.4.0") && ruby_version < Gem::Version.new("3.5.0")
364
+ Fractor::WINDOWS_RUBY_34
369
365
  end
370
366
 
371
367
  # Handle a stuck ractor by identifying and removing it from the active pool
@@ -19,7 +19,7 @@ module Fractor
19
19
  active_ractors = get_active_ractors
20
20
 
21
21
  # Check for new work from callbacks if in continuous mode
22
- process_work_callbacks if continuous_mode? && !work_callbacks.empty?
22
+ process_work_callbacks if continuous_mode? && @supervisor.callback_registry.has_work_callbacks?
23
23
 
24
24
  # Handle edge cases - break if edge case handler indicates we should
25
25
  next if handle_edge_cases(active_ractors, processed_count)
@@ -46,7 +46,7 @@ module Fractor
46
46
  # @return [Array<Ractor>]
47
47
  def get_active_ractors
48
48
  ractors_map.keys.reject do |ractor|
49
- ractor == wakeup_ractor && !(continuous_mode? && !work_callbacks.empty?)
49
+ ractor == wakeup_ractor && !(continuous_mode? && @supervisor.callback_registry.has_work_callbacks?)
50
50
  end
51
51
  end
52
52
 
@@ -54,16 +54,14 @@ module Fractor
54
54
  #
55
55
  # @return [void]
56
56
  def process_work_callbacks
57
- work_callbacks.each do |callback|
58
- new_work = callback.call
59
- if new_work && !new_work.empty?
60
- @supervisor.add_work_items(new_work)
61
- puts "Work source provided #{new_work.size} new items" if @debug
62
-
63
- # Distribute work to idle workers
64
- distributed = work_distribution_manager.distribute_to_idle_workers
65
- puts "Distributed work to #{distributed} idle workers" if @debug && distributed.positive?
66
- end
57
+ new_work = @supervisor.callback_registry.process_work_callbacks
58
+ if new_work && !new_work.empty?
59
+ @supervisor.add_work_items(new_work)
60
+ puts "Work source provided #{new_work.size} new items" if @debug
61
+
62
+ # Distribute work to idle workers
63
+ distributed = work_distribution_manager.distribute_to_idle_workers
64
+ puts "Distributed work to #{distributed} idle workers" if @debug && distributed.positive?
67
65
  end
68
66
  end
69
67
 
@@ -8,10 +8,11 @@ module Fractor
8
8
  class MainLoopHandler4 < MainLoopHandler
9
9
  # Run the main event loop for Ruby 4.0+.
10
10
  def run_loop
11
- # Build mapping of response ports to workers for message routing
12
- port_to_worker = build_port_to_worker_map
13
-
14
11
  loop do
12
+ # Build mapping of response ports to workers for message routing
13
+ # REBUILD ON EACH ITERATION to handle terminated workers
14
+ port_to_worker = build_port_to_worker_map
15
+
15
16
  processed_count = get_processed_count
16
17
 
17
18
  # Check loop termination condition
@@ -22,7 +23,7 @@ module Fractor
22
23
  active_items = get_active_items
23
24
 
24
25
  # Check for new work from callbacks if in continuous mode
25
- process_work_callbacks if continuous_mode? && !work_callbacks.empty?
26
+ process_work_callbacks if continuous_mode? && @supervisor.callback_registry.has_work_callbacks?
26
27
 
27
28
  # Handle edge cases - break if edge case handler indicates we should
28
29
  next if handle_edge_cases_with_ports(active_items, port_to_worker,
@@ -65,6 +66,7 @@ module Fractor
65
66
 
66
67
  # Build mapping of response ports to workers.
67
68
  # This is needed to route messages from ports back to workers.
69
+ # Skips workers whose ractors have terminated to avoid blocking on closed ports.
68
70
  #
69
71
  # @return [Hash] Mapping of Ractor::Port => WrappedRactor
70
72
  def build_port_to_worker_map
@@ -72,6 +74,9 @@ module Fractor
72
74
  ractors_map.each_value do |wrapped_ractor|
73
75
  next unless wrapped_ractor.is_a?(WrappedRactor4)
74
76
 
77
+ # Skip workers whose ractors have terminated
78
+ next if wrapped_ractor.closed?
79
+
75
80
  port = wrapped_ractor.response_port
76
81
  port_map[port] = wrapped_ractor if port
77
82
  end
@@ -80,6 +85,7 @@ module Fractor
80
85
 
81
86
  # Get list of active items for Ractor.select (Ruby 4.0+).
82
87
  # Includes both response ports and ractors (excluding wakeup ractor).
88
+ # Skips workers whose ractors have terminated to avoid blocking on closed ports.
83
89
  #
84
90
  # @return [Array] List of Ractor::Port and Ractor objects
85
91
  def get_active_items
@@ -89,12 +95,17 @@ module Fractor
89
95
  ractors_map.each_value do |wrapped_ractor|
90
96
  next unless wrapped_ractor.is_a?(WrappedRactor4)
91
97
 
98
+ # Skip workers whose ractors have terminated
99
+ next if wrapped_ractor.closed?
100
+
92
101
  port = wrapped_ractor.response_port
93
102
  items << port if port
94
103
  end
95
104
 
96
- # Add wakeup ractor/port if in continuous mode with callbacks
97
- if continuous_mode? && !work_callbacks.empty? && wakeup_ractor && wakeup_port
105
+ # Add wakeup ractor/port if in continuous mode
106
+ # CRITICAL: Always include wakeup port in continuous mode to ensure
107
+ # the main loop can be unblocked for shutdown, even without callbacks.
108
+ if continuous_mode? && wakeup_ractor && wakeup_port
98
109
  items << wakeup_port
99
110
  end
100
111
 
@@ -106,8 +117,10 @@ module Fractor
106
117
  #
107
118
  # @return [Array<Ractor>] List of active Ractor objects
108
119
  def get_active_ractors
120
+ # CRITICAL: Always include wakeup ractor in continuous mode to ensure
121
+ # the main loop can be unblocked for shutdown, even without callbacks.
109
122
  ractors_map.keys.reject do |ractor|
110
- ractor == wakeup_ractor && !(continuous_mode? && !work_callbacks.empty?)
123
+ ractor == wakeup_ractor && !continuous_mode?
111
124
  end
112
125
  end
113
126
 
@@ -115,16 +128,14 @@ module Fractor
115
128
  #
116
129
  # @return [void]
117
130
  def process_work_callbacks
118
- work_callbacks.each do |callback|
119
- new_work = callback.call
120
- if new_work && !new_work.empty?
121
- @supervisor.add_work_items(new_work)
122
- puts "Work source provided #{new_work.size} new items" if @debug
123
-
124
- # Distribute work to idle workers
125
- distributed = work_distribution_manager.distribute_to_idle_workers
126
- puts "Distributed work to #{distributed} idle workers" if @debug && distributed.positive?
127
- end
131
+ new_work = @supervisor.callback_registry.process_work_callbacks
132
+ if new_work && !new_work.empty?
133
+ @supervisor.add_work_items(new_work)
134
+ puts "Work source provided #{new_work.size} new items" if @debug
135
+
136
+ # Distribute work to idle workers
137
+ distributed = work_distribution_manager.distribute_to_idle_workers
138
+ puts "Distributed work to #{distributed} idle workers" if @debug && distributed.positive?
128
139
  end
129
140
  end
130
141
 
@@ -211,8 +222,20 @@ processed_count)
211
222
 
212
223
  ready_item, message = Ractor.select(*active_items)
213
224
 
214
- # Check if this is the wakeup port
215
- if ready_item == wakeup_port
225
+ # Debug: log what we received
226
+ if @debug
227
+ if ready_item.equal?(wakeup_port)
228
+ puts "DEBUG: Received from wakeup_port: #{message.inspect}"
229
+ elsif ready_item.is_a?(Ractor::Port)
230
+ worker = port_to_worker[ready_item]
231
+ puts "DEBUG: Received from port: #{worker&.name || 'unknown'}, message: #{message.inspect}"
232
+ else
233
+ puts "DEBUG: Received from ractor: #{ready_item.inspect}, message: #{message.inspect}"
234
+ end
235
+ end
236
+
237
+ # Check if this is the wakeup port (use equal? for identity comparison)
238
+ if wakeup_port && ready_item.equal?(wakeup_port)
216
239
  puts "Wakeup signal received: #{message[:message]}" if @debug
217
240
  # Remove wakeup ractor from map if shutting down
218
241
  if message && message[:message] == :shutdown
@@ -226,7 +249,7 @@ processed_count)
226
249
  [ready_item, message]
227
250
  rescue Ractor::ClosedError, Ractor::Error => e
228
251
  # Handle closed ports/ractors - remove them from ractors_map
229
- puts "Ractor::Error in select: #{e.message}. Cleaning up closed ports." if @debug
252
+ puts "Ractor::Error in select: #{e.class} - #{e.message}. Cleaning up closed ports." if @debug
230
253
 
231
254
  # Find and remove workers with closed ports
232
255
  closed_ports = active_items.select { |item| item.is_a?(Ractor::Port) }
@@ -242,6 +265,11 @@ processed_count)
242
265
 
243
266
  # Return nil to continue the loop with updated active_items
244
267
  [nil, nil]
268
+ rescue StandardError => e
269
+ # Catch any other unexpected errors and re-raise with context
270
+ puts "Unexpected error in select_from_mixed: #{e.class} - #{e.message}" if @debug
271
+ puts e.backtrace.first(5) if @debug
272
+ raise
245
273
  end
246
274
 
247
275
  # Process a message from a ractor or port (Ruby 4.0+).
@@ -156,21 +156,69 @@ module Fractor
156
156
 
157
157
  # Generate a cache key for a work item.
158
158
  #
159
+ # Optimized to avoid JSON.dump for common simple types.
160
+ # For complex nested structures, falls back to JSON serialization.
161
+ #
159
162
  # @param work [Fractor::Work] The work item
160
163
  # @return [String] The cache key
161
164
  def generate_key(work)
162
- # Create a deterministic key from the work item
163
- data = {
164
- class: work.class.name,
165
- input: work.input,
166
- }
167
- if work.respond_to?(:timeout) && !work.timeout.nil?
168
- data[:timeout] =
169
- work.timeout
170
- end
165
+ # For simple types, use faster string interpolation
166
+ # For complex nested structures, fall back to JSON
167
+ input = work.input
168
+ input_str = if simple_input?(input)
169
+ serialize_simple_input(input)
170
+ else
171
+ JSON.dump(input)
172
+ end
173
+
174
+ # Build key components
175
+ parts = [work.class.name, input_str]
176
+ parts << work.timeout.to_s if work.respond_to?(:timeout) && !work.timeout.nil?
171
177
 
172
178
  # Use SHA256 hash for consistent, collision-resistant keys
173
- Digest::SHA256.hexdigest(JSON.dump(data))
179
+ Digest::SHA256.hexdigest(parts.join("|"))
180
+ end
181
+
182
+ # Check if input is a simple type that can be serialized without JSON.
183
+ # @return [Boolean] true if input is a simple, directly serializable type
184
+ def simple_input?(input)
185
+ case input
186
+ when NilClass, TrueClass, FalseClass, String, Numeric, Symbol
187
+ true
188
+ when Array
189
+ input.all? { |item| simple_input?(item) }
190
+ when Hash
191
+ input.keys.all? { |k| k.is_a?(String) || k.is_a?(Symbol) } &&
192
+ input.values.all? { |v| simple_input?(v) }
193
+ else
194
+ false
195
+ end
196
+ end
197
+
198
+ # Serialize simple input types efficiently without JSON.
199
+ # @return [String] Serialized representation
200
+ def serialize_simple_input(input)
201
+ case input
202
+ when NilClass
203
+ "nil"
204
+ when TrueClass
205
+ "true"
206
+ when FalseClass
207
+ "false"
208
+ when String
209
+ # Escape special characters for consistent hashing
210
+ input.inspect
211
+ when Symbol
212
+ ":#{input}"
213
+ when Array
214
+ "[#{input.map { |item| serialize_simple_input(item) }.join(',')}]"
215
+ when Hash
216
+ pairs = input.map { |k, v| "#{k}=>#{serialize_simple_input(v)}" }
217
+ "{#{pairs.sort.join(',')}}"
218
+ else
219
+ # Fallback - shouldn't happen if simple_input? is correct
220
+ input.to_s
221
+ end
174
222
  end
175
223
 
176
224
  # Check if a cache entry is expired.
@@ -8,29 +8,35 @@ module Fractor
8
8
  # the Single Responsibility Principle.
9
9
  class ShutdownHandler
10
10
  def initialize(workers, wakeup_ractor, timer_thread, performance_monitor,
11
- main_loop_thread: nil, debug: false)
11
+ main_loop_thread: nil, debug: false, continuous_mode: false)
12
12
  @workers = workers
13
13
  @wakeup_ractor = wakeup_ractor
14
14
  @timer_thread = timer_thread
15
15
  @performance_monitor = performance_monitor
16
16
  @main_loop_thread = main_loop_thread
17
17
  @debug = debug
18
+ @continuous_mode = continuous_mode
18
19
  end
19
20
 
20
21
  # Execute a graceful shutdown of all supervisor components.
21
22
  # Components are stopped in the correct order to prevent issues:
22
23
  # 1. Stop performance monitor (to stop metric collection)
23
- # 2. Stop timer thread (to stop periodic wakeups)
24
- # 3. Signal wakeup ractor (to unblock Ractor.select)
25
- # 4. Signal all workers (to stop processing)
26
- # 5. Wait for main loop thread and workers to finish
24
+ # 2. Skip stopping timer thread in continuous mode (it will exit when workers close)
25
+ # 3. Stop timer thread in batch mode (to stop periodic wakeups)
26
+ # 4. Signal wakeup ractor (to unblock Ractor.select)
27
+ # 5. Signal all workers (to stop processing)
28
+ # 6. Wait for main loop thread and workers to finish
27
29
  #
28
30
  # @param wait_for_completion [Boolean] Whether to wait for all workers to close
29
31
  # @param timeout [Integer] Maximum seconds to wait for shutdown (default: 10)
30
32
  # @return [void]
31
33
  def shutdown(wait_for_completion: false, timeout: 10)
32
34
  stop_performance_monitor
33
- stop_timer_thread
35
+
36
+ # Only stop timer thread in batch mode. In continuous mode, the timer thread
37
+ # will exit on its own when workers are closed (it checks this condition).
38
+ stop_timer_thread unless @continuous_mode
39
+
34
40
  signal_wakeup_ractor
35
41
  signal_all_workers
36
42