taski 0.4.2 → 0.7.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +51 -33
  4. data/Steepfile +1 -0
  5. data/docs/GUIDE.md +340 -0
  6. data/examples/README.md +68 -20
  7. data/examples/{context_demo.rb → args_demo.rb} +27 -27
  8. data/examples/clean_demo.rb +204 -0
  9. data/examples/data_pipeline_demo.rb +3 -3
  10. data/examples/group_demo.rb +113 -0
  11. data/examples/nested_section_demo.rb +161 -0
  12. data/examples/parallel_progress_demo.rb +1 -1
  13. data/examples/reexecution_demo.rb +93 -80
  14. data/examples/system_call_demo.rb +56 -0
  15. data/examples/tree_progress_demo.rb +164 -0
  16. data/lib/taski/{context.rb → args.rb} +3 -3
  17. data/lib/taski/execution/execution_context.rb +379 -0
  18. data/lib/taski/execution/executor.rb +538 -0
  19. data/lib/taski/execution/registry.rb +26 -2
  20. data/lib/taski/execution/scheduler.rb +308 -0
  21. data/lib/taski/execution/task_output_pipe.rb +42 -0
  22. data/lib/taski/execution/task_output_router.rb +216 -0
  23. data/lib/taski/execution/task_wrapper.rb +295 -146
  24. data/lib/taski/execution/tree_progress_display.rb +793 -0
  25. data/lib/taski/execution/worker_pool.rb +104 -0
  26. data/lib/taski/section.rb +23 -0
  27. data/lib/taski/static_analysis/analyzer.rb +4 -2
  28. data/lib/taski/static_analysis/visitor.rb +86 -5
  29. data/lib/taski/task.rb +223 -120
  30. data/lib/taski/version.rb +1 -1
  31. data/lib/taski.rb +147 -28
  32. data/sig/taski.rbs +310 -67
  33. metadata +17 -8
  34. data/docs/advanced-features.md +0 -625
  35. data/docs/api-guide.md +0 -509
  36. data/docs/error-handling.md +0 -684
  37. data/lib/taski/execution/coordinator.rb +0 -63
  38. data/lib/taski/execution/parallel_progress_display.rb +0 -201
@@ -22,233 +22,382 @@ 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, :clean_error
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
+ ##
36
+ # Create a new TaskWrapper for the given task and registry.
37
+ # Initializes synchronization primitives, state tracking for execution and cleanup, and timing/result/error holders.
38
+ # @param [Object] task - The task instance being wrapped.
39
+ # @param [Object] registry - The registry used to query abort status and coordinate execution.
40
+ # @param [Object, nil] execution_context - Optional execution context used to trigger and report execution and cleanup.
41
+ def initialize(task, registry:, execution_context: nil)
33
42
  @task = task
34
43
  @registry = registry
35
- @coordinator = coordinator
44
+ @execution_context = execution_context
36
45
  @result = nil
37
46
  @clean_result = nil
38
47
  @error = nil
48
+ @clean_error = nil
39
49
  @monitor = Monitor.new
40
50
  @condition = @monitor.new_cond
41
51
  @clean_condition = @monitor.new_cond
42
52
  @state = STATE_PENDING
43
53
  @clean_state = STATE_PENDING
44
54
  @timing = nil
45
-
46
- register_with_progress_display
47
- end
48
-
49
- # @return [Object] The result of task execution
50
- def run
51
- execute_task_if_needed
52
- raise @error if @error # steep:ignore
53
- @result
55
+ @clean_timing = nil
54
56
  end
55
57
 
56
- # @return [Object] The result of cleanup
57
- def clean
58
- execute_clean_if_needed
59
- @clean_result
58
+ # @return [Symbol] Current state
59
+ def state
60
+ @monitor.synchronize { @state }
60
61
  end
61
62
 
62
- # @param method_name [Symbol] The name of the exported method
63
- # @return [Object] The exported value
64
- def get_exported_value(method_name)
65
- execute_task_if_needed
66
- raise @error if @error # steep:ignore
67
- @task.public_send(method_name)
63
+ # @return [Boolean] true if task is pending
64
+ def pending?
65
+ state == STATE_PENDING
68
66
  end
69
67
 
70
- private
71
-
72
- def start_thread_with(&block)
73
- thread = Thread.new(&block)
74
- @registry.register_thread(thread)
68
+ # @return [Boolean] true if task is completed
69
+ def completed?
70
+ state == STATE_COMPLETED
75
71
  end
76
72
 
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)
73
+ # Resets the wrapper state to allow re-execution.
74
+ # Clears all cached results and returns state to pending.
75
+ def reset!
80
76
  @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
77
+ @state = STATE_PENDING
78
+ @clean_state = STATE_PENDING
79
+ @result = nil
80
+ @clean_result = nil
81
+ @error = nil
82
+ @clean_error = nil
83
+ @timing = nil
84
+ @clean_timing = nil
91
85
  end
