taski 0.5.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +40 -21
- data/docs/GUIDE.md +340 -0
- data/examples/README.md +65 -17
- data/examples/{context_demo.rb → args_demo.rb} +27 -27
- data/examples/clean_demo.rb +204 -0
- data/examples/group_demo.rb +113 -0
- data/examples/reexecution_demo.rb +93 -80
- data/examples/system_call_demo.rb +56 -0
- data/lib/taski/{context.rb → args.rb} +3 -3
- data/lib/taski/execution/execution_context.rb +379 -0
- data/lib/taski/execution/executor.rb +408 -117
- data/lib/taski/execution/registry.rb +17 -1
- data/lib/taski/execution/scheduler.rb +308 -0
- data/lib/taski/execution/task_output_pipe.rb +42 -0
- data/lib/taski/execution/task_output_router.rb +216 -0
- data/lib/taski/execution/task_wrapper.rb +210 -40
- data/lib/taski/execution/tree_progress_display.rb +385 -98
- data/lib/taski/execution/worker_pool.rb +104 -0
- data/lib/taski/section.rb +16 -3
- data/lib/taski/task.rb +222 -36
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +138 -23
- data/sig/taski.rbs +207 -27
- metadata +13 -7
- data/docs/advanced-features.md +0 -625
- data/docs/api-guide.md +0 -509
- data/docs/error-handling.md +0 -684
- data/examples/section_progress_demo.rb +0 -78
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Taski
|
|
6
|
+
module Execution
|
|
7
|
+
# WorkerPool manages a pool of worker threads that execute tasks.
|
|
8
|
+
# It provides methods to start, stop, and enqueue tasks for execution.
|
|
9
|
+
#
|
|
10
|
+
# == Responsibilities
|
|
11
|
+
#
|
|
12
|
+
# - Manage worker thread lifecycle (start, shutdown)
|
|
13
|
+
# - Distribute tasks to worker threads via Queue
|
|
14
|
+
# - Execute tasks via callback provided by Executor
|
|
15
|
+
#
|
|
16
|
+
# == API
|
|
17
|
+
#
|
|
18
|
+
# - {#start} - Start all worker threads
|
|
19
|
+
# - {#enqueue} - Add a task to the execution queue
|
|
20
|
+
# - {#shutdown} - Gracefully shutdown all worker threads
|
|
21
|
+
# - {#execution_queue} - Access the underlying Queue (for testing)
|
|
22
|
+
#
|
|
23
|
+
# == Thread Safety
|
|
24
|
+
#
|
|
25
|
+
# WorkerPool uses Queue for thread-safe task distribution.
|
|
26
|
+
# The Queue handles synchronization between the main thread
|
|
27
|
+
# (which enqueues tasks) and worker threads (which pop tasks).
|
|
28
|
+
class WorkerPool
|
|
29
|
+
attr_reader :execution_queue
|
|
30
|
+
|
|
31
|
+
# @param registry [Registry] The task registry for thread tracking
|
|
32
|
+
# @param worker_count [Integer, nil] Number of worker threads (defaults to CPU count)
|
|
33
|
+
# @param on_execute [Proc] Callback to execute a task, receives (task_class, wrapper)
|
|
34
|
+
def initialize(registry:, worker_count: nil, &on_execute)
|
|
35
|
+
@worker_count = worker_count || default_worker_count
|
|
36
|
+
@registry = registry
|
|
37
|
+
@on_execute = on_execute
|
|
38
|
+
@execution_queue = Queue.new
|
|
39
|
+
@workers = []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Start all worker threads.
|
|
43
|
+
def start
|
|
44
|
+
@worker_count.times do
|
|
45
|
+
worker = Thread.new { worker_loop }
|
|
46
|
+
@workers << worker
|
|
47
|
+
@registry.register_thread(worker)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Enqueue a task for execution.
|
|
52
|
+
#
|
|
53
|
+
# @param task_class [Class] The task class to execute
|
|
54
|
+
# @param wrapper [TaskWrapper] The task wrapper
|
|
55
|
+
def enqueue(task_class, wrapper)
|
|
56
|
+
@execution_queue.push({task_class: task_class, wrapper: wrapper})
|
|
57
|
+
debug_log("Enqueued: #{task_class}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Shutdown all worker threads gracefully.
|
|
61
|
+
def shutdown
|
|
62
|
+
enqueue_shutdown_signals
|
|
63
|
+
@workers.each(&:join)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Enqueue shutdown signals for all workers.
|
|
67
|
+
def enqueue_shutdown_signals
|
|
68
|
+
@worker_count.times { @execution_queue.push(:shutdown) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def default_worker_count
|
|
74
|
+
Etc.nprocessors.clamp(2, 8)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def worker_loop
|
|
78
|
+
loop do
|
|
79
|
+
work_item = @execution_queue.pop
|
|
80
|
+
break if work_item == :shutdown
|
|
81
|
+
|
|
82
|
+
task_class = work_item[:task_class]
|
|
83
|
+
wrapper = work_item[:wrapper]
|
|
84
|
+
|
|
85
|
+
debug_log("Worker executing: #{task_class}")
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
@on_execute.call(task_class, wrapper)
|
|
89
|
+
rescue => e
|
|
90
|
+
# Log error but don't crash the worker thread.
|
|
91
|
+
# Task-level errors are handled in the execute callback.
|
|
92
|
+
# This catches unexpected errors in the callback itself.
|
|
93
|
+
warn "[WorkerPool] Unexpected error executing #{task_class}: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def debug_log(message)
|
|
99
|
+
return unless ENV["TASKI_DEBUG"]
|
|
100
|
+
puts "[WorkerPool] #{message}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/taski/section.rb
CHANGED
|
@@ -24,6 +24,9 @@ module Taski
|
|
|
24
24
|
raise "Section #{self.class} does not have an implementation. Override 'impl' method."
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Register runtime dependency for clean phase (before register_impl_selection)
|
|
28
|
+
register_runtime_dependency(implementation_class)
|
|
29
|
+
|
|
27
30
|
# Register selected impl for progress display
|
|
28
31
|
register_impl_selection(implementation_class)
|
|
29
32
|
|
|
@@ -43,11 +46,21 @@ module Taski
|
|
|
43
46
|
|
|
44
47
|
private
|
|
45
48
|
|
|
49
|
+
# Register the selected implementation as a runtime dependency.
|
|
50
|
+
# This allows the clean phase to include the dynamically selected impl.
|
|
51
|
+
# Handles nil ExecutionContext gracefully.
|
|
52
|
+
#
|
|
53
|
+
# @param impl_class [Class] The selected implementation class
|
|
54
|
+
def register_runtime_dependency(impl_class)
|
|
55
|
+
context = Execution::ExecutionContext.current
|
|
56
|
+
context&.register_runtime_dependency(self.class, impl_class)
|
|
57
|
+
end
|
|
58
|
+
|
|
46
59
|
def register_impl_selection(implementation_class)
|
|
47
|
-
|
|
48
|
-
return unless
|
|
60
|
+
context = Execution::ExecutionContext.current
|
|
61
|
+
return unless context
|
|
49
62
|
|
|
50
|
-
|
|
63
|
+
context.notify_section_impl_selected(self.class, implementation_class)
|
|
51
64
|
end
|
|
52
65
|
|
|
53
66
|
# @param implementation_class [Class] The implementation task class
|
data/lib/taski/task.rb
CHANGED
|
@@ -5,8 +5,34 @@ require_relative "execution/registry"
|
|
|
5
5
|
require_relative "execution/task_wrapper"
|
|
6
6
|
|
|
7
7
|
module Taski
|
|
8
|
+
# Base class for all tasks in the Taski framework.
|
|
9
|
+
# Tasks define units of work with dependencies and exported values.
|
|
10
|
+
#
|
|
11
|
+
# @example Defining a simple task
|
|
12
|
+
# class MyTask < Taski::Task
|
|
13
|
+
# exports :result
|
|
14
|
+
#
|
|
15
|
+
# def run
|
|
16
|
+
# @result = "completed"
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
8
19
|
class Task
|
|
9
20
|
class << self
|
|
21
|
+
##
|
|
22
|
+
# Callback invoked when a subclass is created.
|
|
23
|
+
# Automatically creates a task-specific Error class for each subclass.
|
|
24
|
+
# @param subclass [Class] The newly created subclass.
|
|
25
|
+
def inherited(subclass)
|
|
26
|
+
super
|
|
27
|
+
# Create TaskClass::Error that inherits from Taski::TaskError
|
|
28
|
+
error_class = Class.new(Taski::TaskError)
|
|
29
|
+
subclass.const_set(:Error, error_class)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# Declares exported methods that will be accessible after task execution.
|
|
34
|
+
# Creates instance reader and class accessor methods for each export.
|
|
35
|
+
# @param export_methods [Array<Symbol>] The method names to export.
|
|
10
36
|
def exports(*export_methods)
|
|
11
37
|
@exported_methods = export_methods
|
|
12
38
|
|
|
@@ -16,75 +42,128 @@ module Taski
|
|
|
16
42
|
end
|
|
17
43
|
end
|
|
18
44
|
|
|
45
|
+
##
|
|
46
|
+
# Returns the list of exported method names.
|
|
47
|
+
# @return [Array<Symbol>] The exported method names.
|
|
19
48
|
def exported_methods
|
|
20
49
|
@exported_methods ||= []
|
|
21
50
|
end
|
|
22
51
|
|
|
23
|
-
|
|
52
|
+
##
|
|
53
|
+
# Creates a fresh TaskWrapper instance for re-execution support.
|
|
24
54
|
# Use class methods (e.g., MyTask.result) for cached single execution.
|
|
55
|
+
# @return [Execution::TaskWrapper] A new wrapper for this task.
|
|
25
56
|
def new
|
|
26
|
-
|
|
27
|
-
task_instance = allocate
|
|
28
|
-
task_instance.send(:initialize)
|
|
29
|
-
wrapper = Execution::TaskWrapper.new(
|
|
30
|
-
task_instance,
|
|
31
|
-
registry: fresh_registry
|
|
32
|
-
)
|
|
33
|
-
# Pre-register to prevent Executor from creating a duplicate wrapper
|
|
34
|
-
fresh_registry.register(self, wrapper)
|
|
35
|
-
wrapper
|
|
57
|
+
fresh_wrapper
|
|
36
58
|
end
|
|
37
59
|
|
|
60
|
+
##
|
|
61
|
+
# Returns cached static dependencies for this task class.
|
|
62
|
+
# Dependencies are analyzed from the run method body using static analysis.
|
|
63
|
+
# @return [Set<Class>] The set of task classes this task depends on.
|
|
38
64
|
def cached_dependencies
|
|
39
65
|
@dependencies_cache ||= StaticAnalysis::Analyzer.analyze(self)
|
|
40
66
|
end
|
|
41
67
|
|
|
68
|
+
##
|
|
69
|
+
# Clears the cached dependency analysis.
|
|
70
|
+
# Useful when task code has changed and dependencies need to be re-analyzed.
|
|
42
71
|
def clear_dependency_cache
|
|
43
72
|
@dependencies_cache = nil
|
|
44
73
|
end
|
|
45
74
|
|
|
46
|
-
|
|
47
|
-
|
|
75
|
+
##
|
|
76
|
+
# Executes the task and all its dependencies.
|
|
77
|
+
# Creates a fresh registry each time for independent execution.
|
|
78
|
+
# @param args [Hash] User-defined arguments accessible via Taski.args.
|
|
79
|
+
# @param workers [Integer, nil] Number of worker threads for parallel execution.
|
|
80
|
+
# Must be a positive integer or nil.
|
|
81
|
+
# Use workers: 1 for sequential execution (useful for debugging).
|
|
82
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
83
|
+
# @return [Object] The result of task execution.
|
|
84
|
+
def run(args: {}, workers: nil)
|
|
85
|
+
validate_workers!(workers)
|
|
86
|
+
Taski.start_args(options: args.merge(_workers: workers), root_task: self)
|
|
48
87
|
validate_no_circular_dependencies!
|
|
49
|
-
|
|
88
|
+
fresh_wrapper.run
|
|
89
|
+
ensure
|
|
90
|
+
Taski.reset_args!
|
|
50
91
|
end
|
|
51
92
|
|
|
52
|
-
|
|
53
|
-
|
|
93
|
+
##
|
|
94
|
+
# Executes the clean phase for the task and all its dependencies.
|
|
95
|
+
# Clean is executed in reverse dependency order.
|
|
96
|
+
# Creates a fresh registry each time for independent execution.
|
|
97
|
+
# @param args [Hash] User-defined arguments accessible via Taski.args.
|
|
98
|
+
# @param workers [Integer, nil] Number of worker threads for parallel execution.
|
|
99
|
+
# Must be a positive integer or nil.
|
|
100
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
101
|
+
def clean(args: {}, workers: nil)
|
|
102
|
+
validate_workers!(workers)
|
|
103
|
+
Taski.start_args(options: args.merge(_workers: workers), root_task: self)
|
|
54
104
|
validate_no_circular_dependencies!
|
|
55
|
-
|
|
105
|
+
fresh_wrapper.clean
|
|
106
|
+
ensure
|
|
107
|
+
Taski.reset_args!
|
|
56
108
|
end
|
|
57
109
|
|
|
58
|
-
|
|
59
|
-
|
|
110
|
+
##
|
|
111
|
+
# Execute run followed by clean in a single operation.
|
|
112
|
+
# If run fails, clean is still executed for resource release.
|
|
113
|
+
# Creates a fresh registry for both operations to share.
|
|
114
|
+
#
|
|
115
|
+
# @param args [Hash] User-defined arguments accessible via Taski.args.
|
|
116
|
+
# @param workers [Integer, nil] Number of worker threads for parallel execution.
|
|
117
|
+
# Must be a positive integer or nil.
|
|
118
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
119
|
+
# @return [Object] The result of task execution
|
|
120
|
+
def run_and_clean(args: {}, workers: nil)
|
|
121
|
+
validate_workers!(workers)
|
|
122
|
+
Taski.start_args(options: args.merge(_workers: workers), root_task: self)
|
|
123
|
+
validate_no_circular_dependencies!
|
|
124
|
+
fresh_wrapper.run_and_clean
|
|
125
|
+
ensure
|
|
126
|
+
Taski.reset_args!
|
|
60
127
|
end
|
|
61
128
|
|
|
129
|
+
##
|
|
130
|
+
# Resets the task state and progress display.
|
|
131
|
+
# Useful for testing or re-running tasks from scratch.
|
|
62
132
|
def reset!
|
|
63
|
-
|
|
64
|
-
Taski.
|
|
65
|
-
Taski.reset_context!
|
|
133
|
+
Taski.reset_args!
|
|
134
|
+
Taski.reset_progress_display!
|
|
66
135
|
@circular_dependency_checked = false
|
|
67
136
|
end
|
|
68
137
|
|
|
138
|
+
##
|
|
139
|
+
# Renders a static tree representation of the task dependencies.
|
|
140
|
+
# @return [String] The rendered tree string.
|
|
69
141
|
def tree
|
|
70
142
|
Execution::TreeProgressDisplay.render_static_tree(self)
|
|
71
143
|
end
|
|
72
144
|
|
|
73
145
|
private
|
|
74
146
|
|
|
75
|
-
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
147
|
+
##
|
|
148
|
+
# Creates a fresh TaskWrapper with its own registry.
|
|
149
|
+
# Used for class method execution (Task.run) where each call is independent.
|
|
150
|
+
# @return [Execution::TaskWrapper] A new wrapper with fresh registry.
|
|
151
|
+
def fresh_wrapper
|
|
152
|
+
fresh_registry = Execution::Registry.new
|
|
153
|
+
task_instance = allocate
|
|
154
|
+
task_instance.__send__(:initialize)
|
|
155
|
+
wrapper = Execution::TaskWrapper.new(
|
|
156
|
+
task_instance,
|
|
157
|
+
registry: fresh_registry,
|
|
158
|
+
execution_context: Execution::ExecutionContext.current
|
|
159
|
+
)
|
|
160
|
+
fresh_registry.register(self, wrapper)
|
|
161
|
+
wrapper
|
|
86
162
|
end
|
|
87
163
|
|
|
164
|
+
##
|
|
165
|
+
# Defines an instance reader method for an exported value.
|
|
166
|
+
# @param method [Symbol] The method name to define.
|
|
88
167
|
def define_instance_reader(method)
|
|
89
168
|
undef_method(method) if method_defined?(method)
|
|
90
169
|
|
|
@@ -94,16 +173,48 @@ module Taski
|
|
|
94
173
|
end
|
|
95
174
|
end
|
|
96
175
|
|
|
176
|
+
##
|
|
177
|
+
# Defines a class accessor method for an exported value.
|
|
178
|
+
# When called inside an execution, returns cached value from registry.
|
|
179
|
+
# When called outside execution, creates fresh execution.
|
|
180
|
+
# @param method [Symbol] The method name to define.
|
|
97
181
|
def define_class_accessor(method)
|
|
98
182
|
singleton_class.undef_method(method) if singleton_class.method_defined?(method)
|
|
99
183
|
|
|
100
184
|
define_singleton_method(method) do
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
185
|
+
# Check if running inside an execution with a registry
|
|
186
|
+
registry = Taski.current_registry
|
|
187
|
+
if registry
|
|
188
|
+
# Inside execution - get or create wrapper in current registry
|
|
189
|
+
# This handles both pre-registered dependencies and dynamic ones (like Section impl)
|
|
190
|
+
wrapper = registry.get_or_create(self) do
|
|
191
|
+
task_instance = allocate
|
|
192
|
+
task_instance.__send__(:initialize)
|
|
193
|
+
Execution::TaskWrapper.new(
|
|
194
|
+
task_instance,
|
|
195
|
+
registry: registry,
|
|
196
|
+
execution_context: Execution::ExecutionContext.current
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
wrapper.get_exported_value(method)
|
|
200
|
+
else
|
|
201
|
+
# Outside execution - fresh execution (top-level call)
|
|
202
|
+
args_was_nil = Taski.args.nil?
|
|
203
|
+
begin
|
|
204
|
+
Taski.start_args(options: {}, root_task: self) if args_was_nil
|
|
205
|
+
validate_no_circular_dependencies!
|
|
206
|
+
fresh_wrapper.get_exported_value(method)
|
|
207
|
+
ensure
|
|
208
|
+
# Only reset if we set args
|
|
209
|
+
Taski.reset_args! if args_was_nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
104
212
|
end
|
|
105
213
|
end
|
|
106
214
|
|
|
215
|
+
##
|
|
216
|
+
# Validates that no circular dependencies exist in the task graph.
|
|
217
|
+
# @raise [Taski::CircularDependencyError] If circular dependencies are detected.
|
|
107
218
|
def validate_no_circular_dependencies!
|
|
108
219
|
return if @circular_dependency_checked
|
|
109
220
|
|
|
@@ -116,15 +227,90 @@ module Taski
|
|
|
116
227
|
|
|
117
228
|
@circular_dependency_checked = true
|
|
118
229
|
end
|
|
230
|
+
|
|
231
|
+
##
|
|
232
|
+
# Validates the workers parameter.
|
|
233
|
+
# @param workers [Object] The workers parameter to validate.
|
|
234
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
235
|
+
def validate_workers!(workers)
|
|
236
|
+
return if workers.nil?
|
|
237
|
+
|
|
238
|
+
unless workers.is_a?(Integer) && workers >= 1
|
|
239
|
+
raise ArgumentError, "workers must be a positive integer or nil, got: #{workers.inspect}"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
119
242
|
end
|
|
120
243
|
|
|
244
|
+
##
|
|
245
|
+
# Executes the task's main logic.
|
|
246
|
+
# Subclasses must override this method to implement task behavior.
|
|
247
|
+
# @raise [NotImplementedError] If not overridden in a subclass.
|
|
121
248
|
def run
|
|
122
249
|
raise NotImplementedError, "Subclasses must implement the run method"
|
|
123
250
|
end
|
|
124
251
|
|
|
252
|
+
##
|
|
253
|
+
# Cleans up resources after task execution.
|
|
254
|
+
# Override in subclasses to implement cleanup logic.
|
|
125
255
|
def clean
|
|
126
256
|
end
|
|
127
257
|
|
|
258
|
+
# Override system() to capture subprocess output through the pipe-based architecture.
|
|
259
|
+
# Uses Kernel.system with :out option to redirect output to the task's pipe.
|
|
260
|
+
# If user provides :out or :err options, they are respected (no automatic redirection).
|
|
261
|
+
# @param args [Array] Command arguments (shell mode if single string, exec mode if array)
|
|
262
|
+
# @param opts [Hash] Options passed to Kernel.system
|
|
263
|
+
# @return [Boolean, nil] true if command succeeded, false if failed, nil if command not found
|
|
264
|
+
def system(*args, **opts)
|
|
265
|
+
write_io = $stdout.respond_to?(:current_write_io) ? $stdout.current_write_io : nil
|
|
266
|
+
|
|
267
|
+
if write_io && !opts.key?(:out)
|
|
268
|
+
# Redirect subprocess output to the task's pipe (stderr merged into stdout)
|
|
269
|
+
Kernel.system(*args, out: write_io, err: [:child, :out], **opts)
|
|
270
|
+
else
|
|
271
|
+
# No capture active or user provided custom :out, use normal system
|
|
272
|
+
Kernel.system(*args, **opts)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Groups related output within a task for organized progress display.
|
|
277
|
+
# The group name is shown in the progress tree as a child of the task.
|
|
278
|
+
# Groups cannot be nested.
|
|
279
|
+
#
|
|
280
|
+
# @param name [String] The group name to display
|
|
281
|
+
# @yield The block to execute within the group
|
|
282
|
+
# @return [Object] The result of the block
|
|
283
|
+
# @raise Re-raises any exception from the block after marking group as failed
|
|
284
|
+
#
|
|
285
|
+
# @example
|
|
286
|
+
# def run
|
|
287
|
+
# group("Preparing") do
|
|
288
|
+
# puts "Checking dependencies..."
|
|
289
|
+
# puts "Validating config..."
|
|
290
|
+
# end
|
|
291
|
+
# group("Deploying") do
|
|
292
|
+
# puts "Uploading files..."
|
|
293
|
+
# end
|
|
294
|
+
# end
|
|
295
|
+
def group(name)
|
|
296
|
+
context = Execution::ExecutionContext.current
|
|
297
|
+
context&.notify_group_started(self.class, name)
|
|
298
|
+
start_time = Time.now
|
|
299
|
+
|
|
300
|
+
begin
|
|
301
|
+
result = yield
|
|
302
|
+
duration_ms = ((Time.now - start_time) * 1000).round(0)
|
|
303
|
+
context&.notify_group_completed(self.class, name, duration: duration_ms)
|
|
304
|
+
result
|
|
305
|
+
rescue => e
|
|
306
|
+
duration_ms = ((Time.now - start_time) * 1000).round(0)
|
|
307
|
+
context&.notify_group_completed(self.class, name, duration: duration_ms, error: e)
|
|
308
|
+
raise
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
##
|
|
313
|
+
# Resets the instance's exported values to nil.
|
|
128
314
|
def reset!
|
|
129
315
|
self.class.exported_methods.each do |method|
|
|
130
316
|
instance_variable_set("@#{method}", nil)
|
data/lib/taski/version.rb
CHANGED
data/lib/taski.rb
CHANGED
|
@@ -5,12 +5,13 @@ require_relative "taski/static_analysis/analyzer"
|
|
|
5
5
|
require_relative "taski/static_analysis/visitor"
|
|
6
6
|
require_relative "taski/static_analysis/dependency_graph"
|
|
7
7
|
require_relative "taski/execution/registry"
|
|
8
|
+
require_relative "taski/execution/execution_context"
|
|
8
9
|
require_relative "taski/execution/task_wrapper"
|
|
10
|
+
require_relative "taski/execution/scheduler"
|
|
11
|
+
require_relative "taski/execution/worker_pool"
|
|
9
12
|
require_relative "taski/execution/executor"
|
|
10
13
|
require_relative "taski/execution/tree_progress_display"
|
|
11
|
-
require_relative "taski/
|
|
12
|
-
require_relative "taski/task"
|
|
13
|
-
require_relative "taski/section"
|
|
14
|
+
require_relative "taski/args"
|
|
14
15
|
|
|
15
16
|
module Taski
|
|
16
17
|
class TaskAbortException < StandardError
|
|
@@ -28,41 +29,125 @@ module Taski
|
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
# Represents a single task failure with its context
|
|
33
|
+
class TaskFailure
|
|
34
|
+
attr_reader :task_class, :error
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
# @param task_class [Class] The task class that failed
|
|
37
|
+
# @param error [Exception] The exception that was raised
|
|
38
|
+
def initialize(task_class:, error:)
|
|
39
|
+
@task_class = task_class
|
|
40
|
+
@error = error
|
|
41
|
+
end
|
|
37
42
|
end
|
|
38
43
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
# Mixin for exception classes to enable transparent rescue matching with AggregateError.
|
|
45
|
+
# When extended by an exception class, `rescue ThatError` will also match
|
|
46
|
+
# an AggregateError that contains ThatError.
|
|
47
|
+
#
|
|
48
|
+
# @note TaskError and all TaskClass::Error classes already extend this module.
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# begin
|
|
52
|
+
# MyTask.value # raises AggregateError containing MyTask::Error
|
|
53
|
+
# rescue MyTask::Error => e
|
|
54
|
+
# puts "MyTask failed: #{e.message}"
|
|
55
|
+
# end
|
|
56
|
+
module AggregateAware
|
|
57
|
+
def ===(other)
|
|
58
|
+
return super unless other.is_a?(Taski::AggregateError)
|
|
59
|
+
|
|
60
|
+
other.includes?(self)
|
|
45
61
|
end
|
|
46
62
|
end
|
|
47
63
|
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
# Base class for task-specific error wrappers.
|
|
65
|
+
# Each Task subclass automatically gets a ::Error class that inherits from this.
|
|
66
|
+
# This allows rescuing errors by task class: rescue MyTask::Error => e
|
|
67
|
+
#
|
|
68
|
+
# @example Rescuing task-specific errors
|
|
69
|
+
# begin
|
|
70
|
+
# MyTask.value
|
|
71
|
+
# rescue MyTask::Error => e
|
|
72
|
+
# puts "MyTask failed: #{e.message}"
|
|
73
|
+
# end
|
|
74
|
+
class TaskError < StandardError
|
|
75
|
+
extend AggregateAware
|
|
76
|
+
|
|
77
|
+
# @return [Exception] The original error that occurred in the task
|
|
78
|
+
attr_reader :cause
|
|
79
|
+
|
|
80
|
+
# @return [Class] The task class where the error occurred
|
|
81
|
+
attr_reader :task_class
|
|
82
|
+
|
|
83
|
+
# @param cause [Exception] The original error
|
|
84
|
+
# @param task_class [Class] The task class where the error occurred
|
|
85
|
+
def initialize(cause, task_class:)
|
|
86
|
+
@cause = cause
|
|
87
|
+
@task_class = task_class
|
|
88
|
+
super(cause.message)
|
|
89
|
+
set_backtrace(cause.backtrace)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Raised when multiple tasks fail during parallel execution
|
|
94
|
+
class AggregateError < StandardError
|
|
95
|
+
attr_reader :errors
|
|
96
|
+
|
|
97
|
+
# @param errors [Array<TaskFailure>] List of task failures
|
|
98
|
+
def initialize(errors)
|
|
99
|
+
@errors = errors
|
|
100
|
+
super(build_message)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Returns the first error for compatibility with exception chaining
|
|
104
|
+
# @return [Exception, nil] The first error or nil if no errors
|
|
105
|
+
def cause
|
|
106
|
+
errors.first&.error
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Check if this aggregate contains an error of the given type
|
|
110
|
+
# @param exception_class [Class] The exception class to check for
|
|
111
|
+
# @return [Boolean] true if any contained error is of the given type
|
|
112
|
+
def includes?(exception_class)
|
|
113
|
+
errors.any? { |f| f.error.is_a?(exception_class) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def build_message
|
|
119
|
+
task_word = (errors.size == 1) ? "task" : "tasks"
|
|
120
|
+
"#{errors.size} #{task_word} failed:\n" +
|
|
121
|
+
errors.map { |f| " - #{f.task_class.name}: #{f.error.message}" }.join("\n")
|
|
122
|
+
end
|
|
52
123
|
end
|
|
53
124
|
|
|
54
|
-
|
|
55
|
-
|
|
125
|
+
@args_monitor = Monitor.new
|
|
126
|
+
|
|
127
|
+
# Get the current runtime arguments
|
|
128
|
+
# @return [Args, nil] The current args or nil if no task is running
|
|
129
|
+
def self.args
|
|
130
|
+
@args_monitor.synchronize { @args }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Start new runtime arguments (internal use only)
|
|
134
|
+
# @api private
|
|
135
|
+
def self.start_args(options:, root_task:)
|
|
136
|
+
@args_monitor.synchronize do
|
|
137
|
+
return if @args
|
|
138
|
+
@args = Args.new(options: options, root_task: root_task)
|
|
139
|
+
end
|
|
56
140
|
end
|
|
57
141
|
|
|
58
|
-
|
|
59
|
-
|
|
142
|
+
# Reset the runtime arguments (internal use only)
|
|
143
|
+
# @api private
|
|
144
|
+
def self.reset_args!
|
|
145
|
+
@args_monitor.synchronize { @args = nil }
|
|
60
146
|
end
|
|
61
147
|
|
|
62
148
|
# Progress display is enabled by default (tree-style).
|
|
63
149
|
# Environment variables:
|
|
64
150
|
# - TASKI_PROGRESS_DISABLE=1: Disable progress display entirely
|
|
65
|
-
# - TASKI_FORCE_PROGRESS=1: Force enable even without TTY (for testing)
|
|
66
151
|
def self.progress_display
|
|
67
152
|
return nil if progress_disabled?
|
|
68
153
|
@progress_display ||= Execution::TreeProgressDisplay.new
|
|
@@ -76,4 +161,34 @@ module Taski
|
|
|
76
161
|
@progress_display&.stop
|
|
77
162
|
@progress_display = nil
|
|
78
163
|
end
|
|
164
|
+
|
|
165
|
+
# Get the worker count from the current args (set via Task.run(workers: n))
|
|
166
|
+
# @return [Integer, nil] The worker count or nil to use WorkerPool default
|
|
167
|
+
# @api private
|
|
168
|
+
def self.args_worker_count
|
|
169
|
+
args&.fetch(:_workers, nil)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Get the current registry for this thread (used during dependency resolution)
|
|
173
|
+
# @return [Execution::Registry, nil] The current registry or nil
|
|
174
|
+
# @api private
|
|
175
|
+
def self.current_registry
|
|
176
|
+
Thread.current[:taski_current_registry]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Set the current registry for this thread (internal use only)
|
|
180
|
+
# @api private
|
|
181
|
+
def self.set_current_registry(registry)
|
|
182
|
+
Thread.current[:taski_current_registry] = registry
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Clear the current registry for this thread (internal use only)
|
|
186
|
+
# @api private
|
|
187
|
+
def self.clear_current_registry
|
|
188
|
+
Thread.current[:taski_current_registry] = nil
|
|
189
|
+
end
|
|
79
190
|
end
|
|
191
|
+
|
|
192
|
+
# Load Task and Section after Taski module is defined (they depend on TaskError)
|
|
193
|
+
require_relative "taski/task"
|
|
194
|
+
require_relative "taski/section"
|