async 2.28.0 → 2.29.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3d7b98c33c331424b3e803d3081ee28abc72ca52e910cc0ee49a51a1a90d8f0
4
- data.tar.gz: 306153a2296017bfb169251642c9b64409b5152b98a28e49b1e02158b5c69634
3
+ metadata.gz: 643f4000360902d473fec7d3c35dfa4d810499a7ee2d419bb6a44e185e4ce9a6
4
+ data.tar.gz: fd8150c4df0e9834259c612811856764dde221f3e21a58cb33ae7a9903822f8a
5
5
  SHA512:
6
- metadata.gz: 580a4f849c6344fd1968c7376b801ce5ed2ee1a3eb79390793f1e9a3c370f12abb2a5664d46cf3b6d75067896365fd762ccdcf491a3fdf25471458284ac86fa2
7
- data.tar.gz: 914a5d6bcfaca6c91bf8437b9d5d3e0c894d9666c3819a0c822cc1173af44ce92c9ae4ce901cc1292543d5624c58aa693aed8a4e0ae9e96a7b7c8b9150bc003d
6
+ metadata.gz: 39886cbaf6b6d9a10522956c67d2c5228e22aeefe27104456f3e767441b43b13887ddb41c71e7bc2cffab7956e17117a9e0a8df64d585ecbfe532a87c2f2d592
7
+ data.tar.gz: b282710c3ce0fc5db35b7de431fa7d8121c9ca3d2ef4ccb1b0c5cafc26a84162eff01aae53b1b267683d8a0c635a991afae54e4d73d3cf7c2a8b23e2e8d32cdf
checksums.yaml.gz.sig CHANGED
Binary file
data/lib/async/barrier.rb CHANGED
@@ -43,6 +43,8 @@ module Async
43
43
  # Execute a child task and add it to the barrier.
44
44
  # @asynchronous Executes the given block concurrently.
45
45
  def async(*arguments, parent: (@parent or Task.current), **options, &block)
46
+ raise "Barrier is stopped!" if @finished.closed?
47
+
46
48
  waiting = nil
47
49
 
48
50
  parent.async(*arguments, **options) do |task, *arguments|
@@ -50,7 +52,7 @@ module Async
50
52
  @tasks.append(waiting)
51
53
  block.call(task, *arguments)
52
54
  ensure
53
- @finished.signal(waiting)
55
+ @finished.signal(waiting) unless @finished.closed?
54
56
  end
55
57
  end
56
58
 
@@ -13,65 +13,47 @@ module Async
13
13
  class Condition
14
14
  # Create a new condition.
15
15
  def initialize
16
- @waiting = List.new
16
+ @ready = ::Thread::Queue.new
17
17
  end
18
18
 
19
- class FiberNode < List::Node
20
- def initialize(fiber)
21
- @fiber = fiber
22
- end
23
-
24
- def transfer(*arguments)
25
- @fiber.transfer(*arguments)
26
- end
27
-
28
- def alive?
29
- @fiber.alive?
30
- end
31
- end
32
-
33
- private_constant :FiberNode
34
-
35
19
  # Queue up the current fiber and wait on yielding the task.
36
20
  # @returns [Object]
37
21
  def wait
38
- @waiting.stack(FiberNode.new(Fiber.current)) do
39
- Fiber.scheduler.transfer
40
- end
22
+ @ready.pop
41
23
  end
42
24
 
43
- # @deprecated Replaced by {#waiting?}
25
+ # @returns [Boolean] If there are no fibers waiting on this condition.
44
26
  def empty?