86
+ @task.reset! if @task.respond_to?(:reset!)
87
+ @registry.reset!
92
88
  end
93
89
 
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 }
90
+ # Called by user code to get result. Triggers execution if needed.
91
+ # Sets up args if not already set (for Task.new.run usage).
92
+ # @return [Object] The result of task execution
93
+ def run
94
+ with_args_lifecycle do
95
+ trigger_execution_and_wait
96
+ raise @error if @error # steep:ignore
97
+ @result
98
+ end
112
99
  end
113
100
 
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
101
+ # Called by user code to clean. Triggers clean execution if needed.
102
+ # Sets up args if not already set (for Task.new.clean usage).
103
+ # @return [Object] The result of cleanup
104
+ def clean
105
+ with_args_lifecycle do
106
+ trigger_clean_and_wait
107
+ @clean_result
144
108
  end
145
109
  end
146
110
 
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
- )
111
+ # Called by user code to run and clean. Runs execution followed by cleanup.
112
+ # If run fails, clean is still executed for resource release.
113
+ # Pre-increments progress display nest_level to prevent double rendering.
114
+ # @return [Object] The result of task execution
115
+ def run_and_clean
116
+ context = ensure_execution_context
117
+ context.notify_start # Pre-increment nest_level to prevent double rendering
118
+ run
119
+ ensure
120
+ clean
121
+ context&.notify_stop # Final decrement and render
153
122
  end
154
123
 
155
- def start_async_clean
156
- @clean_state = STATE_RUNNING
157
- start_thread_with { execute_clean }
124
+ # Called by user code to get exported value. Triggers execution if needed.
125
+ # Sets up args if not already set (for Task.new usage).
126
+ # @param method_name [Symbol] The name of the exported method
127
+ # @return [Object] The exported value
128
+ def get_exported_value(method_name)
129
+ with_args_lifecycle do
130
+ trigger_execution_and_wait
131
+ raise @error if @error # steep:ignore
132
+ @task.public_send(method_name)
133
+ end
158
134
  end
159
135
 
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
136
+ # Called by Executor to mark task as running
137
+ def mark_running
138
+ @monitor.synchronize do
139
+ return false unless @state == STATE_PENDING
140
+ @state = STATE_RUNNING
141
+ @timing = TaskTiming.start_now
142
+ true
143
+ end
166
144
  end
167
145
 
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
146
+ # Called by Executor after task.run completes successfully
147
+ # @param result [Object] The result of task execution
148
+ def mark_completed(result)
149
+ @timing = @timing&.with_end_now
150
+ @monitor.synchronize do
151
+ @result = result
152
+ @state = STATE_COMPLETED
153
+ @condition.broadcast
176
154
  end
177
-
178
- wait_threads.each(&:join)
155
+ update_progress(:completed, duration: @timing&.duration_ms)
179
156
  end
180
157
 
181
- def mark_completed
158
+ # Called by Executor when task.run raises an error
159
+ ##
160
+ # Marks the task as failed and records the error.
161
+ # Records the provided error, sets the task state to completed, updates the timing end time, notifies threads waiting for completion, and reports the failure to the execution context.
162
+ # @param [Exception] error - The exception raised during task execution.
163
+ def mark_failed(error)
182
164
  @timing = @timing&.with_end_now
183
165
  @monitor.synchronize do
166
+ @error = error
184
167
  @state = STATE_COMPLETED
185
168
  @condition.broadcast
186
169
  end
170
+ update_progress(:failed, error: error)
171
+ end
187
172
 
188
- if @error
189
- update_progress(:failed, error: @error)
190
- else
191
- update_progress(:completed, duration: @timing&.duration_ms)
173
+ # Called by Executor to mark clean as running
174
+ ##
175
+ # Mark the task's cleanup state as running and start timing.
176
+ # @return [Boolean] `true` if the clean state was changed from pending to running, `false` otherwise.
177
+ def mark_clean_running
178
+ @monitor.synchronize do
179
+ return false unless @clean_state == STATE_PENDING
180
+ @clean_state = STATE_RUNNING
181
+ @clean_timing = TaskTiming.start_now
182
+ true
192
183
  end
193
184
  end
194
185
 
195
- def mark_clean_completed
186
+ # Called by Executor after clean completes
187
+ ##
188
+ # Marks the cleanup run as completed, stores the cleanup result, sets the clean state to COMPLETED,
189
+ # notifies any waiters, and reports completion to observers.
190
+ # @param [Object] result - The result of the cleanup operation.
191
+ def mark_clean_completed(result)
192
+ @clean_timing = @clean_timing&.with_end_now
196
193
  @monitor.synchronize do
