async 2.32.0 → 2.39.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.
data/lib/async/barrier.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2025, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
+ # Copyright, 2026, by Tavian Barnes.
5
6
 
6
7
  require_relative "list"
7
8
  require_relative "task"
@@ -18,6 +19,7 @@ module Async
18
19
  def initialize(parent: nil)
19
20
  @tasks = List.new
20
21
  @finished = Queue.new
22
+ @condition = Condition.new
21
23
 
22
24
  @parent = parent
23
25
  end
@@ -42,18 +44,32 @@ module Async
42
44
 
43
45
  # Execute a child task and add it to the barrier.
44
46
  # @asynchronous Executes the given block concurrently.
47
+ # @returns [Task] The task which was created to execute the block.
45
48
  def async(*arguments, parent: (@parent or Task.current), **options, &block)
46
49
  raise "Barrier is stopped!" if @finished.closed?
47
50
 
48
51
  waiting = nil
49
52
 
50
- parent.async(*arguments, **options) do |task, *arguments|
51
- waiting = TaskNode.new(task)
52
- @tasks.append(waiting)
53
+ task = parent.async(*arguments, **options) do |task, *arguments|
54
+ # Create a new list node for the task and add it to the list of waiting tasks:
55
+ node = TaskNode.new(task)
56
+ @tasks.append(node)
57
+
58
+ # Signal the outer async block that we have added the task to the list of waiting tasks, and that it can now wait for it to finish:
59
+ waiting = node
60
+ @condition.signal
61
+
62
+ # Invoke the block, which may raise an error. If it does, we will still signal that the task has finished:
53
63
  block.call(task, *arguments)
54
64
  ensure
55
- @finished.signal(waiting) unless @finished.closed?
65
+ # Signal that the task has finished, which will unblock the waiting task:
66
+ @finished.signal(node) unless @finished.closed?
56
67
  end
68
+
69
+ # `parent.async` may yield before the child block executes, so we wait here until the child has appended itself to `@tasks`, ensuring `wait` cannot return early and miss tracking it:
70
+ @condition.wait while waiting.nil?
71
+
72
+ return task
57
73
  end
58
74
 
59
75
  # Whether there are any tasks being held by the barrier.
@@ -65,12 +81,17 @@ module Async
65
81
  # Wait for all tasks to complete by invoking {Task#wait} on each waiting task, which may raise an error. As long as the task has completed, it will be removed from the barrier.
66
82
  #
67
83
  # @yields {|task| ...} If a block is given, the unwaited task is yielded. You must invoke {Task#wait} yourself. In addition, you may `break` if you have captured enough results.
84
+ # @returns [Integer | Nil] The number of tasks which were waited for, or `nil` if there were no tasks to wait for.
68
85
  #
69
86
  # @asynchronous Will wait for tasks to finish executing.
70
87
  def wait
71
- while !@tasks.empty?
88
+ return nil if @tasks.empty?
89
+ count = 0
90
+
91
+ while true
72
92
  # Wait for a task to finish (we get the task node):
73
- return unless waiting = @finished.wait
93
+ break unless waiting = @finished.wait
94
+ count += 1
74
95
 
75
96
  # Remove the task as it is now finishing:
76
97
  @tasks.remove?(waiting)
@@ -85,17 +106,27 @@ module Async
85
106
  # Wait for it to either complete or raise an error:
86
107
  task.wait
87
108
  end
109
+
110
+ break if @tasks.empty?
88
111
  end
112
+
113
+ return count
89
114
  end
90
115
 
91
- # Stop all tasks held by the barrier.
116
+ # Cancel all tasks held by the barrier.
92
117
  # @asynchronous May wait for tasks to finish executing.
93
- def stop
118
+ def cancel
94
119
  @tasks.each do |waiting|
95
- waiting.task.stop
120
+ waiting.task.cancel
96
121
  end
97
122
 
98
123
  @finished.close
99
124
  end
125
+
126
+ # Backward compatibility alias for {#cancel}.
127
+ # @deprecated Use {#cancel} instead.
128
+ def stop
129
+ cancel
130
+ end
100
131
  end
