async 2.28.1 → 2.32.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: efc7dde75b60834caa61f17a3f742ff7836422c0dbc8ab6dc0f67392c00bff0e
4
- data.tar.gz: ee3ffc8bab4adc4f98c5df81dda26f74266eb12512aba815b65cab587bb280e5
3
+ metadata.gz: 5a816b65e2cdf3f1f9ee99d421b32f13ed00c858bfa2b1ff0f814cf33c9dd470
4
+ data.tar.gz: 85dc9dfad110e4ae2f0d4db45db9b1f941b797891f945da1caaa302df233f813
5
5
  SHA512:
6
- metadata.gz: 239dfdc750425c0ac90abdf981faa3358593b2d4aa782897e9b7b073fddff04d631606d6bd61d60904fbdd71fb91536dddae5b63343d831a850d67d7ee9782b7
7
- data.tar.gz: 83d4af2ab658fef715ad268cb936bbc30851948135b935191d7bdaecad45d7eef9d3f988b8c0857129d4bff0d58648724410ee16671639a7c64d718526e7a175
6
+ metadata.gz: e6cf114ad81e9eb511504ed44b719f9838ad84623fea14aab43acd531a9d8493b92eb0a24a49bb410670c0be82c8de5617280f39e984f1d9477c4828d96d2fc5
7
+ data.tar.gz: 2acb3e1516b2670a57cb717713d882f9d75807864924bf7aa691aa82cd5406a2b273c7aa813a30037a3dabce338095eb7faa5efff8f5f5b56290a07b3b0927b5
checksums.yaml.gz.sig CHANGED
Binary file
@@ -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
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require_relative "clock"
7
+
8
+ # @namespace
9
+ module Async
10
+ # Represents a deadline timeout with decrementing remaining time.
11
+ # Includes an efficient representation for zero (non-blocking) timeouts.
12
+ # @public Since *Async v2.31*.
13
+ class Deadline
14
+ # Singleton module for immediate timeouts (zero or negative).
15
+ # Avoids object allocation for fast path (non-blocking) timeouts.
16
+ module Zero
17
+ # Check if the deadline has expired.
18
+ # @returns [Boolean] Always returns true since zero timeouts are immediately expired.
19
+ def self.expired?
20
+ true
21
+ end
22
+
23
+ # Get the remaining time.
24
+ # @returns [Integer] Always returns 0 since zero timeouts have no remaining time.
25
+ def self.remaining
26
+ 0
27
+ end
28
+ end
29
+
30
+ # Create a deadline for the given timeout.
31
+ # @parameter timeout [Numeric | Nil] The timeout duration, or nil for no timeout.
32
+ # @returns [Deadline | Nil] A deadline instance, Zero singleton, or nil.
33
+ def self.start(timeout)
34
+ if timeout.nil?
35
+ nil
36
+ elsif timeout <= 0
37
+ Zero
38
+ else
39
+ self.new(timeout)
40
+ end
41
+ end
42
+
43
+ # Create a new deadline with the specified remaining time.
44
+ # @parameter remaining [Numeric] The initial remaining time.
45
+ def initialize(remaining)
46
+ @remaining = remaining
47
+ @start = Clock.now
48
+ end
49
+
50
+ # Get the remaining time, updating internal state.
51
+ # Each call to this method advances the internal clock and reduces
52
+ # the remaining time by the elapsed duration since the last call.
53
+ # @returns [Numeric] The remaining time (may be negative if expired).
54
+ def remaining
55
+ now = Clock.now
56
+ delta = now - @start
57
+ @start = now
58
+
59
+ @remaining -= delta
60
+
61
+ return @remaining
62
+ end
63
+
64
+ # Check if the deadline has expired.
65
+ # @returns [Boolean] True if no time remains.
66
+ def expired?
67
+ self.remaining <= 0
68
+ end
69
+ end
70
+ 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,253 @@
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, timeout = nil)
43
+ condition.wait(mutex, timeout)
44
+ return self.value
45
+ end
46
+
47
+ # Invalidate this waiter, making it unusable and detectable as abandoned.
48
+ def invalidate!
49
+ self.fiber = nil
50
+ end
51
+
52
+ # Check if this waiter has been invalidated.
53
+ def valid?
54
+ self.fiber&.alive?
55
+ end
56
+ end
57
+
58
+ private_constant :Waiter
59
+
60
+ # Create a new priority queue.
61
+ #
62
+ # @parameter parent [Interface(:async) | Nil] The parent task to use for async operations.
63
+ def initialize(parent: nil)
64
+ @items = []
65
+ @closed = false
66
+ @parent = parent
67
+ @waiting = IO::Event::PriorityHeap.new
68
+ @sequence = 0
69
+
70
+ @mutex = Mutex.new
71
+ end
72
+
73
+ # Close the queue, causing all waiting tasks to return `nil`.
74
+ # Any subsequent calls to {enqueue} will raise an exception.
75
+ def close
76
+ @mutex.synchronize do
77
+ @closed = true
78
+
79
+ # Signal all waiting fibers with nil, skipping dead/invalid ones:
80
+ while waiter = @waiting.pop
81
+ waiter.signal(nil)
82
+ end
83
+ end
84
+ end
85
+
86
+ # @returns [Boolean] Whether the queue is closed.
87
+ def closed?
88
+ @closed
89
+ end
90
+
91
+ # @attribute [Array] The items in the queue.
92
+ attr :items
93
+
94
+ # @returns [Integer] The number of items in the queue.
95
+ def size
96
+ @items.size
97
+ end
98
+
99
+ # @returns [Boolean] Whether the queue is empty.
100
+ def empty?
101
+ @items.empty?
102
+ end
103
+
104
+ # @returns [Integer] The number of fibers waiting to dequeue.
105
+ def waiting_count
106
+ @mutex.synchronize do
107
+ @waiting.size
108
+ end
109
+ end
110
+
111
+ # @deprecated Use {#waiting_count} instead.
112
+ alias waiting waiting_count
113
+
114
+ # Add an item to the queue.
115
+ #
116
+ # @parameter item [Object] The item to add to the queue.
117
+ def push(item)
118
+ @mutex.synchronize do
119
+ if @closed
120
+ raise ClosedError, "Cannot push items to a closed queue."
121
+ end
122
+
123
+ @items << item
124
+
125
+ # Wake up the highest priority waiter if any, skipping dead/invalid waiters:
126
+ while waiter = @waiting.pop
127
+ if waiter.valid?
128
+ value = @items.shift
129
+ waiter.signal(value)
130
+ break
131
+ end
132
+ # Dead/invalid waiter discarded, try next one.
133
+ end
134
+ end
135
+ end
136
+
137
+ # Compatibility with {::Queue#push}.
138
+ def <<(item)
139
+ self.push(item)
140
+ end
141
+
142
+ # Add multiple items to the queue.
143
+ #
144
+ # @parameter items [Array] The items to add to the queue.
145
+ def enqueue(*items)
146
+ @mutex.synchronize do
147
+ if @closed
148
+ raise ClosedError, "Cannot enqueue items to a closed queue."
149
+ end
150
+
151
+ @items.concat(items)
152
+
153
+ # Wake up waiting fibers in priority order, skipping dead/invalid waiters:
154
+ while !@items.empty? && (waiter = @waiting.pop)
155
+ if waiter.valid?
156
+ value = @items.shift
157
+ waiter.signal(value)
158
+ end
159
+ # Dead/invalid waiter discarded, continue to next one.
160
+ end
161
+ end
162
+ end
163
+
164
+ # Remove and return the next item from the queue.
165
+ #
166
+ # If the queue is empty, this method will block until an item is available or timeout expires.
167
+ # Fibers are served in priority order, with higher priority fibers receiving
168
+ # items first.
169
+ #
170
+ # @parameter priority [Numeric] The priority of this consumer (higher = served first).
171
+ # @parameter timeout [Numeric, nil] Maximum time to wait for an item. If nil, waits indefinitely. If 0, returns immediately.
172
+ # @returns [Object, nil] The next item in the queue, or nil if timeout expires.
173
+ def dequeue(priority: 0, timeout: nil)
174
+ @mutex.synchronize do
175
+ # If queue is closed and empty, return nil immediately:
176
+ if @closed && @items.empty?
177
+ return nil
178
+ end
179
+
180
+ # Fast path: if items available and either no waiters or we have higher priority:
181
+ unless @items.empty?
182
+ head = @waiting.peek
183
+ if head.nil? or priority > head.priority
184
+ return @items.shift
185
+ end
186
+ end
187
+
188
+ # Handle immediate timeout (non-blocking)
189
+ return nil if timeout == 0
190
+
191
+ # Need to wait - create our own condition variable and add to waiting queue:
192
+ sequence = @sequence
193
+ @sequence += 1
194
+
195
+ condition = ConditionVariable.new
196
+
197
+ begin
198
+ waiter = Waiter.new(Fiber.current, priority, sequence, condition, nil)
199
+ @waiting.push(waiter)
200
+
201
+ # Wait for our specific condition variable to be signaled:
202
+ return waiter.wait_for_value(@mutex, timeout)
203
+ ensure
204
+ waiter&.invalidate!
205
+ end
206
+ end
207
+ end
208
+
209
+ # Compatibility with {::Queue#pop}.
210
+ #
211
+ # @parameter priority [Numeric] The priority of this consumer.
212
+ # @parameter timeout [Numeric, nil] Maximum time to wait for an item. If nil, waits indefinitely. If 0, returns immediately.
213
+ # @returns [Object, nil] The dequeued item, or nil if timeout expires.
214
+ def pop(priority: 0, timeout: nil)
215
+ self.dequeue(priority: priority, timeout: timeout)
216
+ end
217
+
218
+ # Process each item in the queue.
219
+ #
220
+ # @asynchronous Executes the given block concurrently for each item.
221
+ #
222
+ # @parameter priority [Numeric] The priority for processing items.
223
+ # @parameter parent [Interface(:async) | Nil] The parent task to use for async operations.
224
+ # @parameter options [Hash] The options to pass to the task.
225
+ # @yields {|task| ...} When the system is idle, the block will be executed in a new task.
226
+ def async(priority: 0, parent: (@parent or Task.current), **options, &block)
227
+ while item = self.dequeue(priority: priority)
228
+ parent.async(item, **options, &block)
229
+ end
230
+ end
231
+
232
+ # Enumerate each item in the queue.
233
+ #
234
+ # @parameter priority [Numeric] The priority for dequeuing items.
235
+ def each(priority: 0)
236
+ while item = self.dequeue(priority: priority)
237
+ yield item
238
+ end
239
+ end
240
+
241
+ # Signal the queue with a value, the same as {#enqueue}.
242
+ def signal(value = nil)
243
+ self.enqueue(value)
244
+ end
245
+
246
+ # Wait for an item to be available, the same as {#dequeue}.
247
+ #
248
+ # @parameter priority [Numeric] The priority of this consumer.
249
+ def wait(priority: 0)
250
+ self.dequeue(priority: priority)
251
+ end
252
+ end
253
+ 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