taski 0.5.0 → 0.7.1
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 +168 -21
- data/docs/GUIDE.md +394 -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/data_pipeline_demo.rb +1 -1
- data/examples/group_demo.rb +113 -0
- data/examples/large_tree_demo.rb +519 -0
- data/examples/reexecution_demo.rb +93 -80
- data/examples/simple_progress_demo.rb +80 -0
- data/examples/system_call_demo.rb +56 -0
- data/lib/taski/{context.rb → args.rb} +3 -3
- data/lib/taski/execution/base_progress_display.rb +348 -0
- data/lib/taski/execution/execution_context.rb +383 -0
- data/lib/taski/execution/executor.rb +405 -134
- data/lib/taski/execution/plain_progress_display.rb +76 -0
- data/lib/taski/execution/registry.rb +17 -1
- data/lib/taski/execution/scheduler.rb +308 -0
- data/lib/taski/execution/simple_progress_display.rb +173 -0
- data/lib/taski/execution/task_output_pipe.rb +42 -0
- data/lib/taski/execution/task_output_router.rb +287 -0
- data/lib/taski/execution/task_wrapper.rb +215 -52
- data/lib/taski/execution/tree_progress_display.rb +349 -212
- data/lib/taski/execution/worker_pool.rb +104 -0
- data/lib/taski/section.rb +16 -3
- data/lib/taski/static_analysis/visitor.rb +3 -0
- data/lib/taski/task.rb +218 -37
- data/lib/taski/test_helper/errors.rb +13 -0
- data/lib/taski/test_helper/minitest.rb +38 -0
- data/lib/taski/test_helper/mock_registry.rb +51 -0
- data/lib/taski/test_helper/mock_wrapper.rb +46 -0
- data/lib/taski/test_helper/rspec.rb +38 -0
- data/lib/taski/test_helper.rb +214 -0
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +211 -23
- data/sig/taski.rbs +207 -27
- metadata +25 -8
- 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,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require_relative "task_output_pipe"
|
|
5
|
+
|
|
6
|
+
module Taski
|
|
7
|
+
module Execution
|
|
8
|
+
# Central coordinator that manages all task pipes and polling.
|
|
9
|
+
# Also acts as an IO proxy for $stdout, routing writes to the appropriate pipe
|
|
10
|
+
# based on the current thread.
|
|
11
|
+
#
|
|
12
|
+
# Architecture:
|
|
13
|
+
# - Each task gets a dedicated IO pipe for output capture
|
|
14
|
+
# - Writes are routed to the appropriate pipe based on Thread.current
|
|
15
|
+
# - A reader thread polls all pipes using IO.select for efficiency
|
|
16
|
+
# - When no pipe is registered for a thread, output goes to original stdout
|
|
17
|
+
class TaskOutputRouter
|
|
18
|
+
include MonitorMixin
|
|
19
|
+
|
|
20
|
+
POLL_TIMEOUT = 0.05 # 50ms timeout for IO.select
|
|
21
|
+
POLL_INTERVAL = 0.1 # 100ms between polls (matches TreeProgressDisplay)
|
|
22
|
+
READ_BUFFER_SIZE = 4096
|
|
23
|
+
MAX_RECENT_LINES = 30 # Maximum number of recent lines to keep per task
|
|
24
|
+
|
|
25
|
+
def initialize(original_stdout)
|
|
26
|
+
super()
|
|
27
|
+
@original = original_stdout
|
|
28
|
+
@pipes = {} # task_class => TaskOutputPipe
|
|
29
|
+
@thread_map = {} # Thread => task_class
|
|
30
|
+
@recent_lines = {} # task_class => Array<String>
|
|
31
|
+
@poll_thread = nil
|
|
32
|
+
@polling = false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Start the background polling thread
|
|
36
|
+
# This ensures pipes are drained even when display doesn't poll
|
|
37
|
+
def start_polling
|
|
38
|
+
synchronize do
|
|
39
|
+
return if @polling
|
|
40
|
+
@polling = true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@poll_thread = Thread.new do
|
|
44
|
+
loop do
|
|
45
|
+
break unless @polling
|
|
46
|
+
poll
|
|
47
|
+
sleep POLL_INTERVAL
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Stop the background polling thread
|
|
53
|
+
def stop_polling
|
|
54
|
+
synchronize { @polling = false }
|
|
55
|
+
@poll_thread&.join(0.5)
|
|
56
|
+
@poll_thread = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Start capturing output for the current thread
|
|
60
|
+
# Creates a new pipe for the task and registers the thread mapping
|
|
61
|
+
# @param task_class [Class] The task class being executed
|
|
62
|
+
def start_capture(task_class)
|
|
63
|
+
synchronize do
|
|
64
|
+
pipe = TaskOutputPipe.new(task_class)
|
|
65
|
+
@pipes[task_class] = pipe
|
|
66
|
+
@thread_map[Thread.current] = task_class
|
|
67
|
+
debug_log("Started capture for #{task_class} on thread #{Thread.current.object_id}")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Stop capturing output for the current thread
|
|
72
|
+
# Closes the write end of the pipe and drains remaining data
|
|
73
|
+
def stop_capture
|
|
74
|
+
task_class = nil
|
|
75
|
+
pipe = nil
|
|
76
|
+
|
|
77
|
+
synchronize do
|
|
78
|
+
task_class = @thread_map.delete(Thread.current)
|
|
79
|
+
unless task_class
|
|
80
|
+
debug_log("Warning: stop_capture called for unregistered thread #{Thread.current.object_id}")
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
pipe = @pipes[task_class]
|
|
85
|
+
pipe&.close_write
|
|
86
|
+
debug_log("Stopped capture for #{task_class} on thread #{Thread.current.object_id}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Drain any remaining data from the pipe after closing write end
|
|
90
|
+
drain_pipe(pipe) if pipe
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Drain all remaining data from a pipe
|
|
94
|
+
# Called after close_write to ensure all output is captured
|
|
95
|
+
def drain_pipe(pipe)
|
|
96
|
+
return if pipe.read_closed?
|
|
97
|
+
|
|
98
|
+
loop do
|
|
99
|
+
data = pipe.read_io.read_nonblock(READ_BUFFER_SIZE)
|
|
100
|
+
debug_log("drain_pipe read #{data.bytesize} bytes for #{pipe.task_class}")
|
|
101
|
+
store_output_lines(pipe.task_class, data)
|
|
102
|
+
rescue IO::WaitReadable
|
|
103
|
+
# Check if there's more data with a very short timeout
|
|
104
|
+
ready, = IO.select([pipe.read_io], nil, nil, 0.001)
|
|
105
|
+
break unless ready
|
|
106
|
+
rescue IOError
|
|
107
|
+
# All data has been read (EOFError) or pipe was closed by another thread
|
|
108
|
+
synchronize { pipe.close_read }
|
|
109
|
+
break
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Poll all open pipes for available data
|
|
114
|
+
# Should be called periodically from the display thread
|
|
115
|
+
def poll
|
|
116
|
+
readable_pipes = synchronize do
|
|
117
|
+
@pipes.values.reject { |p| p.read_closed? }.map(&:read_io)
|
|
118
|
+
end
|
|
119
|
+
return if readable_pipes.empty?
|
|
120
|
+
|
|
121
|
+
# Handle race condition: pipe may be closed between check and select
|
|
122
|
+
ready, = IO.select(readable_pipes, nil, nil, POLL_TIMEOUT)
|
|
123
|
+
return unless ready
|
|
124
|
+
|
|
125
|
+
ready.each do |read_io|
|
|
126
|
+
pipe = synchronize { @pipes.values.find { |p| p.read_io == read_io } }
|
|
127
|
+
next unless pipe
|
|
128
|
+
|
|
129
|
+
read_from_pipe(pipe)
|
|
130
|
+
end
|
|
131
|
+
rescue IOError
|
|
132
|
+
# Pipe was closed by another thread (drain_pipe), ignore
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Get the last output line for a task
|
|
136
|
+
# @param task_class [Class] The task class
|
|
137
|
+
# @return [String, nil] The last output line
|
|
138
|
+
def last_line_for(task_class)
|
|
139
|
+
synchronize { @recent_lines[task_class]&.last }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Get recent output lines for a task (up to MAX_RECENT_LINES)
|
|
143
|
+
# @param task_class [Class] The task class
|
|
144
|
+
# @return [Array<String>] Recent output lines
|
|
145
|
+
def recent_lines_for(task_class)
|
|
146
|
+
synchronize { (@recent_lines[task_class] || []).dup }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Close all pipes and clean up
|
|
150
|
+
def close_all
|
|
151
|
+
synchronize do
|
|
152
|
+
@pipes.each_value(&:close)
|
|
153
|
+
@pipes.clear
|
|
154
|
+
@thread_map.clear
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Check if there are any active (not fully closed) pipes
|
|
159
|
+
# @return [Boolean] true if there are active pipes
|
|
160
|
+
def active?
|
|
161
|
+
synchronize do
|
|
162
|
+
@pipes.values.any? { |p| !p.read_closed? }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# IO interface methods - route to pipe when capturing, otherwise pass through
|
|
167
|
+
|
|
168
|
+
def write(str)
|
|
169
|
+
pipe = current_thread_pipe
|
|
170
|
+
if pipe && !pipe.write_closed?
|
|
171
|
+
begin
|
|
172
|
+
pipe.write_io.write(str)
|
|
173
|
+
rescue IOError
|
|
174
|
+
# Pipe was closed by another thread (e.g., stop_capture), fall back to original
|
|
175
|
+
@original.write(str)
|
|
176
|
+
end
|
|
177
|
+
else
|
|
178
|
+
@original.write(str)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def puts(*args)
|
|
183
|
+
if args.empty?
|
|
184
|
+
write("\n")
|
|
185
|
+
else
|
|
186
|
+
args.each do |arg|
|
|
187
|
+
str = arg.to_s
|
|
188
|
+
write(str)
|
|
189
|
+
write("\n") unless str.end_with?("\n")
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def print(*args)
|
|
196
|
+
args.each { |arg| write(arg.to_s) }
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def <<(str)
|
|
201
|
+
write(str.to_s)
|
|
202
|
+
self
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def flush
|
|
206
|
+
@original.flush
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def tty?
|
|
210
|
+
@original.tty?
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def isatty
|
|
214
|
+
@original.isatty
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def winsize
|
|
218
|
+
@original.winsize
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Get the write IO for the current thread's pipe
|
|
222
|
+
# Used by Task#system to redirect subprocess output directly to the pipe
|
|
223
|
+
# @return [IO, nil] The write IO or nil if not capturing
|
|
224
|
+
def current_write_io
|
|
225
|
+
synchronize do
|
|
226
|
+
task_class = @thread_map[Thread.current]
|
|
227
|
+
return nil unless task_class
|
|
228
|
+
pipe = @pipes[task_class]
|
|
229
|
+
return nil if pipe.nil? || pipe.write_closed?
|
|
230
|
+
pipe.write_io
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Delegate unknown methods to original stdout
|
|
235
|
+
def method_missing(method, ...)
|
|
236
|
+
@original.send(method, ...)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def respond_to_missing?(method, include_private = false)
|
|
240
|
+
@original.respond_to?(method, include_private)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private
|
|
244
|
+
|
|
245
|
+
def current_thread_pipe
|
|
246
|
+
synchronize do
|
|
247
|
+
task_class = @thread_map[Thread.current]
|
|
248
|
+
return nil unless task_class
|
|
249
|
+
@pipes[task_class]
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def read_from_pipe(pipe)
|
|
254
|
+
data = pipe.read_io.read_nonblock(READ_BUFFER_SIZE)
|
|
255
|
+
store_output_lines(pipe.task_class, data)
|
|
256
|
+
rescue IO::WaitReadable
|
|
257
|
+
# No data available yet
|
|
258
|
+
rescue IOError
|
|
259
|
+
# Pipe closed by writer (EOFError) or by another thread, close read end
|
|
260
|
+
synchronize { pipe.close_read }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def store_output_lines(task_class, data)
|
|
264
|
+
return if data.nil? || data.empty?
|
|
265
|
+
|
|
266
|
+
lines = data.lines
|
|
267
|
+
synchronize do
|
|
268
|
+
@recent_lines[task_class] ||= []
|
|
269
|
+
lines.each do |line|
|
|
270
|
+
stripped = line.chomp
|
|
271
|
+
@recent_lines[task_class] << stripped unless stripped.strip.empty?
|
|
272
|
+
end
|
|
273
|
+
# Keep only the last MAX_RECENT_LINES
|
|
274
|
+
if @recent_lines[task_class].size > MAX_RECENT_LINES
|
|
275
|
+
@recent_lines[task_class] = @recent_lines[task_class].last(MAX_RECENT_LINES)
|
|
276
|
+
end
|
|
277
|
+
debug_log("store_output_lines: #{task_class} now has #{@recent_lines[task_class].size} lines")
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def debug_log(message)
|
|
282
|
+
return unless ENV["TASKI_DEBUG"]
|
|
283
|
+
warn "[TaskOutputRouter] #{message}"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -26,24 +26,33 @@ module Taski
|
|
|
26
26
|
# In the Producer-Consumer pattern, TaskWrapper does NOT start threads.
|
|
27
27
|
# The Executor controls all scheduling and execution.
|
|
28
28
|
class TaskWrapper
|
|
29
|
-
attr_reader :task, :result, :error, :timing
|
|
29
|
+
attr_reader :task, :result, :error, :timing, :clean_error
|
|
30
30
|
|
|
31
31
|
STATE_PENDING = :pending
|
|
32
32
|
STATE_RUNNING = :running
|
|
33
33
|
STATE_COMPLETED = :completed
|
|
34
34
|
|
|
35
|
-
|
|
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)
|
|
36
42
|
@task = task
|
|
37
43
|
@registry = registry
|
|
44
|
+
@execution_context = execution_context
|
|
38
45
|
@result = nil
|
|
39
46
|
@clean_result = nil
|
|
40
47
|
@error = nil
|
|
48
|
+
@clean_error = nil
|
|
41
49
|
@monitor = Monitor.new
|
|
42
50
|
@condition = @monitor.new_cond
|
|
43
51
|
@clean_condition = @monitor.new_cond
|
|
44
52
|
@state = STATE_PENDING
|
|
45
53
|
@clean_state = STATE_PENDING
|
|
46
54
|
@timing = nil
|
|
55
|
+
@clean_timing = nil
|
|
47
56
|
end
|
|
48
57
|
|
|
49
58
|
# @return [Symbol] Current state
|
|
@@ -61,28 +70,67 @@ module Taski
|
|
|
61
70
|
state == STATE_COMPLETED
|
|
62
71
|
end
|
|
63
72
|
|
|
73
|
+
# Resets the wrapper state to allow re-execution.
|
|
74
|
+
# Clears all cached results and returns state to pending.
|
|
75
|
+
def reset!
|
|
76
|
+
@monitor.synchronize do
|
|
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
|
|
85
|
+
end
|
|
86
|
+
@task.reset! if @task.respond_to?(:reset!)
|
|
87
|
+
@registry.reset!
|
|
88
|
+
end
|
|
89
|
+
|
|
64
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).
|
|
65
92
|
# @return [Object] The result of task execution
|
|
66
93
|
def run
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
94
|
+
with_args_lifecycle do
|
|
95
|
+
trigger_execution_and_wait
|
|
96
|
+
raise @error if @error # steep:ignore
|
|
97
|
+
@result
|
|
98
|
+
end
|
|
70
99
|
end
|
|
71
100
|
|
|
72
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).
|
|
73
103
|
# @return [Object] The result of cleanup
|
|
74
104
|
def clean
|
|
75
|
-
|
|
76
|
-
|
|
105
|
+
with_args_lifecycle do
|
|
106
|
+
trigger_clean_and_wait
|
|
107
|
+
@clean_result
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
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
|
|
77
122
|
end
|
|
78
123
|
|
|
79
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).
|
|
80
126
|
# @param method_name [Symbol] The name of the exported method
|
|
81
127
|
# @return [Object] The exported value
|
|
82
128
|
def get_exported_value(method_name)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
86
134
|
end
|
|
87
135
|
|
|
88
136
|
# Called by Executor to mark task as running
|
|
@@ -108,7 +156,10 @@ module Taski
|
|
|
108
156
|
end
|
|
109
157
|
|
|
110
158
|
# Called by Executor when task.run raises an error
|
|
111
|
-
|
|
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.
|
|
112
163
|
def mark_failed(error)
|
|
113
164
|
@timing = @timing&.with_end_now
|
|
114
165
|
@monitor.synchronize do
|
|
@@ -119,30 +170,75 @@ module Taski
|
|
|
119
170
|
update_progress(:failed, error: error)
|
|
120
171
|
end
|
|
121
172
|
|
|
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
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
122
186
|
# Called by Executor after clean completes
|
|
123
|
-
|
|
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.
|
|
124
191
|
def mark_clean_completed(result)
|
|
192
|
+
@clean_timing = @clean_timing&.with_end_now
|
|
125
193
|
@monitor.synchronize do
|
|
126
194
|
@clean_result = result
|
|
127
195
|
@clean_state = STATE_COMPLETED
|
|
128
196
|
@clean_condition.broadcast
|
|
129
197
|
end
|
|
198
|
+
update_clean_progress(:clean_completed, duration: @clean_timing&.duration_ms)
|
|
199
|
+
end
|
|
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)
|
|
130
214
|
end
|
|
131
215
|
|
|
132
|
-
|
|
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.
|
|
133
221
|
def wait_for_completion
|
|
134
222
|
@monitor.synchronize do
|
|
135
223
|
@condition.wait_until { @state == STATE_COMPLETED }
|
|
136
224
|
end
|
|
137
225
|
end
|
|
138
226
|
|
|
139
|
-
|
|
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.
|
|
140
230
|
def wait_for_clean_completion
|
|
141
231
|
@monitor.synchronize do
|
|
142
232
|
@clean_condition.wait_until { @clean_state == STATE_COMPLETED }
|
|
143
233
|
end
|
|
144
234
|
end
|
|
145
235
|
|
|
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.
|
|
146
242
|
def method_missing(method_name, *args, &block)
|
|
147
243
|
if @task.class.method_defined?(method_name)
|
|
148
244
|
get_exported_value(method_name)
|
|
@@ -151,22 +247,65 @@ module Taski
|
|
|
151
247
|
end
|
|
152
248
|
end
|
|
153
249
|
|
|
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.
|
|
154
255
|
def respond_to_missing?(method_name, include_private = false)
|
|
155
256
|
@task.class.method_defined?(method_name) || super
|
|
156
257
|
end
|
|
157
258
|
|
|
158
259
|
private
|
|
159
260
|
|
|
160
|
-
|
|
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(&block)
|
|
267
|
+
Taski.with_args(options: {}, root_task: @task.class, &block)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
##
|
|
271
|
+
# Ensures the task is executed if still pending and waits for completion.
|
|
272
|
+
# 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.
|
|
273
|
+
# @raise [Taski::TaskAbortException] If the registry requested an abort before execution begins.
|
|
161
274
|
def trigger_execution_and_wait
|
|
275
|
+
trigger_and_wait(
|
|
276
|
+
state_accessor: -> { @state },
|
|
277
|
+
condition: @condition,
|
|
278
|
+
trigger: ->(ctx) { ctx.trigger_execution(@task.class, registry: @registry) }
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
##
|
|
283
|
+
# Triggers task cleanup through the configured execution mechanism and waits until the cleanup completes.
|
|
284
|
+
#
|
|
285
|
+
# If an ExecutionContext is configured the cleanup is invoked through it; otherwise a fallback executor is used.
|
|
286
|
+
# @raise [Taski::TaskAbortException] if the registry has requested an abort.
|
|
287
|
+
def trigger_clean_and_wait
|
|
288
|
+
trigger_and_wait(
|
|
289
|
+
state_accessor: -> { @clean_state },
|
|
290
|
+
condition: @clean_condition,
|
|
291
|
+
trigger: ->(ctx) { ctx.trigger_clean(@task.class, registry: @registry) }
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Generic trigger-and-wait implementation for both run and clean phases.
|
|
296
|
+
# @param state_accessor [Proc] Lambda returning the current state
|
|
297
|
+
# @param condition [MonitorMixin::ConditionVariable] Condition to wait on
|
|
298
|
+
# @param trigger [Proc] Lambda receiving context to trigger execution
|
|
299
|
+
# @raise [Taski::TaskAbortException] If the registry requested an abort
|
|
300
|
+
def trigger_and_wait(state_accessor:, condition:, trigger:)
|
|
162
301
|
should_execute = false
|
|
163
302
|
@monitor.synchronize do
|
|
164
|
-
case
|
|
303
|
+
case state_accessor.call
|
|
165
304
|
when STATE_PENDING
|
|
166
305
|
check_abort!
|
|
167
306
|
should_execute = true
|
|
168
307
|
when STATE_RUNNING
|
|
169
|
-
|
|
308
|
+
condition.wait_until { state_accessor.call == STATE_COMPLETED }
|
|
170
309
|
when STATE_COMPLETED
|
|
171
310
|
# Already done
|
|
172
311
|
end
|
|
@@ -174,57 +313,81 @@ module Taski
|
|
|
174
313
|
|
|
175
314
|
if should_execute
|
|
176
315
|
# Execute outside the lock to avoid deadlock
|
|
177
|
-
|
|
178
|
-
|
|
316
|
+
context = ensure_execution_context
|
|
317
|
+
trigger.call(context)
|
|
318
|
+
# After execution returns, the task is completed
|
|
179
319
|
end
|
|
180
320
|
end
|
|
181
321
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
322
|
+
##
|
|
323
|
+
# Checks whether the registry has requested an abort and raises an exception to stop starting new tasks.
|
|
324
|
+
# @raise [Taski::TaskAbortException] if `@registry.abort_requested?` is true — raised with the message "Execution aborted - no new tasks will start".
|
|
325
|
+
def check_abort!
|
|
326
|
+
if @registry.abort_requested?
|
|
327
|
+
raise Taski::TaskAbortException, "Execution aborted - no new tasks will start"
|
|
197
328
|
end
|
|
198
329
|
end
|
|
199
330
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
331
|
+
##
|
|
332
|
+
# Ensures an execution context exists for this wrapper.
|
|
333
|
+
# Returns the existing context if set, otherwise creates a shared context.
|
|
334
|
+
# This enables run and clean phases to share state like runtime dependencies.
|
|
335
|
+
# @return [ExecutionContext] The execution context for this wrapper
|
|
336
|
+
def ensure_execution_context
|
|
337
|
+
@execution_context ||= create_shared_context
|
|
206
338
|
end
|
|
207
339
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
340
|
+
##
|
|
341
|
+
# Creates a shared execution context with proper triggers for run and clean.
|
|
342
|
+
# The context is configured to reuse itself when triggering nested executions.
|
|
343
|
+
# @return [ExecutionContext] A new execution context
|
|
344
|
+
def create_shared_context
|
|
345
|
+
context = ExecutionContext.new
|
|
346
|
+
progress = Taski.progress_display
|
|
347
|
+
context.add_observer(progress) if progress
|
|
211
348
|
|
|
212
|
-
|
|
213
|
-
|
|
349
|
+
# Set triggers to reuse this context for nested executions
|
|
350
|
+
context.execution_trigger = ->(task_class, registry) do
|
|
351
|
+
Executor.execute(task_class, registry: registry, execution_context: context)
|
|
214
352
|
end
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def check_abort!
|
|
219
|
-
if @registry.abort_requested?
|
|
220
|
-
raise Taski::TaskAbortException, "Execution aborted - no new tasks will start"
|
|
353
|
+
context.clean_trigger = ->(task_class, registry) do
|
|
354
|
+
Executor.execute_clean(task_class, registry: registry, execution_context: context)
|
|
221
355
|
end
|
|
356
|
+
|
|
357
|
+
context
|
|
222
358
|
end
|
|
223
359
|
|
|
360
|
+
##
|
|
361
|
+
# Notifies the execution context of task completion or failure.
|
|
362
|
+
# Falls back to getting the current context if not set during initialization.
|
|
363
|
+
# @param state [Symbol] The completion state (unused, kept for API consistency).
|
|
364
|
+
# @param duration [Numeric, nil] The execution duration in milliseconds.
|
|
365
|
+
# @param error [Exception, nil] The error if the task failed.
|
|
224
366
|
def update_progress(state, duration: nil, error: nil)
|
|
225
|
-
|
|
367
|
+
# Defensive fallback: try to get current context if not set during initialization
|
|
368
|
+
@execution_context ||= ExecutionContext.current
|
|
369
|
+
return unless @execution_context
|
|
370
|
+
|
|
371
|
+
@execution_context.notify_task_completed(@task.class, duration: duration, error: error)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
##
|
|
375
|
+
# Notifies the execution context of clean completion or failure.
|
|
376
|
+
# Falls back to getting the current context if not set during initialization.
|
|
377
|
+
# @param state [Symbol] The clean state (unused, kept for API consistency).
|
|
378
|
+
# @param duration [Numeric, nil] The clean duration in milliseconds.
|
|
379
|
+
# @param error [Exception, nil] The error if the clean failed.
|
|
380
|
+
def update_clean_progress(state, duration: nil, error: nil)
|
|
381
|
+
# Defensive fallback: try to get current context if not set during initialization
|
|
382
|
+
@execution_context ||= ExecutionContext.current
|
|
383
|
+
return unless @execution_context
|
|
384
|
+
|
|
385
|
+
@execution_context.notify_clean_completed(@task.class, duration: duration, error: error)
|
|
226
386
|
end
|
|
227
387
|
|
|
388
|
+
##
|
|
389
|
+
# Outputs a debug message if TASKI_DEBUG environment variable is set.
|
|
390
|
+
# @param message [String] The debug message to output.
|
|
228
391
|
def debug_log(message)
|
|
229
392
|
return unless ENV["TASKI_DEBUG"]
|
|
230
393
|
puts "[TaskWrapper] #{message}"
|