taski 0.8.3 → 0.9.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +108 -50
  4. data/docs/GUIDE.md +79 -55
  5. data/examples/README.md +10 -29
  6. data/examples/clean_demo.rb +25 -65
  7. data/examples/large_tree_demo.rb +356 -0
  8. data/examples/message_demo.rb +0 -1
  9. data/examples/progress_demo.rb +13 -24
  10. data/examples/reexecution_demo.rb +8 -44
  11. data/lib/taski/execution/execution_facade.rb +150 -0
  12. data/lib/taski/execution/executor.rb +167 -359
  13. data/lib/taski/execution/fiber_protocol.rb +27 -0
  14. data/lib/taski/execution/registry.rb +15 -19
  15. data/lib/taski/execution/scheduler.rb +161 -140
  16. data/lib/taski/execution/task_observer.rb +41 -0
  17. data/lib/taski/execution/task_output_router.rb +41 -58
  18. data/lib/taski/execution/task_wrapper.rb +123 -219
  19. data/lib/taski/execution/worker_pool.rb +279 -64
  20. data/lib/taski/logging.rb +105 -0
  21. data/lib/taski/progress/layout/base.rb +600 -0
  22. data/lib/taski/progress/layout/filters.rb +126 -0
  23. data/lib/taski/progress/layout/log.rb +27 -0
  24. data/lib/taski/progress/layout/simple.rb +166 -0
  25. data/lib/taski/progress/layout/tags.rb +76 -0
  26. data/lib/taski/progress/layout/theme_drop.rb +84 -0
  27. data/lib/taski/progress/layout/tree.rb +300 -0
  28. data/lib/taski/progress/theme/base.rb +224 -0
  29. data/lib/taski/progress/theme/compact.rb +58 -0
  30. data/lib/taski/progress/theme/default.rb +25 -0
  31. data/lib/taski/progress/theme/detail.rb +48 -0
  32. data/lib/taski/progress/theme/plain.rb +40 -0
  33. data/lib/taski/static_analysis/analyzer.rb +5 -17
  34. data/lib/taski/static_analysis/dependency_graph.rb +19 -1
  35. data/lib/taski/static_analysis/start_dep_analyzer.rb +400 -0
  36. data/lib/taski/static_analysis/visitor.rb +1 -39
  37. data/lib/taski/task.rb +49 -58
  38. data/lib/taski/task_proxy.rb +59 -0
  39. data/lib/taski/test_helper/errors.rb +1 -1
  40. data/lib/taski/test_helper.rb +22 -36
  41. data/lib/taski/version.rb +1 -1
  42. data/lib/taski.rb +62 -61
  43. data/sig/taski.rbs +194 -203
  44. metadata +34 -8
  45. data/examples/section_demo.rb +0 -195
  46. data/lib/taski/execution/base_progress_display.rb +0 -393
  47. data/lib/taski/execution/execution_context.rb +0 -390
  48. data/lib/taski/execution/plain_progress_display.rb +0 -76
  49. data/lib/taski/execution/simple_progress_display.rb +0 -247
  50. data/lib/taski/execution/tree_progress_display.rb +0 -643
  51. data/lib/taski/section.rb +0 -74
@@ -4,45 +4,25 @@ require "monitor"
4
4
 
5
5
  module Taski
6
6
  module Execution
7
- class TaskTiming < Data.define(:start_time, :end_time)
8
- # @return [Float, nil] Duration in milliseconds or nil if not available
9
- def duration_ms
10
- return nil unless start_time && end_time
11
- ((end_time - start_time) * 1000).round(1)
12
- end
13
-
14
- # @return [TaskTiming] New timing with current time as start
15
- def self.start_now
16
- new(start_time: Time.now, end_time: nil)
17
- end
18
-
19
- # @return [TaskTiming] New timing with current time as end
20
- def with_end_now
21
- with(end_time: Time.now)
22
- end
23
- end
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.
7
+ # Manages state and synchronization for a single task instance.
8
+ # Does NOT start threads or fibers Executor and WorkerPool control scheduling.
9
+ #
10
+ # State transitions (both run and clean phases):
11
+ # pending -> running -> completed | failed
12
+ # pending -> skipped (run-phase only)
28
13
  class TaskWrapper