45
- warn("`Async::Condition#empty?` is deprecated, use `Async::Condition#waiting?` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
46
-
47
- @waiting.empty?
27
+ @ready.num_waiting.zero?
48
28
  end
49
29
 
50
30
  # @returns [Boolean] Is any fiber waiting on this notification?
51
31
  def waiting?
52
- @waiting.size > 0
32
+ !self.empty?
53
33
  end
54
34
 
55
35
  # Signal to a given task that it should resume operations.
56
36
  # @parameter value [Object | Nil] The value to return to the waiting fibers.
57
37
  def signal(value = nil)
58
- return if @waiting.empty?
38
+ return if empty?
59
39
 
60
- waiting = self.exchange
40
+ ready = self.exchange
61
41
 
62
- waiting.each do |fiber|
63
- Fiber.scheduler.resume(fiber, value) if fiber.alive?
42
+ ready.num_waiting.times do
43
+ ready.push(value)
64
44
  end
65
45
 
46
+ ready.close
47
+
66
48
  return nil
67
49
  end
68
50
 
69
51
  protected
70
52
 
71
53
  def exchange
72
- waiting = @waiting
73
- @waiting = List.new
74
- return waiting
54
+ ready = @ready
55
+ @ready = ::Thread::Queue.new
56
+ return ready
75
57
  end
76
58
  end
77
59
  end
@@ -12,23 +12,25 @@ module Async
12
12
  # Signal to a given task that it should resume operations.
13
13
  #
14
14
  # @returns [Boolean] if a task was signalled.
15
- def signal(value = nil, task: Task.current)
16
- return false if @waiting.empty?
15
+ def signal(value = nil)
16
+ return false if empty?
17
17
 
18
18
  Fiber.scheduler.push Signal.new(self.exchange, value)
19
19
 
20
20
  return true
21
21
  end
22
22
 
23
- Signal = Struct.new(:waiting, :value) do
23
+ Signal = Struct.new(:ready, :value) do
24
24
  def alive?
25
25
  true
26
26
  end
27
27
 
28
28
  def transfer
29
- waiting.each do |fiber|
30
- fiber.transfer(value) if fiber.alive?
29
+ ready.num_waiting.times do
30
+ ready.push(value)
31
31
  end
32
+
33
+ ready.close
32
34
  end
33
35
  end
34
36
 
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Shopify Inc.
5
+ # Copyright, 2025, by Samuel Williams.
6
+
7
+ require "io/event/priority_heap"
8
+ require "thread"
9
+
10
+ require_relative "queue"
11
+
12
+ module Async
13
+ # A queue which allows items to be processed in priority order of consumers.
14
+ #
15
+ # Unlike a traditional priority queue where items have priorities, this queue
16
+ # assigns priorities to consumers (fibers waiting to dequeue). Higher priority
17
+ # consumers are served first when items become available.
18
+ #
19
+ # @public Since *Async v2*.
20
+ class PriorityQueue
21
+ ClosedError = Queue::ClosedError
22
+
23
+ # A waiter represents a fiber waiting to dequeue with a given priority.
24
+ Waiter = Struct.new(:fiber, :priority, :sequence, :condition, :value) do
25
+ include Comparable
26
+
27
+ def <=>(other)
28
+ # Higher priority comes first, then FIFO for equal priorities:
29
+ if priority == other.priority
30
+ # Use sequence for FIFO behavior (lower sequence = earlier):
31
+ sequence <=> other.sequence
32
+ else
33
+ other.priority <=> priority # Reverse for max-heap behavior
34
+ end
35
+ end
36
+
37
+ def signal(value)
38
+ self.value = value
39
+ condition.signal
40
+ end
41
+
42
+ def wait_for_value(mutex)
43
+ condition.wait(mutex)
44
+ return self.value
45
+ end
46
+ end
47
+
48
+ # Create a new priority queue.
49
+ #
50
+ # @parameter parent [Interface(:async) | Nil] The parent task to use for async operations.
51
+ def initialize(parent: nil)
52
+ @items = []
53
+ @closed = false
54
+ @parent = parent
55
+ @waiting = IO::Event::PriorityHeap.new
56
+ @sequence = 0
57
+
58
+ @mutex = Mutex.new
59
+ end
60
+
61
+ # Close the queue, causing all waiting tasks to return `nil`.
62
+ # Any subsequent calls to {enqueue} will raise an exception.
63
+ def close
64
+ @mutex.synchronize do
65
+ @closed = true
66
+
67
+ # Signal all waiting fibers with nil, skipping dead ones:
68
+ while waiter = @waiting.pop
69
+ if waiter.fiber.alive?
70
+ waiter.signal(nil)
71
+ end
72
+ # Dead waiter discarded, continue to next one.
73
+ end
74
+ end
75
+ end
76
+
77
+ # @attribute [Array] The items in the queue.
78
+ attr :items
79
+
80
+ # @returns [Integer] The number of items in the queue.
81
+ def size
82
+ @items.size
83
+ end
84
+
85
+ # @returns [Boolean] Whether the queue is empty.
86
+ def empty?
87
+ @items.empty?
88
+ end
89
+
90
+ # @returns [Integer] The number of fibers waiting to dequeue.
91
+ def waiting
92
+ @mutex.synchronize do
93
+ @waiting.size
94
+ end
95
+ end
96
+
97
+ # Add an item to the queue.
98
+ #
99
+ # @parameter item [Object] The item to add to the queue.
100
+ def push(item)
101
+ @mutex.synchronize do
102
+ if @closed
103
+ raise ClosedError, "Cannot push items to a closed queue."
104
+ end
105
+
106
+ @items << item
107
+
108
+ # Wake up the highest priority waiter if any, skipping dead waiters:
109
+ while waiter = @waiting.pop
110
+ if waiter.fiber.alive?
111
+ value = @items.shift
112
+ waiter.signal(value)
113
+ break
114
+ end
115
+ # Dead waiter discarded, try next one.
116
+ end
117
+ end
118
+ end
119
+
120
+ # Compatibility with {::Queue#push}.
121
+ def <<(item)
122
+ self.push(item)
123
+ end
124
+
125
+ # Add multiple items to the queue.
126
+ #
127
+ # @parameter items [Array] The items to add to the queue.
128
+ def enqueue(*items)
129
+ @mutex.synchronize do
130
+ if @closed
131
+ raise ClosedError, "Cannot enqueue items to a closed queue."
132
+ end
133
+
134
+ @items.concat(items)
135
+
136
+ # Wake up waiting fibers in priority order, skipping dead waiters:
137
+ while !@items.empty? && (waiter = @waiting.pop)
138
+ if waiter.fiber.alive?
139
+ value = @items.shift
140
+ waiter.signal(value)
141
+ end
142
+ # Dead waiter discarded, continue to next one.
143
+ end
144
+ end
145
+ end
146
+
147
+ # Remove and return the next item from the queue.
148
+ #
149
+ # If the queue is empty, this method will block until an item is available.
150
+ # Fibers are served in priority order, with higher priority fibers receiving
151
+ # items first.
152
+ #
153
+ # @parameter priority [Numeric] The priority of this consumer (higher = served first).
154
+ # @returns [Object] The next item in the queue.
155
+ def dequeue(priority: 0)
156
+ @mutex.synchronize do
157
+ # If queue is closed and empty, return nil immediately:
158
+ if @closed && @items.empty?
159
+ return nil
160
+ end
161
+
162
+ # Fast path: if items available and either no waiters or we have higher priority:
163
+ unless @items.empty?
164
+ head = @waiting.peek
165
+ if head.nil? or priority > head.priority
166
+ return @items.shift
167
+ end
168
+ end
169
+
170
+ # Need to wait - create our own condition variable and add to waiting queue:
171
+ sequence = @sequence
172
+ @sequence += 1
173
+
174
+ condition = ConditionVariable.new
175
+ waiter = Waiter.new(Fiber.current, priority, sequence, condition, nil)
176
+ @waiting.push(waiter)
177
+
178
+ # Wait for our specific condition variable to be signaled:
179
+ # The mutex is released during wait, reacquired after:
180
+ return waiter.wait_for_value(@mutex)
181
+ end
182
+ end
183
+
184
+ # Compatibility with {::Queue#pop}.
185
+ #
186
+ # @parameter priority [Numeric] The priority of this consumer.
187
+ def pop(priority: 0)
188
+ self.dequeue(priority: priority)
189
+ end
190
+
191
+ # Process each item in the queue.
192
+ #
193
+ # @asynchronous Executes the given block concurrently for each item.
194
+ #
195
+ # @parameter priority [Numeric] The priority for processing items.
196
+ # @parameter parent [Interface(:async) | Nil] The parent task to use for async operations.
197
+ # @parameter options [Hash] The options to pass to the task.
198
+ # @yields {|task| ...} When the system is idle, the block will be executed in a new task.
199
+ def async(priority: 0, parent: (@parent or Task.current), **options, &block)
200
+ while item = self.dequeue(priority: priority)
201
+ parent.async(item, **options, &block)
202
+ end
203
+ end
204
+
205
+ # Enumerate each item in the queue.
206
+ #
207
+ # @parameter priority [Numeric] The priority for dequeuing items.
208
+ def each(priority: 0)
209
+ while item = self.dequeue(priority: priority)
210
+ yield item
211
+ end
212
+ end
213
+
214
+ # Signal the queue with a value, the same as {#enqueue}.
215
+ def signal(value = nil)
216
+ self.enqueue(value)
217
+ end
218
+
219
+ # Wait for an item to be available, the same as {#dequeue}.
220
+ #
221
+ # @parameter priority [Numeric] The priority of this consumer.
222
+ def wait(priority: 0)
223
+ self.dequeue(priority: priority)
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Shopify Inc.
5
+ # Copyright, 2025, by Samuel Williams.
6
+
7
+ module Async
8
+ # A promise represents a value that will be available in the future.
9
+ # Unlike Condition, once resolved (or rejected), all future waits return immediately
10
+ # with the stored value or raise the stored exception.
11
+ #
12
+ # This is thread-safe and integrates with the fiber scheduler.
13
+ #
14
+ # @public Since *Async v2*.
15
+ class Promise
16
+ # Create a new promise.
17
+ def initialize
18
+ # nil = pending, :completed = success, :failed = failure, :cancelled = cancelled:
19
+ @resolved = nil
20
+
21
+ # Stores either the result value or the exception:
22
+ @value = nil
23
+
24
+ # Track how many fibers are currently waiting:
25
+ @waiting = 0
26
+
27
+ @mutex = Mutex.new
28
+ @condition = ConditionVariable.new
29
+ end
30
+
31
+ # @returns [Boolean] Whether the promise has been resolved or rejected.
32
+ def resolved?
33
+ @mutex.synchronize {!!@resolved}
34
+ end
35
+
36
+ # @returns [Symbol | Nil] The internal resolved state (:completed, :failed, :cancelled, or nil if pending).
37
+ # @private For internal use by Task.
38
+ def resolved
39
+ @mutex.synchronize {@resolved}
40
+ end
41
+
42
+ # @returns [Boolean] Whether the promise has been cancelled.
43
+ def cancelled?
44
+ @mutex.synchronize {@resolved == :cancelled}
45
+ end
46
+
47
+ # @returns [Boolean] Whether the promise failed with an exception.
48
+ def failed?
49
+ @mutex.synchronize {@resolved == :failed}
50
+ end
51
+
52
+ # @returns [Boolean] Whether the promise has completed successfully.
53
+ def completed?
54
+ @mutex.synchronize {@resolved == :completed}
55
+ end
56
+
57
+ # @returns [Boolean] Whether any fibers are currently waiting for this promise.
58
+ def waiting?
59
+ @mutex.synchronize {@waiting > 0}
60
+ end
61
+
62
+ # Artificially mark that someone is waiting (useful for suppressing warnings).
63
+ # @private Internal use only.
64
+ def suppress_warnings!
65
+ @mutex.synchronize {@waiting += 1}
66
+ end
67
+
68
+ # Non-blocking access to the current value. Returns nil if not yet resolved.
69
+ # Does not raise exceptions even if the promise was rejected or cancelled.
70
+ # For resolved promises, returns the raw stored value (result, exception, or cancel exception).
71
+ #
72
+ # @returns [Object | Nil] The stored value, or nil if pending.
73
+ def value
74
+ @mutex.synchronize {@resolved ? @value : nil}
75
+ end
76
+
77
+ # Wait for the promise to be resolved and return the value.
78
+ # If already resolved, returns immediately. If rejected, raises the stored exception.
79
+ #
80
+ # @returns [Object] The resolved value.
81
+ # @raises [Exception] The rejected or cancelled exception.
82
+ def wait
83
+ @mutex.synchronize do
84
+ # Increment waiting count:
85
+ @waiting += 1
86
+
87
+ begin
88
+ # Wait for resolution if not already resolved:
89
+ @condition.wait(@mutex) unless @resolved
90
+
91
+ # Return value or raise exception based on resolution type:
92
+ if @resolved == :completed
93
+ return @value
94
+ else
95
+ # Both :failed and :cancelled store exceptions in @value
96
+ raise @value
97
+ end
98
+ ensure
99
+ # Decrement waiting count when done:
100
+ @waiting -= 1
101
+ end
102
+ end
103
+ end
104
+
105
+ # Resolve the promise with a value.
106
+ # All current and future waiters will receive this value.
107
+ # Can only be called once - subsequent calls are ignored.
108
+ #
109
+ # @parameter value [Object] The value to resolve the promise with.
110
+ def resolve(value)
111
+ @mutex.synchronize do
112
+ return if @resolved
113
+
114
+ @value = value
115
+ @resolved = :completed
116
+
117
+ # Wake up all waiting fibers:
118
+ @condition.broadcast
119
+ end
120
+
121
+ return value
122
+ end
123
+
124
+ # Reject the promise with an exception.
125
+ # All current and future waiters will receive this exception.
126
+ # Can only be called once - subsequent calls are ignored.
127
+ #
128
+ # @parameter exception [Exception] The exception to reject the promise with.
129
+ def reject(exception)
130
+ @mutex.synchronize do
131
+ return if @resolved
132
+
133
+ @value = exception
134
+ @resolved = :failed
135
+
136
+ # Wake up all waiting fibers:
137
+ @condition.broadcast
138
+ end
139
+
140
+ return nil
141
+ end
142
+
143
+ # Exception used to indicate cancellation.
144
+ class Cancel < Exception
145
+ end
146
+
147
+ # Cancel the promise, indicating cancellation.
148
+ # All current and future waiters will receive nil.
149
+ # Can only be called on pending promises - no-op if already resolved.
150
+ def cancel(exception = Cancel.new("Promise was cancelled!"))
151
+ @mutex.synchronize do
152
+ # No-op if already in any final state
153
+ return if @resolved
154
+
155
+ @value = exception
156
+ @resolved = :cancelled
157
+
158
+ # Wake up all waiting fibers:
159
+ @condition.broadcast
160
+ end
161
+
162
+ return nil
163
+ end
164
+
165
+ # Resolve the promise with the result of the block.
166
+ # If the block raises an exception, the promise will be rejected.
167
+ # If the promise was already resolved, the block will not be called.
168
+ # @yields {...} The block to call to resolve the promise.
169
+ # @returns [Object] The result of the block.
170
+ def fulfill(&block)
171
+ raise "Promise already resolved!" if @resolved
172
+
173
+ begin
174
+ return self.resolve(yield)
175
+ rescue Cancel => exception
176
+ return self.cancel(exception)
177
+ rescue => error
178
+ return self.reject(error)
179
+ rescue Exception => exception
180
+ self.reject(exception)
181
+ raise
182
+ ensure
183
+ # Handle non-local exits (throw, etc.) that bypass normal flow:
184
+ self.resolve(nil) unless @resolved
185
+ end
186
+ end
187
+ end
188
+ end
data/lib/async/queue.rb CHANGED
@@ -10,10 +10,14 @@
10
10
  require_relative "notification"
11
11
 
12
12
  module Async
13
- # A queue which allows items to be processed in order.
13
+ # A thread-safe queue which allows items to be processed in order.
14
+ #
15
+ # This implementation uses Thread::Queue internally for thread safety while
16
+ # maintaining compatibility with the fiber scheduler.
14
17
  #
15
18
  # It has a compatible interface with {Notification} and {Condition}, except that it's multi-value.
16
19
  #
20
+ # @asynchronous This class is thread-safe.
17
21
  # @public Since *Async v1*.
18
22
  class Queue
19
23
  # An error raised when trying to enqueue items to a closed queue.
@@ -21,48 +25,39 @@ module Async
21
25
  class ClosedError < RuntimeError
22
26
  end
23
27
 
24
- # Create a new queue.
28
+ # Create a new thread-safe queue.
25
29
  #
26
30
  # @parameter parent [Interface(:async) | Nil] The parent task to use for async operations.
27
- # @parameter available [Notification] The notification to use for signaling when items are available.
28
- def initialize(parent: nil, available: Notification.new)
29
- @items = []
30
- @closed = false
31
+ def initialize(parent: nil, delegate: Thread::Queue.new)
32
+ @delegate = delegate
31
33
  @parent = parent
32
- @available = available
34
+ end
35
+
36
+ # @returns [Boolean] Whether the queue is closed.
37
+ def closed?
38
+ @delegate.closed?
33
39
  end
34
40
 
35
41
  # Close the queue, causing all waiting tasks to return `nil`. Any subsequent calls to {enqueue} will raise an exception.
36
42
  def close
37
- @closed = true
38
-
39
- while @available.waiting?
40
- @available.signal(nil)
41
- end
43
+ @delegate.close
42
44
  end
43
45
 
44
- # @attribute [Array] The items in the queue.
45
- attr :items
46
-
47
46
  # @returns [Integer] The number of items in the queue.
48
47
  def size
49
- @items.size
48
+ @delegate.size
50
49
  end
51
50
 
52
51
  # @returns [Boolean] Whether the queue is empty.
53
52
  def empty?
54
- @items.empty?
53
+ @delegate.empty?
55
54
  end
56
55
 
57
56
  # Add an item to the queue.
58
57
  def push(item)
59
- if @closed
60
- raise ClosedError, "Cannot push items to a closed queue."
61
- end
62
-
63
- @items << item
64
-
65
- @available.signal unless self.empty?
58
+ @delegate.push(item)
59
+ rescue ClosedQueueError
60
+ raise ClosedError, "Cannot enqueue items to a closed queue!"
66
61
  end
67
62
 
68
63
  # Compatibility with {::Queue#push}.
@@ -72,31 +67,19 @@ module Async
72
67
 
73
68
  # Add multiple items to the queue.
74
69
  def enqueue(*items)
75
- if @closed
76
- raise ClosedError, "Cannot enqueue items to a closed queue."
77
- end
78
-
79
- @items.concat(items)
80
-
81
- @available.signal unless self.empty?
70
+ items.each {|item| @delegate.push(item)}
71
+ rescue ClosedQueueError
72
+ raise ClosedError, "Cannot enqueue items to a closed queue!"
82
73
  end
83
74
 
84
75
  # Remove and return the next item from the queue.
85
76
  def dequeue
86
- while @items.empty?
87
- if @closed
88
- return nil
89
- end
90
-
91
- @available.wait
92
- end
93
-
94
- @items.shift
77
+ @delegate.pop
95
78
  end
96
79
 
97
80
  # Compatibility with {::Queue#pop}.
98
- def pop
99
- self.dequeue
81
+ def pop(...)
82
+ @delegate.pop(...)
100
83
  end
101
84
 
102
85
  # Process each item in the queue.
@@ -131,7 +114,8 @@ module Async
131
114
  end
132
115
  end
133
116
 
134
- # A queue which limits the number of items that can be enqueued.
117
+ # A thread-safe queue which limits the number of items that can be enqueued.
118
+ #
135
119
  # @public Since *Async v1*.
136
120
  class LimitedQueue < Queue
137
121
  # @private This exists purely for emitting a warning.
@@ -144,78 +128,19 @@ module Async
144
128
  # Create a new limited queue.
145
129
  #
146
130
  # @parameter limit [Integer] The maximum number of items that can be enqueued.
147
- # @parameter full [Notification] The notification to use for signaling when the queue is full.
148
- def initialize(limit = 1, full: Notification.new, **options)
149
- super(**options)
150
-
151
- @limit = limit
152
- @full = full
131
+ # @parameter full [Notification] The notification to use for signaling when the queue is full. (ignored, for compatibility)
132
+ def initialize(limit = 1, **options)
133
+ super(**options, delegate: Thread::SizedQueue.new(limit))
153
134
  end
154
135
 
155
136
  # @attribute [Integer] The maximum number of items that can be enqueued.
156
- attr :limit
157
-
158
- # Close the queue, causing all waiting tasks to return `nil`. Any subsequent calls to {enqueue} will raise an exception.
159
- # Also signals all tasks waiting for the queue to be full.
160
- def close
161
- super
162
-
163
- while @full.waiting?
164
- @full.signal(nil)
165
- end
137
+ def limit
138
+ @delegate.max
166
139
  end
167
140
 
168
141
  # @returns [Boolean] Whether trying to enqueue an item would block.
169
142
  def limited?
170
- !@closed && @items.size >= @limit
171
- end
172
-
173
- # Add an item to the queue.
174
- #
175
- # If the queue is full, this method will block until there is space available.
176
- #
177
- # @parameter item [Object] The item to add to the queue.
178
- def push(item)
179
- while limited?
180
- @full.wait
181
- end
182
-
183
- super
184
- end
185
-
186
- # Add multiple items to the queue.
187
- #
188
- # If the queue is full, this method will block until there is space available.
189
- #
190
- # @parameter items [Array] The items to add to the queue.
191
- def enqueue(*items)
192
- while !items.empty?
193
- while limited?
194
- @full.wait
195
- end
196
-
197
- if @closed
198
- raise ClosedError, "Cannot enqueue items to a closed queue."
199
- end
200
-
201
- available = @limit - @items.size
202
- @items.concat(items.shift(available))
203
-
204
- @available.signal unless self.empty?
205
- end
206
- end
207
-
208
- # Remove and return the next item from the queue.
209
- #
210
- # If the queue is empty, this method will block until an item is available.
211
- #
212
- # @returns [Object] The next item in the queue.
213
- def dequeue
214
- item = super
215
-
216
- @full.signal
217
-
218
- return item
143
+ !@delegate.closed? && @delegate.size >= @delegate.max
219
144
  end
220
145
  end
221
146
  end
data/lib/async/task.rb CHANGED
@@ -7,12 +7,14 @@
7
7
  # Copyright, 2020, by Patrik Wenger.
8
8
  # Copyright, 2023, by Math Ieu.
9
9
  # Copyright, 2025, by Shigeru Nakajima.
10
+ # Copyright, 2025, by Shopify Inc.
10
11
 
11
12
  require "fiber"
12
13
  require "console"
13
14
 
14
15
  require_relative "node"
15
16
  require_relative "condition"
17
+ require_relative "promise"
16
18
  require_relative "stop"
17
19
 
18
20
  Fiber.attr_accessor :async_task
@@ -63,14 +65,24 @@ module Async
63
65
 
64
66
  # These instance variables are critical to the state of the task.
65
67
  # In the initialized state, the @block should be set, but the @fiber should be nil.
66
- # In the running state, the @fiber should be set.
68
+ # In the running state, the @fiber should be set, and @block should be nil.
67
69
  # In a finished state, the @block should be nil, and the @fiber should be nil.
68
70
  @block = block
69
71
  @fiber = nil
70
72
 
71
- @status = :initialized
72
- @result = nil
73
- @finished = finished
73
+ @promise = Promise.new
74
+
75
+ # Handle finished: parameter for backward compatibility:
76
+ case finished
77
+ when false
78
+ # `finished: false` suppresses warnings for expected task failures:
79
+ @promise.suppress_warnings!
80
+ when nil
81
+ # `finished: nil` is the default, no special handling:
82
+ else
83
+ # All other `finished:` values are deprecated:
84
+ warn("finished: argument with non-false value is deprecated and will be removed.", uplevel: 1, category: :deprecated) if $VERBOSE
85
+ end
74
86
 
75
87
  @defer_stop = nil
76
88
  end
@@ -109,7 +121,7 @@ module Async
109
121
 
110
122
  # @returns [String] A description of the task and it's current status.
111
123
  def to_s
112
- "\#<#{self.description} (#{@status})>"
124
+ "\#<#{self.description} (#{self.status})>"
113
125
  end
114
126
 
115
127
  # @deprecated Prefer {Kernel#sleep} except when compatibility with `stable-v1` is required.
@@ -146,22 +158,22 @@ module Async
146
158
 
147
159
  # @returns [Boolean] Whether the task is running.
148
160
  def running?
149
- @status == :running
161
+ self.alive?
150
162
  end
151
163
 
152
164
  # @returns [Boolean] Whether the task failed with an exception.
153
165
  def failed?
154
- @status == :failed
166
+ @promise.failed?
155
167
  end
156
168
 
157
169
  # @returns [Boolean] Whether the task has been stopped.
158
170
  def stopped?
159
- @status == :stopped
171
+ @promise.cancelled?
160
172
  end
161
173
 
162
174
  # @returns [Boolean] Whether the task has completed execution and generated a result.
163
175
  def completed?
164
- @status == :completed
176
+ @promise.completed?
165
177
  end
166
178
 
167
179
  # Alias for {#completed?}.
@@ -170,20 +182,32 @@ module Async
170
182
  end
171
183
 
172
184
  # @attribute [Symbol] The status of the execution of the task, one of `:initialized`, `:running`, `:complete`, `:stopped` or `:failed`.
173
- attr :status
185
+ def status
186
+ case @promise.resolved
187
+ when :cancelled
188
+ :stopped
189
+ when :failed
190
+ :failed
191
+ when :completed
192
+ :completed
193
+ when nil
194
+ self.running? ? :running : :initialized
195
+ end
196
+ end
174
197
 
175
198
  # Begin the execution of the task.
176
199
  #
177
200
  # @raises [RuntimeError] If the task is already running.
178
201
  def run(*arguments)
179
- if @status == :initialized
180
- @status = :running
202
+ # Move from initialized to running by clearing @block
203
+ if block = @block
204
+ @block = nil
181
205
 
182
206
  schedule do
183
- @block.call(self, *arguments)
207
+ block.call(self, *arguments)
184
208
  rescue => error
185
209
  # I'm not completely happy with this overhead, but the alternative is to not log anything which makes debugging extremely difficult. Maybe we can introduce a debug wrapper which adds extra logging.
186
- if @finished.nil?
210
+ unless @promise.waiting?
187
211
  warn(self, "Task may have ended with unhandled exception.", exception: error)
188
212
  end
189
213
 
@@ -225,24 +249,29 @@ module Async
225
249
  #
226
250
  # @raises [RuntimeError] If the task's fiber is the current fiber.
227
251
  # @returns [Object] The final expression/result of the task's block.
252
+ # @asynchronous This method is thread-safe.
228
253
  def wait
229
254
  raise "Cannot wait on own fiber!" if Fiber.current.equal?(@fiber)
230
255
 
231
- # `finish!` will set both of these to nil before signaling the condition:
232
- if @block || @fiber
233
- @finished ||= Condition.new
234
- @finished.wait
235
- end
236
-
237
- if @status == :failed
238
- raise @result
239
- else
240
- return @result
256
+ # Wait for the task to complete - Promise handles all the complexity:
257
+ begin
258
+ @promise.wait
259
+ rescue Promise::Cancel
260
+ # For backward compatibility, stopped tasks return nil
261
+ return nil
241
262
  end
242
263
  end
243
264
 
244
265
  # Access the result of the task without waiting. May be nil if the task is not completed. Does not raise exceptions.
245
- attr :result
266
+ def result
267
+ value = @promise.value
268
+ # For backward compatibility, return nil for stopped tasks
269
+ if @promise.cancelled?
270
+ nil
271
+ else
272
+ value
273
+ end
274
+ end
246
275
 
247
276
  # Stop the task and all of its children.
248
277
  #
@@ -375,29 +404,25 @@ module Async
375
404
 
376
405
  # Attempt to remove this node from the task tree.
377
406
  consume
378
-
379
- # If this task was being used as a future, signal completion here:
380
- if @finished
381
- @finished.signal(self)
382
- @finished = nil
383
- end
384
407
  end
385
408
 
386
409
  # State transition into the completed state.
387
410
  def completed!(result)
388
- @result = result
389
- @status = :completed
411
+ # Resolve the promise with the result:
412
+ @promise&.resolve(result)
390
413
  end
391
414
 
392
415
  # State transition into the failed state.
393
416
  def failed!(exception = false)
394
- @result = exception
395
- @status = :failed
417
+ # Reject the promise with the exception:
418
+ @promise&.reject(exception)
396
419
  end
397
420
 
398
421
  def stopped!
399
422
  # Console.info(self, status:) {"Task #{self} was stopped with #{@children&.size.inspect} children!"}
400
- @status = :stopped
423
+
424
+ # Cancel the promise:
425
+ @promise&.cancel
401
426
 
402
427
  stopped = false
403
428
 
@@ -442,7 +467,7 @@ module Async
442
467
 
443
468
  @fiber.async_task = self
444
469
 
445
- self.root.resume(@fiber)
470
+ (Fiber.scheduler || self.reactor).resume(@fiber)
446
471
  end
447
472
  end
448
473
  end
@@ -2,16 +2,21 @@
2
2
 
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2021-2025, by Samuel Williams.
5
+ # Copyright, 2025, by Shopify Inc.
5
6
 
6
7
  require_relative "condition"
7
8
 
8
9
  module Async
9
10
  # A synchronization primitive that allows one task to wait for another task to resolve a value.
11
+ #
12
+ # @deprecated Use {Async::Promise} instead.
10
13
  class Variable
11
14
  # Create a new variable.
12
15
  #
13
16
  # @parameter condition [Condition] The condition to use for synchronization.
14
17
  def initialize(condition = Condition.new)
18
+ warn("`Async::Variable` is deprecated, use `Async::Promise` instead.", category: :deprecated, uplevel: 1) if $VERBOSE
19
+
15
20
  @condition = condition
16
21
  @value = nil
17
22
  end
data/lib/async/version.rb CHANGED
@@ -4,5 +4,5 @@
4
4
  # Copyright, 2017-2025, by Samuel Williams.
5
5
 
6
6
  module Async
7
- VERSION = "2.28.0"
7
+ VERSION = "2.29.0"
8
8
  end
data/lib/async/waiter.rb CHANGED
@@ -3,9 +3,11 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2022-2025, by Samuel Williams.
5
5
  # Copyright, 2024, by Patrik Wenger.
6
+ # Copyright, 2025, by Shopify Inc.
6
7
 
7
8
  module Async
8
9
  # A composable synchronization primitive, which allows one task to wait for a number of other tasks to complete. It can be used in conjunction with {Semaphore} and/or {Barrier}.
10
+ #
9
11
  # @deprecated `Async::Waiter` is deprecated, use `Async::Barrier` instead.
10
12
  class Waiter
11
13
  # Create a waiter instance.
data/lib/kernel/sync.rb CHANGED
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2024, by Samuel Williams.
4
+ # Copyright, 2019-2025, by Samuel Williams.
5
5
  # Copyright, 2020, by Brian Morearty.
6
6
  # Copyright, 2024, by Patrik Wenger.
7
+ # Copyright, 2025, by Shopify Inc.
7
8
 
8
9
  require_relative "../async/reactor"
9
10
 
@@ -30,7 +31,10 @@ module Kernel
30
31
  reactor = Async::Reactor.new
31
32
 
32
33
  begin
33
- return reactor.run(annotation: annotation, finished: ::Async::Condition.new, &block).wait
34
+ # Use finished: false to suppress warnings since we're handling exceptions explicitly
35
+ task = reactor.async(annotation: annotation, finished: false, &block)
36
+ reactor.run
37
+ return task.wait
34
38
  ensure
35
39
  Fiber.set_scheduler(nil)
36
40
  end
data/readme.md CHANGED
@@ -35,6 +35,20 @@ Please see the [project documentation](https://socketry.github.io/async/) for mo
35
35
 
36
36
  Please see the [project releases](https://socketry.github.io/async/releases/index) for all releases.
37
37
 
38
+ ### v2.29.0
39
+
40
+ This release introduces thread-safety as a core concept of Async. Many core classes now have thread-safe guarantees, allowing them to be used safely across multiple threads.
41
+
42
+ - Thread-safe `Async::Condition` and `Async::Notification`, implemented using `Thread::Queue`.
43
+ - Thread-safe `Async::Queue` and `Async::LimitedQueue`, implemented using `Thread::Queue` and `Thread::LimitedQueue` respectively.
44
+ - `Async::Variable` is deprecated in favor of `Async::Promise`.
45
+ - [Introduce `Async::Promise`](https://socketry.github.io/async/releases/index#introduce-async::promise)
46
+ - [Introduce `Async::PriorityQueue`](https://socketry.github.io/async/releases/index#introduce-async::priorityqueue)
47
+
48
+ ### v2.28.1
49
+
50
+ - Fix race condition between `Async::Barrier#stop` and finish signalling.
51
+
38
52
  ### v2.28.0
39
53
 
40
54
  - Use `Traces.current_context` and `Traces.with_context` for better integration with OpenTelemetry.
@@ -77,17 +91,6 @@ Please see the [project releases](https://socketry.github.io/async/releases/inde
77
91
  - [Use `IO::Event::WorkerPool` for Blocking Operations](https://socketry.github.io/async/releases/index#use-io::event::workerpool-for-blocking-operations)
78
92
  - [Better handling of `IO#close` using `fiber_interrupt`](https://socketry.github.io/async/releases/index#better-handling-of-io#close-using-fiber_interrupt)
79
93
 
80
- ### v2.24.0
81
-
82
- - Ruby v3.1 support is dropped.
83
- - `Async::Wrapper` which was previously deprecated, is now removed.
84
- - [Flexible Timeouts](https://socketry.github.io/async/releases/index#flexible-timeouts)
85
-
86
- ### v2.23.0
87
-
88
- - Rename `ASYNC_SCHEDULER_DEFAULT_WORKER_POOL` to `ASYNC_SCHEDULER_WORKER_POOL`.
89
- - [Fiber Stall Profiler](https://socketry.github.io/async/releases/index#fiber-stall-profiler)
90
-
91
94
  ## See Also
92
95
 
93
96
  - [async-http](https://github.com/socketry/async-http) — Asynchronous HTTP client/server.
data/releases.md CHANGED
@@ -1,5 +1,87 @@
1
1
  # Releases
2
2
 
3
+ ## v2.29.0
4
+
5
+ This release introduces thread-safety as a core concept of Async. Many core classes now have thread-safe guarantees, allowing them to be used safely across multiple threads.
6
+
7
+ - Thread-safe `Async::Condition` and `Async::Notification`, implemented using `Thread::Queue`.
8
+ - Thread-safe `Async::Queue` and `Async::LimitedQueue`, implemented using `Thread::Queue` and `Thread::LimitedQueue` respectively.
9
+ - `Async::Variable` is deprecated in favor of `Async::Promise`.
10
+
11
+ ### Introduce `Async::Promise`
12
+
13
+ This release introduces the new `Async::Promise` class and refactors `Async::Task` to use promises for state management internally. This architectural improvement achieves the design goal that "a task should be a promise with attached computation and cancellation handling."
14
+
15
+ - **Thread-safe promise implementation** with immutable state transitions.
16
+ - **Consistent state management** using symbols: `:completed`, `:failed`, `:cancelled`.
17
+ - **Promise cancellation** with `cancel()` method and `Cancel` exception class.
18
+ - **Comprehensive test coverage** with 47 new test cases covering all edge cases.
19
+
20
+ <!-- end list -->
21
+
22
+ ``` ruby
23
+ require 'async/promise'
24
+
25
+ # Basic promise usage - works independently of Async framework
26
+ promise = Async::Promise.new
27
+
28
+ # In another thread or fiber, resolve the promise
29
+ Thread.new do
30
+ sleep(1) # Simulate some work
31
+ promise.resolve("Hello, World!")
32
+ end
33
+
34
+ # Wait for the result
35
+ result = promise.wait
36
+ puts result # => "Hello, World!"
37
+
38
+ # Check promise state
39
+ puts promise.resolved? # => true
40
+ puts promise.completed? # => true
41
+ ```
42
+
43
+ Promises bridge Thread and Fiber concurrency models - a promise resolved in one thread can be awaited in a fiber, and vice versa.
44
+
45
+ ### Introduce `Async::PriorityQueue`
46
+
47
+ The new `Async::PriorityQueue` provides a thread-safe, fiber-aware queue where consumers can specify priority levels. Higher priority consumers are served first when items become available, with FIFO ordering maintained for equal priorities. This is useful for implementing priority-based task processing systems where critical operations need to be handled before lower priority work.
48
+
49
+ ``` ruby
50
+ require 'async'
51
+ require 'async/priority_queue'
52
+
53
+ Async do
54
+ queue = Async::PriorityQueue.new
55
+
56
+ # Start consumers with different priorities
57
+ low_priority = async do
58
+ puts "Low priority consumer got: #{queue.dequeue(priority: 1)}"
59
+ end
60
+
61
+ medium_priority = async do
62
+ puts "Medium priority consumer got: #{queue.dequeue(priority: 5)}"
63
+ end
64
+
65
+ high_priority = async do
66
+ puts "High priority consumer got: #{queue.dequeue(priority: 10)}"
67
+ end
68
+
69
+ # Add items to the queue
70
+ queue.push("first item")
71
+ queue.push("second item")
72
+ queue.push("third item")
73
+
74
+ # Output:
75
+ # High priority consumer got: first item
76
+ # Medium priority consumer got: second item
77
+ # Low priority consumer got: third item
78
+ end
79
+ ```
80
+
81
+ ## v2.28.1
82
+
83
+ - Fix race condition between `Async::Barrier#stop` and finish signalling.
84
+
3
85
  ## v2.28.0
4
86
 
5
87
  - Use `Traces.current_context` and `Traces.with_context` for better integration with OpenTelemetry.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.28.0
4
+ version: 2.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -145,7 +145,6 @@ executables: []
145
145
  extensions: []
146
146
  extra_rdoc_files: []
147
147
  files:
148
- - agent.md
149
148
  - context/best-practices.md
150
149
  - context/debugging.md
151
150
  - context/getting-started.md
@@ -165,6 +164,8 @@ files:
165
164
  - lib/async/list.rb
166
165
  - lib/async/node.rb
167
166
  - lib/async/notification.rb
167
+ - lib/async/priority_queue.rb
168
+ - lib/async/promise.rb
168
169
  - lib/async/queue.rb
169
170
  - lib/async/reactor.rb
170
171
  - lib/async/scheduler.rb
metadata.gz.sig CHANGED
Binary file
data/agent.md DELETED
@@ -1,63 +0,0 @@
1
- # Agent
2
-
3
- ## Context
4
-
5
- This section provides links to documentation from installed packages. It is automatically generated and may be updated by running `bake agent:context:install`.
6
-
7
- **Important:** Before performing any code, documentation, or analysis tasks, always read and apply the full content of any relevant documentation referenced in the following sections. These context files contain authoritative standards and best practices for documentation, code style, and project-specific workflows. **Do not proceed with any actions until you have read and incorporated the guidance from relevant context files.**
8
-
9
- ### agent-context
10
-
11
- Install and manage context files from Ruby gems.
12
-
13
- #### [Usage Guide](.context/agent-context/usage.md)
14
-
15
- `agent-context` is a tool that helps you discover and install contextual information from Ruby gems for AI agents. Gems can provide additional documentation, examples, and guidance in a `context/` ...
16
-
17
- ### decode
18
-
19
- Code analysis for documentation generation.
20
-
21
- #### [Getting Started with Decode](.context/decode/getting-started.md)
22
-
23
- The Decode gem provides programmatic access to Ruby code structure and metadata. It can parse Ruby files and extract definitions, comments, and documentation pragmas, enabling code analysis, docume...
24
-
25
- #### [Documentation Coverage](.context/decode/coverage.md)
26
-
27
- This guide explains how to test and monitor documentation coverage in your Ruby projects using the Decode gem's built-in bake tasks.
28
-
29
- #### [Ruby Documentation](.context/decode/ruby-documentation.md)
30
-
31
- This guide covers documentation practices and pragmas supported by the Decode gem for documenting Ruby code. These pragmas provide structured documentation that can be parsed and used to generate A...
32
-
33
- #### [Setting Up RBS Types and Steep Type Checking for Ruby Gems](.context/decode/types.md)
34
-
35
- This guide covers the process for establishing robust type checking in Ruby gems using RBS and Steep, focusing on automated generation from source documentation and proper validation.
36
-
37
- ### sus
38
-
39
- A fast and scalable test runner.
40
-
41
- #### [Using Sus Testing Framework](.context/sus/usage.md)
42
-
43
- Sus is a modern Ruby testing framework that provides a clean, BDD-style syntax for writing tests. It's designed to be fast, simple, and expressive.
44
-
45
- #### [Mocking](.context/sus/mocking.md)
46
-
47
- There are two types of mocking in sus: `receive` and `mock`. The `receive` matcher is a subset of full mocking and is used to set expectations on method calls, while `mock` can be used to replace m...
48
-
49
- #### [Shared Test Behaviors and Fixtures](.context/sus/shared.md)
50
-
51
- Sus provides shared test contexts which can be used to define common behaviours or tests that can be reused across one or more test files.
52
-
53
- ### sus-fixtures-agent-context
54
-
55
- Test fixtures for running in Async.
56
-
57
- #### [Getting Started](.context/sus-fixtures-agent-context/getting-started.md)
58
-
59
- This guide explains how to use the `sus-fixtures-agent-context` gem to test agent contexts.
60
-
61
- #### [GitHub Actions](.context/sus-fixtures-agent-context/github-actions.md)
62
-
63
- This guide explains how to integrate the `sus-fixtures-agent-context` gem with GitHub Actions for testing agent contexts.