taski 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "etc"
5
+
6
+ module Taski
7
+ module Execution
8
+ # Producer-Consumer pattern executor for parallel task execution.
9
+ #
10
+ # Architecture:
11
+ # - Main Thread: Manages all state, coordinates execution, handles events
12
+ # - Worker Threads: Execute tasks and send completion events
13
+ #
14
+ # Communication Queues:
15
+ # - Execution Queue (Main -> Worker): Tasks ready to execute
16
+ # - Completion Queue (Worker -> Main): Events from workers
17
+ class Executor
18
+ # Task execution states for the executor's internal tracking
19
+ STATE_PENDING = :pending
20
+ STATE_ENQUEUED = :enqueued
21
+ STATE_COMPLETED = :completed
22
+
23
+ class << self
24
+ # Execute a task and all its dependencies
25
+ # @param root_task_class [Class] The root task class to execute
26
+ # @param registry [Registry] The task registry
27
+ def execute(root_task_class, registry:)
28
+ new(registry: registry).execute(root_task_class)
29
+ end
30
+ end
31
+
32
+ def initialize(registry:, worker_count: nil)
33
+ @registry = registry
34
+ @worker_count = worker_count || default_worker_count
35
+ @execution_queue = Queue.new
36
+ @completion_queue = Queue.new
37
+ @workers = []
38
+
39
+ # State managed by main thread only
40
+ @dependencies = {}
41
+ @task_states = {}
42
+ @completed_tasks = Set.new
43
+ end
44
+
45
+ # Execute root task and all dependencies
46
+ # @param root_task_class [Class] The root task class to execute
47
+ def execute(root_task_class)
48
+ # Build dependency graph from static analysis
49
+ build_dependency_graph(root_task_class)
50
+
51
+ # Set up tree progress display with root task (before start)
52
+ setup_tree_progress(root_task_class)
53
+
54
+ # Start progress display automatically for tree progress
55
+ start_progress_display
56
+
57
+ # Start worker threads
58
+ start_workers
59
+
60
+ # Enqueue tasks with no dependencies
61
+ enqueue_ready_tasks
62
+
63
+ # Main event loop - continues until root task completes
64
+ run_main_loop(root_task_class)
65
+
66
+ # Shutdown workers
67
+ shutdown_workers
68
+
69
+ # Stop progress display
70
+ stop_progress_display
71
+ end
72
+
73
+ private
74
+
75
+ def default_worker_count
76
+ Etc.nprocessors.clamp(2, 8)
77
+ end
78
+
79
+ # Build dependency graph by traversing from root task
80
+ # Populates @dependencies and @task_states
81
+ def build_dependency_graph(root_task_class)
82
+ # @type var queue: Array[singleton(Taski::Task)]
83
+ queue = [root_task_class]
84
+
85
+ while (task_class = queue.shift)
86
+ next if @task_states.key?(task_class)
87
+
88
+ deps = task_class.cached_dependencies
89
+ @dependencies[task_class] = deps.dup
90
+ @task_states[task_class] = STATE_PENDING
91
+
92
+ deps.each { |dep| queue << dep }
93
+ end
94
+ end
95
+
96
+ # Enqueue tasks that have all dependencies completed
97
+ def enqueue_ready_tasks
98
+ @task_states.each_key do |task_class|
99
+ next unless @task_states[task_class] == STATE_PENDING
100
+ next unless ready_to_execute?(task_class)
101
+
102
+ enqueue_task(task_class)
103
+ end
104
+ end
105
+
106
+ # Check if a task is ready to execute
107
+ def ready_to_execute?(task_class)
108
+ task_deps = @dependencies[task_class] || Set.new
109
+ task_deps.subset?(@completed_tasks)
110
+ end
111
+
112
+ # Enqueue a single task for execution
113
+ def enqueue_task(task_class)
114
+ return if @registry.abort_requested?
115
+
116
+ @task_states[task_class] = STATE_ENQUEUED
117
+
118
+ wrapper = get_or_create_wrapper(task_class)
119
+ return unless wrapper.mark_running
120
+
121
+ Taski.progress_display&.register_task(task_class)
122
+ Taski.progress_display&.update_task(task_class, state: :running)
123
+
124
+ @execution_queue.push({task_class: task_class, wrapper: wrapper})
125
+
126
+ debug_log("Enqueued: #{task_class}")
127
+ end
128
+
129
+ # Get or create a task wrapper via Registry
130
+ def get_or_create_wrapper(task_class)
131
+ @registry.get_or_create(task_class) do
132
+ task_instance = task_class.allocate
133
+ task_instance.send(:initialize)
134
+ TaskWrapper.new(task_instance, registry: @registry)
135
+ end
136
+ end
137
+
138
+ # Start worker threads
139
+ def start_workers
140
+ @worker_count.times do
141
+ worker = Thread.new { worker_loop }
142
+ @workers << worker
143
+ @registry.register_thread(worker)
144
+ end
145
+ end
146
+
147
+ # Worker thread main loop
148
+ def worker_loop
149
+ loop do
150
+ work_item = @execution_queue.pop
151
+ break if work_item == :shutdown
152
+
153
+ task_class = work_item[:task_class]
154
+ wrapper = work_item[:wrapper]
155
+
156
+ debug_log("Worker executing: #{task_class}")
157
+
158
+ execute_task(task_class, wrapper)
159
+ end
160
+ end
161
+
162
+ # Execute a task and send completion event
163
+ def execute_task(task_class, wrapper)
164
+ return if @registry.abort_requested?
165
+
166
+ begin
167
+ result = execute_task_run(wrapper)
168
+ wrapper.mark_completed(result)
169
+ @completion_queue.push({task_class: task_class, wrapper: wrapper})
170
+ rescue Taski::TaskAbortException => e
171
+ @registry.request_abort!
172
+ wrapper.mark_failed(e)
173
+ @completion_queue.push({task_class: task_class, wrapper: wrapper, error: e})
174
+ rescue => e
175
+ wrapper.mark_failed(e)
176
+ @completion_queue.push({task_class: task_class, wrapper: wrapper, error: e})
177
+ end
178
+ end
179
+
180
+ # Execute task run method
181
+ # Note: Previously captured stdout for progress display, but this was removed
182
+ # due to thread-safety concerns with global $stdout mutation.
183
+ def execute_task_run(wrapper)
184
+ wrapper.task.run
185
+ end
186
+
187
+ # Main thread event loop - continues until root task completes
188
+ def run_main_loop(root_task_class)
189
+ until @completed_tasks.include?(root_task_class)
190
+ break if @registry.abort_requested? && no_running_tasks?
191
+
192
+ event = @completion_queue.pop
193
+ handle_completion(event)
194
+ end
195
+ end
196
+
197
+ def no_running_tasks?
198
+ @task_states.values.none? { |state| state == STATE_ENQUEUED }
199
+ end
200
+
201
+ # Handle task completion event
202
+ def handle_completion(event)
203
+ task_class = event[:task_class]
204
+
205
+ debug_log("Completed: #{task_class}")
206
+
207
+ @task_states[task_class] = STATE_COMPLETED
208
+ @completed_tasks.add(task_class)
209
+
210
+ # Enqueue newly ready tasks
211
+ enqueue_ready_tasks
212
+ end
213
+
214
+ # Shutdown worker threads
215
+ def shutdown_workers
216
+ @worker_count.times { @execution_queue.push(:shutdown) }
217
+ @workers.each(&:join)
218
+ end
219
+
220
+ def setup_tree_progress(root_task_class)
221
+ progress = Taski.progress_display
222
+ return unless progress.is_a?(TreeProgressDisplay)
223
+
224
+ progress.set_root_task(root_task_class)
225
+ end
226
+
227
+ def start_progress_display
228
+ progress = Taski.progress_display
229
+ return unless progress.is_a?(TreeProgressDisplay)
230
+
231
+ progress.start
232
+ end
233
+
234
+ def stop_progress_display
235
+ progress = Taski.progress_display
236
+ return unless progress.is_a?(TreeProgressDisplay)
237
+
238
+ progress.stop
239
+ end
240
+
241
+ def debug_log(message)
242
+ return unless ENV["TASKI_DEBUG"]
243
+ puts "[Executor] #{message}"
244
+ end
245
+ end
246
+ end
247
+ end
@@ -19,6 +19,12 @@ module Taski
19
19
  @tasks[task_class] ||= yield