29
- attr_reader :task, :result, :error, :timing, :clean_error
14
+ attr_reader :task, :result, :error, :clean_error
30
15
 
31
16
  STATE_PENDING = :pending
32
17
  STATE_RUNNING = :running
33
18
  STATE_COMPLETED = :completed
19
+ STATE_FAILED = :failed
20
+ STATE_SKIPPED = :skipped
34
21
 
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
- # @param [Hash, nil] args - User-defined arguments for Task.new usage.
42
- def initialize(task, registry:, execution_context: nil, args: nil)
22
+ def initialize(task, registry:, execution_facade: nil, args: nil)
43
23
  @task = task
44
24
  @registry = registry
45
- @execution_context = execution_context
25
+ @execution_facade = execution_facade
46
26
  @args = args
47
27
  @result = nil
48
28
  @clean_result = nil
@@ -53,27 +33,18 @@ module Taski
53
33
  @clean_condition = @monitor.new_cond
54
34
  @state = STATE_PENDING
55
35
  @clean_state = STATE_PENDING
56
- @timing = nil
57
- @clean_timing = nil
36
+ @waiters = []
58
37
  end
59
38
 
60
- # @return [Symbol] Current state
61
39
  def state
62
40
  @monitor.synchronize { @state }
63
41
  end
64
42
 
65
- # @return [Boolean] true if task is pending
66
- def pending?
67
- state == STATE_PENDING
68
- end
69
-
70
- # @return [Boolean] true if task is completed
71
- def completed?
72
- state == STATE_COMPLETED
73
- end
43
+ def pending? = state == STATE_PENDING
44
+ def completed? = state == STATE_COMPLETED
45
+ def failed? = state == STATE_FAILED
46
+ def skipped? = state == STATE_SKIPPED
74
47
 
75
- # Resets the wrapper state to allow re-execution.
76
- # Clears all cached results and returns state to pending.
77
48
  def reset!
78
49
  @monitor.synchronize do
79
50
  @state = STATE_PENDING
@@ -82,16 +53,11 @@ module Taski
82
53
  @clean_result = nil
83
54
  @error = nil
84
55
  @clean_error = nil
85
- @timing = nil
86
- @clean_timing = nil
87
56
  end
88
57
  @task.reset! if @task.respond_to?(:reset!)
89
58
  @registry.reset!
90
59
  end
91
60
 
92
- # Called by user code to get result. Triggers execution if needed.
93
- # Sets up args if not already set (for Task.new.run usage).
94
- # @return [Object] The result of task execution
95
61
  def run
96
62
  with_args_lifecycle do
97
63
  trigger_execution_and_wait
@@ -100,9 +66,6 @@ module Taski
100
66
  end
101
67
  end
102
68
 
103
- # Called by user code to clean. Triggers clean execution if needed.
104
- # Sets up args if not already set (for Task.new.clean usage).
105
- # @return [Object] The result of cleanup
106
69
  def clean
107
70
  with_args_lifecycle do
108
71
  trigger_clean_and_wait
@@ -110,23 +73,22 @@ module Taski
110
73
  end
111
74
  end
112
75
 
113
- # Called by user code to run and clean. Runs execution followed by cleanup.
114
- # If run fails, clean is still executed for resource release.
115
- # Pre-increments progress display nest_level to prevent double rendering.
116
- # @return [Object] The result of task execution
117
- def run_and_clean
118
- context = ensure_execution_context
119
- context.notify_start # Pre-increment nest_level to prevent double rendering
120
- run
76
+ # Runs execution followed by cleanup. Block is called between phases.
77
+ # @param clean_on_failure [Boolean] When true, clean runs even if run raises.
78
+ # Default is false (clean is skipped on run failure).
79
+ def run_and_clean(clean_on_failure: false, &block)
80
+ facade = ensure_facade
81
+ facade.notify_start # Pre-increment nest_level to prevent double rendering
82
+ run_succeeded = false
83
+ result = run
84
+ run_succeeded = true
85
+ block&.call
86
+ result
121
87
  ensure