101
132
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Async
7
+ # Raised when a task is explicitly cancelled.
8
+ class Cancel < Exception
9
+ # Represents the source of the cancel operation.
10
+ class Cause < Exception
11
+ if RUBY_VERSION >= "3.4"
12
+ # @returns [Array(Thread::Backtrace::Location)] The backtrace of the caller.
13
+ def self.backtrace
14
+ caller_locations(2..-1)
15
+ end
16
+ else
17
+ # @returns [Array(String)] The backtrace of the caller.
18
+ def self.backtrace
19
+ caller(2..-1)
20
+ end
21
+ end
22
+
23
+ # Create a new cause of the cancel operation, with the given message.
24
+ #
25
+ # @parameter message [String] The error message.
26
+ # @returns [Cause] The cause of the cancel operation.
27
+ def self.for(message = "Task was cancelled!")
28
+ instance = self.new(message)
29
+ instance.set_backtrace(self.backtrace)
30
+ return instance
31
+ end
32
+ end
33
+
34
+ if RUBY_VERSION < "3.5"
35
+ # Create a new cancel operation.
36
+ #
37
+ # This is a compatibility method for Ruby versions before 3.5 where cause is not propagated correctly when using {Fiber#raise}
38
+ #
39
+ # @parameter message [String | Hash] The error message or a hash containing the cause.
40
+ def initialize(message = "Task was cancelled")
41
+
42
+ if message.is_a?(Hash)
43
+ @cause = message[:cause]
44
+ message = "Task was cancelled"
45
+ end
46
+
47
+ super(message)
48
+ end
49
+
50
+ # @returns [Exception] The cause of the cancel operation.
51
+ #
52
+ # This is a compatibility method for Ruby versions before 3.5 where cause is not propagated correctly when using {Fiber#raise}, we explicitly capture the cause here.
53
+ def cause
54
+ super || @cause
55
+ end
56
+ end
57
+
58
+ # Used to defer cancelling the current task until later.
59
+ class Later
60
+ # Create a new cancel later operation.
61
+ #
62
+ # @parameter task [Task] The task to cancel later.
63
+ # @parameter cause [Exception] The cause of the cancel operation.
64
+ def initialize(task, cause = nil)
65
+ @task = task
66
+ @cause = cause
67
+ end
68
+
69
+ # @returns [Boolean] Whether the task is alive.
70
+ def alive?
71
+ true
72
+ end
73
+
74
+ # Transfer control to the operation - this will cancel the task.
75
+ def transfer
76
+ @task.cancel(false, cause: @cause)
77
+ end
78
+ end
79
+ end
80
+ end
data/lib/async/clock.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2025, by Samuel Williams.
4
+ # Copyright, 2018-2026, by Samuel Williams.
5
+ # Copyright, 2026, by Shopify Inc.
5
6
 
6
7
  module Async
7
8
  # A convenient wrapper around the internal monotonic clock.
@@ -36,6 +37,9 @@ module Async
36
37
  @started = nil
37
38
  end
38
39
 
40
+ # @returns [Numeric | Nil] The time when the clock was started, or nil if not started.
41
+ attr :started
42
+
39
43
  # Start measuring a duration.
40
44
  def start!
41
45
  @started ||= Clock.now
@@ -70,5 +74,22 @@ module Async
70
74
  @started = Clock.now
71
75
  end
72
76
  end
77
+
78
+ # Convert the clock to a JSON-compatible hash.
79
+ #
80
+ # @returns [Hash] The JSON-compatible hash.
81
+ def as_json(...)
82
+ {
83
+ started: self.started,
84
+ total: self.total,
85
+ }
86
+ end
87
+
88
+ # Convert the clock to a JSON string.
89
+ #
90
+ # @returns [String] The JSON string.
91
+ def to_json(...)
92
+ self.as_json.to_json(...)
93
+ end
73
94
  end
74
95
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
+ # Copyright, 2025, by Shopify Inc.
4
5
  # Copyright, 2025, by Samuel Williams.
5
6
 
