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.
@@ -1,19 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'async/barrier'
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 < StandardError; end
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
- @shutdown = ::Async::Condition.new
50
- @metrics = Metrics.new(
51
- worker_index: worker_index,
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
- @drain_barrier = ::Async::Barrier.new
58
- @semaphore = ::Async::Semaphore.new(job_count, parent: @drain_barrier)
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
- Async do |task|
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(task)
68
- start_queue_listener(task) if @listen_queue
72
+ start_signal_watcher
73
+ start_queue_listener if @listen_queue
69
74
 
70
- scheduler_loop(task)
71
- drain_and_close_queue
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
- logger.info { 'Async::Background: stopping gracefully' }
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(task)
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
- loop do
94
+ while running?
95
95
  entry = heap.peek
96
96
  break unless entry
97
97
 
98
- wait_for_next_entry(task, 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(task, 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(task, wait)
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
- if entry.running
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
- semaphore.async do |job_task|
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 ::Async::TimeoutError
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(task)
184
- task.async(transient: true) do
185
- loop do
186
- @signal_r.wait_readable
187
- @signal_r.read_nonblock(256) rescue nil
188
- shutdown.signal
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 wait_with_shutdown(task, duration)
196
- task.with_timeout(duration) { shutdown.wait }
197
- rescue ::Async::TimeoutError
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 drain_and_close_queue
201
- @drain_barrier.wait
202
- @queue_store&.close
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