122
- clean
123
- context&.notify_stop # Final decrement and render
88
+ clean if run_succeeded || clean_on_failure
89
+ facade&.notify_stop # Final decrement and render
124
90
  end
125
91
 
126
- # Called by user code to get exported value. Triggers execution if needed.
127
- # Sets up args if not already set (for Task.new usage).
128
- # @param method_name [Symbol] The name of the exported method
129
- # @return [Object] The exported value
130
92
  def get_exported_value(method_name)
131
93
  with_args_lifecycle do
132
94
  trigger_execution_and_wait
@@ -135,138 +97,128 @@ module Taski
135
97
  end
136
98
  end
137
99
 
138
- # Called by Executor to mark task as running
100
+ # Atomically resolve the dependency value for a waiting Fiber.
101
+ # Returns a status tuple indicating how the caller should proceed:
102
+ # - [:completed, value] → dependency already done, resume immediately
103
+ # - [:failed, error] → dependency failed, propagate error
104
+ # - [:wait] → dependency running, Fiber parked (will be resumed via thread_queue)
105
+ # - [:start] → dependency was PENDING, now RUNNING (caller must drive it)
106
+ def request_value(method, thread_queue, fiber)
107
+ @monitor.synchronize do
108
+ case @state
109
+ when STATE_COMPLETED
110
+ value = @task.public_send(method)
111
+ [:completed, value]
112
+ when STATE_FAILED
113
+ [:failed, @error]
114
+ when STATE_RUNNING
115
+ @waiters << [thread_queue, fiber, method]
116
+ [:wait]
117
+ else
118
+ # PENDING → atomically transition to RUNNING
119
+ @state = STATE_RUNNING
120
+ @waiters << [thread_queue, fiber, method]
121
+ [:start]
122
+ end
123
+ end
124
+ end
125
+
139
126
  def mark_running
140
127
  @monitor.synchronize do
141
128
  return false unless @state == STATE_PENDING
142
129
  @state = STATE_RUNNING
143
- @timing = TaskTiming.start_now
144
130
  true
145
131
  end
146
132
  end
147
133
 
148
- # Called by Executor after task.run completes successfully
149
- # @param result [Object] The result of task execution
150
134
  def mark_completed(result)
151
- @timing = @timing&.with_end_now
135
+ waiters_to_notify = nil
152
136
  @monitor.synchronize do
153
137
  @result = result
154
138
  @state = STATE_COMPLETED
155
139
  @condition.broadcast
140
+ waiters_to_notify = @waiters.dup
141
+ @waiters.clear
156
142
  end
157
- update_progress(:completed, duration: @timing&.duration_ms)
143
+ notify_fiber_waiters_completed(waiters_to_notify)
144
+ update_progress(:completed)
158
145
  end
159
146
 
160
- # Called by Executor when task.run raises an error
161
- ##
162
- # Marks the task as failed and records the error.
163
- # 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.
164
- # @param [Exception] error - The exception raised during task execution.
165
147
  def mark_failed(error)
166
- @timing = @timing&.with_end_now
148
+ waiters_to_notify = nil
167
149
  @monitor.synchronize do
168
150
  @error = error
169
- @state = STATE_COMPLETED
151
+ @state = STATE_FAILED
152
+ @condition.broadcast
153
+ waiters_to_notify = @waiters.dup
154
+ @waiters.clear
155
+ end
156
+ notify_fiber_waiters_failed(waiters_to_notify, error)
157
+ update_progress(:failed)
158
+ end
159
+
160
+ def mark_skipped
161
+ @monitor.synchronize do
162
+ return false unless @state == STATE_PENDING
163
+ @state = STATE_SKIPPED
170
164
  @condition.broadcast
171
165
  end
172
- update_progress(:failed, error: error)
166
+ notify_skipped
167
+ true
173
168
  end
174
169
 
175
- # Called by Executor to mark clean as running
176
- ##
177
- # Mark the task's cleanup state as running and start timing.
178
- # @return [Boolean] `true` if the clean state was changed from pending to running, `false` otherwise.
179
170
  def mark_clean_running