6
7
  require_relative "clock"
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Async
7
+ # Raised if a timeout occurs on a specific Fiber. Handled gracefully by `Task`.
8
+ # @public Since *Async v1*.
9
+ class TimeoutError < StandardError
10
+ # Create a new timeout error.
11
+ #
12
+ # @parameter message [String] The error message.
13
+ def initialize(message = "execution expired")
14
+ super
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025-2026, by Shopify Inc.
5
+ # Copyright, 2025-2026, by Samuel Williams.
6
+
7
+ module Async
8
+ # Private module that hooks into Process._fork to handle fork events.
9
+ #
10
+ # If `Scheduler#process_fork` hook is adopted in Ruby 4, this code can be removed after Ruby < 4 is no longer supported.
11
+ module ForkHandler
12
+ def _fork(&block)
13
+ result = super
14
+
15
+ if result.zero?
16
+ # Child process:
17
+ if Fiber.scheduler.respond_to?(:process_fork)
18
+ Fiber.scheduler.process_fork
19
+ end
20
+ end
21
+
22
+ return result
23
+ end
24
+ end
25
+
26
+ private_constant :ForkHandler
27
+
28
+ # Hook into Process._fork to handle fork events automatically:
29
+ unless RUBY_VERSION > "4"
30
+ ::Process.singleton_class.prepend(ForkHandler)
31
+ end
32
+ end
data/lib/async/idler.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024, by Samuel Williams.
4
+ # Copyright, 2024-2025, by Samuel Williams.
5
5
 
6
6
  module Async
7
7
  # A load balancing mechanism that can be used process work when the system is idle.
@@ -13,10 +13,13 @@ module Async
13
13
  # @parameter maximum_load [Numeric] The maximum load before we start shedding work.
14
14
  # @parameter backoff [Numeric] The initial backoff time, used for delaying work.
15
15
  # @parameter parent [Interface(:async) | Nil] The parent task to use for async operations.
16
- def initialize(maximum_load = 0.8, backoff: 0.01, parent: nil)
16
+ def initialize(maximum_load = 0.8, backoff: 0.001, parent: nil)
17
17
  @maximum_load = maximum_load
18
18
  @backoff = backoff
19
+ @current = backoff
20
+
19
21
  @parent = parent
22
+ @mutex = Mutex.new
20
23
  end
21
24
 
22
25
  # Wait until the system is idle, then execute the given block in a new task.
@@ -38,20 +41,29 @@ module Async
38
41
  #
39
42
  # If the scheduler is overloaded, this method will sleep for an exponentially increasing amount of time.
40
43
  def wait
41
- scheduler = Fiber.scheduler
42
- backoff = nil
43
-
44
- while true
45
- load = scheduler.load
46
-
47
- break if load < @maximum_load
44
+ @mutex.synchronize do
45
+ scheduler = Fiber.scheduler
48
46
 
49
- if backoff
50
- sleep(backoff)
51
- backoff *= 2.0
52
- else
53
- scheduler.yield
54
- backoff = @backoff
47
+ while true
48
+ load = scheduler.load
49
+
50
+ if load <= @maximum_load
51
+ # Even though load is okay, if @current is high, we were recently overloaded. Sleep proportionally to prevent burst after load drop:
52
+ if @current > @backoff
53
+ # Sleep a fraction of @current to rate limit:
54
+ sleep(@current - @backoff)
55
+
56
+ # Decay @current gently towards @backoff:
57
+ alpha = 0.99
58
+ @current *= alpha + (1.0 - alpha) * (load / @maximum_load)
59
+ end
60
+
61
+ break
62
+ else
63
+ # We're overloaded, so increase backoff:
64
+ @current *= (load / @maximum_load)
65
+ sleep(@current)
66
+ end
55
67
  end
56
68
  end
57
69
  end