20
20
  end
21
21
 
22
+ # @param task_class [Class] The task class
23
+ # @param wrapper [TaskWrapper] The wrapper instance to register
24
+ def register(task_class, wrapper)
25
+ @tasks[task_class] = wrapper
26
+ end
27
+
22
28
  # @param task_class [Class] The task class
23
29
  # @return [Object] The task instance
24
30
  # @raise [RuntimeError] If the task is not registered
@@ -65,7 +71,9 @@ module Taski
65
71
 
66
72
  wait_all
67
73
 
68
- get_task(task_class).result
74
+ # @type var wrapper: Taski::Execution::TaskWrapper
75
+ wrapper = get_task(task_class)
76
+ wrapper.result
69
77
  end
70
78
  end
71
79
  end
@@ -22,17 +22,19 @@ module Taski
22
22
  end
23
23
  end
24
24
 
25
+ # TaskWrapper manages the state and synchronization for a single task.
26
+ # In the Producer-Consumer pattern, TaskWrapper does NOT start threads.
27
+ # The Executor controls all scheduling and execution.
25
28
  class TaskWrapper
26
- attr_reader :task, :result
29
+ attr_reader :task, :result, :error, :timing
27
30
 
28
31
  STATE_PENDING = :pending
29
32
  STATE_RUNNING = :running