180
171
  @monitor.synchronize do
181
172
  return false unless @clean_state == STATE_PENDING
182
173
  @clean_state = STATE_RUNNING
183
- @clean_timing = TaskTiming.start_now
184
174
  true
185
175
  end
186
176
  end
187
177
 
188
- # Called by Executor after clean completes
189
- ##
190
- # Marks the cleanup run as completed, stores the cleanup result, sets the clean state to COMPLETED,
191
- # notifies any waiters, and reports completion to observers.
192
- # @param [Object] result - The result of the cleanup operation.
193
178
  def mark_clean_completed(result)
194
- @clean_timing = @clean_timing&.with_end_now
195
179
  @monitor.synchronize do
196
180
  @clean_result = result
197
181
  @clean_state = STATE_COMPLETED
198
182
  @clean_condition.broadcast
199
183
  end
200
- update_clean_progress(:clean_completed, duration: @clean_timing&.duration_ms)
184
+ update_clean_progress(:completed)
201
185
  end
202
186
 
203
- # Called by Executor when clean raises an error
204
- ##
205
- # Marks the cleanup as failed by storing the cleanup error, transitioning the cleanup state to completed,
206
- # notifying any waiters, and reports failure to observers.
207
- # @param [Exception] error - The exception raised during the cleanup run.
208
187
  def mark_clean_failed(error)
209
- @clean_timing = @clean_timing&.with_end_now
210
188
  @monitor.synchronize do
211
189
  @clean_error = error
212
- @clean_state = STATE_COMPLETED
190
+ @clean_state = STATE_FAILED
213
191
  @clean_condition.broadcast
214
192
  end
215
- update_clean_progress(:clean_failed, duration: @clean_timing&.duration_ms, error: error)
193
+ update_clean_progress(:failed)
216
194
  end
217
195
 
218
- ##
219
- # Blocks the current thread until the task reaches the completed state.
220
- #
221
- # The caller will be suspended until the wrapper's state becomes STATE_COMPLETED.
222
- # This method does not raise on its own; any errors from task execution are surfaced elsewhere.
223
196
  def wait_for_completion
224
197
  @monitor.synchronize do
225
- @condition.wait_until { @state == STATE_COMPLETED }
198
+ @condition.wait_until { @state == STATE_COMPLETED || @state == STATE_FAILED || @state == STATE_SKIPPED }
226
199
  end
227
200
  end
228
201
 
229
- ##
230
- # Blocks the current thread until the task's clean phase reaches the completed state.
231
- # The caller will be suspended until the wrapper's clean_state becomes STATE_COMPLETED.
232
202
  def wait_for_clean_completion
233
203
  @monitor.synchronize do
234
- @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
204
+ @clean_condition.wait_until { @clean_state == STATE_COMPLETED || @clean_state == STATE_FAILED }
235
205
  end
236
206
  end
237
207
 
238
- ##
239
- # Delegates method calls to get_exported_value for exported task methods.
240
- # @param method_name [Symbol] The method name being called.
241
- # @param args [Array] Arguments passed to the method.
242
- # @param block [Proc] Block passed to the method.
243
- # @return [Object] The exported value for the method.
244
208
  def method_missing(method_name, *args, &block)
245
- if @task.class.method_defined?(method_name)
209
+ if @task.class.exported_methods.include?(method_name)
246
210
  get_exported_value(method_name)
247
211
  else
248
212
  super
249
213
  end
250
214
  end
251
215
 
252
- ##
253
- # Returns true if the task class defines the given method.
254
- # @param method_name [Symbol] The method name to check.
255
- # @param include_private [Boolean] Whether to include private methods.
256
- # @return [Boolean] true if the task responds to the method.
257
216
  def respond_to_missing?(method_name, include_private = false)
258
- @task.class.method_defined?(method_name) || super
217
+ @task.class.exported_methods.include?(method_name) || super
259
218
  end
260
219
 
261
220
  private
262
221
 