194
+ @clean_result = result
197
195
  @clean_state = STATE_COMPLETED
198
196
  @clean_condition.broadcast
199
197
  end
198
+ update_clean_progress(:clean_completed, duration: @clean_timing&.duration_ms)
200
199
  end
201
200
 
201
+ # Called by Executor when clean raises an error
202
+ ##
203
+ # Marks the cleanup as failed by storing the cleanup error, transitioning the cleanup state to completed,
204
+ # notifying any waiters, and reports failure to observers.
205
+ # @param [Exception] error - The exception raised during the cleanup run.
206
+ def mark_clean_failed(error)
207
+ @clean_timing = @clean_timing&.with_end_now
208
+ @monitor.synchronize do
209
+ @clean_error = error
210
+ @clean_state = STATE_COMPLETED
211
+ @clean_condition.broadcast
212
+ end
213
+ update_clean_progress(:clean_failed, duration: @clean_timing&.duration_ms, error: error)
214
+ end
215
+
216
+ ##
217
+ # Blocks the current thread until the task reaches the completed state.
218
+ #
219
+ # The caller will be suspended until the wrapper's state becomes STATE_COMPLETED.
220
+ # This method does not raise on its own; any errors from task execution are surfaced elsewhere.
202
221
  def wait_for_completion
203
- @condition.wait_until { @state == STATE_COMPLETED }
222
+ @monitor.synchronize do
223
+ @condition.wait_until { @state == STATE_COMPLETED }
224
+ end
204
225
  end
205
226
 
227
+ ##
228
+ # Blocks the current thread until the task's clean phase reaches the completed state.
229
+ # The caller will be suspended until the wrapper's clean_state becomes STATE_COMPLETED.
206
230
  def wait_for_clean_completion
207
- @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
231
+ @monitor.synchronize do
232
+ @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
233
+ end
208
234
  end
209
235
 
210
- def debug_log(message)
211
- return unless ENV["TASKI_DEBUG"]
212
- puts message
236
+ ##
237
+ # Delegates method calls to get_exported_value for exported task methods.
238
+ # @param method_name [Symbol] The method name being called.
239
+ # @param args [Array] Arguments passed to the method.
240
+ # @param block [Proc] Block passed to the method.
241
+ # @return [Object] The exported value for the method.
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
247
+ end
213
248
  end
214
249
 
215
- def log_start
216
- debug_log("Invoking #{@task.class} wrapper in thread #{Thread.current.object_id}...")
250
+ ##
251
+ # Returns true if the task class defines the given method.
252
+ # @param method_name [Symbol] The method name to check.
253
+ # @param include_private [Boolean] Whether to include private methods.
254
+ # @return [Boolean] true if the task responds to the method.
255
+ def respond_to_missing?(method_name, include_private = false)
256
+ @task.class.method_defined?(method_name) || super
217
257
  end
218
258
 
219
- def log_completion
220
- debug_log("Wrapper #{@task.class} completed in thread #{Thread.current.object_id}.")
259
+ private
260
+
261
+ ##
262
+ # Ensures args are set during block execution, then resets if they weren't set before.
263
+ # This allows Task.new.run usage without requiring explicit args setup.
264
+ # @yield The block to execute with args lifecycle management
265
+ # @return [Object] The result of the block
266
+ def with_args_lifecycle
267
+ args_was_nil = Taski.args.nil?
268
+ Taski.start_args(options: {}, root_task: @task.class) if args_was_nil
269
+ yield
270
+ ensure
271
+ Taski.reset_args! if args_was_nil
221
272
  end
222
273
 
223
- def log_clean_start
224
- debug_log("Cleaning #{@task.class} in thread #{Thread.current.object_id}...")
274
+ ##
275
+ # Ensures the task is executed if still pending and waits for completion.
276
+ # If the task is pending, triggers execution (via the configured ExecutionContext when present, otherwise via Executor) outside the monitor; if the task is running, waits until it becomes completed; if already completed, returns immediately.
277
+ # @raise [Taski::TaskAbortException] If the registry requested an abort before execution begins.
278
+ def trigger_execution_and_wait
279
+ should_execute = false
280
+ @monitor.synchronize do
281
+ case @state
282
+ when STATE_PENDING
283
+ check_abort!
284
+ should_execute = true
285
+ when STATE_RUNNING
286
+ @condition.wait_until { @state == STATE_COMPLETED }
287
+ when STATE_COMPLETED
288
+ # Already done
289
+ end
290
+ end
291
+
292
+ if should_execute
293
+ # Execute outside the lock to avoid deadlock
294
+ # Use ensure_execution_context to create a shared context if not set
295
+ context = ensure_execution_context
296
+ context.trigger_execution(@task.class, registry: @registry)
297
+ # After execution returns, the task is completed
298
+ end
225
299
  end