30
33
  STATE_COMPLETED = :completed
31
34
 
32
- def initialize(task, registry:, coordinator:)
35
+ def initialize(task, registry:)
33
36
  @task = task
34
37
  @registry = registry
35
- @coordinator = coordinator
36
38
  @result = nil
37
39
  @clean_result = nil
38
40
  @error = nil
@@ -42,213 +44,190 @@ module Taski
42
44
  @state = STATE_PENDING
43
45
  @clean_state = STATE_PENDING
44
46
  @timing = nil
47
+ end
48
+
49
+ # @return [Symbol] Current state
50
+ def state
51
+ @monitor.synchronize { @state }
52
+ end
45
53
 
46
- register_with_progress_display
54
+ # @return [Boolean] true if task is pending
55
+ def pending?
56
+ state == STATE_PENDING
47
57
  end
48
58
 
59
+ # @return [Boolean] true if task is completed
60
+ def completed?
61
+ state == STATE_COMPLETED
62
+ end
63
+
64
+ # Called by user code to get result. Triggers execution if needed.
49
65
  # @return [Object] The result of task execution
50
66
  def run
51
- execute_task_if_needed
67
+ trigger_execution_and_wait
52
68
  raise @error if @error # steep:ignore
53
69
  @result
54
70
  end
55
71
 
72
+ # Called by user code to clean. Triggers clean execution if needed.
56
73
  # @return [Object] The result of cleanup
57
74
  def clean
58
- execute_clean_if_needed
75
+ trigger_clean_and_wait
59
76
  @clean_result
60
77
  end
61
78
 
79
+ # Called by user code to get exported value. Triggers execution if needed.
62
80
  # @param method_name [Symbol] The name of the exported method
63
81
  # @return [Object] The exported value
64
82
  def get_exported_value(method_name)
65
- execute_task_if_needed
83
+ trigger_execution_and_wait
66
84
  raise @error if @error # steep:ignore
67
85
  @task.public_send(method_name)
68
86
  end
69
87
 
70
- private
71
-
72
- def start_thread_with(&block)
73
- thread = Thread.new(&block)
74
- @registry.register_thread(thread)
75
- end
76
-
77
- # Thread-safe state machine that ensures operations are executed exactly once.
78
- # Uses pattern matching for exhaustive state handling.
79
- def execute_with_state_pattern(state_getter:, starter:, waiter:, pre_start_check: nil)
88
+ # Called by Executor to mark task as running
89
+ def mark_running
80
90
  @monitor.synchronize do