263
- ##
264
- # Ensures args are set during block execution, then resets if they weren't set before.
265
- # This allows Task.new.run usage without requiring explicit args setup.
266
- # If args are already set (e.g., from Task.run class method), just yields the block.
267
- # Uses stored @args if set (from Task.new), otherwise uses empty hash.
268
- # @yield The block to execute with args lifecycle management
269
- # @return [Object] The result of the block
270
222
  def with_args_lifecycle(&block)
271
223
  # If args are already set, just execute the block
272
224
  return yield if Taski.args
@@ -277,37 +229,19 @@ module Taski
277
229
  end
278
230
  end
279
231
 
280
- ##
281
- # Ensures the task is executed if still pending and waits for completion.
282
- # 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.
283
- # @raise [Taski::TaskAbortException] If the registry requested an abort before execution begins.
284
232
  def trigger_execution_and_wait
285
- trigger_and_wait(
286
- state_accessor: -> { @state },
287
- condition: @condition,
288
- trigger: ->(ctx) { ctx.trigger_execution(@task.class, registry: @registry) }
289
- )
233
+ trigger_and_wait(state_accessor: -> { @state }, condition: @condition) do |facade|
234
+ facade.trigger_execution(@task.class, registry: @registry)
235
+ end
290
236
  end
291
237
 
292
- ##
293
- # Triggers task cleanup through the configured execution mechanism and waits until the cleanup completes.
294
- #
295
- # If an ExecutionContext is configured the cleanup is invoked through it; otherwise a fallback executor is used.
296
- # @raise [Taski::TaskAbortException] if the registry has requested an abort.
297
238
  def trigger_clean_and_wait
298
- trigger_and_wait(
299
- state_accessor: -> { @clean_state },
300
- condition: @clean_condition,
301
- trigger: ->(ctx) { ctx.trigger_clean(@task.class, registry: @registry) }
302
- )
239
+ trigger_and_wait(state_accessor: -> { @clean_state }, condition: @clean_condition) do |facade|
240
+ facade.trigger_clean(@task.class, registry: @registry)
241
+ end
303
242
  end
304
243
 
305
- # Generic trigger-and-wait implementation for both run and clean phases.
306
- # @param state_accessor [Proc] Lambda returning the current state
307
- # @param condition [MonitorMixin::ConditionVariable] Condition to wait on
308
- # @param trigger [Proc] Lambda receiving context to trigger execution
309
- # @raise [Taski::TaskAbortException] If the registry requested an abort
310
- def trigger_and_wait(state_accessor:, condition:, trigger:)
244
+ def trigger_and_wait(state_accessor:, condition:)
311
245
  should_execute = false
312
246
  @monitor.synchronize do
313
247
  case state_accessor.call
@@ -315,92 +249,62 @@ module Taski
315
249
  check_abort!
316
250
  should_execute = true
317
251
  when STATE_RUNNING
318
- condition.wait_until { state_accessor.call == STATE_COMPLETED }
319
- when STATE_COMPLETED
252
+ condition.wait_until { [STATE_COMPLETED, STATE_FAILED, STATE_SKIPPED].include?(state_accessor.call) }
253
+ when STATE_COMPLETED, STATE_FAILED, STATE_SKIPPED
320
254
  # Already done
321
255
  end
322
256
  end
323
257
 
324
- if should_execute
325
- # Execute outside the lock to avoid deadlock
326
- context = ensure_execution_context
327
- trigger.call(context)
328
- # After execution returns, the task is completed
329
- end
258
+ yield ensure_facade if should_execute
330
259
  end
331
260
 
332
- ##
333
- # Checks whether the registry has requested an abort and raises an exception to stop starting new tasks.
334
- # @raise [Taski::TaskAbortException] if `@registry.abort_requested?` is true — raised with the message "Execution aborted - no new tasks will start".
335
261
  def check_abort!
336
262
  if @registry.abort_requested?
337
263
  raise Taski::TaskAbortException, "Execution aborted - no new tasks will start"
338
264
  end
339
265
  end
340
266
 
