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.
@@ -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
- def initialize(task, registry:)
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
- trigger_execution_and_wait
68
- raise @error if @error # steep:ignore
69
- @result
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
- trigger_clean_and_wait
76
- @clean_result
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
- trigger_execution_and_wait
84
- raise @error if @error # steep:ignore
85
- @task.public_send(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
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
- # @param error [Exception] The error that occurred
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
- # @param result [Object] The result of cleanup
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)
130
199
  end
131
200
 
132
- # Wait until task is completed
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.
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
- # Wait until clean is completed
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,13 +247,34 @@ 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
- # Trigger execution via Executor and wait for completion
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
272
+ end
273
+
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.
161
278
  def trigger_execution_and_wait
162
279
  should_execute = false
163
280
  @monitor.synchronize do
@@ -174,57 +291,110 @@ module Taski
174
291
 
175
292
  if should_execute
176
293
  # Execute outside the lock to avoid deadlock
177
- Executor.execute(@task.class, registry: @registry)
178
- # After Executor.execute returns, the task is completed
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
179
298
  end
180
299
  end
181
300
 
182
- # Trigger clean execution and wait for completion
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.
183
306
  def trigger_clean_and_wait
307
+ should_execute = false
184
308
  @monitor.synchronize do
185
309
  case @clean_state
186
310
  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 }
311
+ check_abort!
312
+ should_execute = true
192
313
  when STATE_RUNNING
193
314
  @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
194
315
  when STATE_COMPLETED
195
316
  # Already done
196
317
  end
197
318
  end
198
- end
199
319
 
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.")
206
- end
207
-
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 }
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
214
326
  end
215
- wait_threads.each(&:join)
216
327
  end
217
328
 
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".
218
332
  def check_abort!
219
333
  if @registry.abort_requested?
220
334
  raise Taski::TaskAbortException, "Execution aborted - no new tasks will start"
221
335
  end
222
336
  end
223
337
 
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
345
+ end
346
+
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)
362
+ end
363
+
364
+ context
365
+ end
366
+
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.
224
373
  def update_progress(state, duration: nil, error: nil)
225
- Taski.progress_display&.update_task(@task.class, state: state, duration: duration, error: error)
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)
226
393
  end
227
394
 
395
+ ##
396
+ # Outputs a debug message if TASKI_DEBUG environment variable is set.
397
+ # @param message [String] The debug message to output.
228
398
  def debug_log(message)
229
399
  return unless ENV["TASKI_DEBUG"]
230
400
  puts "[TaskWrapper] #{message}"