81
- case state_getter.call
82
- in STATE_PENDING
83
- pre_start_check&.call
84
- starter.call
85
- waiter.call
86
- in STATE_RUNNING
87
- waiter.call
88
- in STATE_COMPLETED
89
- return
90
- end
91
- end
92
- end
93
-
94
- def execute_task_if_needed
95
- execute_with_state_pattern(
96
- state_getter: -> { @state },
97
- starter: -> { start_async_execution },
98
- waiter: -> { wait_for_completion },
99
- pre_start_check: -> {
100
- if @registry.abort_requested?
101
- raise Taski::TaskAbortException, "Execution aborted - no new tasks will start"
102
- end
103
- }
104
- )
105
- end
106
-
107
- def start_async_execution
108
- @state = STATE_RUNNING
109
- @timing = TaskTiming.start_now
110
- update_progress(:running)
111
- start_thread_with { execute_task }
112
- end
113
-
114
- def execute_task
115
- if @registry.abort_requested?
116
- @error = Taski::TaskAbortException.new("Execution aborted - no new tasks will start")
117
- mark_completed
118
- return
119
- end
120
-
121
- log_start
122
- @coordinator.start_dependencies(@task.class)
123
- wait_for_dependencies
124
- @result = @task.run
125
- mark_completed
126
- log_completion
127
- rescue Taski::TaskAbortException => e
128
- @registry.request_abort!
129
- @error = e
130
- mark_completed
131
- rescue => e
132
- @error = e
133
- mark_completed
134
- end
135
-
136
- def wait_for_dependencies
137
- dependencies = @task.class.cached_dependencies
138
- return if dependencies.empty?
139
-
140
- dependencies.each do |dep_class|
141
- dep_class.exported_methods.each do |method|
142
- dep_class.public_send(method)
143
- end
91
+ return false unless @state == STATE_PENDING
92
+ @state = STATE_RUNNING
93
+ @timing = TaskTiming.start_now
94
+ true
144
95
  end
145
96
  end
146
97
 
147
- def execute_clean_if_needed
148
- execute_with_state_pattern(
149
- state_getter: -> { @clean_state },
150
- starter: -> { start_async_clean },
151
- waiter: -> { wait_for_clean_completion }
152
- )
153
- end
154
-
155
- def start_async_clean
156
- @clean_state = STATE_RUNNING
157
- start_thread_with { execute_clean }
158
- end
159
-
160
- def execute_clean
161
- log_clean_start
162
- @clean_result = @task.clean
163
- wait_for_clean_dependencies
164
- mark_clean_completed
165
- log_clean_completion
166
- end
167
-
168
- def wait_for_clean_dependencies
169
- dependencies = @task.class.cached_dependencies
170
- return if dependencies.empty?
171
-
172
- wait_threads = dependencies.map do |dep_class|
173
- Thread.new do
174
- dep_class.public_send(:clean)
175
- end
98
+ # Called by Executor after task.run completes successfully
99
+ # @param result [Object] The result of task execution
100
+ def mark_completed(result)
101
+ @timing = @timing&.with_end_now
102
+ @monitor.synchronize do
103
+ @result = result
104
+ @state = STATE_COMPLETED
105
+ @condition.broadcast
176
106
  end
177
-
178
- wait_threads.each(&:join)
107
+ update_progress(:completed, duration: @timing&.duration_ms)
179
108
  end
180
109
 
181
- def mark_completed
110
+ # Called by Executor when task.run raises an error
111
+ # @param error [Exception] The error that occurred
112
+ def mark_failed(error)
182
113
  @timing = @timing&.with_end_now
183
114
  @monitor.synchronize do
115
+ @error = error
184
116
  @state = STATE_COMPLETED
185
117
  @condition.broadcast
186
118
  end
187
-
188
- if @error
189
- update_progress(:failed, error: @error)
190
- else
191
- update_progress(:completed, duration: @timing&.duration_ms)
192
- end
119
+ update_progress(:failed, error: error)
193
120
  end
194
121
 
195
- def mark_clean_completed
122
+ # Called by Executor after clean completes
123
+ # @param result [Object] The result of cleanup
124
+ def mark_clean_completed(result)
196
125
  @monitor.synchronize do
126
+ @clean_result = result
197
127
  @clean_state = STATE_COMPLETED
198
128
  @clean_condition.broadcast
199
129
  end
200
130
  end
201
131
 
132
+ # Wait until task is completed
202
133
  def wait_for_completion
203
- @condition.wait_until { @state == STATE_COMPLETED }
134
+ @monitor.synchronize do
135
+ @condition.wait_until { @state == STATE_COMPLETED }
136
+ end
204
137
  end
205
138
 
139
+ # Wait until clean is completed
206
140
  def wait_for_clean_completion
207
- @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
141
+ @monitor.synchronize do
142
+ @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
143
+ end
208
144
  end
