async-background 1.0.1 → 1.1.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 +4 -4
- data/CHANGELOG.md +153 -0
- data/README.md +36 -15
- data/async-background.gemspec +13 -7
- data/lib/async/background/metrics.rb +1 -3
- data/lib/async/background/queue/schema.rb +14 -14
- data/lib/async/background/queue/socket_notifier.rb +44 -13
- data/lib/async/background/queue/socket_waker.rb +115 -49
- data/lib/async/background/queue/sql.rb +2 -1
- data/lib/async/background/queue/store.rb +98 -75
- data/lib/async/background/runner/queue_execution.rb +32 -8
- data/lib/async/background/runner/schedule.rb +16 -13
- data/lib/async/background/runner.rb +132 -52
- data/lib/async/background/runtime/notification.rb +41 -0
- data/lib/async/background/runtime/semaphore.rb +147 -0
- data/lib/async/background/runtime/task.rb +145 -0
- data/lib/async/background/runtime/task_group.rb +81 -0
- data/lib/async/background/runtime.rb +243 -0
- data/lib/async/background/scheduler.rb +145 -0
- data/lib/async/background/version.rb +1 -1
- data/lib/async/background/web/app.rb +22 -26
- data/lib/async/background/web/configuration.rb +40 -93
- data/lib/async/background/web/cursor.rb +10 -21
- data/lib/async/background/web/event_hub.rb +12 -7
- data/lib/async/background/web/metrics_reader.rb +29 -35
- data/lib/async/background/web/response.rb +39 -51
- data/lib/async/background/web/serializer.rb +23 -63
- data/lib/async/background/web/snapshot.rb +32 -78
- data/lib/async/background/web/sql.rb +58 -64
- data/lib/async/background.rb +1 -2
- metadata +26 -22
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
require_relative 'runtime'
|
|
5
4
|
require_relative 'runner/queue_execution'
|
|
6
5
|
require_relative 'runner/schedule'
|
|
7
6
|
|
|
8
7
|
module Async
|
|
9
8
|
module Background
|
|
10
|
-
class ConfigError <
|
|
9
|
+
class ConfigError < Error; end
|
|
11
10
|
|
|
12
11
|
DEFAULT_TIMEOUT = 30
|
|
13
12
|
MIN_SLEEP_TIME = 0.1
|
|
14
13
|
MAX_JITTER = 5
|
|
15
14
|
QUEUE_POLL_INTERVAL = 5
|
|
16
15
|
MIN_QUEUE_WAIT = 0.001
|
|
16
|
+
QUEUE_ERROR_BACKOFF = 0.5
|
|
17
|
+
SERVICE_SHUTDOWN_GRACE = 5
|
|
18
|
+
SERVICE_CANCEL_GRACE = 2
|
|
19
|
+
DRAIN_GRACE = 30
|
|
20
|
+
JOB_CANCEL_GRACE = 5
|
|
17
21
|
|
|
18
22
|
class Runner
|
|
19
23
|
include Clock
|
|
@@ -27,11 +31,10 @@ module Async
|
|
|
27
31
|
:total_workers,
|
|
28
32
|
:shutdown,
|
|
29
33
|
:metrics,
|
|
30
|
-
:queue_store
|
|
34
|
+
:queue_store,
|
|
35
|
+
:jobs,
|
|
36
|
+
:services
|
|
31
37
|
|
|
32
|
-
# `config_path: nil` explicitly disables recurring jobs. This keeps the
|
|
33
|
-
# dynamic SQLite queue usable on its own; a supplied path remains strict
|
|
34
|
-
# so a typo cannot silently disable scheduled work.
|
|
35
38
|
def initialize(
|
|
36
39
|
config_path: nil,
|
|
37
40
|
job_count: 2,
|
|
@@ -40,35 +43,37 @@ module Async
|
|
|
40
43
|
queue_socket_dir: nil,
|
|
41
44
|
queue_db_path: nil,
|
|
42
45
|
queue_mmap: true,
|
|
46
|
+
drain_timeout: DRAIN_GRACE,
|
|
43
47
|
metrics_shm_path: Metrics.default_shm_path
|
|
44
48
|
)
|
|
45
49
|
@logger = Console.logger
|
|
46
50
|
@worker_index = worker_index
|
|
47
51
|
@total_workers = total_workers
|
|
48
52
|
@running = true
|
|
49
|
-
@
|
|
50
|
-
@
|
|
51
|
-
|
|
52
|
-
total_workers: total_workers,
|
|
53
|
-
shm_path: metrics_shm_path
|
|
54
|
-
)
|
|
53
|
+
@drain_timeout = drain_timeout
|
|
54
|
+
@shutdown = Runtime::Notification.new
|
|
55
|
+
@metrics = Metrics.new(worker_index: worker_index, total_workers: total_workers, shm_path: metrics_shm_path)
|
|
55
56
|
logger.info { "Async::Background worker_index=#{worker_index}/#{total_workers}, job_count=#{job_count}" }
|
|
56
57
|
|
|
57
|
-
@
|
|
58
|
-
@
|
|
58
|
+
@jobs = Runtime::TaskGroup.new(on_error: error_handler, on_release: method(:job_released))
|
|
59
|
+
@services = Runtime::TaskGroup.new(on_error: error_handler)
|
|
60
|
+
@semaphore = Runtime::Semaphore.new(job_count)
|
|
59
61
|
@heap = config_path.nil? ? MinHeap.new : build_heap(config_path)
|
|
60
62
|
setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
|
|
61
63
|
validate_work_source!(config_path)
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
def run
|
|
65
|
-
|
|
67
|
+
Runtime.scheduler!
|
|
68
|
+
warn_unsafe_timeouts
|
|
69
|
+
|
|
70
|
+
Runtime.with_error_handler(error_handler) do
|
|
66
71
|
setup_signal_handlers
|
|
67
|
-
start_signal_watcher
|
|
68
|
-
start_queue_listener
|
|
72
|
+
start_signal_watcher
|
|
73
|
+
start_queue_listener if @listen_queue
|
|
69
74
|
|
|
70
|
-
scheduler_loop
|
|
71
|
-
|
|
75
|
+
scheduler_loop
|
|
76
|
+
shutdown_gracefully
|
|
72
77
|
end
|
|
73
78
|
end
|
|
74
79
|
|
|
@@ -76,26 +81,21 @@ module Async
|
|
|
76
81
|
return unless @running
|
|
77
82
|
|
|
78
83
|
@running = false
|
|
79
|
-
|
|
80
|
-
shutdown.signal
|
|
81
|
-
@queue_waker&.signal
|
|
84
|
+
wake_signal_watcher
|
|
82
85
|
end
|
|
83
86
|
|
|
84
87
|
def running? = @running
|
|
85
88
|
|
|
86
89
|
private
|
|
87
90
|
|
|
88
|
-
def scheduler_loop
|
|
89
|
-
# Queue-only workers have no heap entry to sleep on. Keep the runner
|
|
90
|
-
# alive until #stop / SIGTERM wakes this condition; the queue listener
|
|
91
|
-
# continues independently in its own Async task.
|
|
91
|
+
def scheduler_loop
|
|
92
92
|
return shutdown.wait if heap.empty? && @listen_queue
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
while running?
|
|
95
95
|
entry = heap.peek
|
|
96
96
|
break unless entry
|
|
97
97
|
|
|
98
|
-
wait_for_next_entry(
|
|
98
|
+
wait_for_next_entry(entry)
|
|
99
99
|
break unless running?
|
|
100
100
|
|
|
101
101
|
dispatch_due_entries
|
|
@@ -108,9 +108,9 @@ module Async
|
|
|
108
108
|
raise ConfigError, 'Runner requires config_path or queue_socket_dir'
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
-
def wait_for_next_entry(
|
|
111
|
+
def wait_for_next_entry(entry)
|
|
112
112
|
wait = [entry.next_run_at - monotonic_now, MIN_SLEEP_TIME].max
|
|
113
|
-
wait_with_shutdown(
|
|
113
|
+
wait_with_shutdown(wait)
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
def dispatch_due_entries
|
|
@@ -123,12 +123,7 @@ module Async
|
|
|
123
123
|
end
|
|
124
124
|
|
|
125
125
|
def dispatch_entry(entry)
|
|
126
|
-
|
|
127
|
-
skip_entry(entry)
|
|
128
|
-
else
|
|
129
|
-
execute_entry(entry)
|
|
130
|
-
end
|
|
131
|
-
|
|
126
|
+
entry.running ? skip_entry(entry) : execute_entry(entry)
|
|
132
127
|
entry.reschedule(monotonic_now)
|
|
133
128
|
heap.replace_top(entry)
|
|
134
129
|
end
|
|
@@ -140,13 +135,23 @@ module Async
|
|
|
140
135
|
|
|
141
136
|
def execute_entry(entry)
|
|
142
137
|
entry.running = true
|
|
143
|
-
|
|
138
|
+
spawn_job do |job_task|
|
|
144
139
|
run_job(job_task, entry)
|
|
145
140
|
ensure
|
|
146
141
|
entry.running = false
|
|
147
142
|
end
|
|
148
143
|
end
|
|
149
144
|
|
|
145
|
+
def spawn_job(&block)
|
|
146
|
+
@jobs.spawn(name: 'job') { |task| semaphore.acquire { block.call(task) } }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def job_released(_task)
|
|
150
|
+
return unless @queue_saturated
|
|
151
|
+
|
|
152
|
+
@queue_waker&.signal
|
|
153
|
+
end
|
|
154
|
+
|
|
150
155
|
def run_job(job_task, entry)
|
|
151
156
|
metrics_started = false
|
|
152
157
|
metrics.job_started(entry)
|
|
@@ -157,7 +162,7 @@ module Async
|
|
|
157
162
|
duration = monotonic_now - started_at
|
|
158
163
|
metrics.job_succeeded(entry, duration)
|
|
159
164
|
logger.info('Async::Background') { "#{entry.name}: completed in #{duration.round(2)}s" }
|
|
160
|
-
rescue ::
|
|
165
|
+
rescue Runtime::TimeoutError
|
|
161
166
|
metrics.job_timed_out(entry)
|
|
162
167
|
logger.error('Async::Background') { "#{entry.name}: timed out after #{entry.timeout}s" }
|
|
163
168
|
rescue StandardError => error
|
|
@@ -180,27 +185,102 @@ module Async
|
|
|
180
185
|
end
|
|
181
186
|
end
|
|
182
187
|
|
|
183
|
-
def start_signal_watcher
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
def start_signal_watcher
|
|
189
|
+
@services.spawn(name: 'signal-watcher') do
|
|
190
|
+
while running?
|
|
191
|
+
begin
|
|
192
|
+
@signal_r.wait_readable
|
|
193
|
+
@signal_r.read_nonblock(256)
|
|
194
|
+
rescue IO::WaitReadable
|
|
195
|
+
next
|
|
196
|
+
rescue IOError, Errno::EBADF
|
|
197
|
+
break
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
logger.info { 'Async::Background: stopping gracefully' } unless running?
|
|
201
|
+
|
|
202
|
+
shutdown.signal_all
|
|
189
203
|
@queue_waker&.signal
|
|
190
|
-
break unless running?
|
|
191
204
|
end
|
|
192
205
|
end
|
|
193
206
|
end
|
|
194
207
|
|
|
195
|
-
def
|
|
196
|
-
|
|
197
|
-
|
|
208
|
+
def error_handler
|
|
209
|
+
@error_handler ||= begin
|
|
210
|
+
log = logger
|
|
211
|
+
lambda do |task, error|
|
|
212
|
+
log.error('Async::Background') do
|
|
213
|
+
"task #{task&.name || 'unnamed'} died: #{error.class} #{error.message}\n" \
|
|
214
|
+
"#{Array(error.backtrace).join("\n")}"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
198
218
|
end
|
|
199
219
|
|
|
200
|
-
def
|
|
201
|
-
|
|
202
|
-
|
|
220
|
+
def warn_unsafe_timeouts
|
|
221
|
+
return if Runtime.native_timeouts?
|
|
222
|
+
|
|
223
|
+
logger.warn('Async::Background') do
|
|
224
|
+
"#{Fiber.scheduler.class} does not implement #timeout_after; job timeouts " \
|
|
225
|
+
'fall back to stdlib Timeout and may interrupt an unrelated fiber'
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def wake_signal_watcher
|
|
230
|
+
@signal_w&.write_nonblock('.')
|
|
231
|
+
rescue StandardError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def wait_with_shutdown(duration)
|
|
236
|
+
shutdown.wait(duration)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def shutdown_gracefully
|
|
240
|
+
wake_services
|
|
241
|
+
drain_jobs
|
|
242
|
+
stop_services
|
|
243
|
+
close_queue
|
|
244
|
+
close_signal_pipe
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def wake_services
|
|
248
|
+
shutdown.signal_all
|
|
249
|
+
wake_signal_watcher
|
|
250
|
+
@queue_waker&.signal
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def drain_jobs
|
|
254
|
+
return if @jobs.empty?
|
|
255
|
+
|
|
256
|
+
logger.info { "Async::Background: draining #{@jobs.size} in-flight job(s)" }
|
|
257
|
+
@jobs.wait(@drain_timeout)
|
|
258
|
+
rescue Runtime::TimeoutError
|
|
259
|
+
logger.warn('Async::Background') do
|
|
260
|
+
"#{@jobs.size} job(s) still running after #{@drain_timeout}s, cancelling"
|
|
261
|
+
end
|
|
262
|
+
@jobs.stop_all(JOB_CANCEL_GRACE)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def stop_services
|
|
266
|
+
return if @services.empty?
|
|
267
|
+
|
|
268
|
+
@services.wait(SERVICE_SHUTDOWN_GRACE)
|
|
269
|
+
rescue Runtime::TimeoutError
|
|
270
|
+
logger.warn('Async::Background') { 'service tasks did not exit in time, cancelling' }
|
|
271
|
+
@services.stop_all(SERVICE_CANCEL_GRACE)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def close_queue
|
|
203
275
|
@queue_waker&.close
|
|
276
|
+
@queue_store&.close
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def close_signal_pipe
|
|
280
|
+
@signal_r&.close
|
|
281
|
+
@signal_w&.close
|
|
282
|
+
rescue IOError
|
|
283
|
+
nil
|
|
204
284
|
end
|
|
205
285
|
end
|
|
206
286
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Runtime
|
|
6
|
+
class Notification
|
|
7
|
+
def initialize
|
|
8
|
+
@waiters = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def waiting = @waiters.size
|
|
12
|
+
|
|
13
|
+
def wait(timeout = nil)
|
|
14
|
+
wait_until(Runtime.deadline_for(timeout))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def wait_until(deadline = nil)
|
|
18
|
+
Runtime.with_waiter(self, @waiters) do |waiter|
|
|
19
|
+
Runtime.park(self, waiter, deadline) { false }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def signal
|
|
24
|
+
while (waiter = @waiters.shift)
|
|
25
|
+
return true if Runtime.wake_dequeued(waiter, self)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def signal_all
|
|
32
|
+
return nil if @waiters.empty?
|
|
33
|
+
|
|
34
|
+
pending, @waiters = @waiters, []
|
|
35
|
+
pending.each { |waiter| Runtime.wake_dequeued(waiter, self) }
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Runtime
|
|
6
|
+
class Semaphore
|
|
7
|
+
attr_reader :limit
|
|
8
|
+
|
|
9
|
+
def initialize(limit)
|
|
10
|
+
@limit = Integer(limit)
|
|
11
|
+
raise ArgumentError, 'limit must be >= 1' unless @limit.positive?
|
|
12
|
+
|
|
13
|
+
@available = @limit
|
|
14
|
+
@waiting = WaitList.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def available = @available
|
|
18
|
+
def waiting = @waiting.size
|
|
19
|
+
|
|
20
|
+
def acquire
|
|
21
|
+
wait
|
|
22
|
+
return @available unless block_given?
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
yield
|
|
26
|
+
ensure
|
|
27
|
+
release
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def release
|
|
32
|
+
while (node = @waiting.shift)
|
|
33
|
+
node.grant!
|
|
34
|
+
return @available if node.resume
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@available += 1
|
|
38
|
+
@available
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def wait
|
|
44
|
+
return (@available -= 1) if @available.positive?
|
|
45
|
+
|
|
46
|
+
task = Runtime.current_task
|
|
47
|
+
node = FiberNode.new(Fiber.current, self)
|
|
48
|
+
@waiting.push(node)
|
|
49
|
+
task&.enter_block(node.waiter)
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
until node.granted?
|
|
53
|
+
node.suspend
|
|
54
|
+
abandon_wait!(node, task) if task&.cancelled?
|
|
55
|
+
end
|
|
56
|
+
abandon_wait!(node, task) if task&.cancelled?
|
|
57
|
+
ensure
|
|
58
|
+
task&.exit_block
|
|
59
|
+
@waiting.remove(node) unless node.granted? || node.list.nil?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def abandon_wait!(node, task)
|
|
64
|
+
node.granted? ? release : @waiting.remove(node)
|
|
65
|
+
task.raise_if_cancelled!
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class WaitList
|
|
69
|
+
attr_reader :size
|
|
70
|
+
|
|
71
|
+
def initialize
|
|
72
|
+
@head = @tail = nil
|
|
73
|
+
@size = 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def first = @head
|
|
77
|
+
def empty? = @head.nil?
|
|
78
|
+
|
|
79
|
+
def push(node)
|
|
80
|
+
raise ArgumentError, 'node already queued' if node.list
|
|
81
|
+
|
|
82
|
+
node.list = self
|
|
83
|
+
node.prev = @tail
|
|
84
|
+
node.next = nil
|
|
85
|
+
@tail ? (@tail.next = node) : (@head = node)
|
|
86
|
+
@tail = node
|
|
87
|
+
@size += 1
|
|
88
|
+
node
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def shift
|
|
92
|
+
node = @head or return nil
|
|
93
|
+
|
|
94
|
+
remove(node)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def remove(node)
|
|
98
|
+
return nil unless node.list.equal?(self)
|
|
99
|
+
|
|
100
|
+
node.prev ? (node.prev.next = node.next) : (@head = node.next)
|
|
101
|
+
node.next ? (node.next.prev = node.prev) : (@tail = node.prev)
|
|
102
|
+
node.list = node.prev = node.next = nil
|
|
103
|
+
@size -= 1
|
|
104
|
+
node
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class FiberNode
|
|
109
|
+
attr_accessor :list, :prev, :next
|
|
110
|
+
attr_reader :fiber, :waiter, :blocker
|
|
111
|
+
|
|
112
|
+
def initialize(fiber, blocker)
|
|
113
|
+
@fiber = fiber
|
|
114
|
+
@blocker = blocker
|
|
115
|
+
@list = @prev = @next = nil
|
|
116
|
+
@granted = false
|
|
117
|
+
@scheduler = Runtime.scheduler!
|
|
118
|
+
@waiter = {fiber: fiber, scheduler: @scheduler, ready: false, blocker: blocker, queued: true}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def granted? = @granted
|
|
122
|
+
|
|
123
|
+
def grant!
|
|
124
|
+
@granted = true
|
|
125
|
+
@waiter[:ready] = true
|
|
126
|
+
@waiter[:queued] = false
|
|
127
|
+
self
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def suspend
|
|
131
|
+
@scheduler.block(@blocker, nil)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def resume
|
|
135
|
+
fiber = @fiber
|
|
136
|
+
return false unless fiber.alive?
|
|
137
|
+
|
|
138
|
+
@scheduler.unblock(@blocker, fiber)
|
|
139
|
+
true
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private_constant :WaitList, :FiberNode
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Runtime
|
|
6
|
+
class Task
|
|
7
|
+
attr_reader :fiber, :name, :error
|
|
8
|
+
|
|
9
|
+
def self.spawn(name: nil, on_error: UNSET, &block)
|
|
10
|
+
task = new(name: name, on_error: on_error)
|
|
11
|
+
task.start(&block)
|
|
12
|
+
task
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(name: nil, group: nil, on_error: UNSET)
|
|
16
|
+
@fiber = @scheduler = @blocker = @result = @error = nil
|
|
17
|
+
@done = @cancelled = false
|
|
18
|
+
|
|
19
|
+
@name = name
|
|
20
|
+
@group = group
|
|
21
|
+
@on_error = on_error
|
|
22
|
+
@waiters = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def start(&block)
|
|
26
|
+
raise ArgumentError, 'block required' unless block
|
|
27
|
+
raise Error, 'task already started' if @fiber || @done
|
|
28
|
+
|
|
29
|
+
@scheduler = Runtime.scheduler!
|
|
30
|
+
takes_task = !block.arity.zero?
|
|
31
|
+
|
|
32
|
+
scheduled = Fiber.schedule do
|
|
33
|
+
adopt_fiber(Fiber.current)
|
|
34
|
+
previous = Runtime.current_task
|
|
35
|
+
Runtime.current_task = self
|
|
36
|
+
begin
|
|
37
|
+
complete(takes_task ? block.call(self) : block.call, nil)
|
|
38
|
+
rescue Cancel
|
|
39
|
+
complete(nil, nil)
|
|
40
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
41
|
+
complete(nil, e)
|
|
42
|
+
ensure
|
|
43
|
+
Runtime.current_task = previous
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@fiber ||= scheduled if scheduled.is_a?(Fiber)
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def wait(timeout = nil)
|
|
52
|
+
unless @done
|
|
53
|
+
deadline = Runtime.deadline_for(timeout)
|
|
54
|
+
raise TimeoutError, 'task did not finish in time' unless join(deadline)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
raise @error if @error
|
|
58
|
+
|
|
59
|
+
@result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def with_timeout(duration, &block)
|
|
63
|
+
Runtime.with_timeout(duration, &block)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def stop
|
|
67
|
+
return false if @done
|
|
68
|
+
|
|
69
|
+
@cancelled = true
|
|
70
|
+
|
|
71
|
+
return true if interrupt_fiber
|
|
72
|
+
return true if release_blocker
|
|
73
|
+
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cancelled? = @cancelled
|
|
78
|
+
def finished? = @done
|
|
79
|
+
def waiting = @waiters ? @waiters.size : 0
|
|
80
|
+
|
|
81
|
+
def raise_if_cancelled!
|
|
82
|
+
raise Cancel, 'task stopped' if @cancelled
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def enter_block(waiter)
|
|
86
|
+
@blocker = waiter
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def exit_block
|
|
90
|
+
@blocker = nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def adopt_fiber(fiber)
|
|
96
|
+
@fiber ||= fiber
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def interrupt_fiber
|
|
100
|
+
fiber = @fiber
|
|
101
|
+
scheduler = @scheduler
|
|
102
|
+
return false unless fiber&.alive?
|
|
103
|
+
return false unless scheduler&.respond_to?(:fiber_interrupt)
|
|
104
|
+
|
|
105
|
+
result = scheduler.fiber_interrupt(fiber, Cancel.new('task stopped'))
|
|
106
|
+
result != false
|
|
107
|
+
rescue FiberError
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def release_blocker
|
|
112
|
+
waiter = @blocker or return false
|
|
113
|
+
|
|
114
|
+
Runtime.wake(waiter, waiter[:blocker]) && waiter[:fiber].alive?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def complete(result, error)
|
|
118
|
+
return if @done
|
|
119
|
+
|
|
120
|
+
@result = result
|
|
121
|
+
@error = error
|
|
122
|
+
@done = true
|
|
123
|
+
|
|
124
|
+
Runtime.report_error(self, error, @on_error) if error && waiting.zero?
|
|
125
|
+
@group&.release(self)
|
|
126
|
+
wake_waiters
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def join(deadline)
|
|
130
|
+
waiters = (@waiters ||= [])
|
|
131
|
+
Runtime.with_waiter(self, waiters) do |waiter|
|
|
132
|
+
Runtime.park(self, waiter, deadline) { @done }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def wake_waiters
|
|
137
|
+
return if @waiters.nil? || @waiters.empty?
|
|
138
|
+
|
|
139
|
+
pending, @waiters = @waiters, []
|
|
140
|
+
pending.each { |waiter| Runtime.wake_dequeued(waiter, self) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Runtime
|
|
6
|
+
class TaskGroup
|
|
7
|
+
attr_accessor :on_release
|
|
8
|
+
|
|
9
|
+
def initialize(on_error: UNSET, on_release: nil)
|
|
10
|
+
@members = {}
|
|
11
|
+
@drained = Notification.new
|
|
12
|
+
@on_error = on_error
|
|
13
|
+
@on_release = on_release
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def size = @members.size
|
|
17
|
+
def empty? = @members.empty?
|
|
18
|
+
def tasks = @members.keys
|
|
19
|
+
|
|
20
|
+
def spawn(name: nil, &block)
|
|
21
|
+
raise ArgumentError, 'block required' unless block
|
|
22
|
+
|
|
23
|
+
task = Task.new(name: name, group: self, on_error: @on_error)
|
|
24
|
+
@members[task] = true
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
task.start(&block)
|
|
28
|
+
rescue Exception
|
|
29
|
+
release(task)
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
task
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def wait(timeout = nil)
|
|
37
|
+
deadline = Runtime.deadline_for(timeout)
|
|
38
|
+
|
|
39
|
+
until @members.empty?
|
|
40
|
+
raise TimeoutError, 'tasks did not finish in time' unless @drained.wait_until(deadline)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def stop_all(grace = nil)
|
|
47
|
+
tasks.each do |task|
|
|
48
|
+
task.stop
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return @members.empty? if grace.nil?
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
wait(grace)
|
|
57
|
+
rescue TimeoutError
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def release(task)
|
|
63
|
+
return unless @members.delete(task)
|
|
64
|
+
|
|
65
|
+
@drained.signal_all if @members.empty?
|
|
66
|
+
notify_release(task)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def notify_release(task)
|
|
72
|
+
handler = @on_release or return
|
|
73
|
+
|
|
74
|
+
handler.call(task)
|
|
75
|
+
rescue StandardError => error
|
|
76
|
+
Runtime.report_error(task, error, @on_error)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|