226
300
 
227
- def log_clean_completion
228
- debug_log("Clean #{@task.class} completed in thread #{Thread.current.object_id}.")
301
+ ##
302
+ # Triggers task cleanup through the configured execution mechanism and waits until the cleanup completes.
303
+ #
304
+ # If an ExecutionContext is configured the cleanup is invoked through it; otherwise a fallback executor is used.
305
+ # @raise [Taski::TaskAbortException] if the registry has requested an abort.
306
+ def trigger_clean_and_wait
307
+ should_execute = false
308
+ @monitor.synchronize do
309
+ case @clean_state
310
+ when STATE_PENDING
311
+ check_abort!
312
+ should_execute = true
313
+ when STATE_RUNNING
314
+ @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
315
+ when STATE_COMPLETED
316
+ # Already done
317
+ end
318
+ end
319
+
320
+ if should_execute
321
+ # Execute outside the lock to avoid deadlock
322
+ # Use ensure_execution_context to reuse the context from run phase
323
+ context = ensure_execution_context
324
+ context.trigger_clean(@task.class, registry: @registry)
325
+ # After execution returns, the task is completed
326
+ end
229
327
  end
230
328
 
231
- def register_with_progress_display
232
- Taski.progress_display&.register_task(@task.class)
329
+ ##
330
+ # Checks whether the registry has requested an abort and raises an exception to stop starting new tasks.
331
+ # @raise [Taski::TaskAbortException] if `@registry.abort_requested?` is true — raised with the message "Execution aborted - no new tasks will start".
332
+ def check_abort!
333
+ if @registry.abort_requested?
334
+ raise Taski::TaskAbortException, "Execution aborted - no new tasks will start"
335
+ end
233
336
  end
234
337
 
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)
338
+ ##
339
+ # Ensures an execution context exists for this wrapper.
340
+ # Returns the existing context if set, otherwise creates a shared context.
341
+ # This enables run and clean phases to share state like runtime dependencies.
342
+ # @return [ExecutionContext] The execution context for this wrapper
343
+ def ensure_execution_context
344
+ @execution_context ||= create_shared_context
240
345
  end
241
346
 
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
347
+ ##
348
+ # Creates a shared execution context with proper triggers for run and clean.
349
+ # The context is configured to reuse itself when triggering nested executions.
350
+ # @return [ExecutionContext] A new execution context
351
+ def create_shared_context
352
+ context = ExecutionContext.new
353
+ progress = Taski.progress_display
354
+ context.add_observer(progress) if progress
355
+
356
+ # Set triggers to reuse this context for nested executions
357
+ context.execution_trigger = ->(task_class, registry) do
358
+ Executor.execute(task_class, registry: registry, execution_context: context)
359
+ end
360
+ context.clean_trigger = ->(task_class, registry) do
361
+ Executor.execute_clean(task_class, registry: registry, execution_context: context)
247
362
  end
363
+
364
+ context
248
365
  end
249
366
 
250
- def respond_to_missing?(method_name, include_private = false)
251
- @task.class.method_defined?(method_name) || super
367
+ ##
368
+ # Notifies the execution context of task completion or failure.
369
+ # Falls back to getting the current context if not set during initialization.
370
+ # @param state [Symbol] The completion state (unused, kept for API consistency).
371
+ # @param duration [Numeric, nil] The execution duration in milliseconds.
372
+ # @param error [Exception, nil] The error if the task failed.
373
+ def update_progress(state, duration: nil, error: nil)
374
+ # Defensive fallback: try to get current context if not set during initialization
375
+ @execution_context ||= ExecutionContext.current
376
+ return unless @execution_context
377
+
378
+ @execution_context.notify_task_completed(@task.class, duration: duration, error: error)
379
+ end
380
+
381
+ ##
382
+ # Notifies the execution context of clean completion or failure.
383
+ # Falls back to getting the current context if not set during initialization.
384
+ # @param state [Symbol] The clean state (unused, kept for API consistency).
385
+ # @param duration [Numeric, nil] The clean duration in milliseconds.
386
+ # @param error [Exception, nil] The error if the clean failed.
387
+ def update_clean_progress(state, duration: nil, error: nil)
388
+ # Defensive fallback: try to get current context if not set during initialization
389
+ @execution_context ||= ExecutionContext.current
390
+ return unless @execution_context
391
+
392
+ @execution_context.notify_clean_completed(@task.class, duration: duration, error: error)
393
+ end
394
+
395
+ ##
396
+ # Outputs a debug message if TASKI_DEBUG environment variable is set.
397
+ # @param message [String] The debug message to output.
398
+ def debug_log(message)
399
+ return unless ENV["TASKI_DEBUG"]
400
+ puts "[TaskWrapper] #{message}"
252
401
  end
253
402
  end
254
403
  end