341
- ##
342
- # Ensures an execution context exists for this wrapper.
343
- # Returns the existing context if set, otherwise creates a shared context.
344
- # This enables run and clean phases to share state like runtime dependencies.
345
- # @return [ExecutionContext] The execution context for this wrapper
346
- def ensure_execution_context
347
- @execution_context ||= create_shared_context
267
+ def ensure_facade
268
+ @execution_facade ||= create_shared_facade
348
269
  end
349
270
 
350
- ##
351
- # Creates a shared execution context with proper triggers for run and clean.
352
- # The context is configured to reuse itself when triggering nested executions.
353
- # @return [ExecutionContext] A new execution context
354
- def create_shared_context
355
- context = ExecutionContext.new
356
- progress = Taski.progress_display
357
- context.add_observer(progress) if progress
358
-
359
- # Set triggers to reuse this context for nested executions
360
- context.execution_trigger = ->(task_class, registry) do
361
- Executor.execute(task_class, registry: registry, execution_context: context)
362
- end
363
- context.clean_trigger = ->(task_class, registry) do
364
- Executor.execute_clean(task_class, registry: registry, execution_context: context)
365
- end
271
+ def create_shared_facade
272
+ ExecutionFacade.build_default(root_task_class: @task.class)
273
+ end
366
274
 
367
- context
275
+ def notify_skipped
276
+ notify_state_change(previous_state: :pending, current_state: :skipped, phase: :run)
368
277
  end
369
278
 
370
- ##
371
- # Notifies the execution context of task completion or failure.
372
- # Falls back to getting the current context if not set during initialization.
373
- # @param state [Symbol] The completion state (unused, kept for API consistency).
374
- # @param duration [Numeric, nil] The execution duration in milliseconds.
375
- # @param error [Exception, nil] The error if the task failed.
376
- def update_progress(state, duration: nil, error: nil)
377
- # Defensive fallback: try to get current context if not set during initialization
378
- @execution_context ||= ExecutionContext.current
379
- return unless @execution_context
279
+ def update_progress(state)
280
+ notify_state_change(previous_state: :running, current_state: state, phase: :run)
281
+ end
380
282
 
381
- @execution_context.notify_task_completed(@task.class, duration: duration, error: error)
283
+ def update_clean_progress(state)
284
+ notify_state_change(previous_state: :running, current_state: state, phase: :clean)
382
285
  end
383
286
 
384
- ##
385
- # Notifies the execution context of clean completion or failure.
386
- # Falls back to getting the current context if not set during initialization.
387
- # @param state [Symbol] The clean state (unused, kept for API consistency).
388
- # @param duration [Numeric, nil] The clean duration in milliseconds.
389
- # @param error [Exception, nil] The error if the clean failed.
390
- def update_clean_progress(state, duration: nil, error: nil)
391
- # Defensive fallback: try to get current context if not set during initialization
392
- @execution_context ||= ExecutionContext.current
393
- return unless @execution_context
287
+ def notify_state_change(previous_state:, current_state:, phase:)
288
+ @execution_facade.notify_task_updated(
289
+ @task.class,
290
+ previous_state: previous_state,
291
+ current_state: current_state,
292
+ phase: phase,
293
+ timestamp: Time.now
294
+ )
295
+ end
394
296
 
395
- @execution_context.notify_clean_completed(@task.class, duration: duration, error: error)
297
+ def notify_fiber_waiters_completed(waiters)
298
+ waiters.each do |thread_queue, fiber, method|
299
+ value = @task.public_send(method)
300
+ thread_queue.push(FiberProtocol::Resume.new(fiber, value))
301
+ end
396
302
  end
397
303
 
398
- ##
399
- # Outputs a debug message if TASKI_DEBUG environment variable is set.
400
- # @param message [String] The debug message to output.
401
- def debug_log(message)
402
- return unless ENV["TASKI_DEBUG"]
403
- puts "[TaskWrapper] #{message}"
304
+ def notify_fiber_waiters_failed(waiters, error)
305
+ waiters.each do |thread_queue, fiber, _method|
306
+ thread_queue.push(FiberProtocol::ResumeError.new(fiber, error))
307
+ end
404
308
  end
405
309
  end
406
310
  end