data/lib/async/loop.rb ADDED
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "console"
7
+
8
+ module Async
9
+ # @namespace
10
+ module Loop
11
+ # Execute a block repeatedly at quantized (time-aligned) intervals.
12
+ #
13
+ # The alignment is computed modulo the current clock time in seconds. For example, with
14
+ # `interval: 60`, executions will occur at 00:00, 01:00, 02:00, etc., regardless of when
15
+ # the loop is started. With `interval: 300` (5 minutes), executions align to 00:00, 00:05,
16
+ # 00:10, etc.
17
+ #
18
+ # This is particularly useful for tasks that should run at predictable wall-clock times,
19
+ # such as metrics collection, periodic cleanup, or scheduled jobs that need to align
20
+ # across multiple processes.
21
+ #
22
+ # If an error occurs during block execution, it is logged and the loop continues.
23
+ #
24
+ # @example Run every minute at :00 seconds:
25
+ # Async::Loop.quantized(interval: 60) do
26
+ # puts "Current time: #{Time.now}"
27
+ # end
28
+ #
29
+ # @example Run every 5 minutes aligned to the hour:
30
+ # Async::Loop.quantized(interval: 300) do
31
+ # collect_metrics
32
+ # end
33
+ #
34
+ # @parameter interval [Numeric] The interval in seconds. Executions will align to multiples of this interval based on the current time.
35
+ # @yields The block to execute at each interval.
36
+ #
37
+ # @public Since *Async v2.37*.
38
+ def self.quantized(interval: 60, &block)
39
+ while true
40
+ # Compute the wait time to the next interval:
41
+ wait = interval - (Time.now.to_f % interval)
42
+ if wait.positive?
43
+ # Sleep until the next interval boundary:
44
+ sleep(wait)
45
+ end
46
+
47
+ begin
48
+ yield
49
+ rescue => error
50
+ Console.error(self, "Loop error:", error)
51
+ end
52
+ end
53
+ end
54
+
55
+ # Execute a block repeatedly with a fixed delay between executions.
56
+ #
57
+ # Unlike {quantized}, this method waits for the specified interval *after* each execution
58
+ # completes. This means the actual time between the start of successive executions will be
59
+ # `interval + execution_time`.
60
+ #
61
+ # If an error occurs during block execution, it is logged and the loop continues.
62
+ #
63
+ # @example Run every 5 seconds (plus execution time):
64
+ # Async::Loop.periodic(interval: 5) do
65
+ # process_queue
66
+ # end
67
+ #
68
+ # @parameter interval [Numeric] The delay in seconds between executions.
69
+ # @yields The block to execute periodically.
70
+ #
71
+ # @public Since *Async v2.37*.
72
+ def self.periodic(interval: 60, &block)
73
+ while true
74
+ begin
75
+ yield
76
+ rescue => error
77
+ Console.error(self, "Loop error:", error)
78
+ end
79
+
80
+ sleep(interval)
81
+ end
82
+ end
83
+ end
84
+ end
data/lib/async/node.rb CHANGED
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2025, by Samuel Williams.
4
+ # Copyright, 2017-2026, by Samuel Williams.
5
5
  # Copyright, 2017, by Kent Gruber.
6
6
  # Copyright, 2022, by Shannon Skipper.
7
- # Copyright, 2025, by Shopify Inc.
7
+ # Copyright, 2025-2026, by Shopify Inc.
8
8
 
9
9
  require "fiber/annotation"
10
10
 
@@ -214,7 +214,8 @@ module Async
214
214
  end
215
215
 
216
216
  protected def remove_child(child)
217
- @children.remove(child)
217
+ # In the case of a fork, the children list may be nil:
218
+ @children&.remove(child)
218
219
  child.set_parent(nil)
219
220
  end
220
221
 
@@ -279,26 +280,44 @@ module Async
279
280
  return @children.nil?
280
281
  end
281
282
 
282
- # Attempt to stop the current node immediately, including all non-transient children. Invokes {#stop_children} to stop all children.
283
+ # Attempt to cancel the current node immediately, including all non-transient children. Invokes {#stop_children} to cancel all children.
283
284
  #
284
- # @parameter later [Boolean] Whether to defer stopping until some point in the future.
285
- def stop(later = false)
285
+ # @parameter later [Boolean] Whether to defer cancelling until some point in the future.
286
+ def cancel(later = false)
286
287
  # The implementation of this method may defer calling `stop_children`.
287
288
  stop_children(later)
288
289
  end
289
290
 
291
+ # Backward compatibility alias for {#cancel}.
292
+ # @deprecated Use {#cancel} instead.
293
+ def stop(...)
294
+ cancel(...)
295
+ end
296
+
290
297
  # Attempt to stop all non-transient children.
291
298
  private def stop_children(later = false)
292
299
  @children&.each do |child|
293
- child.stop(later) unless child.transient?
300
+ child.cancel(later) unless child.transient?
294
301
  end
295
302
  end
296
303
 
297
- # Whether the node has been stopped.
298
- def stopped?
304
+ # Wait for this node to complete. By default, nodes cannot be waited on.
305
+ # Subclasses like Task override this method to provide waiting functionality.
306
+ def wait
307
+ nil
308
+ end
309
+
310
+ # Whether the node has been cancelled.
311
+ def cancelled?
299
312
  @children.nil?
300
313
  end
301
314
 
315
+ # Backward compatibility alias for {#cancelled?}.
316
+ # @deprecated Use {#cancelled?} instead.
317
+ def stopped?
318
+ cancelled?
319
+ end
320
+
302
321
  # Print the hierarchy of the task tree from the given node.
303
322
  #
304
323
  # @parameter out [IO] The output stream to write to.