209
145
 
210
- def debug_log(message)
211
- return unless ENV["TASKI_DEBUG"]
212
- puts message
146
+ def method_missing(method_name, *args, &block)
147
+ if @task.class.method_defined?(method_name)
148
+ get_exported_value(method_name)
149
+ else
150
+ super
151
+ end
213
152
  end
214
153
 
215
- def log_start
216
- debug_log("Invoking #{@task.class} wrapper in thread #{Thread.current.object_id}...")
154
+ def respond_to_missing?(method_name, include_private = false)
155
+ @task.class.method_defined?(method_name) || super
217
156
  end
218
157
 
219
- def log_completion
220
- debug_log("Wrapper #{@task.class} completed in thread #{Thread.current.object_id}.")
221
- end
158
+ private
159
+
160
+ # Trigger execution via Executor and wait for completion
161
+ def trigger_execution_and_wait
162
+ should_execute = false
163
+ @monitor.synchronize do
164
+ case @state
165
+ when STATE_PENDING
166
+ check_abort!
167
+ should_execute = true
168
+ when STATE_RUNNING
169
+ @condition.wait_until { @state == STATE_COMPLETED }
170
+ when STATE_COMPLETED
171
+ # Already done
172
+ end
173
+ end
222
174
 
223
- def log_clean_start
224
- debug_log("Cleaning #{@task.class} in thread #{Thread.current.object_id}...")
175
+ if should_execute
176
+ # Execute outside the lock to avoid deadlock
177
+ Executor.execute(@task.class, registry: @registry)
178
+ # After Executor.execute returns, the task is completed
179
+ end
225
180
  end
226
181
 
227
- def log_clean_completion
228
- debug_log("Clean #{@task.class} completed in thread #{Thread.current.object_id}.")
182
+ # Trigger clean execution and wait for completion
183
+ def trigger_clean_and_wait
184
+ @monitor.synchronize do
185
+ case @clean_state
186
+ when STATE_PENDING
187
+ @clean_state = STATE_RUNNING
188
+ # Execute clean in a thread (clean doesn't use Producer-Consumer)
189
+ thread = Thread.new { execute_clean }
190
+ @registry.register_thread(thread)
191
+ @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
192
+ when STATE_RUNNING
193
+ @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
194
+ when STATE_COMPLETED
195
+ # Already done
196
+ end
197
+ end
229
198
  end
230
199
 
231
- def register_with_progress_display
232
- Taski.progress_display&.register_task(@task.class)
200
+ def execute_clean
201
+ debug_log("Cleaning #{@task.class}...")
202
+ result = @task.clean
203
+ wait_for_clean_dependencies
204
+ mark_clean_completed(result)
205
+ debug_log("Clean #{@task.class} completed.")
233
206
  end
234
207
 
235
- # @param state [Symbol] The new state
236
- # @param duration [Float, nil] Duration in milliseconds
237
- # @param error [Exception, nil] Error object
238
- def update_progress(state, duration: nil, error: nil)
239
- Taski.progress_display&.update_task(@task.class, state: state, duration: duration, error: error)
208
+ def wait_for_clean_dependencies
209
+ dependencies = @task.class.cached_dependencies
210
+ return if dependencies.empty?
211
+
212
+ wait_threads = dependencies.map do |dep_class|
213
+ Thread.new { dep_class.clean }
214
+ end
215
+ wait_threads.each(&:join)
240
216
  end
241
217
 
242
- def method_missing(method_name, *args, &block)
243
- if @task.class.method_defined?(method_name)
244
- get_exported_value(method_name)
245
- else
246
- super
218
+ def check_abort!
219
+ if @registry.abort_requested?
220
+ raise Taski::TaskAbortException, "Execution aborted - no new tasks will start"
247
221
  end
248
222
  end
249
223
 
250
- def respond_to_missing?(method_name, include_private = false)
251
- @task.class.method_defined?(method_name) || super
224
+ def update_progress(state, duration: nil, error: nil)
225
+ Taski.progress_display&.update_task(@task.class, state: state, duration: duration, error: error)
226
+ end
227
+
228
+ def debug_log(message)
229
+ return unless ENV["TASKI_DEBUG"]
230
+ puts "[TaskWrapper] #{message}"
252
231
  end
253
232
  end
254
233
  end